source: mod_gnutls/test/mgstest/tests.py @ 8b72599

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

https-test-client.py: Flush sys.stdout so connection output doesn't mix

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