source: mod_gnutls/test/mgstest/tests.py @ 407ca6e

asyncioproxy-ticket
Last change on this file since 407ca6e was 407ca6e, checked in by Fiona Klute <fiona.klute@…>, 22 months ago

TestRequest? and TestReq10: Support request body

This will allow testing e.g. POST requests.

Also remove the redundant repr(self) method in TestReq10, the
inherited method works just fine.

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