Viewing files at commit a3ea065926c9Commit detailsView latest

Browse files
tests/test_robustness_faultproxy.py 116 lines · 4.5 KB · python
1
"""Fault-injecting proxy (suites/robustness) against a real loopback upstream."""
2
3
from __future__ import annotations
4
5
import http.client
6
import sys
7
import threading
8
import unittest
9
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
10
from pathlib import Path
11
12
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "suites" / "robustness"))
13
14
from robustness.faultproxy import Fault, FaultProxy  # noqa: E402
15
16
17
class Upstream:
18
    def __init__(self):
19
        self.seen: list[tuple[str, bytes]] = []
20
        outer = self
21
22
        class Handler(BaseHTTPRequestHandler):
23
            protocol_version = "HTTP/1.1"
24
25
            def log_message(self, *args):
26
                pass
27
28
            def do_POST(self):
29
                body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
30
                outer.seen.append((self.path, body))
31
                payload = b"applied:" + body * 20
32
                self.send_response(200)
33
                self.send_header("Content-Length", str(len(payload)))
34
                self.end_headers()
35
                self.wfile.write(payload)
36
37
            do_GET = do_POST
38
39
        self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
40
        self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True)
41
        self.thread.start()
42
43
    @property
44
    def port(self) -> int:
45
        return self.httpd.server_address[1]
46
47
    def stop(self):
48
        self.httpd.shutdown()
49
        self.httpd.server_close()
50
51
52
def post(port: int, path: str, body: bytes = b"x") -> tuple[int, bytes]:
53
    conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
54
    try:
55
        conn.request("POST", path, body=body)
56
        response = conn.getresponse()
57
        return response.status, response.read()
58
    finally:
59
        conn.close()
60
61
62
class FaultProxyTests(unittest.TestCase):
63
    def setUp(self):
64
        self.upstream = Upstream()
65
66
    def tearDown(self):
67
        self.upstream.stop()
68
69
    def test_passthrough_records_requests(self):
70
        with FaultProxy(self.upstream.port) as proxy:
71
            status, data = post(proxy.port, "/api/push?x=1", b"abc")
72
        self.assertEqual(status, 200)
73
        self.assertTrue(data.startswith(b"applied:abc"))
74
        self.assertEqual(proxy.keys_in_order(), [("POST /api/push", 1)])
75
76
    def test_fail_status_never_forwards(self):
77
        with FaultProxy(self.upstream.port, [Fault("fail_status", key="POST /api/push", status=503)]) as proxy:
78
            status, _ = post(proxy.port, "/api/push")
79
            second, _ = post(proxy.port, "/api/push")
80
        self.assertEqual((status, second), (503, 200))
81
        self.assertEqual(len(self.upstream.seen), 1)  # only the second reached the server
82
83
    def test_drop_after_send_applies_then_loses_reply(self):
84
        with FaultProxy(self.upstream.port, [Fault("drop_after_send", key="POST /api/push")]) as proxy:
85
            with self.assertRaises((http.client.HTTPException, ConnectionError, OSError)):
86
                post(proxy.port, "/api/push")
87
        self.assertEqual(len(self.upstream.seen), 1)  # the server applied it: unknown outcome
88
        self.assertEqual(proxy.records[0].tag, "REPLY_DROPPED")
89
90
    def test_truncated_reply_is_detectably_incomplete(self):
91
        with FaultProxy(self.upstream.port, [Fault("truncate_reply", key="POST /api/push")]) as proxy:
92
            with self.assertRaises((http.client.IncompleteRead, http.client.HTTPException, ConnectionError, OSError)):
93
                post(proxy.port, "/api/push", b"0123456789")
94
95
    def test_kill_hooks_fire_before_and_after_upstream(self):
96
        calls: list[str] = []
97
        faults = [Fault("kill_before_forward", key="POST /a"), Fault("kill_after_upstream", key="POST /b")]
98
        with FaultProxy(self.upstream.port, faults, kill_hook=lambda: calls.append("kill")) as proxy:
99
            for path in ("/a", "/b"):
100
                with self.assertRaises((http.client.HTTPException, ConnectionError, OSError)):
101
                    post(proxy.port, path)
102
        self.assertEqual(calls, ["kill", "kill"])
103
        self.assertEqual([p for p, _ in self.upstream.seen], ["/b"])
104
105
    def test_occurrence_targets_the_nth_request(self):
106
        with FaultProxy(self.upstream.port, [Fault("fail_status", key="POST /p", occurrence=2)]) as proxy:
107
            statuses = [post(proxy.port, "/p")[0] for _ in range(3)]
108
        self.assertEqual(statuses, [200, 503, 200])
109
110
    def test_refuses_non_loopback_upstream(self):
111
        with self.assertRaises(ValueError):
112
            FaultProxy(443, upstream_host="104.18.0.1")
113
114
115
if __name__ == "__main__":
116
    unittest.main()