source: mod_gnutls/test/mgstest/tests.py @ b22def6

asyncioproxy-ticket
Last change on this file since b22def6 was 5ea6c14, checked in by Fiona Klute <fiona.klute@…>, 17 months ago

Clarify exception message for unexpected headers

Using the representation means the text will include quotes if the
value is a string, but not around None. Also add doctests.

  • Property mode set to 100644
File size: 19.5 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        """
245        >>> r1 = TestRequest(path='/test.txt',
246        ...                  expect={ 'headers': {'X-Forbidden-Header': None,
247        ...                                       'X-Required-Header': 'Hi!' }})
248        >>> r1.check_headers({ 'X-Required-Header': 'Hi!' })
249        >>> r1.check_headers({ 'X-Required-Header': 'Hello!' })
250        Traceback (most recent call last):
251        ...
252        mgstest.TestExpectationFailed: Unexpected value in header X-Required-Header: 'Hello!', expected 'Hi!'
253        >>> r1.check_headers({ 'X-Forbidden-Header': 'Hi!' })
254        Traceback (most recent call last):
255        ...
256        mgstest.TestExpectationFailed: Unexpected value in header X-Forbidden-Header: 'Hi!', expected None
257        """
258        for name, expected in self.expect['headers'].items():
259            value = headers.get(name)
260            expected = subst_env(expected)
261            if value != expected:
262                raise TestExpectationFailed(
263                    f'Unexpected value in header {name}: {value!r}, '
264                    f'expected {expected!r}')
265
266    def check_body(self, body):
267        """
268        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
269        >>> r1.check_body('test\\n')
270        >>> r1.check_body('xyz\\n')
271        Traceback (most recent call last):
272        ...
273        mgstest.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
274        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
275        >>> r2.check_body('test\\n')
276        >>> r2.check_body('est\\n')
277        Traceback (most recent call last):
278        ...
279        mgstest.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
280        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
281        >>> r3.check_body('test\\n')
282        """
283        if 'exactly' in self.expect['body'] \
284           and body != self.expect['body']['exactly']:
285            raise TestExpectationFailed(
286                f'Unexpected body: {body!r} != '
287                f'{self.expect["body"]["exactly"]!r}')
288        if 'contains' in self.expect['body']:
289            if type(self.expect['body']['contains']) is str:
290                self.expect['body']['contains'] = [
291                    self.expect['body']['contains']]
292            for s in self.expect['body']['contains']:
293                if not s in body:
294                    raise TestExpectationFailed(
295                        f'Unexpected body: {body!r} does not contain '
296                        f'{s!r}')
297
298    def check_response(self, response, body):
299        if self.expects_conn_reset():
300            raise TestExpectationFailed(
301                'Got a response, but connection should have failed!')
302        if response.status != self.expect['status']:
303            raise TestExpectationFailed(
304                f'Unexpected status: {response.status} != '
305                f'{self.expect["status"]}')
306        if 'headers' in self.expect:
307            self.check_headers(dict(response.getheaders()))
308        if 'body' in self.expect:
309            self.check_body(body)
310
311    def expects_conn_reset(self):
312        """Returns True if running this request is expected to fail due to the
313        connection being reset. That usually means the underlying TLS
314        connection failed.
315
316        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
317        >>> r1.expects_conn_reset()
318        False
319        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
320        >>> r2.expects_conn_reset()
321        True
322        """
323        if 'reset' in self.expect:
324            return self.expect['reset']
325        return False
326
327    @classmethod
328    def from_yaml(cls, loader, node):
329        fields = loader.construct_mapping(node)
330        req = cls(**fields)
331        return req
332
333
334
335class TestReq10(TestRequest):
336    """Test action that sends a request using a minimal (and likely
337    incomplete) HTTP/1.0 test client for the one test case that
338    strictly requires HTTP/1.0.
339
340    TestReq10 objects use the same YAML parameters and defaults as
341    TestRequest, but note that an empty "headers" parameter means that
342    not even a "Host:" header will be sent. All headers must be
343    specified in the test configuration file.
344
345    """
346    yaml_tag = '!request10'
347    status_re = re.compile(r'^HTTP/([\d\.]+) (\d+) (.*)$')
348    header_re = re.compile(r'^([-\w]+):\s+(.*)$')
349
350    def __init__(self, **kwargs):
351        super().__init__(**kwargs)
352
353    def __repr__(self):
354        return (f'{self.__class__.__name__!s}'
355                f'(method={self.method!r}, path={self.path!r}, '
356                f'headers={self.headers!r}, expect={self.expect!r})')
357
358    def run(self, command, timeout=None, conn_log=None, response_log=None):
359        req = f'{self.method} {self.path} HTTP/1.0\r\n'
360        for name, value in self.headers.items():
361            req = req + f'{name}: {value}\r\n'
362        req = req + f'\r\n'
363        proc = subprocess.Popen(command,
364                                stdout=subprocess.PIPE,
365                                stderr=subprocess.PIPE,
366                                stdin=subprocess.PIPE,
367                                close_fds=True,
368                                bufsize=0)
369        try:
370            outs, errs = proc.communicate(input=req.encode(),
371                                          timeout=timeout)
372        except TimeoutExpired:
373            proc.kill()
374            outs, errs = proc.communicate()
375
376        print(errs.decode())
377        if conn_log:
378            print(errs.decode(), file=conn_log)
379
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
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
398        head, body = rest.split('\r\n\r\n', maxsplit=1)
399        # log response for debugging
400        print(f'{status}\n{head}\n\n{body}')
401        if response_log:
402            print(f'{status}\n{head}\n\n{body}', file=response_log)
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
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
423        if 'body' in self.expect:
424            self.check_body(body)
425
426
427
428def filter_cert_log(in_stream, out_stream):
429    """Filter to stop an erroneous gnutls-cli log message.
430
431    This function filters out a log line about loading client
432    certificates that is mistakenly sent to stdout from gnutls-cli. My
433    fix (https://gitlab.com/gnutls/gnutls/merge_requests/1125) has
434    been merged, but buggy binaries will probably be around for a
435    while.
436
437    The filter is meant to run in a multiprocessing.Process or
438    threading.Thread that receives the stdout of gnutls-cli as
439    in_stream, and a connection for further processing as out_stream.
440
441    """
442    # message to filter
443    cert_log = b'Processed 1 client X.509 certificates...\n'
444
445    # Set the input to non-blocking mode
446    fd = in_stream.fileno()
447    os.set_blocking(fd, False)
448
449    # The poll object allows waiting for events on non-blocking IO
450    # channels.
451    poller = select.poll()
452    poller.register(fd)
453
454    init_done = False
455    run_loop = True
456    while run_loop:
457        # The returned tuples are file descriptor and event, but
458        # we're only listening on one stream anyway, so we don't
459        # need to check it here.
460        for x, event in poller.poll():
461            # Critical: "event" is a bitwise OR of the POLL* constants
462            if event & select.POLLIN or event & select.POLLPRI:
463                data = in_stream.read()
464                if not init_done:
465                    # If the erroneous log line shows up it's the
466                    # first piece of data we receive. Just copy
467                    # everything after.
468                    init_done = True
469                    if cert_log in data:
470                        data = data.replace(cert_log, b'')
471                out_stream.send(data)
472            if event & select.POLLHUP or event & select.POLLRDHUP:
473                # Stop the loop, but process any other events that
474                # might be in the list returned by poll() first.
475                run_loop = False
476
477    in_stream.close()
478    out_stream.close()
479
480
481
482def format_response(resp, body):
483    """Format an http.client.HTTPResponse for logging."""
484    s = f'{resp.status} {resp.reason}\n'
485    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
486    s = s + '\n\n' + body
487    return s
488
489
490
491def subst_env(text):
492    """Use the parameter "text" as a template, substitute with environment
493    variables.
494
495    >>> os.environ['EXAMPLE_VAR'] = 'abc'
496    >>> subst_env('${EXAMPLE_VAR}def')
497    'abcdef'
498
499    Referencing undefined environment variables causes a KeyError.
500
501    >>> subst_env('${EXAMPLE_UNSET}')
502    Traceback (most recent call last):
503    ...
504    KeyError: 'EXAMPLE_UNSET'
505
506    >>> subst_env(None) is None
507    True
508
509    """
510    if not text:
511        return None
512    t = Template(text)
513    return t.substitute(os.environ)
514
515
516
517def run_test_conf(test_config, timeout=5.0, conn_log=None, response_log=None):
518    """Load and run a test configuration.
519
520    The test_conf parameter must be a YAML file, defining one or more
521    TestConnections, to be run in order. The other three parameters
522    are forwarded to TestConnection.run().
523
524    """
525    conns = None
526
527    config = yaml.load(test_config, Loader=yaml.Loader)
528    if type(config) is TestConnection:
529        conns = [config]
530    elif type(config) is list:
531        # assume list elements are connections
532        conns = config
533    else:
534        raise TypeError(f'Unsupported configuration: {config!r}')
535    print(conns)
536    sys.stdout.flush()
537
538    for i, test_conn in enumerate(conns):
539        if test_conn.description:
540            print(f'Running test connection {i}: {test_conn.description}')
541        else:
542            print(f'Running test connection {i}.')
543        sys.stdout.flush()
544        test_conn.run(timeout=timeout, conn_log=conn_log,
545                      response_log=response_log)
Note: See TracBrowser for help on using the repository browser.