source: mod_gnutls/test/mgstest/tests.py @ 3be92d3

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

Optionally log gnutls-cli stderr output to another stream/file

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