source: mod_gnutls/test/mgstest/tests.py @ eb84747

asynciomainproxy-ticket
Last change on this file since eb84747 was eb84747, checked in by Fiona Klute <fiona.klute@…>, 3 years ago

TestConnection?: Add optional "description" attribute for logging

  • Property mode set to 100644
File size: 13.3 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 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 fcntl
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    fl = fcntl.fcntl(fd, fcntl.F_GETFL)
325    fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
326
327    # The poll object allows waiting for events on non-blocking IO
328    # channels.
329    poller = select.poll()
330    poller.register(fd)
331
332    init_done = False
333    run_loop = True
334    while run_loop:
335        # The returned tuples are file descriptor and event, but
336        # we're only listening on one stream anyway, so we don't
337        # need to check it here.
338        for x, event in poller.poll():
339            # Critical: "event" is a bitwise OR of the POLL* constants
340            if event & select.POLLIN or event & select.POLLPRI:
341                data = in_stream.read()
342                if not init_done:
343                    # If the erroneous log line shows up it's the
344                    # first piece of data we receive. Just copy
345                    # everything after.
346                    init_done = True
347                    if cert_log in data:
348                        data = data.replace(cert_log, b'')
349                out_stream.send(data)
350            if event & select.POLLHUP or event & select.POLLRDHUP:
351                # Stop the loop, but process any other events that
352                # might be in the list returned by poll() first.
353                run_loop = False
354
355    in_stream.close()
356    out_stream.close()
357
358
359
360def format_response(resp, body):
361    s = f'{resp.status} {resp.reason}\n'
362    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
363    s = s + '\n\n' + body
364    return s
365
366
367
368def subst_env(text):
369    t = Template(text)
370    return t.substitute(os.environ)
Note: See TracBrowser for help on using the repository browser.