doc webdav: more tests in lib, yaml
[odoo/odoo.git] / addons / document_webdav / test_davclient.py
1 #!/usr/bin/env python
2 # -*- encoding: utf-8 -*-
3 #
4 # Copyright P. Christeas <p_christ@hol.gr> 2008,2009
5 # Copyright OpenERP SA. (http://www.openerp.com) 2010
6 #
7 #
8 # WARNING: This program as such is intended to be used by professional
9 # programmers who take the whole responsability of assessing all potential
10 # consequences resulting from its eventual inadequacies and bugs
11 # End users who are looking for a ready-to-use solution with commercial
12 # garantees and support are strongly adviced to contract a Free Software
13 # Service Company
14 #
15 # This program is Free Software; you can redistribute it and/or
16 # modify it under the terms of the GNU General Public License
17 # as published by the Free Software Foundation; either version 2
18 # of the License, or (at your option) any later version.
19 #
20 # This program is distributed in the hope that it will be useful,
21 # but WITHOUT ANY WARRANTY; without even the implied warranty of
22 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
23 # GNU General Public License for more details.
24 #
25 # You should have received a copy of the GNU General Public License
26 # along with this program; if not, write to the Free Software
27 # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
28 ###############################################################################
29
30 """ A trivial HTTP/WebDAV client, used for testing the server
31 """
32 # code taken from the 'http-client.py' script:
33 # http://git.hellug.gr/?p=xrg/openerp;a=history;f=tests/http-client.py;hb=refs/heads/xrg-60
34
35 import re
36 import gzip
37 import logging
38 import xml.dom.minidom
39
40 import httplib
41
42 from tools import config
43 from xmlrpclib import Transport, ProtocolError
44 import StringIO
45 import base64
46
47 log = logging.getLogger('http-client')
48
49 class HTTP11(httplib.HTTP):
50         _http_vsn = 11
51         _http_vsn_str = 'HTTP/1.1'
52
53 class PersistentTransport(Transport):
54     """Handles an HTTP transaction to an XML-RPC server, persistently."""
55
56     def __init__(self, use_datetime=0):
57         self._use_datetime = use_datetime
58         self._http = {}
59         log.debug("Using persistent transport")
60
61     def make_connection(self, host):
62         # create a HTTP connection object from a host descriptor
63         if not self._http.has_key(host):
64                 host, extra_headers, x509 = self.get_host_info(host)
65                 self._http[host] = HTTP11(host)
66                 log.debug("New connection to %s", host)
67         return self._http[host]
68
69     def get_host_info(self, host):
70         host, extra_headers, x509 = Transport.get_host_info(self,host)
71         if extra_headers == None:
72                 extra_headers = []
73                 
74         extra_headers.append( ( 'Connection', 'keep-alive' ))
75         
76         return host, extra_headers, x509
77
78     def _parse_response(self, file, sock, response):
79         """ read response from input file/socket, and parse it
80             We are persistent, so it is important to only parse
81             the right amount of input
82         """
83
84         p, u = self.getparser()
85
86         if response.msg.get('content-encoding') == 'gzip':
87             gzdata = StringIO.StringIO()
88             while not response.isclosed():
89                 rdata = response.read(1024)
90                 if not rdata:
91                     break
92                 gzdata.write(rdata)
93             gzdata.seek(0)
94             rbuffer = gzip.GzipFile(mode='rb', fileobj=gzdata)
95             while True:
96                 respdata = rbuffer.read()
97                 if not respdata:
98                     break
99                 p.feed(respdata)
100         else:
101             while not response.isclosed():
102                 rdata = response.read(1024)
103                 if not rdata:
104                         break
105                 p.feed(rdata)
106                 if len(rdata)<1024:
107                         break
108
109         p.close()
110         return u.close()
111
112     def request(self, host, handler, request_body, verbose=0):
113         # issue XML-RPC request
114
115         h = self.make_connection(host)
116         if verbose:
117             h.set_debuglevel(1)
118
119         self.send_request(h, handler, request_body)
120         self.send_host(h, host)
121         self.send_user_agent(h)
122         self.send_content(h, request_body)
123
124         resp = h._conn.getresponse()
125         # TODO: except BadStatusLine, e:
126         
127         errcode, errmsg, headers = resp.status, resp.reason, resp.msg
128         
129
130         if errcode != 200:
131             raise ProtocolError(
132                 host + handler,
133                 errcode, errmsg,
134                 headers
135                 )
136
137         self.verbose = verbose
138
139         try:
140             sock = h._conn.sock
141         except AttributeError:
142             sock = None
143
144         return self._parse_response(h.getfile(), sock, resp)
145
146 class CompressedTransport(PersistentTransport):
147     def send_content(self, connection, request_body):
148         connection.putheader("Content-Type", "text/xml")
149         
150         if len(request_body) > 512 or True:
151             buffer = StringIO.StringIO()
152             output = gzip.GzipFile(mode='wb', fileobj=buffer)
153             output.write(request_body)
154             output.close()
155             buffer.seek(0)
156             request_body = buffer.getvalue()
157             connection.putheader('Content-Encoding', 'gzip')
158
159         connection.putheader("Content-Length", str(len(request_body)))
160         connection.putheader("Accept-Encoding",'gzip')
161         connection.endheaders()
162         if request_body:
163             connection.send(request_body)
164
165     def send_request(self, connection, handler, request_body):
166         connection.putrequest("POST", handler, skip_accept_encoding=1)
167
168 class SafePersistentTransport(PersistentTransport):
169     def make_connection(self, host):
170         # create a HTTP connection object from a host descriptor
171         if not self._http.has_key(host):
172                 host, extra_headers, x509 = self.get_host_info(host)
173                 self._http[host] = httplib.HTTPS(host, None, **(x509 or {}))
174                 log.debug("New connection to %s", host)
175         return self._http[host]
176
177 class AuthClient(object):
178     def getAuth(self, atype, realm):
179         raise NotImplementedError("Cannot authenticate for %s" % atype)
180         
181     def resolveFailedRealm(self, realm):
182         """ Called when, using a known auth type, the realm is not in cache
183         """
184         raise NotImplementedError("Cannot authenticate for realm %s" % realm)
185
186 class BasicAuthClient(AuthClient):
187     def __init__(self):
188         self._realm_dict = {}
189
190     def getAuth(self, atype, realm):
191         if atype != 'Basic' :
192             return super(BasicAuthClient,self).getAuth(atype, realm)
193
194         if not self._realm_dict.has_key(realm):
195             log.debug("realm dict: %r", self._realm_dict)
196             log.debug("missing key: \"%s\"" % realm)
197             self.resolveFailedRealm(realm)
198         return 'Basic '+ self._realm_dict[realm]
199         
200     def addLogin(self, realm, username, passwd):
201         """ Add some known username/password for a specific login.
202             This function should be called once, for each realm
203             that we want to authenticate against
204         """
205         assert realm
206         auths = base64.encodestring(username + ':' + passwd)
207         if auths[-1] == "\n":
208             auths = auths[:-1]
209         self._realm_dict[realm] = auths
210
211 class addAuthTransport:
212     """ Intermediate class that authentication algorithm to http transport
213     """
214     
215     def setAuthClient(self, authobj):
216         """ Set the authentication client object.
217             This method must be called before any request is issued, that
218             would require http authentication
219         """
220         assert isinstance(authobj, AuthClient)
221         self._auth_client = authobj
222         
223
224     def request(self, host, handler, request_body, verbose=0):
225         # issue XML-RPC request
226
227         h = self.make_connection(host)
228         if verbose:
229             h.set_debuglevel(1)
230         
231         tries = 0
232         atype = None
233         realm = None
234
235         while(tries < 3):
236             self.send_request(h, handler, request_body)
237             self.send_host(h, host)
238             self.send_user_agent(h)
239             if atype:
240                 # This line will bork if self.setAuthClient has not
241                 # been issued. That is a programming error, fix your code!
242                 auths = self._auth_client.getAuth(atype, realm)
243                 log.debug("sending authorization: %s", auths)
244                 h.putheader('Authorization', auths)
245             self.send_content(h, request_body)
246
247             resp = h._conn.getresponse()
248             #  except BadStatusLine, e:
249             tries += 1
250     
251             if resp.status == 401:
252                 if 'www-authenticate' in resp.msg:
253                     (atype,realm) = resp.msg.getheader('www-authenticate').split(' ',1)
254                     data1 = resp.read()
255                     if realm.startswith('realm="') and realm.endswith('"'):
256                         realm = realm[7:-1]
257                     log.debug("Resp: %r %r", resp.version,resp.isclosed(), resp.will_close)
258                     log.debug("Want to do auth %s for realm %s", atype, realm)
259                     if atype != 'Basic':
260                         raise ProtocolError(host+handler, 403, 
261                                         "Unknown authentication method: %s" % atype, resp.msg)
262                     continue # with the outer while loop
263                 else:
264                     raise ProtocolError(host+handler, 403,
265                                 'Server-incomplete authentication', resp.msg)
266
267             if resp.status != 200:
268                 raise ProtocolError( host + handler,
269                     resp.status, resp.reason, resp.msg )
270     
271             self.verbose = verbose
272     
273             try:
274                 sock = h._conn.sock
275             except AttributeError:
276                 sock = None
277     
278             return self._parse_response(h.getfile(), sock, resp)
279
280         raise ProtocolError(host+handler, 403, "No authentication",'')
281
282 class PersistentAuthTransport(addAuthTransport,PersistentTransport):
283     pass
284
285 class PersistentAuthCTransport(addAuthTransport,CompressedTransport):
286     pass
287
288 class HTTPSConnection(httplib.HTTPSConnection):
289         certs_file = None
290         def connect(self):
291             "Connect to a host on a given (SSL) port. check the certificate"
292             import socket, ssl
293
294             if HTTPSConnection.certs_file:
295                 ca_certs = HTTPSConnection.certs_file
296                 cert_reqs = ssl.CERT_REQUIRED
297             else:
298                 ca_certs = None
299                 cert_reqs = ssl.CERT_NONE
300             sock = socket.create_connection((self.host, self.port), self.timeout)
301             self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file,
302                                 ca_certs=ca_certs,
303                                 cert_reqs=cert_reqs)
304         
305
306         def getpeercert(self):
307                 import ssl
308                 cert = None
309                 if self.sock:
310                         cert =  self.sock.getpeercert()
311                 else:
312                         cert = ssl.get_server_certificate((self.host,self.port),
313                                 ssl_version=ssl.PROTOCOL_SSLv23 )
314                         lf = (len(ssl.PEM_FOOTER)+1)
315                         if cert[0-lf] != '\n':
316                                 cert = cert[:0-lf]+'\n'+cert[0-lf:]
317                         log.debug("len-footer: %s cert: %r", lf, cert[0-lf])
318                 
319                 return cert
320
321
322 class DAVClient(object):
323     """An instance of a WebDAV client, connected to the OpenERP server
324     """
325     
326     def __init__(self, user=None, passwd=None, dbg=0, use_ssl=False, useragent=False):
327         if use_ssl:
328             self.host = config.get_misc('httpsd', 'interface', False)
329             self.port = config.get_misc('httpsd', 'port', 8071)
330             if not self.host:
331                 self.host = config.get('xmlrpcs_interface')
332                 self.port = config.get('xmlrpcs_port')
333         else:
334             self.host = config.get_misc('httpd', 'interface')
335             self.port = config.get_misc('httpd', 'port', 8069)
336             if not self.host:
337                 self.host = config.get('xmlrpc_interface')
338                 self.port = config.get('xmlrpc_port') or self.port
339         if self.host == '0.0.0.0' or not self.host:
340             self.host = '127.0.0.1'
341         self.port = int(self.port)
342         if not config.get_misc('webdav','enable',True):
343             raise Exception("WebDAV is disabled, cannot continue")
344         self.davpath = '/' + config.get_misc('webdav','vdir','webdav')
345         self.user = user
346         self.passwd = passwd
347         self.dbg = dbg
348         self.hdrs = {}
349         if useragent:
350             self.set_useragent(useragent)
351
352     def get_creds(self, obj, cr, uid):
353         """Read back the user credentials from cr, uid
354         
355         @param obj is any orm object, in order to use its pool
356         @param uid is the numeric id, which we will try to reverse resolve
357         
358         note: this is a hackish way to get the credentials. It is expected
359         to break if "base_crypt" is used.
360         """
361         ruob = obj.pool.get('res.users')
362         res = ruob.read(cr, 1, [uid,], ['login', 'password'])
363         assert res, "uid %s not found" % uid
364         self.user = res[0]['login']
365         self.passwd = res[0]['password']
366         return True
367
368     def set_useragent(self, uastr):
369         """ Set the user-agent header to something meaningful.
370         Some shorthand names will be replaced by stock strings.
371         """
372         if uastr in ('KDE4', 'Korganizer'):
373             self.hdrs['User-Agent'] = "Mozilla/5.0 (compatible; Konqueror/4.4; Linux) KHTML/4.4.3 (like Gecko)"
374         elif uastr == 'iPhone3':
375             self.hdrs['User-Agent'] = "DAVKit/5.0 (765); iCalendar/5.0 (79); iPhone/4.1 8B117"
376         elif uastr == "MacOS":
377             self.hdrs['User-Agent'] = "WebDAVFS/1.8 (01808000) Darwin/9.8.0 (i386)"
378         else:
379             self.hdrs['User-Agent'] = uastr
380
381     def _http_request(self, path, method='GET', hdrs=None, body=None):
382         if not hdrs:
383             hdrs = {}
384         import base64
385         dbg = self.dbg
386         hdrs.update(self.hdrs)
387         log.debug("Getting %s http://%s:%d/%s", method, self.host, self.port, path)
388         conn = httplib.HTTPConnection(self.host, port=self.port)
389         conn.set_debuglevel(dbg)
390         if not path:
391             path = "/index.html"
392         if not hdrs.has_key('Connection'):
393                 hdrs['Connection']= 'keep-alive'
394         conn.request(method, path, body, hdrs )
395         try:
396                 r1 = conn.getresponse()
397         except httplib.BadStatusLine, bsl:
398                 log.warning("Bad status line: %s", bsl.line)
399                 raise Exception('Bad status line')
400         if r1.status == 401: # and r1.headers:
401                 if 'www-authenticate' in r1.msg:
402                         (atype,realm) = r1.msg.getheader('www-authenticate').split(' ',1)
403                         data1 = r1.read()
404                         if not self.user:
405                                 raise Exception('Must auth, have no user/pass!')
406                         log.debug("Ver: %s, closed: %s, will close: %s", r1.version,r1.isclosed(), r1.will_close)
407                         log.debug("Want to do auth %s for realm %s", atype, realm)
408                         if atype == 'Basic' :
409                                 auths = base64.encodestring(self.user + ':' + self.passwd)
410                                 if auths[-1] == "\n":
411                                         auths = auths[:-1]
412                                 hdrs['Authorization']= 'Basic '+ auths 
413                                 #sleep(1)
414                                 conn.request(method, path, body, hdrs )
415                                 r1 = conn.getresponse()
416                         else:
417                                 raise Exception("Unknown auth type %s" %atype)
418                 else:
419                         log.warning("Got 401, cannot auth")
420                         raise Exception('No auth')
421
422         log.debug("Reponse: %s %s",r1.status, r1.reason)
423         data1 = r1.read()
424         log.debug("Body:\n%s\nEnd of body", data1)
425         try:
426             ctype = r1.msg.getheader('content-type')
427             if ctype and ';' in ctype:
428                 ctype, encoding = ctype.split(';',1)
429             if ctype == 'text/xml':
430                 doc = xml.dom.minidom.parseString(data1)
431                 log.debug("XML Body:\n %s", doc.toprettyxml(indent="\t"))
432         except Exception:
433             log.warning("could not print xml", exc_info=True)
434             pass
435         conn.close()
436         return r1.status, r1.msg, data1
437
438     def _assert_headers(self, expect, msg):
439         """ Assert that the headers in msg contain the expect values
440         """
441         for k, v in expect.items():
442             hval = msg.getheader(k)
443             if not hval:
444                 raise AssertionError("Header %s not defined in http response" % k)
445             if isinstance(v, (list, tuple)):
446                 delim = ','
447                 hits = map(str.strip, hval.split(delim))
448                 mvits= []
449                 for vit in v:
450                     if vit not in hits:
451                         mvits.append(vit)
452                 if mvits:
453                     raise AssertionError("HTTP header \"%s\" is missing: %s" %(k, ', '.join(mvits)))
454             else:
455                 if hval.strip() != v.strip():
456                     raise AssertionError("HTTP header \"%s: %s\"" % (k, hval))
457
458     def gd_options(self, path='*', expect=None):
459         """ Test the http options functionality
460             If a dictionary is defined in expect, those options are
461             asserted.
462         """
463         if path != '*':
464             path = self.davpath + path
465         hdrs = { 'Content-Length': 0
466                 }
467         s, m, d = self._http_request(path, method='OPTIONS', hdrs=hdrs)
468         assert s == 200, "Status: %r" % s
469         assert 'OPTIONS' in m.getheader('Allow')
470         log.debug('Options: %r', m.getheader('Allow'))
471         
472         if expect:
473             self._assert_headers(expect, m)
474     
475     def _parse_prop_response(self, data):
476         """ Parse a propfind/propname response
477         """
478         def getText(node):
479             rc = []
480             for node in node.childNodes:
481                 if node.nodeType == node.TEXT_NODE:
482                     rc.append(node.data)
483             return ''.join(rc)
484         
485         def getElements(node, namespaces=None, strict=False):
486             for cnod in node.childNodes:
487                 if cnod.nodeType != node.ELEMENT_NODE:
488                     if strict:
489                         log.debug("Found %r inside <%s>", cnod, node.tagName)
490                     continue
491                 if namespaces and (cnod.namespaceURI not in namespaces):
492                     log.debug("Ignoring <%s> in <%s>", cnod.tagName, node.localName)
493                     continue
494                 yield cnod
495
496         nod = xml.dom.minidom.parseString(data)
497         nod_r = nod.documentElement
498         res = {}
499         assert nod_r.localName == 'multistatus', nod_r.tagName
500         for resp in nod_r.getElementsByTagNameNS('DAV:', 'response'):
501             href = None
502             status = 200
503             res_nss = {}
504             for cno in getElements(resp, namespaces=['DAV:',]):
505                 if cno.localName == 'href':
506                     assert href is None, "Second href in same response"
507                     href = getText(cno)
508                 elif cno.localName == 'propstat':
509                     for pno in getElements(cno, namespaces=['DAV:',]):
510                         rstatus = None
511                         if pno.localName == 'prop':
512                             for prop in getElements(pno):
513                                 key = prop.localName
514                                 tval = getText(prop).strip()
515                                 val = tval or (True, rstatus or status)
516                                 if prop.namespaceURI == 'DAV:' and prop.localName == 'resourcetype':
517                                     val = 'plain'
518                                     for rte in getElements(prop, namespaces=['DAV:',]):
519                                         # Note: we only look at DAV:... elements, we
520                                         # actually expect only one DAV:collection child
521                                         val = rte.localName
522                                 res_nss.setdefault(prop.namespaceURI,{})[key] = val
523                         elif pno.localName == 'status':
524                             rstr = getText(pno)
525                             htver, sta, msg = rstr.split(' ', 3)
526                             assert htver == 'HTTP/1.1'
527                             rstatus = int(sta)
528                         else:
529                             log.debug("What is <%s> inside a <propstat>?", pno.tagName)
530                     
531                 else:
532                     log.debug("Unknown node: %s", cno.tagName)
533                 
534             res.setdefault(href,[]).append((status, res_nss))
535
536         return res
537
538     def gd_propfind(self, path, props=None, depth=0):
539         if not props:
540             propstr = '<allprop/>'
541         else:
542             propstr = '<prop>'
543             nscount = 0
544             for p in props:
545                 ns = None
546                 if isinstance(p, tuple):
547                     p, ns = p
548                 if ns is None or ns == 'DAV:':
549                     propstr += '<%s/>' % p
550                 else:
551                     propstr += '<ns%d:%s xmlns:ns%d="%s" />' %(nscount, p, nscount, ns)
552                     nscount += 1
553             propstr += '</prop>'
554                 
555         body="""<?xml version="1.0" encoding="utf-8"?>
556             <propfind xmlns="DAV:">%s</propfind>""" % propstr
557         hdrs = { 'Content-Type': 'text/xml; charset=utf-8',
558                 'Accept': 'text/xml',
559                 'Depth': depth,
560                 }
561
562         s, m, d = self._http_request(self.davpath + path, method='PROPFIND', 
563                                     hdrs=hdrs, body=body)
564         assert s == 207, "Bad status: %s" % s
565         ctype = m.getheader('Content-Type').split(';',1)[0]
566         assert ctype == 'text/xml', m.getheader('Content-Type')
567         res = self._parse_prop_response(d)
568         if depth == 0:
569             assert len(res) == 1
570             res = res.values()[0]
571         else:
572             assert len(res) >= 1
573         return res
574         
575
576     def gd_propname(self, path, depth=0):
577         body="""<?xml version="1.0" encoding="utf-8"?>
578             <propfind xmlns="DAV:"><propname/></propfind>"""
579         hdrs = { 'Content-Type': 'text/xml; charset=utf-8',
580                 'Accept': 'text/xml',
581                 'Depth': depth
582                 }
583         s, m, d = self._http_request(self.davpath + path, method='PROPFIND', 
584                                     hdrs=hdrs, body=body)
585         assert s == 207, "Bad status: %s" % s
586         ctype = m.getheader('Content-Type').split(';',1)[0]
587         assert ctype == 'text/xml', m.getheader('Content-Type')
588         res = self._parse_prop_response(d)
589         if depth == 0:
590             assert len(res) == 1
591             res = res.values()[0]
592         else:
593             assert len(res) >= 1
594         return res
595
596     def gd_getetag(self, path, depth=0):
597         return self.gd_propfind(path, props=['getetag',], depth=depth)
598
599     def gd_lsl(self, path):
600         """ Return a list of 'ls -l' kind of data for a folder
601         
602             This is based on propfind.
603         """
604
605         lspairs = [ ('name', 'displayname', 'n/a'), ('size', 'getcontentlength', '0'),
606                 ('type', 'resourcetype', '----------'), ('uid', 'owner', 'nobody'),
607                 ('gid', 'group', 'nogroup'), ('mtime', 'getlastmodified', 'n/a'),
608                 ('mime', 'getcontenttype', 'application/data'), ]
609
610         propnames = [ l[1] for l in lspairs]
611         propres = self.gd_propfind(path, props=propnames, depth=1)
612         
613         res = []
614         for href, pr in propres.items():
615             lsline = {}
616             for st, nsdic in pr:
617                 davprops = nsdic['DAV:']
618                 if st == 200:
619                     for lsp in lspairs:
620                         if lsp[1] in davprops:
621                             if lsp[1] == 'resourcetype':
622                                 if davprops[lsp[1]] == 'collection':
623                                     lsline[lsp[0]] = 'dr-xr-x---'
624                                 else:
625                                     lsline[lsp[0]] = '-r-xr-x---'
626                             else:
627                                 lsline[lsp[0]] = davprops[lsp[1]]
628                 elif st in (404, 403):
629                     for lsp in lspairs:
630                         if lsp[1] in davprops:
631                             lsline[lsp[0]] = lsp[2]
632                 else:
633                     log.debug("Strange status: %s", st)
634             
635             res.append(lsline)
636             
637         return res
638
639 #eof