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

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

Override yaml.YAMLObject.from_yaml() instead of adding constructors

The code is simpler this way, and also avoids running PyYAML functions
on module load.

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