| 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()
|