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

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

Detailed documentation on test.yml and mgstest.tests

  • Property mode set to 100644
File size: 17.6 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      body:
72        # All strings in this list must occur in the body, in any
73        # order. "Contains" may also contain a single string instead
74        # of a list.
75        contains:
76          - 'Using GnuTLS version: '
77          - 'Current TLS session: (TLS1.3)'
78```
79
80Example of a connection that is expected to fail at the TLS level, in
81this case because the configured CA is not the one that issued the
82server certificate:
83
84```yaml
85- !connection
86  gnutls_params:
87    - x509cafile=rogueca/x509.pem
88  actions:
89    - !request
90      path: /
91      expect:
92        # The connection is expected to reset without an HTTP response.
93        reset: yes
94```
95
96"""
97
98import os
99import re
100import select
101import subprocess
102import sys
103import yaml
104
105from enum import Enum, auto
106from http.client import HTTPConnection
107from string import Template
108
109from . import TestExpectationFailed
110from .http import HTTPSubprocessConnection
111
112class Transports(Enum):
113    """Transports supported by TestConnection."""
114    GNUTLS = auto()
115    PLAIN = auto()
116
117    def __repr__(self):
118        return f'{self.__class__.__name__!s}.{self.name}'
119
120class TestConnection(yaml.YAMLObject):
121    """An HTTP connection in a test. It includes parameters for the
122    transport, and the actions (e.g. sending requests) to take using
123    this connection.
124
125    Note that running one TestConnection object may result in multiple
126    sequential network connections if the transport gets closed in a
127    non-failure way (e.g. following a "Connection: close" request) and
128    there are more actions, or (rarely) if an action requires its own
129    transport.
130
131    """
132    yaml_tag = '!connection'
133
134    def __init__(self, actions, host=None, port=None, gnutls_params=[],
135                 transport='gnutls', description=None):
136        self.gnutls_params = gnutls_params
137        self.actions = actions
138        self.transport = Transports[transport.upper()]
139        self.description = description
140        if host:
141            self.host = subst_env(host)
142        else:
143            self.host = os.environ.get('TEST_TARGET', 'localhost')
144        if port:
145            self.port = int(subst_env(port))
146        else:
147            self.port = int(os.environ.get('TEST_PORT', 8000))
148
149    def __repr__(self):
150        return (f'{self.__class__.__name__!s}'
151                f'(host={self.host!r}, port={self.port!r}, '
152                f'gnutls_params={self.gnutls_params!r}, '
153                f'actions={self.actions!r}, transport={self.transport!r}, '
154                f'description={self.description!r})')
155
156    def run(self, timeout=5.0, conn_log=None, response_log=None):
157        """Set up an HTTP connection and run the configured actions."""
158
159        # note: "--logfile" option requires GnuTLS version >= 3.6.7
160        command = ['gnutls-cli', '--logfile=/dev/stderr']
161        for s in self.gnutls_params:
162            command.append('--' + s)
163        command = command + ['-p', str(self.port), self.host]
164
165        if self.transport == Transports.GNUTLS:
166            conn = HTTPSubprocessConnection(command, self.host, self.port,
167                                            output_filter=filter_cert_log,
168                                            stderr_log=conn_log,
169                                            timeout=timeout)
170        elif self.transport == Transports.PLAIN:
171            conn = HTTPConnection(self.host, port=self.port,
172                                  timeout=timeout)
173
174        try:
175            for act in self.actions:
176                if type(act) is TestRequest:
177                    act.run(conn, response_log)
178                elif type(act) is TestReq10:
179                    act.run(command, timeout, conn_log, response_log)
180                else:
181                    raise TypeError(f'Unsupported action requested: {act!r}')
182        finally:
183            conn.close()
184            sys.stdout.flush()
185
186    @classmethod
187    def from_yaml(cls, loader, node):
188        fields = loader.construct_mapping(node)
189        conn = cls(**fields)
190        return conn
191
192
193
194class TestRequest(yaml.YAMLObject):
195    """Test action that sends an HTTP/1.1 request.
196
197    The path must be specified in the configuration file, all other
198    parameters (method, headers, expected response) have
199    defaults.
200
201    Options for checking the response currently are:
202    * require a specific response status
203    * require specific headers to be present with specific values
204    * require the body to exactly match a specific string
205    * require the body to contain all of a list of strings
206
207    """
208    yaml_tag = '!request'
209    def __init__(self, path, method='GET', headers=dict(),
210                 expect=dict(status=200)):
211        self.method = method
212        self.path = path
213        self.headers = headers
214        self.expect = expect
215
216    def __repr__(self):
217        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
218                f'method={self.method!r}, headers={self.headers!r}, '
219                f'expect={self.expect!r})')
220
221    def run(self, conn, response_log=None):
222        try:
223            conn.request(self.method, self.path, headers=self.headers)
224            resp = conn.getresponse()
225            if self.expects_conn_reset():
226                raise TestExpectationFailed(
227                    'Expected connection reset did not occur!')
228        except (BrokenPipeError, ConnectionResetError) as err:
229            if self.expects_conn_reset():
230                print('connection reset as expected.')
231                return
232            else:
233                raise err
234        body = resp.read().decode()
235        log_str = format_response(resp, body)
236        print(log_str)
237        if response_log:
238            print(log_str, file=response_log)
239        self.check_response(resp, body)
240
241    def check_headers(self, headers):
242        for name, expected in self.expect['headers'].items():
243            value = headers.get(name)
244            expected = subst_env(expected)
245            if value != expected:
246                raise TestExpectationFailed(
247                    f'Unexpected value in header {name}: "{value}", '
248                    f'expected "{expected}"')
249
250    def check_body(self, body):
251        """
252        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
253        >>> r1.check_body('test\\n')
254        >>> r1.check_body('xyz\\n')
255        Traceback (most recent call last):
256        ...
257        mgstest.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
258        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
259        >>> r2.check_body('test\\n')
260        >>> r2.check_body('est\\n')
261        Traceback (most recent call last):
262        ...
263        mgstest.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
264        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
265        >>> r3.check_body('test\\n')
266        """
267        if 'exactly' in self.expect['body'] \
268           and body != self.expect['body']['exactly']:
269            raise TestExpectationFailed(
270                f'Unexpected body: {body!r} != '
271                f'{self.expect["body"]["exactly"]!r}')
272        if 'contains' in self.expect['body']:
273            if type(self.expect['body']['contains']) is str:
274                self.expect['body']['contains'] = [
275                    self.expect['body']['contains']]
276            for s in self.expect['body']['contains']:
277                if not s in body:
278                    raise TestExpectationFailed(
279                        f'Unexpected body: {body!r} does not contain '
280                        f'{s!r}')
281
282    def check_response(self, response, body):
283        if self.expects_conn_reset():
284            raise TestExpectationFailed(
285                'Got a response, but connection should have failed!')
286        if response.status != self.expect['status']:
287            raise TestExpectationFailed(
288                f'Unexpected status: {response.status} != '
289                f'{self.expect["status"]}')
290        if 'headers' in self.expect:
291            self.check_headers(dict(response.getheaders()))
292        if 'body' in self.expect:
293            self.check_body(body)
294
295    def expects_conn_reset(self):
296        """Returns True if running this request is expected to fail due to the
297        connection being reset. That usually means the underlying TLS
298        connection failed.
299
300        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
301        >>> r1.expects_conn_reset()
302        False
303        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
304        >>> r2.expects_conn_reset()
305        True
306        """
307        if 'reset' in self.expect:
308            return self.expect['reset']
309        return False
310
311    @classmethod
312    def from_yaml(cls, loader, node):
313        fields = loader.construct_mapping(node)
314        req = cls(**fields)
315        return req
316
317
318
319class TestReq10(TestRequest):
320    """Test action that sends a request using a minimal (and likely
321    incomplete) HTTP/1.0 test client for the one test case that
322    strictly requires HTTP/1.0.
323
324    TestReq10 objects use the same YAML parameters and defaults as
325    TestRequest, but note that an empty "headers" parameter means that
326    not even a "Host:" header will be sent. All headers must be
327    specified in the test configuration file.
328
329    """
330    yaml_tag = '!request10'
331    status_re = re.compile('^HTTP/([\d\.]+) (\d+) (.*)$')
332
333    def __init__(self, **kwargs):
334        super().__init__(**kwargs)
335
336    def __repr__(self):
337        return (f'{self.__class__.__name__!s}'
338                f'(method={self.method!r}, path={self.path!r}, '
339                f'headers={self.headers!r}, expect={self.expect!r})')
340
341    def run(self, command, timeout=None, conn_log=None, response_log=None):
342        req = f'{self.method} {self.path} HTTP/1.0\r\n'
343        for name, value in self.headers.items():
344            req = req + f'{name}: {value}\r\n'
345        req = req + f'\r\n'
346        proc = subprocess.Popen(command,
347                                stdout=subprocess.PIPE,
348                                stderr=subprocess.PIPE,
349                                stdin=subprocess.PIPE,
350                                close_fds=True,
351                                bufsize=0)
352        try:
353            outs, errs = proc.communicate(input=req.encode(),
354                                          timeout=timeout)
355        except TimeoutExpired:
356            proc.kill()
357            outs, errs = proc.communicate()
358
359        print(errs.decode())
360        if conn_log:
361            print(errs.decode(), file=conn_log)
362
363        # first line of the received data must be the status
364        status, rest = outs.decode().split('\r\n', maxsplit=1)
365        # headers and body are separated by double newline
366        headers, body = rest.split('\r\n\r\n', maxsplit=1)
367        # log response for debugging
368        print(f'{status}\n{headers}\n\n{body}')
369        if response_log:
370            print(f'{status}\n{headers}\n\n{body}', file=response_log)
371
372        m = self.status_re.match(status)
373        if m:
374            status_code = int(m.group(2))
375            status_expect = self.expect.get('status')
376            if status_expect and not status_code == status_expect:
377                raise TestExpectationFailed('Unexpected status code: '
378                                            f'{status}, expected '
379                                            f'{status_expect}')
380        else:
381            raise TestExpectationFailed(f'Invalid status line: "{status}"')
382
383        if 'body' in self.expect:
384            self.check_body(body)
385
386
387
388def filter_cert_log(in_stream, out_stream):
389    """Filter to stop an erroneous gnutls-cli log message.
390
391    This function filters out a log line about loading client
392    certificates that is mistakenly sent to stdout from gnutls-cli. My
393    fix (https://gitlab.com/gnutls/gnutls/merge_requests/1125) has
394    been merged, but buggy binaries will probably be around for a
395    while.
396
397    The filter is meant to run in a multiprocessing.Process or
398    threading.Thread that receives the stdout of gnutls-cli as
399    in_stream, and a connection for further processing as out_stream.
400
401    """
402    # message to filter
403    cert_log = b'Processed 1 client X.509 certificates...\n'
404
405    # Set the input to non-blocking mode
406    fd = in_stream.fileno()
407    os.set_blocking(fd, False)
408
409    # The poll object allows waiting for events on non-blocking IO
410    # channels.
411    poller = select.poll()
412    poller.register(fd)
413
414    init_done = False
415    run_loop = True
416    while run_loop:
417        # The returned tuples are file descriptor and event, but
418        # we're only listening on one stream anyway, so we don't
419        # need to check it here.
420        for x, event in poller.poll():
421            # Critical: "event" is a bitwise OR of the POLL* constants
422            if event & select.POLLIN or event & select.POLLPRI:
423                data = in_stream.read()
424                if not init_done:
425                    # If the erroneous log line shows up it's the
426                    # first piece of data we receive. Just copy
427                    # everything after.
428                    init_done = True
429                    if cert_log in data:
430                        data = data.replace(cert_log, b'')
431                out_stream.send(data)
432            if event & select.POLLHUP or event & select.POLLRDHUP:
433                # Stop the loop, but process any other events that
434                # might be in the list returned by poll() first.
435                run_loop = False
436
437    in_stream.close()
438    out_stream.close()
439
440
441
442def format_response(resp, body):
443    """Format an http.client.HTTPResponse for logging."""
444    s = f'{resp.status} {resp.reason}\n'
445    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
446    s = s + '\n\n' + body
447    return s
448
449
450
451def subst_env(text):
452    """Use the parameter "text" as a template, substitute with environment
453    variables.
454
455    >>> os.environ['EXAMPLE_VAR'] = 'abc'
456    >>> subst_env('${EXAMPLE_VAR}def')
457    'abcdef'
458
459    """
460    t = Template(text)
461    return t.substitute(os.environ)
462
463
464
465def run_test_conf(test_config, timeout=5.0, conn_log=None, response_log=None):
466    """Load and run a test configuration.
467
468    The test_conf parameter must be a YAML file, defining one or more
469    TestConnections, to be run in order. The other three parameters
470    are forwarded to TestConnection.run().
471
472    """
473    conns = None
474
475    config = yaml.load(test_config, Loader=yaml.Loader)
476    if type(config) is TestConnection:
477        conns = [config]
478    elif type(config) is list:
479        # assume list elements are connections
480        conns = config
481    else:
482        raise TypeError(f'Unsupported configuration: {config!r}')
483    print(conns)
484    sys.stdout.flush()
485
486    for i, test_conn in enumerate(conns):
487        if test_conn.description:
488            print(f'Running test connection {i}: {test_conn.description}')
489        else:
490            print(f'Running test connection {i}.')
491        sys.stdout.flush()
492        test_conn.run(timeout=timeout, conn_log=conn_log,
493                      response_log=response_log)
Note: See TracBrowser for help on using the repository browser.