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

Last change on this file since c6d4701 was c6d4701, checked in by Fiona Klute <fiona.klute@…>, 7 months ago

mgstest.tests.TestRequest?: Move tests to unittest

The tests previously done via doctest didn't really document
anything. Running them via unittest is more flexible and might allow
for future extensions.

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