source: mod_gnutls/test/mgstest/tests.py @ 60a415a

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

Fix simple formatting issues reported by flake8

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