source: mod_gnutls/test/mgstest/tests.py @ 45b0a24

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

TestRequest?: Raise exception if expected connection reset didn't occur

  • Property mode set to 100644
File size: 14.2 KB
Line 
1#!/usr/bin/python3
2
3# Copyright 2019 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
21"""
22
23import os
24import re
25import subprocess
26import sys
27import yaml
28
29from enum import Enum, auto
30from http.client import HTTPConnection
31from string import Template
32
33from . import TestExpectationFailed
34from .http import HTTPSubprocessConnection
35
36class Transports(Enum):
37    GNUTLS = auto()
38    PLAIN = auto()
39
40    def __repr__(self):
41        return f'{self.__class__.__name__!s}.{self.name}'
42
43class TestConnection(yaml.YAMLObject):
44    """An HTTP connection in a test. It includes parameters for the
45    transport (currently gnutls-cli only), and the actions
46    (e.g. sending requests) to take using this connection.
47
48    Note that running one TestConnection object may result in multiple
49    sequential network connections, if the transport gets closed in a
50    non-failure way (e.g. following a "Connection: close" request) and
51    there are more actions, or (rarely) if an action requires its own
52    transport.
53
54    """
55    yaml_tag = '!connection'
56
57    def __init__(self, actions, host=None, port=None, gnutls_params=[],
58                 transport='gnutls', description=None):
59        self.gnutls_params = gnutls_params
60        self.actions = actions
61        self.transport = Transports[transport.upper()]
62        self.description = description
63        if host:
64            self.host = subst_env(host)
65        else:
66            self.host = os.environ.get('TEST_TARGET', 'localhost')
67        if port:
68            self.port = int(subst_env(port))
69        else:
70            self.port = int(os.environ.get('TEST_PORT', 8000))
71
72    def __repr__(self):
73        return (f'{self.__class__.__name__!s}'
74                f'(host={self.host!r}, port={self.port!r}, '
75                f'gnutls_params={self.gnutls_params!r}, '
76                f'actions={self.actions!r}, transport={self.transport!r}, '
77                f'description={self.description!r})')
78
79    def run(self, timeout=5.0, conn_log=None):
80        # note: "--logfile" option requires GnuTLS version >= 3.6.7
81        command = ['gnutls-cli', '--logfile=/dev/stderr']
82        for s in self.gnutls_params:
83            command.append('--' + s)
84        command = command + ['-p', str(self.port), self.host]
85
86        if self.transport == Transports.GNUTLS:
87            conn = HTTPSubprocessConnection(command, self.host, self.port,
88                                            output_filter=filter_cert_log,
89                                            stderr_log=conn_log,
90                                            timeout=timeout)
91        elif self.transport == Transports.PLAIN:
92            conn = HTTPConnection(self.host, port=self.port,
93                                  timeout=timeout)
94
95        try:
96            for act in self.actions:
97                if type(act) is TestRequest:
98                    act.run(conn)
99                elif type(act) is TestRaw10:
100                    act.run(command, timeout)
101                else:
102                    raise TypeError(f'Unsupported action requested: {act!r}')
103        finally:
104            conn.close()
105            sys.stdout.flush()
106
107    @classmethod
108    def _from_yaml(cls, loader, node):
109        fields = loader.construct_mapping(node)
110        conn = TestConnection(**fields)
111        return conn
112
113
114
115class TestRequest(yaml.YAMLObject):
116    """Test action that sends an HTTP/1.1 request.
117
118    The path must be specified in the configuration file, all other
119    parameters (method, headers, expected response) have
120    defaults.
121
122    Options for checking the response currently are:
123    * require a specific response status
124    * require the body to exactly match a specific string
125    * require the body to contain all of a list of strings
126
127    """
128    yaml_tag = '!request'
129    def __init__(self, path, method='GET', headers=dict(),
130                 expect=dict(status=200)):
131        self.method = method
132        self.path = path
133        self.headers = headers
134        self.expect = expect
135
136    def __repr__(self):
137        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
138                f'method={self.method!r}, headers={self.headers!r}, '
139                f'expect={self.expect!r})')
140
141    def run(self, conn):
142        try:
143            conn.request(self.method, self.path, headers=self.headers)
144            resp = conn.getresponse()
145            if self.expects_conn_reset():
146                raise TestExpectationFailed(
147                    'Expected connection reset did not occur!')
148        except (BrokenPipeError, ConnectionResetError) as err:
149            if self.expects_conn_reset():
150                print('connection reset as expected.')
151                return
152            else:
153                raise err
154        body = resp.read().decode()
155        print(format_response(resp, body))
156        self.check_response(resp, body)
157
158    def check_headers(self, headers):
159        for name, expected in self.expect['headers'].items():
160            value = headers.get(name)
161            expected = subst_env(expected)
162            if value != expected:
163                raise TestExpectationFailed(
164                    f'Unexpected value in header {name}: "{value}", '
165                    f'expected "{expected}"')
166
167    def check_body(self, body):
168        """
169        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
170        >>> r1.check_body('test\\n')
171        >>> r1.check_body('xyz\\n')
172        Traceback (most recent call last):
173        ...
174        mgstest.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
175        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
176        >>> r2.check_body('test\\n')
177        >>> r2.check_body('est\\n')
178        Traceback (most recent call last):
179        ...
180        mgstest.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
181        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
182        >>> r3.check_body('test\\n')
183        """
184        if 'exactly' in self.expect['body'] \
185           and body != self.expect['body']['exactly']:
186            raise TestExpectationFailed(
187                f'Unexpected body: {body!r} != '
188                f'{self.expect["body"]["exactly"]!r}')
189        if 'contains' in self.expect['body']:
190            if type(self.expect['body']['contains']) is str:
191                self.expect['body']['contains'] = [
192                    self.expect['body']['contains']]
193            for s in self.expect['body']['contains']:
194                if not s in body:
195                    raise TestExpectationFailed(
196                        f'Unexpected body: {body!r} does not contain '
197                        f'{s!r}')
198
199    def check_response(self, response, body):
200        if self.expects_conn_reset():
201            raise TestExpectationFailed(
202                'Got a response, but connection should have failed!')
203        if response.status != self.expect['status']:
204            raise TestExpectationFailed(
205                f'Unexpected status: {response.status} != '
206                f'{self.expect["status"]}')
207        if 'headers' in self.expect:
208            self.check_headers(dict(response.getheaders()))
209        if 'body' in self.expect:
210            self.check_body(body)
211
212    def expects_conn_reset(self):
213        """Returns True if running this request is expected to fail due to the
214        connection being reset. That usually means the underlying TLS
215        connection failed.
216
217        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
218        >>> r1.expects_conn_reset()
219        False
220        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
221        >>> r2.expects_conn_reset()
222        True
223        """
224        if 'reset' in self.expect:
225            return self.expect['reset']
226        return False
227
228    @classmethod
229    def _from_yaml(cls, loader, node):
230        fields = loader.construct_mapping(node)
231        req = TestRequest(**fields)
232        return req
233
234
235
236class TestRaw10(TestRequest):
237    """Test action that sends a request using a minimal (and likely
238    incomplete) HTTP/1.0 test client for the one test case that
239    strictly requires HTTP/1.0.
240
241    All request parameters (method, path, headers) MUST be specified
242    in the config file. Checks on status and body work the same as for
243    TestRequest.
244
245    """
246    yaml_tag = '!raw10'
247    status_re = re.compile('^HTTP/([\d\.]+) (\d+) (.*)$')
248
249    def __init__(self, method, path, headers, expect):
250        self.method = method
251        self.path = path
252        self.headers = headers
253        self.expect = expect
254
255    def __repr__(self):
256        return (f'{self.__class__.__name__!s}'
257                f'(method={self.method!r}, path={self.path!r}, '
258                f'headers={self.headers!r}, expect={self.expect!r})')
259
260    def run(self, command, timeout=None):
261        req = f'{self.method} {self.path} HTTP/1.0\r\n'
262        for name, value in self.headers.items():
263            req = req + f'{name}: {value}\r\n'
264        req = req + f'\r\n'
265        proc = subprocess.Popen(command, stdout=subprocess.PIPE,
266                                stdin=subprocess.PIPE, close_fds=True,
267                                bufsize=0)
268        try:
269            # Note: errs will be empty because stderr is not captured
270            outs, errs = proc.communicate(input=req.encode(),
271                                          timeout=timeout)
272        except TimeoutExpired:
273            proc.kill()
274            outs, errs = proc.communicate()
275
276        # first line of the received data must be the status
277        status, rest = outs.decode().split('\r\n', maxsplit=1)
278        # headers and body are separated by double newline
279        headers, body = rest.split('\r\n\r\n', maxsplit=1)
280        # log response for debugging
281        print(f'{status}\n{headers}\n\n{body}')
282
283        m = self.status_re.match(status)
284        if m:
285            status_code = int(m.group(2))
286            status_expect = self.expect.get('status')
287            if status_expect and not status_code == status_expect:
288                raise TestExpectationFailed('Unexpected status code: '
289                                            f'{status}, expected '
290                                            f'{status_expect}')
291        else:
292            raise TestExpectationFailed(f'Invalid status line: "{status}"')
293
294        if 'body' in self.expect:
295            self.check_body(body)
296
297
298
299# Override the default constructors. Pyyaml ignores default parameters
300# otherwise.
301yaml.add_constructor('!request', TestRequest._from_yaml, yaml.Loader)
302yaml.add_constructor('!connection', TestConnection._from_yaml, yaml.Loader)
303
304
305
306def filter_cert_log(in_stream, out_stream):
307    """Filter to stop an erroneous gnutls-cli log message.
308
309    This function filters out a log line about loading client
310    certificates that is mistakenly sent to stdout from gnutls-cli. My
311    fix (https://gitlab.com/gnutls/gnutls/merge_requests/1125) has
312    been merged, but buggy binaries will probably be around for a
313    while.
314
315    The filter is meant to run in a multiprocessing.Process or
316    threading.Thread that receives the stdout of gnutls-cli as
317    in_stream, and a connection for further processing as out_stream.
318
319    """
320    import os
321    import select
322    # message to filter
323    cert_log = b'Processed 1 client X.509 certificates...\n'
324
325    # Set the input to non-blocking mode
326    fd = in_stream.fileno()
327    os.set_blocking(fd, False)
328
329    # The poll object allows waiting for events on non-blocking IO
330    # channels.
331    poller = select.poll()
332    poller.register(fd)
333
334    init_done = False
335    run_loop = True
336    while run_loop:
337        # The returned tuples are file descriptor and event, but
338        # we're only listening on one stream anyway, so we don't
339        # need to check it here.
340        for x, event in poller.poll():
341            # Critical: "event" is a bitwise OR of the POLL* constants
342            if event & select.POLLIN or event & select.POLLPRI:
343                data = in_stream.read()
344                if not init_done:
345                    # If the erroneous log line shows up it's the
346                    # first piece of data we receive. Just copy
347                    # everything after.
348                    init_done = True
349                    if cert_log in data:
350                        data = data.replace(cert_log, b'')
351                out_stream.send(data)
352            if event & select.POLLHUP or event & select.POLLRDHUP:
353                # Stop the loop, but process any other events that
354                # might be in the list returned by poll() first.
355                run_loop = False
356
357    in_stream.close()
358    out_stream.close()
359
360
361
362def format_response(resp, body):
363    s = f'{resp.status} {resp.reason}\n'
364    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
365    s = s + '\n\n' + body
366    return s
367
368
369
370def subst_env(text):
371    t = Template(text)
372    return t.substitute(os.environ)
373
374
375
376def run_test_conf(test_config, timeout=5.0, conn_log=None):
377    conns = None
378
379    config = yaml.load(test_config, Loader=yaml.Loader)
380    if type(config) is TestConnection:
381        conns = [config]
382    elif type(config) is list:
383        # assume list elements are connections
384        conns = config
385    else:
386        raise TypeError(f'Unsupported configuration: {config!r}')
387    print(conns)
388    sys.stdout.flush()
389
390    for i, test_conn in enumerate(conns):
391        if test_conn.description:
392            print(f'Running test connection {i}: {test_conn.description}')
393        else:
394            print(f'Running test connection {i}.')
395        sys.stdout.flush()
396        test_conn.run(timeout=timeout, conn_log=conn_log)
Note: See TracBrowser for help on using the repository browser.