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

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

TestReq10: Implement checking expected headers

  • Property mode set to 100644
File size: 17.9 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
21The test configuration file defines either a TestConnection, or a list
22of them. Each connection contains a list of actions to run using this
23connection. The actions define their expected results, if an
24expectation is not met mgstest.TestExpectationFailed is raised.
25
26Example of a connection that runs two request actions, which are
27expected to succeed:
28
29```yaml
30!connection
31# "host" defaults to $TEST_TARGET, so usually there's no need to set
32# it. You can use ${VAR} to substitute environment variables.
33host: 'localhost'
34# "port" defaults to $TEST_PORT, so usually there's no need to set it
35# it. You can use ${VAR} to substitute environment variables.
36port: '${TEST_PORT}'
37# All elements of gnutls_params will be prefixed with "--" and passed
38# to gnutls-cli on the command line.
39gnutls_params:
40  - x509cafile=authority/x509.pem
41# The transport encryption. "Gnutls" is the default, "plain" can be
42# set to get an unencrypted connection (e.g. to test redirection to
43# HTTPS).
44transport: 'gnutls'
45description='This connection description will be logged.'
46actions:
47  - !request
48    # GET is the default.
49    method: GET
50    # The path part of the URL, required.
51    path: /test.txt
52    # "Expect" defines how the response must look to pass the test.
53    expect:
54      # 200 (OK) is the default.
55      status: 200
56      # The response body is analyzed only if the "body" element
57      # exists, otherwise any content is accepted.
58      body:
59        # The full response body must exactly match this string.
60        exactly: |
61          test
62  - !request
63    path: /status?auto
64    expect:
65      # The headers are analyzed only if the "headers" element exists.
66      headers:
67        # The Content-Type header must be present with exactly this
68        # value. You can use ${VAR} to substitute environment
69        # variables in the value.
70        Content-Type: 'text/plain; charset=ISO-8859-1'
71      body:
72        # All strings in this list must occur in the body, in any
73        # order. "Contains" may also contain a single string instead
74        # of a list.
75        contains:
76          - 'Using GnuTLS version: '
77          - 'Current TLS session: (TLS1.3)'
78```
79
80Example of a connection that is expected to fail at the TLS level, in
81this case because the configured CA is not the one that issued the
82server certificate:
83
84```yaml
85- !connection
86  gnutls_params:
87    - x509cafile=rogueca/x509.pem
88  actions:
89    - !request
90      path: /
91      expect:
92        # The connection is expected to reset without an HTTP response.
93        reset: yes
94```
95
96"""
97
98import os
99import re
100import select
101import subprocess
102import sys
103import yaml
104
105from enum import Enum, auto
106from http.client import HTTPConnection
107from string import Template
108
109from . import TestExpectationFailed
110from .http import HTTPSubprocessConnection
111
112class Transports(Enum):
113    """Transports supported by TestConnection."""
114    GNUTLS = auto()
115    PLAIN = auto()
116
117    def __repr__(self):
118        return f'{self.__class__.__name__!s}.{self.name}'
119
120class TestConnection(yaml.YAMLObject):
121    """An HTTP connection in a test. It includes parameters for the
122    transport, and the actions (e.g. sending requests) to take using
123    this connection.
124
125    Note that running one TestConnection object may result in multiple
126    sequential network connections if the transport gets closed in a
127    non-failure way (e.g. following a "Connection: close" request) and
128    there are more actions, or (rarely) if an action requires its own
129    transport.
130
131    """
132    yaml_tag = '!connection'
133
134    def __init__(self, actions, host=None, port=None, gnutls_params=[],
135                 transport='gnutls', description=None):
136        self.gnutls_params = gnutls_params
137        self.actions = actions
138        self.transport = Transports[transport.upper()]
139        self.description = description
140        if host:
141            self.host = subst_env(host)
142        else:
143            self.host = os.environ.get('TEST_TARGET', 'localhost')
144        if port:
145            self.port = int(subst_env(port))
146        else:
147            self.port = int(os.environ.get('TEST_PORT', 8000))
148
149    def __repr__(self):
150        return (f'{self.__class__.__name__!s}'
151                f'(host={self.host!r}, port={self.port!r}, '
152                f'gnutls_params={self.gnutls_params!r}, '
153                f'actions={self.actions!r}, transport={self.transport!r}, '
154                f'description={self.description!r})')
155
156    def run(self, timeout=5.0, conn_log=None, response_log=None):
157        """Set up an HTTP connection and run the configured actions."""
158
159        # note: "--logfile" option requires GnuTLS version >= 3.6.7
160        command = ['gnutls-cli', '--logfile=/dev/stderr']
161        for s in self.gnutls_params:
162            command.append('--' + s)
163        command = command + ['-p', str(self.port), self.host]
164
165        if self.transport == Transports.GNUTLS:
166            conn = HTTPSubprocessConnection(command, self.host, self.port,
167                                            output_filter=filter_cert_log,
168                                            stderr_log=conn_log,
169                                            timeout=timeout)
170        elif self.transport == Transports.PLAIN:
171            conn = HTTPConnection(self.host, port=self.port,
172                                  timeout=timeout)
173
174        try:
175            for act in self.actions:
176                if type(act) is TestRequest:
177                    act.run(conn, response_log)
178                elif type(act) is TestReq10:
179                    act.run(command, timeout, conn_log, response_log)
180                else:
181                    raise TypeError(f'Unsupported action requested: {act!r}')
182        finally:
183            conn.close()
184            sys.stdout.flush()
185
186    @classmethod
187    def from_yaml(cls, loader, node):
188        fields = loader.construct_mapping(node)
189        conn = cls(**fields)
190        return conn
191
192
193
194class TestRequest(yaml.YAMLObject):
195    """Test action that sends an HTTP/1.1 request.
196
197    The path must be specified in the configuration file, all other
198    parameters (method, headers, expected response) have
199    defaults.
200
201    Options for checking the response currently are:
202    * require a specific response status
203    * require specific headers to be present with specific values
204    * require the body to exactly match a specific string
205    * require the body to contain all of a list of strings
206
207    """
208    yaml_tag = '!request'
209    def __init__(self, path, method='GET', headers=dict(),
210                 expect=dict(status=200)):
211        self.method = method
212        self.path = path
213        self.headers = headers
214        self.expect = expect
215
216    def __repr__(self):
217        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
218                f'method={self.method!r}, headers={self.headers!r}, '
219                f'expect={self.expect!r})')
220
221    def run(self, conn, response_log=None):
222        try:
223            conn.request(self.method, self.path, headers=self.headers)
224            resp = conn.getresponse()
225            if self.expects_conn_reset():
226                raise TestExpectationFailed(
227                    'Expected connection reset did not occur!')
228        except (BrokenPipeError, ConnectionResetError) as err:
229            if self.expects_conn_reset():
230                print('connection reset as expected.')
231                return
232            else:
233                raise err
234        body = resp.read().decode()
235        log_str = format_response(resp, body)
236        print(log_str)
237        if response_log:
238            print(log_str, file=response_log)
239        self.check_response(resp, body)
240
241    def check_headers(self, headers):
242        for name, expected in self.expect['headers'].items():
243            value = headers.get(name)
244            expected = subst_env(expected)
245            if value != expected:
246                raise TestExpectationFailed(
247                    f'Unexpected value in header {name}: "{value}", '
248                    f'expected "{expected}"')
249
250    def check_body(self, body):
251        """
252        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
253        >>> r1.check_body('test\\n')
254        >>> r1.check_body('xyz\\n')
255        Traceback (most recent call last):
256        ...
257        mgstest.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
258        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
259        >>> r2.check_body('test\\n')
260        >>> r2.check_body('est\\n')
261        Traceback (most recent call last):
262        ...
263        mgstest.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
264        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
265        >>> r3.check_body('test\\n')
266        """
267        if 'exactly' in self.expect['body'] \
268           and body != self.expect['body']['exactly']:
269            raise TestExpectationFailed(
270                f'Unexpected body: {body!r} != '
271                f'{self.expect["body"]["exactly"]!r}')
272        if 'contains' in self.expect['body']:
273            if type(self.expect['body']['contains']) is str:
274                self.expect['body']['contains'] = [
275                    self.expect['body']['contains']]
276            for s in self.expect['body']['contains']:
277                if not s in body:
278                    raise TestExpectationFailed(
279                        f'Unexpected body: {body!r} does not contain '
280                        f'{s!r}')
281
282    def check_response(self, response, body):
283        if self.expects_conn_reset():
284            raise TestExpectationFailed(
285                'Got a response, but connection should have failed!')
286        if response.status != self.expect['status']:
287            raise TestExpectationFailed(
288                f'Unexpected status: {response.status} != '
289                f'{self.expect["status"]}')
290        if 'headers' in self.expect:
291            self.check_headers(dict(response.getheaders()))
292        if 'body' in self.expect:
293            self.check_body(body)
294
295    def expects_conn_reset(self):
296        """Returns True if running this request is expected to fail due to the
297        connection being reset. That usually means the underlying TLS
298        connection failed.
299
300        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
301        >>> r1.expects_conn_reset()
302        False
303        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
304        >>> r2.expects_conn_reset()
305        True
306        """
307        if 'reset' in self.expect:
308            return self.expect['reset']
309        return False
310
311    @classmethod
312    def from_yaml(cls, loader, node):
313        fields = loader.construct_mapping(node)
314        req = cls(**fields)
315        return req
316
317
318
319class TestReq10(TestRequest):
320    """Test action that sends a request using a minimal (and likely
321    incomplete) HTTP/1.0 test client for the one test case that
322    strictly requires HTTP/1.0.
323
324    TestReq10 objects use the same YAML parameters and defaults as
325    TestRequest, but note that an empty "headers" parameter means that
326    not even a "Host:" header will be sent. All headers must be
327    specified in the test configuration file.
328
329    """
330    yaml_tag = '!request10'
331    status_re = re.compile(r'^HTTP/([\d\.]+) (\d+) (.*)$')
332    header_re = re.compile(r'^([-\w]+):\s+(.*)$')
333
334    def __init__(self, **kwargs):
335        super().__init__(**kwargs)
336
337    def __repr__(self):
338        return (f'{self.__class__.__name__!s}'
339                f'(method={self.method!r}, path={self.path!r}, '
340                f'headers={self.headers!r}, expect={self.expect!r})')
341
342    def run(self, command, timeout=None, conn_log=None, response_log=None):
343        req = f'{self.method} {self.path} HTTP/1.0\r\n'
344        for name, value in self.headers.items():
345            req = req + f'{name}: {value}\r\n'
346        req = req + f'\r\n'
347        proc = subprocess.Popen(command,
348                                stdout=subprocess.PIPE,
349                                stderr=subprocess.PIPE,
350                                stdin=subprocess.PIPE,
351                                close_fds=True,
352                                bufsize=0)
353        try:
354            outs, errs = proc.communicate(input=req.encode(),
355                                          timeout=timeout)
356        except TimeoutExpired:
357            proc.kill()
358            outs, errs = proc.communicate()
359
360        print(errs.decode())
361        if conn_log:
362            print(errs.decode(), file=conn_log)
363
364        # first line of the received data must be the status
365        status, rest = outs.decode().split('\r\n', maxsplit=1)
366        # headers and body are separated by double newline
367        head, body = rest.split('\r\n\r\n', maxsplit=1)
368        # log response for debugging
369        print(f'{status}\n{head}\n\n{body}')
370        if response_log:
371            print(f'{status}\n{head}\n\n{body}', file=response_log)
372
373        m = self.status_re.match(status)
374        if m:
375            status_code = int(m.group(2))
376            status_expect = self.expect.get('status')
377            if status_expect and not status_code == status_expect:
378                raise TestExpectationFailed('Unexpected status code: '
379                                            f'{status}, expected '
380                                            f'{status_expect}')
381        else:
382            raise TestExpectationFailed(f'Invalid status line: "{status}"')
383
384        if 'headers' in self.expect:
385            headers = dict()
386            for line in head.splitlines():
387                m = self.header_re.fullmatch(line)
388                if m:
389                    headers[m.group(1)] = m.group(2)
390            self.check_headers(headers)
391
392        if 'body' in self.expect:
393            self.check_body(body)
394
395
396
397def filter_cert_log(in_stream, out_stream):
398    """Filter to stop an erroneous gnutls-cli log message.
399
400    This function filters out a log line about loading client
401    certificates that is mistakenly sent to stdout from gnutls-cli. My
402    fix (https://gitlab.com/gnutls/gnutls/merge_requests/1125) has
403    been merged, but buggy binaries will probably be around for a
404    while.
405
406    The filter is meant to run in a multiprocessing.Process or
407    threading.Thread that receives the stdout of gnutls-cli as
408    in_stream, and a connection for further processing as out_stream.
409
410    """
411    # message to filter
412    cert_log = b'Processed 1 client X.509 certificates...\n'
413
414    # Set the input to non-blocking mode
415    fd = in_stream.fileno()
416    os.set_blocking(fd, False)
417
418    # The poll object allows waiting for events on non-blocking IO
419    # channels.
420    poller = select.poll()
421    poller.register(fd)
422
423    init_done = False
424    run_loop = True
425    while run_loop:
426        # The returned tuples are file descriptor and event, but
427        # we're only listening on one stream anyway, so we don't
428        # need to check it here.
429        for x, event in poller.poll():
430            # Critical: "event" is a bitwise OR of the POLL* constants
431            if event & select.POLLIN or event & select.POLLPRI:
432                data = in_stream.read()
433                if not init_done:
434                    # If the erroneous log line shows up it's the
435                    # first piece of data we receive. Just copy
436                    # everything after.
437                    init_done = True
438                    if cert_log in data:
439                        data = data.replace(cert_log, b'')
440                out_stream.send(data)
441            if event & select.POLLHUP or event & select.POLLRDHUP:
442                # Stop the loop, but process any other events that
443                # might be in the list returned by poll() first.
444                run_loop = False
445
446    in_stream.close()
447    out_stream.close()
448
449
450
451def format_response(resp, body):
452    """Format an http.client.HTTPResponse for logging."""
453    s = f'{resp.status} {resp.reason}\n'
454    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
455    s = s + '\n\n' + body
456    return s
457
458
459
460def subst_env(text):
461    """Use the parameter "text" as a template, substitute with environment
462    variables.
463
464    >>> os.environ['EXAMPLE_VAR'] = 'abc'
465    >>> subst_env('${EXAMPLE_VAR}def')
466    'abcdef'
467
468    """
469    t = Template(text)
470    return t.substitute(os.environ)
471
472
473
474def run_test_conf(test_config, timeout=5.0, conn_log=None, response_log=None):
475    """Load and run a test configuration.
476
477    The test_conf parameter must be a YAML file, defining one or more
478    TestConnections, to be run in order. The other three parameters
479    are forwarded to TestConnection.run().
480
481    """
482    conns = None
483
484    config = yaml.load(test_config, Loader=yaml.Loader)
485    if type(config) is TestConnection:
486        conns = [config]
487    elif type(config) is list:
488        # assume list elements are connections
489        conns = config
490    else:
491        raise TypeError(f'Unsupported configuration: {config!r}')
492    print(conns)
493    sys.stdout.flush()
494
495    for i, test_conn in enumerate(conns):
496        if test_conn.description:
497            print(f'Running test connection {i}: {test_conn.description}')
498        else:
499            print(f'Running test connection {i}.')
500        sys.stdout.flush()
501        test_conn.run(timeout=timeout, conn_log=conn_log,
502                      response_log=response_log)
Note: See TracBrowser for help on using the repository browser.