- Timestamp:
- Dec 4, 2019, 12:57:11 PM (15 months ago)
- Branches:
- asyncio, master, proxy-ticket
- Children:
- 42097fb
- Parents:
- a2d1bb9
- Location:
- test
- Files:
-
- 3 added
- 3 edited
Legend:
- Unmodified
- Added
- Removed
-
test/.gitignore
ra2d1bb9 r6d3dc34 6 6 authority/client/uid 7 7 authority/tofu.db 8 /__pycache__/8 __pycache__/ 9 9 10 10 # generated X.509 data -
test/Makefile.am
ra2d1bb9 r6d3dc34 54 54 endif 55 55 56 # Python tools for tests 57 noinst_PYTHON = https-test-client.py mgstest/http.py mgstest/__init__.py \ 58 mgstest/tests.py 59 56 60 # Identities in the miniature CA, server, and client environment for 57 61 # the test suite … … 249 253 EXTRA_DIST = $(apache_data) $(cert_templates) $(shared_identities:=/uid.in) \ 250 254 apache_service.bash common.bash runtests authority/server/crl.template \ 251 softhsm.bash https-test-client.py255 softhsm.bash 252 256 253 257 # Lockfile for the main Apache process -
test/https-test-client.py
ra2d1bb9 r6d3dc34 16 16 # limitations under the License. 17 17 18 import re19 import socket20 import subprocess21 18 import yaml 22 19 23 from http.client import HTTPConnection 24 from multiprocessing import Process 25 from time import sleep 26 27 class HTTPSubprocessConnection(HTTPConnection): 28 def __init__(self, command, host, port=None, 29 output_filter=None, 30 timeout=socket._GLOBAL_DEFAULT_TIMEOUT, 31 blocksize=8192): 32 super(HTTPSubprocessConnection, self).__init__(host, port, timeout, 33 source_address=None, 34 blocksize=blocksize) 35 # "command" must be a list containing binary and command line 36 # parameters 37 self.command = command 38 # This will be the subprocess reference when connected 39 self._sproc = None 40 # The subprocess return code is stored here on close() 41 self.returncode = None 42 # The set_tunnel method of the super class is not supported 43 # (see exception doc) 44 self.set_tunnel = None 45 # This method will be run in a separate process and filter the 46 # stdout of self._sproc. Its arguments are self._sproc.stdout 47 # and the socket back to the HTTP connection (write-only). 48 self._output_filter = output_filter 49 # output filter process 50 self._fproc = None 51 52 def connect(self): 53 s_local, s_remote = socket.socketpair(socket.AF_UNIX, 54 socket.SOCK_STREAM) 55 s_local.settimeout(self.timeout) 56 57 # TODO: Maybe capture stderr? 58 if self._output_filter: 59 self._sproc = subprocess.Popen(self.command, stdout=subprocess.PIPE, 60 stdin=s_remote, close_fds=True, 61 bufsize=0) 62 self._fproc = Process(target=self._output_filter, 63 args=(self._sproc.stdout, s_remote)) 64 self._fproc.start() 65 else: 66 self._sproc = subprocess.Popen(self.command, stdout=s_remote, 67 stdin=s_remote, close_fds=True, 68 bufsize=0) 69 s_remote.close() 70 self.sock = s_local 71 72 def close(self): 73 # close socket to subprocess for writing 74 if self.sock: 75 self.sock.shutdown(socket.SHUT_WR) 76 77 # Wait for the process to stop, send SIGTERM/SIGKILL if 78 # necessary 79 if self._sproc: 80 try: 81 self.returncode = self._sproc.wait(self.timeout) 82 except subprocess.TimeoutExpired: 83 try: 84 self._sproc.terminate() 85 self.returncode = self._sproc.wait(self.timeout) 86 except subprocess.TimeoutExpired: 87 self._sproc.kill() 88 self.returncode = self._sproc.wait(self.timeout) 89 90 # filter process receives HUP on pipe when the subprocess 91 # terminates 92 if self._fproc: 93 self._fproc.join() 94 95 # close the connection in the super class, which also calls 96 # self.sock.close() 97 super().close() 98 99 100 101 class TestRequest(yaml.YAMLObject): 102 yaml_tag = '!request' 103 def __init__(self, path, method='GET', headers=dict(), 104 expect=dict(status=200)): 105 self.method = method 106 self.path = path 107 self.headers = headers 108 self.expect = expect 109 110 def __repr__(self): 111 return (f'{self.__class__.__name__!s}(path={self.path!r}, ' 112 f'method={self.method!r}, headers={self.headers!r}, ' 113 f'expect={self.expect!r})') 114 115 def run(self, conn): 116 try: 117 conn.request(self.method, self.path, headers=self.headers) 118 resp = conn.getresponse() 119 except ConnectionResetError as err: 120 if self.expects_conn_reset(): 121 print('connection reset as expected.') 122 return 123 else: 124 raise err 125 body = resp.read().decode() 126 print(format_response(resp, body)) 127 self.check_response(resp, body) 128 129 def check_body(self, body): 130 """ 131 >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}}) 132 >>> r1.check_body('test\\n') 133 >>> r1.check_body('xyz\\n') 134 Traceback (most recent call last): 135 ... 136 https-test-client.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n' 137 >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}}) 138 >>> r2.check_body('test\\n') 139 >>> r2.check_body('est\\n') 140 Traceback (most recent call last): 141 ... 142 https-test-client.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes' 143 >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}}) 144 >>> r3.check_body('test\\n') 145 """ 146 if 'exactly' in self.expect['body'] \ 147 and body != self.expect['body']['exactly']: 148 raise TestExpectationFailed( 149 f'Unexpected body: {body!r} != ' 150 f'{self.expect["body"]["exactly"]!r}') 151 if 'contains' in self.expect['body']: 152 if type(self.expect['body']['contains']) is str: 153 self.expect['body']['contains'] = [ 154 self.expect['body']['contains']] 155 for s in self.expect['body']['contains']: 156 if not s in body: 157 raise TestExpectationFailed( 158 f'Unexpected body: {body!r} does not contain ' 159 f'{s!r}') 160 161 def check_response(self, response, body): 162 if self.expects_conn_reset(): 163 raise TestExpectationFailed( 164 'Got a response, but connection should have failed!') 165 if response.status != self.expect['status']: 166 raise TestExpectationFailed( 167 f'Unexpected status: {response.status} != ' 168 f'{self.expect["status"]}') 169 if 'body' in self.expect: 170 self.check_body(body) 171 172 def expects_conn_reset(self): 173 """Returns True if running this request is expected to fail due to the 174 connection being reset. That usually means the underlying TLS 175 connection failed. 176 177 >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}}) 178 >>> r1.expects_conn_reset() 179 False 180 >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True}) 181 >>> r2.expects_conn_reset() 182 True 183 """ 184 if 'reset' in self.expect: 185 return self.expect['reset'] 186 return False 187 188 @classmethod 189 def _from_yaml(cls, loader, node): 190 fields = loader.construct_mapping(node) 191 req = TestRequest(**fields) 192 return req 193 194 class TestConnection(yaml.YAMLObject): 195 yaml_tag = '!connection' 196 197 def __init__(self, actions, gnutls_params=[], transport='gnutls'): 198 self.gnutls_params = gnutls_params 199 self.actions = actions 200 self.transport = transport 201 202 def __repr__(self): 203 return (f'{self.__class__.__name__!s}' 204 f'(gnutls_params={self.gnutls_params!r}, ' 205 f'actions={self.actions!r}, transport={self.transport!r})') 206 207 def run(self, host, port, timeout=5.0): 208 # note: "--logfile" option requires GnuTLS version >= 3.6.7 209 command = ['gnutls-cli', '--logfile=/dev/stderr'] 210 for s in self.gnutls_params: 211 command.append('--' + s) 212 command = command + ['-p', str(port), host] 213 214 conn = HTTPSubprocessConnection(command, host, port, 215 output_filter=filter_cert_log, 216 timeout=timeout) 217 218 try: 219 for act in self.actions: 220 if type(act) is TestRequest: 221 act.run(conn) 222 elif type(act) is TestRaw10: 223 act.run(command, timeout) 224 else: 225 raise TypeError(f'Unsupported action requested: {act!r}') 226 finally: 227 conn.close() 228 229 @classmethod 230 def _from_yaml(cls, loader, node): 231 fields = loader.construct_mapping(node) 232 conn = TestConnection(**fields) 233 return conn 234 235 class TestRaw10(TestRequest): 236 """This is a minimal (and likely incomplete) HTTP/1.0 test client for 237 the one test case that strictly requires HTTP/1.0. All request 238 parameters (method, path, headers) MUST be specified in the config 239 file. 240 241 """ 242 yaml_tag = '!raw10' 243 status_re = re.compile('^HTTP/([\d\.]+) (\d+) (.*)$') 244 245 def __init__(self, method, path, headers, expect): 246 self.method = method 247 self.path = path 248 self.headers = headers 249 self.expect = expect 250 251 def __repr__(self): 252 return (f'{self.__class__.__name__!s}' 253 f'(method={self.method!r}, path={self.path!r}, ' 254 f'headers={self.headers!r}, expect={self.expect!r})') 255 256 def run(self, command, timeout=None): 257 req = f'{self.method} {self.path} HTTP/1.0\r\n' 258 for name, value in self.headers.items(): 259 req = req + f'{name}: {value}\r\n' 260 req = req + f'\r\n' 261 proc = subprocess.Popen(command, stdout=subprocess.PIPE, 262 stdin=subprocess.PIPE, close_fds=True, 263 bufsize=0) 264 try: 265 # Note: errs will be empty because stderr is not captured 266 outs, errs = proc.communicate(input=req.encode(), 267 timeout=timeout) 268 except TimeoutExpired: 269 proc.kill() 270 outs, errs = proc.communicate() 271 272 # first line of the received data must be the status 273 status, rest = outs.decode().split('\r\n', maxsplit=1) 274 # headers and body are separated by double newline 275 headers, body = rest.split('\r\n\r\n', maxsplit=1) 276 # log response for debugging 277 print(f'{status}\n{headers}\n\n{body}') 278 279 m = self.status_re.match(status) 280 if m: 281 status_code = int(m.group(2)) 282 status_expect = self.expect.get('status') 283 if status_expect and not status_code == status_expect: 284 raise TestExpectationFailed('Unexpected status code: ' 285 f'{status}, expected ' 286 f'{status_expect}') 287 else: 288 raise TestExpectationFailed(f'Invalid status line: "{status}"') 289 290 if 'body' in self.expect: 291 self.check_body(body) 292 293 # Override the default constructors. Pyyaml ignores default parameters 294 # otherwise. 295 yaml.add_constructor('!request', TestRequest._from_yaml, yaml.Loader) 296 yaml.add_constructor('!connection', TestConnection._from_yaml, yaml.Loader) 297 298 299 300 class TestExpectationFailed(Exception): 301 """Raise if a test failed. The constructor should be called with a 302 string describing the problem.""" 303 pass 304 305 306 307 def filter_cert_log(in_stream, out_stream): 308 import fcntl 309 import os 310 import select 311 # This filters out a log line about loading client 312 # certificates that is mistakenly sent to stdout. My fix has 313 # been merged, but buggy binaries will probably be around for 314 # a while. 315 # https://gitlab.com/gnutls/gnutls/merge_requests/1125 316 cert_log = b'Processed 1 client X.509 certificates...\n' 317 318 # Set the input to non-blocking mode 319 fd = in_stream.fileno() 320 fl = fcntl.fcntl(fd, fcntl.F_GETFL) 321 fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK) 322 323 # The poll object allows waiting for events on non-blocking IO 324 # channels. 325 poller = select.poll() 326 poller.register(fd) 327 328 init_done = False 329 run_loop = True 330 while run_loop: 331 # The returned tuples are file descriptor and event, but 332 # we're only listening on one stream anyway, so we don't 333 # need to check it here. 334 for x, event in poller.poll(): 335 # Critical: "event" is a bitwise OR of the POLL* constants 336 if event & select.POLLIN or event & select.POLLPRI: 337 data = in_stream.read() 338 if not init_done: 339 # If the erroneous log line shows up it's the 340 # first piece of data we receive. Just copy 341 # everything after. 342 init_done = True 343 if cert_log in data: 344 data = data.replace(cert_log, b'') 345 out_stream.send(data) 346 if event & select.POLLHUP or event & select.POLLRDHUP: 347 # Stop the loop, but process any other events that 348 # might be in the list returned by poll() first. 349 run_loop = False 350 351 in_stream.close() 352 out_stream.close() 353 354 355 356 def format_response(resp, body): 357 s = f'{resp.status} {resp.reason}\n' 358 s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders()) 359 s = s + '\n\n' + body 360 return s 361 362 20 from mgstest.tests import TestConnection 363 21 364 22 if __name__ == "__main__":
Note: See TracChangeset
for help on using the changeset viewer.