source: mod_gnutls/test/mgstest/tests.py @ 1fe7cac

asyncioproxy-ticket
Last change on this file since 1fe7cac was 1fe7cac, checked in by Fiona Klute <fiona.klute@…>, 2 years ago

TestReq10: Handle expected and unexpected connection failure

With this TestReq10 supports the same options as TestRequest?.

  • Property mode set to 100644
File size: 18.8 KB
Line 
1#!/usr/bin/python3
2
3# Copyright 2019-2020 Fiona Klute
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""Test objects and support functions for the mod_gnutls test
18suite. The classes defined in this module represent structures in the
19YAML test configuration files.
20
21The test configuration file defines either a TestConnection, or a list
22of them. Each connection contains a list of actions to run using this
23connection. The actions define their expected results, if an
24expectation is not met mgstest.TestExpectationFailed is raised.
25
26Example of a connection that runs two request actions, which are
27expected to succeed:
28
29```yaml
30!connection
31# "host" defaults to $TEST_TARGET, so usually there's no need to set
32# it. You can use ${VAR} to substitute environment variables.
33host: 'localhost'
34# "port" defaults to $TEST_PORT, so usually there's no need to set it
35# it. You can use ${VAR} to substitute environment variables.
36port: '${TEST_PORT}'
37# All elements of gnutls_params will be prefixed with "--" and passed
38# to gnutls-cli on the command line.
39gnutls_params:
40  - x509cafile=authority/x509.pem
41# The transport encryption. "Gnutls" is the default, "plain" can be
42# set to get an unencrypted connection (e.g. to test redirection to
43# HTTPS).
44transport: 'gnutls'
45description: 'This connection description will be logged.'
46actions:
47  - !request
48    # GET is the default.
49    method: GET
50    # The path part of the URL, required.
51    path: /test.txt
52    # "Expect" defines how the response must look to pass the test.
53    expect:
54      # 200 (OK) is the default.
55      status: 200
56      # The response body is analyzed only if the "body" element
57      # exists, otherwise any content is accepted.
58      body:
59        # The full response body must exactly match this string.
60        exactly: |
61          test
62  - !request
63    path: /status?auto
64    expect:
65      # The headers are analyzed only if the "headers" element exists.
66      headers:
67        # The Content-Type header must be present with exactly this
68        # value. You can use ${VAR} to substitute environment
69        # variables in the value.
70        Content-Type: 'text/plain; charset=ISO-8859-1'
71        # You can check the absence of a header by expecting null:
72        X-Forbidden-Header: null
73      body:
74        # All strings in this list must occur in the body, in any
75        # order. "Contains" may also contain a single string instead
76        # of a list.
77        contains:
78          - 'Using GnuTLS version: '
79          - 'Current TLS session: (TLS1.3)'
80```
81
82Example of a connection that is expected to fail at the TLS level, in
83this case because the configured CA is not the one that issued the
84server certificate:
85
86```yaml
87- !connection
88  gnutls_params:
89    - x509cafile=rogueca/x509.pem
90  actions:
91    - !request
92      path: /
93      expect:
94        # The connection is expected to reset without an HTTP response.
95        reset: yes
96```
97
98"""
99
100import os
101import re
102import select
103import subprocess
104import sys
105import yaml
106
107from enum import Enum, auto
108from http.client import HTTPConnection
109from string import Template
110
111from . import TestExpectationFailed
112from .http import HTTPSubprocessConnection
113
114class Transports(Enum):
115    """Transports supported by TestConnection."""
116    GNUTLS = auto()
117    PLAIN = auto()
118
119    def __repr__(self):
120        return f'{self.__class__.__name__!s}.{self.name}'
121
122class TestConnection(yaml.YAMLObject):
123    """An HTTP connection in a test. It includes parameters for the
124    transport, and the actions (e.g. sending requests) to take using
125    this connection.
126
127    Note that running one TestConnection object may result in multiple
128    sequential network connections if the transport gets closed in a
129    non-failure way (e.g. following a "Connection: close" request) and
130    there are more actions, or (rarely) if an action requires its own
131    transport.
132
133    """
134    yaml_tag = '!connection'
135
136    def __init__(self, actions, host=None, port=None, gnutls_params=[],
137                 transport='gnutls', description=None):
138        self.gnutls_params = gnutls_params
139        self.actions = actions
140        self.transport = Transports[transport.upper()]
141        self.description = description
142        if host:
143            self.host = subst_env(host)
144        else:
145            self.host = os.environ.get('TEST_TARGET', 'localhost')
146        if port:
147            self.port = int(subst_env(port))
148        else:
149            self.port = int(os.environ.get('TEST_PORT', 8000))
150
151    def __repr__(self):
152        return (f'{self.__class__.__name__!s}'
153                f'(host={self.host!r}, port={self.port!r}, '
154                f'gnutls_params={self.gnutls_params!r}, '
155                f'actions={self.actions!r}, transport={self.transport!r}, '
156                f'description={self.description!r})')
157
158    def run(self, timeout=5.0, conn_log=None, response_log=None):
159        """Set up an HTTP connection and run the configured actions."""
160
161        # note: "--logfile" option requires GnuTLS version >= 3.6.7
162        command = ['gnutls-cli', '--logfile=/dev/stderr']
163        for s in self.gnutls_params:
164            command.append('--' + s)
165        command = command + ['-p', str(self.port), self.host]
166
167        if self.transport == Transports.GNUTLS:
168            conn = HTTPSubprocessConnection(command, self.host, self.port,
169                                            output_filter=filter_cert_log,
170                                            stderr_log=conn_log,
171                                            timeout=timeout)
172        elif self.transport == Transports.PLAIN:
173            conn = HTTPConnection(self.host, port=self.port,
174                                  timeout=timeout)
175
176        try:
177            for act in self.actions:
178                if type(act) is TestRequest:
179                    act.run(conn, response_log)
180                elif type(act) is TestReq10:
181                    act.run(command, timeout, conn_log, response_log)
182                else:
183                    raise TypeError(f'Unsupported action requested: {act!r}')
184        finally:
185            conn.close()
186            sys.stdout.flush()
187
188    @classmethod
189    def from_yaml(cls, loader, node):
190        fields = loader.construct_mapping(node)
191        conn = cls(**fields)
192        return conn
193
194
195
196class TestRequest(yaml.YAMLObject):
197    """Test action that sends an HTTP/1.1 request.
198
199    The path must be specified in the configuration file, all other
200    parameters (method, headers, expected response) have
201    defaults.
202
203    Options for checking the response currently are:
204    * require a specific response status
205    * require specific headers to be present with specific values
206    * require the body to exactly match a specific string
207    * require the body to contain all of a list of strings
208
209    """
210    yaml_tag = '!request'
211    def __init__(self, path, method='GET', headers=dict(),
212                 expect=dict(status=200)):
213        self.method = method
214        self.path = path
215        self.headers = headers
216        self.expect = expect
217
218    def __repr__(self):
219        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
220                f'method={self.method!r}, headers={self.headers!r}, '
221                f'expect={self.expect!r})')
222
223    def run(self, conn, response_log=None):
224        try:
225            conn.request(self.method, self.path, headers=self.headers)
226            resp = conn.getresponse()
227            if self.expects_conn_reset():
228                raise TestExpectationFailed(
229                    'Expected connection reset did not occur!')
230        except (BrokenPipeError, ConnectionResetError) as err:
231            if self.expects_conn_reset():
232                print('connection reset as expected.')
233                return
234            else:
235                raise err
236        body = resp.read().decode()
237        log_str = format_response(resp, body)
238        print(log_str)
239        if response_log:
240            print(log_str, file=response_log)
241        self.check_response(resp, body)
242
243    def check_headers(self, headers):
244        for name, expected in self.expect['headers'].items():
245            value = headers.get(name)
246            expected = subst_env(expected)
247            if value != expected:
248                raise TestExpectationFailed(
249                    f'Unexpected value in header {name}: "{value}", '
250                    f'expected "{expected}"')
251
252    def check_body(self, body):
253        """
254        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
255        >>> r1.check_body('test\\n')
256        >>> r1.check_body('xyz\\n')
257        Traceback (most recent call last):
258        ...
259        mgstest.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
260        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
261        >>> r2.check_body('test\\n')
262        >>> r2.check_body('est\\n')
263        Traceback (most recent call last):
264        ...
265        mgstest.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
266        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
267        >>> r3.check_body('test\\n')
268        """
269        if 'exactly' in self.expect['body'] \
270           and body != self.expect['body']['exactly']:
271            raise TestExpectationFailed(
272                f'Unexpected body: {body!r} != '
273                f'{self.expect["body"]["exactly"]!r}')
274        if 'contains' in self.expect['body']:
275            if type(self.expect['body']['contains']) is str:
276                self.expect['body']['contains'] = [
277                    self.expect['body']['contains']]
278            for s in self.expect['body']['contains']:
279                if not s in body:
280                    raise TestExpectationFailed(
281                        f'Unexpected body: {body!r} does not contain '
282                        f'{s!r}')
283
284    def check_response(self, response, body):
285        if self.expects_conn_reset():
286            raise TestExpectationFailed(
287                'Got a response, but connection should have failed!')
288        if response.status != self.expect['status']:
289            raise TestExpectationFailed(
290                f'Unexpected status: {response.status} != '
291                f'{self.expect["status"]}')
292        if 'headers' in self.expect:
293            self.check_headers(dict(response.getheaders()))
294        if 'body' in self.expect:
295            self.check_body(body)
296
297    def expects_conn_reset(self):
298        """Returns True if running this request is expected to fail due to the
299        connection being reset. That usually means the underlying TLS
300        connection failed.
301
302        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
303        >>> r1.expects_conn_reset()
304        False
305        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
306        >>> r2.expects_conn_reset()
307        True
308        """
309        if 'reset' in self.expect:
310            return self.expect['reset']
311        return False
312
313    @classmethod
314    def from_yaml(cls, loader, node):
315        fields = loader.construct_mapping(node)
316        req = cls(**fields)
317        return req
318
319
320
321class TestReq10(TestRequest):
322    """Test action that sends a request using a minimal (and likely
323    incomplete) HTTP/1.0 test client for the one test case that
324    strictly requires HTTP/1.0.
325
326    TestReq10 objects use the same YAML parameters and defaults as
327    TestRequest, but note that an empty "headers" parameter means that
328    not even a "Host:" header will be sent. All headers must be
329    specified in the test configuration file.
330
331    """
332    yaml_tag = '!request10'
333    status_re = re.compile(r'^HTTP/([\d\.]+) (\d+) (.*)$')
334    header_re = re.compile(r'^([-\w]+):\s+(.*)$')
335
336    def __init__(self, **kwargs):
337        super().__init__(**kwargs)
338
339    def __repr__(self):
340        return (f'{self.__class__.__name__!s}'
341                f'(method={self.method!r}, path={self.path!r}, '
342                f'headers={self.headers!r}, expect={self.expect!r})')
343
344    def run(self, command, timeout=None, conn_log=None, response_log=None):
345        req = f'{self.method} {self.path} HTTP/1.0\r\n'
346        for name, value in self.headers.items():
347            req = req + f'{name}: {value}\r\n'
348        req = req + f'\r\n'
349        proc = subprocess.Popen(command,
350                                stdout=subprocess.PIPE,
351                                stderr=subprocess.PIPE,
352                                stdin=subprocess.PIPE,
353                                close_fds=True,
354                                bufsize=0)
355        try:
356            outs, errs = proc.communicate(input=req.encode(),
357                                          timeout=timeout)
358        except TimeoutExpired:
359            proc.kill()
360            outs, errs = proc.communicate()
361
362        print(errs.decode())
363        if conn_log:
364            print(errs.decode(), file=conn_log)
365
366        if proc.returncode != 0:
367            if len(outs) != 0:
368                raise TestExpectationFailed(
369                    f'Connection failed, but got output: {outs!r}')
370            if self.expects_conn_reset():
371                print('connection reset as expected.')
372                return
373            else:
374                raise TestExpectationFailed(
375                    'Connection failed unexpectedly!')
376        else:
377            if self.expects_conn_reset():
378                raise TestExpectationFailed(
379                    'Expected connection reset did not occur!')
380
381        # first line of the received data must be the status
382        status, rest = outs.decode().split('\r\n', maxsplit=1)
383        # headers and body are separated by double newline
384        head, body = rest.split('\r\n\r\n', maxsplit=1)
385        # log response for debugging
386        print(f'{status}\n{head}\n\n{body}')
387        if response_log:
388            print(f'{status}\n{head}\n\n{body}', file=response_log)
389
390        m = self.status_re.match(status)
391        if m:
392            status_code = int(m.group(2))
393            status_expect = self.expect.get('status')
394            if status_expect and not status_code == status_expect:
395                raise TestExpectationFailed('Unexpected status code: '
396                                            f'{status}, expected '
397                                            f'{status_expect}')
398        else:
399            raise TestExpectationFailed(f'Invalid status line: "{status}"')
400
401        if 'headers' in self.expect:
402            headers = dict()
403            for line in head.splitlines():
404                m = self.header_re.fullmatch(line)
405                if m:
406                    headers[m.group(1)] = m.group(2)
407            self.check_headers(headers)
408
409        if 'body' in self.expect:
410            self.check_body(body)
411
412
413
414def filter_cert_log(in_stream, out_stream):
415    """Filter to stop an erroneous gnutls-cli log message.
416
417    This function filters out a log line about loading client
418    certificates that is mistakenly sent to stdout from gnutls-cli. My
419    fix (https://gitlab.com/gnutls/gnutls/merge_requests/1125) has
420    been merged, but buggy binaries will probably be around for a
421    while.
422
423    The filter is meant to run in a multiprocessing.Process or
424    threading.Thread that receives the stdout of gnutls-cli as
425    in_stream, and a connection for further processing as out_stream.
426
427    """
428    # message to filter
429    cert_log = b'Processed 1 client X.509 certificates...\n'
430
431    # Set the input to non-blocking mode
432    fd = in_stream.fileno()
433    os.set_blocking(fd, False)
434
435    # The poll object allows waiting for events on non-blocking IO
436    # channels.
437    poller = select.poll()
438    poller.register(fd)
439
440    init_done = False
441    run_loop = True
442    while run_loop:
443        # The returned tuples are file descriptor and event, but
444        # we're only listening on one stream anyway, so we don't
445        # need to check it here.
446        for x, event in poller.poll():
447            # Critical: "event" is a bitwise OR of the POLL* constants
448            if event & select.POLLIN or event & select.POLLPRI:
449                data = in_stream.read()
450                if not init_done:
451                    # If the erroneous log line shows up it's the
452                    # first piece of data we receive. Just copy
453                    # everything after.
454                    init_done = True
455                    if cert_log in data:
456                        data = data.replace(cert_log, b'')
457                out_stream.send(data)
458            if event & select.POLLHUP or event & select.POLLRDHUP:
459                # Stop the loop, but process any other events that
460                # might be in the list returned by poll() first.
461                run_loop = False
462
463    in_stream.close()
464    out_stream.close()
465
466
467
468def format_response(resp, body):
469    """Format an http.client.HTTPResponse for logging."""
470    s = f'{resp.status} {resp.reason}\n'
471    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
472    s = s + '\n\n' + body
473    return s
474
475
476
477def subst_env(text):
478    """Use the parameter "text" as a template, substitute with environment
479    variables.
480
481    >>> os.environ['EXAMPLE_VAR'] = 'abc'
482    >>> subst_env('${EXAMPLE_VAR}def')
483    'abcdef'
484
485    Referencing undefined environment variables causes a KeyError.
486
487    >>> subst_env('${EXAMPLE_UNSET}')
488    Traceback (most recent call last):
489    ...
490    KeyError: 'EXAMPLE_UNSET'
491
492    >>> subst_env(None) is None
493    True
494
495    """
496    if not text:
497        return None
498    t = Template(text)
499    return t.substitute(os.environ)
500
501
502
503def run_test_conf(test_config, timeout=5.0, conn_log=None, response_log=None):
504    """Load and run a test configuration.
505
506    The test_conf parameter must be a YAML file, defining one or more
507    TestConnections, to be run in order. The other three parameters
508    are forwarded to TestConnection.run().
509
510    """
511    conns = None
512
513    config = yaml.load(test_config, Loader=yaml.Loader)
514    if type(config) is TestConnection:
515        conns = [config]
516    elif type(config) is list:
517        # assume list elements are connections
518        conns = config
519    else:
520        raise TypeError(f'Unsupported configuration: {config!r}')
521    print(conns)
522    sys.stdout.flush()
523
524    for i, test_conn in enumerate(conns):
525        if test_conn.description:
526            print(f'Running test connection {i}: {test_conn.description}')
527        else:
528            print(f'Running test connection {i}.')
529        sys.stdout.flush()
530        test_conn.run(timeout=timeout, conn_log=conn_log,
531                      response_log=response_log)
Note: See TracBrowser for help on using the repository browser.