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

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

Support checking response headers

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