source: mod_gnutls/test/mgstest/tests.py @ 0b3733d

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

Test suite: Support checking absence of headers

  • Property mode set to 100644
File size: 18.2 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        # first line of the received data must be the status
367        status, rest = outs.decode().split('\r\n', maxsplit=1)
368        # headers and body are separated by double newline
369        head, body = rest.split('\r\n\r\n', maxsplit=1)
370        # log response for debugging
371        print(f'{status}\n{head}\n\n{body}')
372        if response_log:
373            print(f'{status}\n{head}\n\n{body}', file=response_log)
374
375        m = self.status_re.match(status)
376        if m:
377            status_code = int(m.group(2))
378            status_expect = self.expect.get('status')
379            if status_expect and not status_code == status_expect:
380                raise TestExpectationFailed('Unexpected status code: '
381                                            f'{status}, expected '
382                                            f'{status_expect}')
383        else:
384            raise TestExpectationFailed(f'Invalid status line: "{status}"')
385
386        if 'headers' in self.expect:
387            headers = dict()
388            for line in head.splitlines():
389                m = self.header_re.fullmatch(line)
390                if m:
391                    headers[m.group(1)] = m.group(2)
392            self.check_headers(headers)
393
394        if 'body' in self.expect:
395            self.check_body(body)
396
397
398
399def filter_cert_log(in_stream, out_stream):
400    """Filter to stop an erroneous gnutls-cli log message.
401
402    This function filters out a log line about loading client
403    certificates that is mistakenly sent to stdout from gnutls-cli. My
404    fix (https://gitlab.com/gnutls/gnutls/merge_requests/1125) has
405    been merged, but buggy binaries will probably be around for a
406    while.
407
408    The filter is meant to run in a multiprocessing.Process or
409    threading.Thread that receives the stdout of gnutls-cli as
410    in_stream, and a connection for further processing as out_stream.
411
412    """
413    # message to filter
414    cert_log = b'Processed 1 client X.509 certificates...\n'
415
416    # Set the input to non-blocking mode
417    fd = in_stream.fileno()
418    os.set_blocking(fd, False)
419
420    # The poll object allows waiting for events on non-blocking IO
421    # channels.
422    poller = select.poll()
423    poller.register(fd)
424
425    init_done = False
426    run_loop = True
427    while run_loop:
428        # The returned tuples are file descriptor and event, but
429        # we're only listening on one stream anyway, so we don't
430        # need to check it here.
431        for x, event in poller.poll():
432            # Critical: "event" is a bitwise OR of the POLL* constants
433            if event & select.POLLIN or event & select.POLLPRI:
434                data = in_stream.read()
435                if not init_done:
436                    # If the erroneous log line shows up it's the
437                    # first piece of data we receive. Just copy
438                    # everything after.
439                    init_done = True
440                    if cert_log in data:
441                        data = data.replace(cert_log, b'')
442                out_stream.send(data)
443            if event & select.POLLHUP or event & select.POLLRDHUP:
444                # Stop the loop, but process any other events that
445                # might be in the list returned by poll() first.
446                run_loop = False
447
448    in_stream.close()
449    out_stream.close()
450
451
452
453def format_response(resp, body):
454    """Format an http.client.HTTPResponse for logging."""
455    s = f'{resp.status} {resp.reason}\n'
456    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
457    s = s + '\n\n' + body
458    return s
459
460
461
462def subst_env(text):
463    """Use the parameter "text" as a template, substitute with environment
464    variables.
465
466    >>> os.environ['EXAMPLE_VAR'] = 'abc'
467    >>> subst_env('${EXAMPLE_VAR}def')
468    'abcdef'
469
470    Referencing undefined environment variables causes a KeyError.
471
472    >>> subst_env('${EXAMPLE_UNSET}')
473    Traceback (most recent call last):
474    ...
475    KeyError: 'EXAMPLE_UNSET'
476
477    >>> subst_env(None) is None
478    True
479
480    """
481    if not text:
482        return None
483    t = Template(text)
484    return t.substitute(os.environ)
485
486
487
488def run_test_conf(test_config, timeout=5.0, conn_log=None, response_log=None):
489    """Load and run a test configuration.
490
491    The test_conf parameter must be a YAML file, defining one or more
492    TestConnections, to be run in order. The other three parameters
493    are forwarded to TestConnection.run().
494
495    """
496    conns = None
497
498    config = yaml.load(test_config, Loader=yaml.Loader)
499    if type(config) is TestConnection:
500        conns = [config]
501    elif type(config) is list:
502        # assume list elements are connections
503        conns = config
504    else:
505        raise TypeError(f'Unsupported configuration: {config!r}')
506    print(conns)
507    sys.stdout.flush()
508
509    for i, test_conn in enumerate(conns):
510        if test_conn.description:
511            print(f'Running test connection {i}: {test_conn.description}')
512        else:
513            print(f'Running test connection {i}.')
514        sys.stdout.flush()
515        test_conn.run(timeout=timeout, conn_log=conn_log,
516                      response_log=response_log)
Note: See TracBrowser for help on using the repository browser.