source: mod_gnutls/test/mgstest/tests.py @ 1a85a3d

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

mgstest.tests.TestConnection?: Read environment variables at runtime

Previously environment variables were evaluated during init, which
has confusing results if environment variables are changed (e.g. in a
prepare_env hook) between the time the configuration is read and the
test runs.

  • 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
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        self.host = host
143        self.port = port
144
145    def __repr__(self):
146        return (f'{self.__class__.__name__!s}'
147                f'(host={self.host!r}, port={self.port!r}, '
148                f'gnutls_params={self.gnutls_params!r}, '
149                f'actions={self.actions!r}, transport={self.transport!r}, '
150                f'description={self.description!r})')
151
152    def run(self, timeout=5.0, conn_log=None, response_log=None):
153        """Set up an HTTP connection and run the configured actions."""
154
155        if self.host:
156            self.host = subst_env(self.host)
157        else:
158            self.host = os.environ.get('TEST_TARGET', 'localhost')
159        if self.port:
160            self.port = int(subst_env(self.port))
161        else:
162            self.port = int(os.environ.get('TEST_PORT', 8000))
163
164        # note: "--logfile" option requires GnuTLS version >= 3.6.7
165        command = ['gnutls-cli', '--logfile=/dev/stderr']
166        for s in self.gnutls_params:
167            command.append('--' + s)
168        command = command + ['-p', str(self.port), self.host]
169
170        if self.transport == Transports.GNUTLS:
171            conn = HTTPSubprocessConnection(command, self.host, self.port,
172                                            output_filter=filter_cert_log,
173                                            stderr_log=conn_log,
174                                            timeout=timeout)
175        elif self.transport == Transports.PLAIN:
176            conn = HTTPConnection(self.host, port=self.port,
177                                  timeout=timeout)
178
179        try:
180            for act in self.actions:
181                if type(act) is TestRequest:
182                    act.run(conn, response_log)
183                elif type(act) is TestReq10:
184                    act.run(command, timeout, conn_log, response_log)
185                elif type(act) is Resume:
186                    act.run(conn, command)
187                else:
188                    raise TypeError(f'Unsupported action requested: {act!r}')
189        finally:
190            conn.close()
191            sys.stdout.flush()
192
193    @classmethod
194    def from_yaml(cls, loader, node):
195        fields = loader.construct_mapping(node)
196        conn = cls(**fields)
197        return conn
198
199
200
201class TestRequest(yaml.YAMLObject):
202    """Test action that sends an HTTP/1.1 request.
203
204    The path must be specified in the configuration file, all other
205    parameters (method, headers, expected response) have
206    defaults.
207
208    Options for checking the response currently are:
209    * require a specific response status
210    * require specific headers to be present with specific values
211    * require the body to exactly match a specific string
212    * require the body to contain all of a list of strings
213
214    """
215    yaml_tag = '!request'
216    def __init__(self, path, method='GET', body=None, headers=dict(),
217                 expect=dict(status=200)):
218        self.method = method
219        self.path = path
220        self.body = body.encode('utf-8') if body else None
221        self.headers = headers
222        self.expect = expect
223
224    def __repr__(self):
225        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
226                f'method={self.method!r}, body={self.body!r}, '
227                f'headers={self.headers!r}, expect={self.expect!r})')
228
229    def run(self, conn, response_log=None):
230        try:
231            conn.request(self.method, self.path, body=self.body,
232                         headers=self.headers)
233            resp = conn.getresponse()
234            if self.expects_conn_reset():
235                raise TestExpectationFailed(
236                    'Expected connection reset did not occur!')
237        except (BrokenPipeError, ConnectionResetError) as err:
238            if self.expects_conn_reset():
239                print('connection reset as expected.')
240                return
241            else:
242                raise err
243        body = resp.read().decode()
244        log_str = format_response(resp, body)
245        print(log_str)
246        if response_log:
247            print(log_str, file=response_log)
248        self.check_response(resp, body)
249
250    def check_headers(self, headers):
251        """
252        >>> r1 = TestRequest(path='/test.txt',
253        ...                  expect={ 'headers': {'X-Forbidden-Header': None,
254        ...                                       'X-Required-Header': 'Hi!' }})
255        >>> r1.check_headers({ 'X-Required-Header': 'Hi!' })
256        >>> r1.check_headers({ 'X-Required-Header': 'Hello!' })
257        Traceback (most recent call last):
258        ...
259        mgstest.TestExpectationFailed: Unexpected value in header X-Required-Header: 'Hello!', expected 'Hi!'
260        >>> r1.check_headers({ 'X-Forbidden-Header': 'Hi!' })
261        Traceback (most recent call last):
262        ...
263        mgstest.TestExpectationFailed: Unexpected value in header X-Forbidden-Header: 'Hi!', expected None
264        """
265        for name, expected in self.expect['headers'].items():
266            value = headers.get(name)
267            expected = subst_env(expected)
268            if value != expected:
269                raise TestExpectationFailed(
270                    f'Unexpected value in header {name}: {value!r}, '
271                    f'expected {expected!r}')
272
273    def check_body(self, body):
274        """
275        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
276        >>> r1.check_body('test\\n')
277        >>> r1.check_body('xyz\\n')
278        Traceback (most recent call last):
279        ...
280        mgstest.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
281        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
282        >>> r2.check_body('test\\n')
283        >>> r2.check_body('est\\n')
284        Traceback (most recent call last):
285        ...
286        mgstest.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
287        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
288        >>> r3.check_body('test\\n')
289        """
290        if 'exactly' in self.expect['body'] \
291           and body != self.expect['body']['exactly']:
292            raise TestExpectationFailed(
293                f'Unexpected body: {body!r} != '
294                f'{self.expect["body"]["exactly"]!r}')
295        if 'contains' in self.expect['body']:
296            if type(self.expect['body']['contains']) is str:
297                self.expect['body']['contains'] = [
298                    self.expect['body']['contains']]
299            for s in self.expect['body']['contains']:
300                if not s in body:
301                    raise TestExpectationFailed(
302                        f'Unexpected body: {body!r} does not contain '
303                        f'{s!r}')
304
305    def check_response(self, response, body):
306        if self.expects_conn_reset():
307            raise TestExpectationFailed(
308                'Got a response, but connection should have failed!')
309        if response.status != self.expect['status']:
310            raise TestExpectationFailed(
311                f'Unexpected status: {response.status} != '
312                f'{self.expect["status"]}')
313        if 'headers' in self.expect:
314            self.check_headers(dict(response.getheaders()))
315        if 'body' in self.expect:
316            self.check_body(body)
317
318    def expects_conn_reset(self):
319        """Returns True if running this request is expected to fail due to the
320        connection being reset. That usually means the underlying TLS
321        connection failed.
322
323        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
324        >>> r1.expects_conn_reset()
325        False
326        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
327        >>> r2.expects_conn_reset()
328        True
329        """
330        if 'reset' in self.expect:
331            return self.expect['reset']
332        return False
333
334    @classmethod
335    def from_yaml(cls, loader, node):
336        fields = loader.construct_mapping(node)
337        req = cls(**fields)
338        return req
339
340
341
342class TestReq10(TestRequest):
343    """Test action that sends a request using a minimal (and likely
344    incomplete) HTTP/1.0 test client for the one test case that
345    strictly requires HTTP/1.0.
346
347    TestReq10 objects use the same YAML parameters and defaults as
348    TestRequest, but note that an empty "headers" parameter means that
349    not even a "Host:" header will be sent. All headers must be
350    specified in the test configuration file.
351
352    """
353    yaml_tag = '!request10'
354    status_re = re.compile(r'^HTTP/([\d\.]+) (\d+) (.*)$')
355    header_re = re.compile(r'^([-\w]+):\s+(.*)$')
356
357    def __init__(self, **kwargs):
358        super().__init__(**kwargs)
359
360    def run(self, command, timeout=None, conn_log=None, response_log=None):
361        req = f'{self.method} {self.path} HTTP/1.0\r\n'
362        for name, value in self.headers.items():
363            req = req + f'{name}: {value}\r\n'
364        req = req.encode('utf-8') + b'\r\n'
365        if self.body:
366            req = req + self.body
367        proc = subprocess.Popen(command,
368                                stdout=subprocess.PIPE,
369                                stderr=subprocess.PIPE,
370                                stdin=subprocess.PIPE,
371                                close_fds=True,
372                                bufsize=0)
373        try:
374            outs, errs = proc.communicate(input=req, timeout=timeout)
375        except TimeoutExpired:
376            proc.kill()
377            outs, errs = proc.communicate()
378
379        print(errs.decode())
380        if conn_log:
381            print(errs.decode(), file=conn_log)
382
383        if proc.returncode != 0:
384            if len(outs) != 0:
385                raise TestExpectationFailed(
386                    f'Connection failed, but got output: {outs!r}')
387            if self.expects_conn_reset():
388                print('connection reset as expected.')
389                return
390            else:
391                raise TestExpectationFailed(
392                    'Connection failed unexpectedly!')
393        else:
394            if self.expects_conn_reset():
395                raise TestExpectationFailed(
396                    'Expected connection reset did not occur!')
397
398        # first line of the received data must be the status
399        status, rest = outs.decode().split('\r\n', maxsplit=1)
400        # headers and body are separated by double newline
401        head, body = rest.split('\r\n\r\n', maxsplit=1)
402        # log response for debugging
403        print(f'{status}\n{head}\n\n{body}')
404        if response_log:
405            print(f'{status}\n{head}\n\n{body}', file=response_log)
406
407        m = self.status_re.match(status)
408        if m:
409            status_code = int(m.group(2))
410            status_expect = self.expect.get('status')
411            if status_expect and not status_code == status_expect:
412                raise TestExpectationFailed('Unexpected status code: '
413                                            f'{status}, expected '
414                                            f'{status_expect}')
415        else:
416            raise TestExpectationFailed(f'Invalid status line: "{status}"')
417
418        if 'headers' in self.expect:
419            headers = dict()
420            for line in head.splitlines():
421                m = self.header_re.fullmatch(line)
422                if m:
423                    headers[m.group(1)] = m.group(2)
424            self.check_headers(headers)
425
426        if 'body' in self.expect:
427            self.check_body(body)
428
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    def run(self, conn, command):
445        if not '--inline-commands' in command:
446            raise ValueError('gnutls_params must include "inline-commands" '
447                             'to use the resume action!')
448        if not type(conn) is HTTPSubprocessConnection:
449            raise TypeError('Resume action works only with '
450                            'HTTPSubprocessConnection.')
451        conn.sock.send(b'^resume^\n')
452
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
508
509def format_response(resp, body):
510    """Format an http.client.HTTPResponse for logging."""
511    s = f'{resp.status} {resp.reason}\n'
512    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
513    s = s + '\n\n' + body
514    return s
515
516
517
518def subst_env(text):
519    """Use the parameter "text" as a template, substitute with environment
520    variables.
521
522    >>> os.environ['EXAMPLE_VAR'] = 'abc'
523    >>> subst_env('${EXAMPLE_VAR}def')
524    'abcdef'
525
526    Referencing undefined environment variables causes a KeyError.
527
528    >>> subst_env('${EXAMPLE_UNSET}')
529    Traceback (most recent call last):
530    ...
531    KeyError: 'EXAMPLE_UNSET'
532
533    >>> subst_env(None) is None
534    True
535
536    """
537    if not text:
538        return None
539    t = Template(text)
540    return t.substitute(os.environ)
541
542
543
544def run_test_conf(test_config, timeout=5.0, conn_log=None, response_log=None):
545    """Load and run a test configuration.
546
547    The test_conf parameter must be a YAML file, defining one or more
548    TestConnections, to be run in order. The other three parameters
549    are forwarded to TestConnection.run().
550
551    """
552    conns = None
553
554    config = yaml.load(test_config, Loader=yaml.Loader)
555    if type(config) is TestConnection:
556        conns = [config]
557    elif type(config) is list:
558        # assume list elements are connections
559        conns = config
560    else:
561        raise TypeError(f'Unsupported configuration: {config!r}')
562    sys.stdout.flush()
563
564    for i, test_conn in enumerate(conns):
565        if test_conn.description:
566            print(f'Running test connection {i}: {test_conn.description}')
567        else:
568            print(f'Running test connection {i}.')
569        sys.stdout.flush()
570        test_conn.run(timeout=timeout, conn_log=conn_log,
571                      response_log=response_log)
Note: See TracBrowser for help on using the repository browser.