source: mod_gnutls/test/mgstest/tests.py

asyncio
Last change on this file was 6cec675, checked in by Fiona Klute <fiona.klute@…>, 4 months ago

Fix remaining flake8 warnings in mgstest.tests

That's all, minus instances of "E722 do not use bare 'except'", which
I think is valid in certain cases (usually with a "raise" in the
handler block). :-)

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