source: mod_gnutls/test/mgstest/tests.py @ 779406c

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

Always log test connection number

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