source: mod_gnutls/test/mgstest/tests.py @ 442c6a6

asyncioproxy-ticket
Last change on this file since 442c6a6 was 7543db4, checked in by Fiona Klute <fiona.klute@…>, 16 months ago

Remove debug output of raw test connection config

  • Property mode set to 100644
File size: 20.4 KB
RevLine 
[6d3dc34]1#!/usr/bin/python3
2
[f7e47b5]3# Copyright 2019-2020 Fiona Klute
[6d3dc34]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
[0560bb9]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
[bbc9b03]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'
[1fe7cac]45description: 'This connection description will be logged.'
[bbc9b03]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'
[0b3733d]71        # You can check the absence of a header by expecting null:
72        X-Forbidden-Header: null
[bbc9b03]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
[0560bb9]98"""
99
[e3e0de1]100import os
[6d3dc34]101import re
[3deb86e]102import select
[6d3dc34]103import subprocess
[8b72599]104import sys
[6d3dc34]105import yaml
106
[0e069b6]107from enum import Enum, auto
108from http.client import HTTPConnection
[e3e0de1]109from string import Template
110
[6d3dc34]111from . import TestExpectationFailed
112from .http import HTTPSubprocessConnection
113
[0e069b6]114class Transports(Enum):
[bbc9b03]115    """Transports supported by TestConnection."""
[0e069b6]116    GNUTLS = auto()
117    PLAIN = auto()
118
119    def __repr__(self):
120        return f'{self.__class__.__name__!s}.{self.name}'
121
[6d3dc34]122class TestConnection(yaml.YAMLObject):
[0560bb9]123    """An HTTP connection in a test. It includes parameters for the
[bbc9b03]124    transport, and the actions (e.g. sending requests) to take using
125    this connection.
[0560bb9]126
127    Note that running one TestConnection object may result in multiple
[bbc9b03]128    sequential network connections if the transport gets closed in a
[0560bb9]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    """
[6d3dc34]134    yaml_tag = '!connection'
135
[e3e0de1]136    def __init__(self, actions, host=None, port=None, gnutls_params=[],
[eb84747]137                 transport='gnutls', description=None):
[6d3dc34]138        self.gnutls_params = gnutls_params
139        self.actions = actions
[0e069b6]140        self.transport = Transports[transport.upper()]
[eb84747]141        self.description = description
[e3e0de1]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))
[6d3dc34]150
151    def __repr__(self):
152        return (f'{self.__class__.__name__!s}'
[e3e0de1]153                f'(host={self.host!r}, port={self.port!r}, '
154                f'gnutls_params={self.gnutls_params!r}, '
[eb84747]155                f'actions={self.actions!r}, transport={self.transport!r}, '
156                f'description={self.description!r})')
[6d3dc34]157
[09774e2]158    def run(self, timeout=5.0, conn_log=None, response_log=None):
[bbc9b03]159        """Set up an HTTP connection and run the configured actions."""
160
[6d3dc34]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)
[e3e0de1]165        command = command + ['-p', str(self.port), self.host]
[6d3dc34]166
[0e069b6]167        if self.transport == Transports.GNUTLS:
168            conn = HTTPSubprocessConnection(command, self.host, self.port,
169                                            output_filter=filter_cert_log,
[3be92d3]170                                            stderr_log=conn_log,
[0e069b6]171                                            timeout=timeout)
172        elif self.transport == Transports.PLAIN:
173            conn = HTTPConnection(self.host, port=self.port,
174                                  timeout=timeout)
[6d3dc34]175
176        try:
177            for act in self.actions:
178                if type(act) is TestRequest:
[09774e2]179                    act.run(conn, response_log)
[7089dbc]180                elif type(act) is TestReq10:
[09774e2]181                    act.run(command, timeout, conn_log, response_log)
[4fe52e6]182                elif type(act) is Resume:
183                    act.run(conn, command)
[6d3dc34]184                else:
185                    raise TypeError(f'Unsupported action requested: {act!r}')
186        finally:
187            conn.close()
[8b72599]188            sys.stdout.flush()
[6d3dc34]189
190    @classmethod
[f7e47b5]191    def from_yaml(cls, loader, node):
[6d3dc34]192        fields = loader.construct_mapping(node)
[6615d91]193        conn = cls(**fields)
[6d3dc34]194        return conn
195
196
197
198class TestRequest(yaml.YAMLObject):
[0560bb9]199    """Test action that sends an HTTP/1.1 request.
200
201    The path must be specified in the configuration file, all other
202    parameters (method, headers, expected response) have
203    defaults.
204
205    Options for checking the response currently are:
206    * require a specific response status
[bbc9b03]207    * require specific headers to be present with specific values
[0560bb9]208    * require the body to exactly match a specific string
209    * require the body to contain all of a list of strings
210
211    """
[6d3dc34]212    yaml_tag = '!request'
[407ca6e]213    def __init__(self, path, method='GET', body=None, headers=dict(),
[6d3dc34]214                 expect=dict(status=200)):
215        self.method = method
216        self.path = path
[1c76ea7]217        self.body = body.encode('utf-8') if body else None
[6d3dc34]218        self.headers = headers
219        self.expect = expect
220
221    def __repr__(self):
222        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
[407ca6e]223                f'method={self.method!r}, body={self.body!r}, '
224                f'headers={self.headers!r}, expect={self.expect!r})')
[6d3dc34]225
[09774e2]226    def run(self, conn, response_log=None):
[6d3dc34]227        try:
[407ca6e]228            conn.request(self.method, self.path, body=self.body,
229                         headers=self.headers)
[6d3dc34]230            resp = conn.getresponse()
[45b0a24]231            if self.expects_conn_reset():
232                raise TestExpectationFailed(
233                    'Expected connection reset did not occur!')
[b57d2c2]234        except (BrokenPipeError, ConnectionResetError) as err:
[6d3dc34]235            if self.expects_conn_reset():
236                print('connection reset as expected.')
237                return
238            else:
239                raise err
240        body = resp.read().decode()
[09774e2]241        log_str = format_response(resp, body)
242        print(log_str)
243        if response_log:
244            print(log_str, file=response_log)
[6d3dc34]245        self.check_response(resp, body)
246
[7054040]247    def check_headers(self, headers):
[5ea6c14]248        """
249        >>> r1 = TestRequest(path='/test.txt',
250        ...                  expect={ 'headers': {'X-Forbidden-Header': None,
251        ...                                       'X-Required-Header': 'Hi!' }})
252        >>> r1.check_headers({ 'X-Required-Header': 'Hi!' })
253        >>> r1.check_headers({ 'X-Required-Header': 'Hello!' })
254        Traceback (most recent call last):
255        ...
256        mgstest.TestExpectationFailed: Unexpected value in header X-Required-Header: 'Hello!', expected 'Hi!'
257        >>> r1.check_headers({ 'X-Forbidden-Header': 'Hi!' })
258        Traceback (most recent call last):
259        ...
260        mgstest.TestExpectationFailed: Unexpected value in header X-Forbidden-Header: 'Hi!', expected None
261        """
[7054040]262        for name, expected in self.expect['headers'].items():
263            value = headers.get(name)
264            expected = subst_env(expected)
265            if value != expected:
266                raise TestExpectationFailed(
[5ea6c14]267                    f'Unexpected value in header {name}: {value!r}, '
268                    f'expected {expected!r}')
[7054040]269
[6d3dc34]270    def check_body(self, body):
271        """
272        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
273        >>> r1.check_body('test\\n')
274        >>> r1.check_body('xyz\\n')
275        Traceback (most recent call last):
276        ...
[f9e13a5]277        mgstest.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
[6d3dc34]278        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
279        >>> r2.check_body('test\\n')
280        >>> r2.check_body('est\\n')
281        Traceback (most recent call last):
282        ...
[f9e13a5]283        mgstest.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
[6d3dc34]284        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
285        >>> r3.check_body('test\\n')
286        """
287        if 'exactly' in self.expect['body'] \
288           and body != self.expect['body']['exactly']:
289            raise TestExpectationFailed(
290                f'Unexpected body: {body!r} != '
291                f'{self.expect["body"]["exactly"]!r}')
292        if 'contains' in self.expect['body']:
293            if type(self.expect['body']['contains']) is str:
294                self.expect['body']['contains'] = [
295                    self.expect['body']['contains']]
296            for s in self.expect['body']['contains']:
297                if not s in body:
298                    raise TestExpectationFailed(
299                        f'Unexpected body: {body!r} does not contain '
300                        f'{s!r}')
301
302    def check_response(self, response, body):
303        if self.expects_conn_reset():
304            raise TestExpectationFailed(
305                'Got a response, but connection should have failed!')
306        if response.status != self.expect['status']:
307            raise TestExpectationFailed(
308                f'Unexpected status: {response.status} != '
309                f'{self.expect["status"]}')
[7054040]310        if 'headers' in self.expect:
311            self.check_headers(dict(response.getheaders()))
[6d3dc34]312        if 'body' in self.expect:
313            self.check_body(body)
314
315    def expects_conn_reset(self):
316        """Returns True if running this request is expected to fail due to the
317        connection being reset. That usually means the underlying TLS
318        connection failed.
319
320        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
321        >>> r1.expects_conn_reset()
322        False
323        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
324        >>> r2.expects_conn_reset()
325        True
326        """
327        if 'reset' in self.expect:
328            return self.expect['reset']
329        return False
330
331    @classmethod
[f7e47b5]332    def from_yaml(cls, loader, node):
[6d3dc34]333        fields = loader.construct_mapping(node)
[6615d91]334        req = cls(**fields)
[6d3dc34]335        return req
336
337
338
[7089dbc]339class TestReq10(TestRequest):
[0560bb9]340    """Test action that sends a request using a minimal (and likely
341    incomplete) HTTP/1.0 test client for the one test case that
342    strictly requires HTTP/1.0.
343
[bbc9b03]344    TestReq10 objects use the same YAML parameters and defaults as
345    TestRequest, but note that an empty "headers" parameter means that
346    not even a "Host:" header will be sent. All headers must be
347    specified in the test configuration file.
[6d3dc34]348
349    """
[7089dbc]350    yaml_tag = '!request10'
[bdf5917]351    status_re = re.compile(r'^HTTP/([\d\.]+) (\d+) (.*)$')
352    header_re = re.compile(r'^([-\w]+):\s+(.*)$')
[6d3dc34]353
[52636ee]354    def __init__(self, **kwargs):
355        super().__init__(**kwargs)
[6d3dc34]356
[09774e2]357    def run(self, command, timeout=None, conn_log=None, response_log=None):
[6d3dc34]358        req = f'{self.method} {self.path} HTTP/1.0\r\n'
359        for name, value in self.headers.items():
360            req = req + f'{name}: {value}\r\n'
[1c76ea7]361        req = req.encode('utf-8') + b'\r\n'
[407ca6e]362        if self.body:
363            req = req + self.body
[09774e2]364        proc = subprocess.Popen(command,
365                                stdout=subprocess.PIPE,
366                                stderr=subprocess.PIPE,
367                                stdin=subprocess.PIPE,
368                                close_fds=True,
[6d3dc34]369                                bufsize=0)
370        try:
[1c76ea7]371            outs, errs = proc.communicate(input=req, timeout=timeout)
[6d3dc34]372        except TimeoutExpired:
373            proc.kill()
374            outs, errs = proc.communicate()
375
[3039495]376        print(errs.decode())
[09774e2]377        if conn_log:
378            print(errs.decode(), file=conn_log)
379
[1fe7cac]380        if proc.returncode != 0:
381            if len(outs) != 0:
382                raise TestExpectationFailed(
383                    f'Connection failed, but got output: {outs!r}')
384            if self.expects_conn_reset():
385                print('connection reset as expected.')
386                return
387            else:
388                raise TestExpectationFailed(
389                    'Connection failed unexpectedly!')
390        else:
391            if self.expects_conn_reset():
392                raise TestExpectationFailed(
393                    'Expected connection reset did not occur!')
394
[6d3dc34]395        # first line of the received data must be the status
396        status, rest = outs.decode().split('\r\n', maxsplit=1)
397        # headers and body are separated by double newline
[bdf5917]398        head, body = rest.split('\r\n\r\n', maxsplit=1)
[6d3dc34]399        # log response for debugging
[bdf5917]400        print(f'{status}\n{head}\n\n{body}')
[09774e2]401        if response_log:
[bdf5917]402            print(f'{status}\n{head}\n\n{body}', file=response_log)
[6d3dc34]403
404        m = self.status_re.match(status)
405        if m:
406            status_code = int(m.group(2))
407            status_expect = self.expect.get('status')
408            if status_expect and not status_code == status_expect:
409                raise TestExpectationFailed('Unexpected status code: '
410                                            f'{status}, expected '
411                                            f'{status_expect}')
412        else:
413            raise TestExpectationFailed(f'Invalid status line: "{status}"')
414
[bdf5917]415        if 'headers' in self.expect:
416            headers = dict()
417            for line in head.splitlines():
418                m = self.header_re.fullmatch(line)
419                if m:
420                    headers[m.group(1)] = m.group(2)
421            self.check_headers(headers)
422
[6d3dc34]423        if 'body' in self.expect:
424            self.check_body(body)
425
426
427
[4fe52e6]428class Resume(yaml.YAMLObject):
429    """Test action to close and resume the TLS session.
430
431    Send the gnutls-cli inline command "^resume^" to close and resume
432    the TLS session. "inline-commands" must be present in
433    gnutls_params of the parent connection. This action does not need
434    any arguments, but you must specify with an explicitly empty
435    dictionary for YAML parsing to work, like this:
436
437      !resume {}
438
439    """
440    yaml_tag = '!resume'
441    def run(self, conn, command):
442        if not '--inline-commands' in command:
443            raise ValueError('gnutls_params must include "inline-commands" '
444                             'to use the resume action!')
445        if not type(conn) is HTTPSubprocessConnection:
446            raise TypeError('Resume action works only with '
447                            'HTTPSubprocessConnection.')
448        conn.sock.send(b'^resume^\n')
449
450
451
[6d3dc34]452def filter_cert_log(in_stream, out_stream):
[0560bb9]453    """Filter to stop an erroneous gnutls-cli log message.
454
455    This function filters out a log line about loading client
456    certificates that is mistakenly sent to stdout from gnutls-cli. My
457    fix (https://gitlab.com/gnutls/gnutls/merge_requests/1125) has
458    been merged, but buggy binaries will probably be around for a
459    while.
460
461    The filter is meant to run in a multiprocessing.Process or
462    threading.Thread that receives the stdout of gnutls-cli as
463    in_stream, and a connection for further processing as out_stream.
464
465    """
466    # message to filter
[6d3dc34]467    cert_log = b'Processed 1 client X.509 certificates...\n'
468
469    # Set the input to non-blocking mode
470    fd = in_stream.fileno()
[3fbe087]471    os.set_blocking(fd, False)
[6d3dc34]472
473    # The poll object allows waiting for events on non-blocking IO
474    # channels.
475    poller = select.poll()
476    poller.register(fd)
477
478    init_done = False
479    run_loop = True
480    while run_loop:
481        # The returned tuples are file descriptor and event, but
482        # we're only listening on one stream anyway, so we don't
483        # need to check it here.
484        for x, event in poller.poll():
485            # Critical: "event" is a bitwise OR of the POLL* constants
486            if event & select.POLLIN or event & select.POLLPRI:
487                data = in_stream.read()
488                if not init_done:
489                    # If the erroneous log line shows up it's the
490                    # first piece of data we receive. Just copy
491                    # everything after.
492                    init_done = True
493                    if cert_log in data:
494                        data = data.replace(cert_log, b'')
495                out_stream.send(data)
496            if event & select.POLLHUP or event & select.POLLRDHUP:
497                # Stop the loop, but process any other events that
498                # might be in the list returned by poll() first.
499                run_loop = False
500
501    in_stream.close()
502    out_stream.close()
503
504
505
506def format_response(resp, body):
[bbc9b03]507    """Format an http.client.HTTPResponse for logging."""
[6d3dc34]508    s = f'{resp.status} {resp.reason}\n'
509    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
510    s = s + '\n\n' + body
511    return s
[e3e0de1]512
513
514
515def subst_env(text):
[bbc9b03]516    """Use the parameter "text" as a template, substitute with environment
517    variables.
518
519    >>> os.environ['EXAMPLE_VAR'] = 'abc'
520    >>> subst_env('${EXAMPLE_VAR}def')
521    'abcdef'
522
[0b3733d]523    Referencing undefined environment variables causes a KeyError.
524
525    >>> subst_env('${EXAMPLE_UNSET}')
526    Traceback (most recent call last):
527    ...
528    KeyError: 'EXAMPLE_UNSET'
529
530    >>> subst_env(None) is None
531    True
532
[bbc9b03]533    """
[0b3733d]534    if not text:
535        return None
[e3e0de1]536    t = Template(text)
537    return t.substitute(os.environ)
[c96a965]538
539
540
[09774e2]541def run_test_conf(test_config, timeout=5.0, conn_log=None, response_log=None):
[bbc9b03]542    """Load and run a test configuration.
543
544    The test_conf parameter must be a YAML file, defining one or more
545    TestConnections, to be run in order. The other three parameters
546    are forwarded to TestConnection.run().
547
548    """
[c96a965]549    conns = None
550
551    config = yaml.load(test_config, Loader=yaml.Loader)
552    if type(config) is TestConnection:
553        conns = [config]
554    elif type(config) is list:
555        # assume list elements are connections
556        conns = config
557    else:
558        raise TypeError(f'Unsupported configuration: {config!r}')
559    sys.stdout.flush()
560
561    for i, test_conn in enumerate(conns):
562        if test_conn.description:
563            print(f'Running test connection {i}: {test_conn.description}')
[779406c]564        else:
565            print(f'Running test connection {i}.')
566        sys.stdout.flush()
[09774e2]567        test_conn.run(timeout=timeout, conn_log=conn_log,
568                      response_log=response_log)
Note: See TracBrowser for help on using the repository browser.