source: mod_gnutls/test/mgstest/tests.py @ 6d3dc34

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

Split infrastructure from https-test-client.py into modules

  • Property mode set to 100644
File size: 10.4 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
17import re
18import subprocess
19import yaml
20
21from . import TestExpectationFailed
22from .http import HTTPSubprocessConnection
23
24class TestConnection(yaml.YAMLObject):
25    yaml_tag = '!connection'
26
27    def __init__(self, actions, gnutls_params=[], transport='gnutls'):
28        self.gnutls_params = gnutls_params
29        self.actions = actions
30        self.transport = transport
31
32    def __repr__(self):
33        return (f'{self.__class__.__name__!s}'
34                f'(gnutls_params={self.gnutls_params!r}, '
35                f'actions={self.actions!r}, transport={self.transport!r})')
36
37    def run(self, host, port, timeout=5.0):
38        # note: "--logfile" option requires GnuTLS version >= 3.6.7
39        command = ['gnutls-cli', '--logfile=/dev/stderr']
40        for s in self.gnutls_params:
41            command.append('--' + s)
42        command = command + ['-p', str(port), host]
43
44        conn = HTTPSubprocessConnection(command, host, port,
45                                        output_filter=filter_cert_log,
46                                        timeout=timeout)
47
48        try:
49            for act in self.actions:
50                if type(act) is TestRequest:
51                    act.run(conn)
52                elif type(act) is TestRaw10:
53                    act.run(command, timeout)
54                else:
55                    raise TypeError(f'Unsupported action requested: {act!r}')
56        finally:
57            conn.close()
58
59    @classmethod
60    def _from_yaml(cls, loader, node):
61        fields = loader.construct_mapping(node)
62        conn = TestConnection(**fields)
63        return conn
64
65
66
67class TestRequest(yaml.YAMLObject):
68    yaml_tag = '!request'
69    def __init__(self, path, method='GET', headers=dict(),
70                 expect=dict(status=200)):
71        self.method = method
72        self.path = path
73        self.headers = headers
74        self.expect = expect
75
76    def __repr__(self):
77        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
78                f'method={self.method!r}, headers={self.headers!r}, '
79                f'expect={self.expect!r})')
80
81    def run(self, conn):
82        try:
83            conn.request(self.method, self.path, headers=self.headers)
84            resp = conn.getresponse()
85        except ConnectionResetError as err:
86            if self.expects_conn_reset():
87                print('connection reset as expected.')
88                return
89            else:
90                raise err
91        body = resp.read().decode()
92        print(format_response(resp, body))
93        self.check_response(resp, body)
94
95    def check_body(self, body):
96        """
97        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
98        >>> r1.check_body('test\\n')
99        >>> r1.check_body('xyz\\n')
100        Traceback (most recent call last):
101        ...
102        https-test-client.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
103        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
104        >>> r2.check_body('test\\n')
105        >>> r2.check_body('est\\n')
106        Traceback (most recent call last):
107        ...
108        https-test-client.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
109        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
110        >>> r3.check_body('test\\n')
111        """
112        if 'exactly' in self.expect['body'] \
113           and body != self.expect['body']['exactly']:
114            raise TestExpectationFailed(
115                f'Unexpected body: {body!r} != '
116                f'{self.expect["body"]["exactly"]!r}')
117        if 'contains' in self.expect['body']:
118            if type(self.expect['body']['contains']) is str:
119                self.expect['body']['contains'] = [
120                    self.expect['body']['contains']]
121            for s in self.expect['body']['contains']:
122                if not s in body:
123                    raise TestExpectationFailed(
124                        f'Unexpected body: {body!r} does not contain '
125                        f'{s!r}')
126
127    def check_response(self, response, body):
128        if self.expects_conn_reset():
129            raise TestExpectationFailed(
130                'Got a response, but connection should have failed!')
131        if response.status != self.expect['status']:
132            raise TestExpectationFailed(
133                f'Unexpected status: {response.status} != '
134                f'{self.expect["status"]}')
135        if 'body' in self.expect:
136            self.check_body(body)
137
138    def expects_conn_reset(self):
139        """Returns True if running this request is expected to fail due to the
140        connection being reset. That usually means the underlying TLS
141        connection failed.
142
143        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
144        >>> r1.expects_conn_reset()
145        False
146        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
147        >>> r2.expects_conn_reset()
148        True
149        """
150        if 'reset' in self.expect:
151            return self.expect['reset']
152        return False
153
154    @classmethod
155    def _from_yaml(cls, loader, node):
156        fields = loader.construct_mapping(node)
157        req = TestRequest(**fields)
158        return req
159
160
161
162class TestRaw10(TestRequest):
163    """This is a minimal (and likely incomplete) HTTP/1.0 test client for
164    the one test case that strictly requires HTTP/1.0. All request
165    parameters (method, path, headers) MUST be specified in the config
166    file.
167
168    """
169    yaml_tag = '!raw10'
170    status_re = re.compile('^HTTP/([\d\.]+) (\d+) (.*)$')
171
172    def __init__(self, method, path, headers, expect):
173        self.method = method
174        self.path = path
175        self.headers = headers
176        self.expect = expect
177
178    def __repr__(self):
179        return (f'{self.__class__.__name__!s}'
180                f'(method={self.method!r}, path={self.path!r}, '
181                f'headers={self.headers!r}, expect={self.expect!r})')
182
183    def run(self, command, timeout=None):
184        req = f'{self.method} {self.path} HTTP/1.0\r\n'
185        for name, value in self.headers.items():
186            req = req + f'{name}: {value}\r\n'
187        req = req + f'\r\n'
188        proc = subprocess.Popen(command, stdout=subprocess.PIPE,
189                                stdin=subprocess.PIPE, close_fds=True,
190                                bufsize=0)
191        try:
192            # Note: errs will be empty because stderr is not captured
193            outs, errs = proc.communicate(input=req.encode(),
194                                          timeout=timeout)
195        except TimeoutExpired:
196            proc.kill()
197            outs, errs = proc.communicate()
198
199        # first line of the received data must be the status
200        status, rest = outs.decode().split('\r\n', maxsplit=1)
201        # headers and body are separated by double newline
202        headers, body = rest.split('\r\n\r\n', maxsplit=1)
203        # log response for debugging
204        print(f'{status}\n{headers}\n\n{body}')
205
206        m = self.status_re.match(status)
207        if m:
208            status_code = int(m.group(2))
209            status_expect = self.expect.get('status')
210            if status_expect and not status_code == status_expect:
211                raise TestExpectationFailed('Unexpected status code: '
212                                            f'{status}, expected '
213                                            f'{status_expect}')
214        else:
215            raise TestExpectationFailed(f'Invalid status line: "{status}"')
216
217        if 'body' in self.expect:
218            self.check_body(body)
219
220
221
222# Override the default constructors. Pyyaml ignores default parameters
223# otherwise.
224yaml.add_constructor('!request', TestRequest._from_yaml, yaml.Loader)
225yaml.add_constructor('!connection', TestConnection._from_yaml, yaml.Loader)
226
227
228
229def filter_cert_log(in_stream, out_stream):
230    import fcntl
231    import os
232    import select
233    # This filters out a log line about loading client
234    # certificates that is mistakenly sent to stdout. My fix has
235    # been merged, but buggy binaries will probably be around for
236    # a while.
237    # https://gitlab.com/gnutls/gnutls/merge_requests/1125
238    cert_log = b'Processed 1 client X.509 certificates...\n'
239
240    # Set the input to non-blocking mode
241    fd = in_stream.fileno()
242    fl = fcntl.fcntl(fd, fcntl.F_GETFL)
243    fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
244
245    # The poll object allows waiting for events on non-blocking IO
246    # channels.
247    poller = select.poll()
248    poller.register(fd)
249
250    init_done = False
251    run_loop = True
252    while run_loop:
253        # The returned tuples are file descriptor and event, but
254        # we're only listening on one stream anyway, so we don't
255        # need to check it here.
256        for x, event in poller.poll():
257            # Critical: "event" is a bitwise OR of the POLL* constants
258            if event & select.POLLIN or event & select.POLLPRI:
259                data = in_stream.read()
260                if not init_done:
261                    # If the erroneous log line shows up it's the
262                    # first piece of data we receive. Just copy
263                    # everything after.
264                    init_done = True
265                    if cert_log in data:
266                        data = data.replace(cert_log, b'')
267                out_stream.send(data)
268            if event & select.POLLHUP or event & select.POLLRDHUP:
269                # Stop the loop, but process any other events that
270                # might be in the list returned by poll() first.
271                run_loop = False
272
273    in_stream.close()
274    out_stream.close()
275
276
277
278def format_response(resp, body):
279    s = f'{resp.status} {resp.reason}\n'
280    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
281    s = s + '\n\n' + body
282    return s
Note: See TracBrowser for help on using the repository browser.