Option C oc_diagnostics
Loading...
Searching...
No Matches
test_fastapi_middleware.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# tests/test_fastapi_middleware.py
4
5"""
6Regression Tests — fastapi_middleware
7=======================================
8
9Purpose
10-------
11Verify the ASGI contract of ``DiagnosticsMiddleware``: correct HTTP scope
12handling, non-HTTP passthrough, exception logging and re-raising, header
13redaction, elapsed-time reporting, and lifespan scope passthrough.
14
15Responsibilities
16----------------
17- Confirm HTTP status codes are captured and passed through to the client.
18- Confirm non-HTTP scopes (WebSocket, lifespan) are forwarded unchanged.
19- Confirm application exceptions are logged at L2 and then re-raised.
20- Confirm sensitive request headers are redacted in log output (FS-17).
21- Confirm elapsed time in milliseconds appears in the completion log line.
22- Confirm lifespan scope passes through without any HTTP logging.
23
24Diagnostics
25-----------
26Domain: TEST
27Levels:
28 L2 — lifecycle
29 L3 — semantic details
30 L4 — deep tracing
31
32Contracts
33---------
34- Must not permanently modify oc_diagnostics global state.
35- Must restore all patched globals and mocked internals in finally blocks.
36- Must not start servers or require network access.
37- Must run deterministically across repeated invocations.
38- Pure ASGI contract — does not require the FastAPI package.
39"""
40
41import asyncio
42import os
43import sys
44import unittest.mock as mock
45
46# ---------------------------------------------------------------------------
47# Ensure oc_diagnostics is importable when run from the project root or the
48# tests/ subdirectory.
49# ---------------------------------------------------------------------------
50_REPO_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
51if _REPO_ROOT not in sys.path:
52 sys.path.insert(0, _REPO_ROOT)
53
55from oc_diagnostics.fastapi_middleware import DiagnosticsMiddleware
56
57
58def _make_http_scope(method="GET", path="/ping", headers=None, client=("127.0.0.1", 9999)):
59 """Build a minimal HTTP ASGI scope dict for testing."""
60 return {
61 "type": "http",
62 "method": method,
63 "path": path,
64 "query_string": b"",
65 "headers": headers or [],
66 "client": client,
67 }
68
69
70# ============================================================
71# HTTP SCOPE
72# ============================================================
73
75 """HTTP response status code is captured and passed through to the caller."""
76 async def _run():
77 received = []
78
79 async def mock_app(scope, receive, send):
80 await send({"type": "http.response.start", "status": 201, "headers": []})
81 await send({"type": "http.response.body", "body": b""})
82
83 async def mock_send(message):
84 received.append(message)
85
86 middleware = DiagnosticsMiddleware(mock_app, domain="TEST_HTTP")
87 await middleware(_make_http_scope(), None, mock_send)
88 assert any(m.get("status") == 201 for m in received), (
89 f"HTTP response status must be passed through; received: {received!r}"
90 )
91
92 asyncio.run(_run())
93
94
95# ============================================================
96# NON-HTTP SCOPES
97# ============================================================
98
100 """WebSocket scope is forwarded to the app without any HTTP logging."""
101 async def _run():
102 called = []
103
104 async def mock_app(scope, receive, send):
105 called.append(scope["type"])
106
107 scope = {"type": "websocket", "path": "/ws"}
108 middleware = DiagnosticsMiddleware(mock_app, domain="TEST_HTTP")
109 await middleware(scope, None, None)
110 assert called == ["websocket"], (
111 f"WebSocket scope must pass through unchanged; app called with: {called!r}"
112 )
113
114 asyncio.run(_run())
115
116
118 """lifespan scope is forwarded to the app without any HTTP logging."""
119 async def _run():
120 called = []
121
122 async def mock_app(scope, receive, send):
123 called.append(scope["type"])
124
125 scope = {"type": "lifespan", "asgi": {"version": "3.0"}}
126 middleware = DiagnosticsMiddleware(mock_app, domain="TEST_HTTP")
127 await middleware(scope, None, None)
128 assert called == ["lifespan"], (
129 f"lifespan scope must pass through unchanged; app called with: {called!r}"
130 )
131
132 asyncio.run(_run())
133
134
135# ============================================================
136# EXCEPTION HANDLING
137# ============================================================
138
140 """Application exceptions are logged at L2 and then re-raised unchanged."""
141 async def _run():
142 async def error_app(scope, receive, send):
143 await send({"type": "http.response.start", "status": 500, "headers": []})
144 raise ValueError("deliberate-test-error")
145
146 async def mock_send(message):
147 pass
148
149 middleware = DiagnosticsMiddleware(error_app, domain="TEST_HTTP")
150 raised = None
151 try:
152 await middleware(_make_http_scope(method="POST", path="/err"), None, mock_send)
153 except ValueError as exc:
154 raised = exc
155 assert raised is not None, "ValueError must be re-raised by middleware"
156 assert "deliberate-test-error" in str(raised), (
157 f"Expected 'deliberate-test-error' in exception, got: {raised!r}"
158 )
159
160 asyncio.run(_run())
161
162
163# ============================================================
164# HEADER REDACTION (FS-17)
165# ============================================================
166
168 """Sensitive request headers appear as [REDACTED] in log output; others are not redacted."""
169 logged = []
170 old_emit = _ud._emit
171 old_redact = _ud.HTTP_REDACT_HEADERS
172 old_levels = _ud.DEBUG_LEVELS.copy()
173
174 def _capture(line, level, record=None):
175 logged.append(line)
176
177 _ud._emit = _capture
178 _ud.HTTP_REDACT_HEADERS = frozenset(["authorization", "cookie"])
179 _ud.DEBUG_LEVELS["_REDACT_TEST_"] = 9
180
181 async def _run():
182 async def mock_app(scope, receive, send):
183 await send({"type": "http.response.start", "status": 200, "headers": []})
184 await send({"type": "http.response.body", "body": b""})
185
186 async def mock_send(message):
187 pass
188
189 scope = _make_http_scope(
190 path="/secure",
191 headers=[
192 (b"authorization", b"Bearer secret-token"),
193 (b"content-type", b"application/json"),
194 ],
195 )
196 middleware = DiagnosticsMiddleware(mock_app, domain="_REDACT_TEST_")
197 await middleware(scope, None, mock_send)
198
199 try:
200 asyncio.run(_run())
201 finally:
202 _ud._emit = old_emit
203 _ud.HTTP_REDACT_HEADERS = old_redact
204 _ud.DEBUG_LEVELS.clear()
205 _ud.DEBUG_LEVELS.update(old_levels)
206
207 auth_lines = [l for l in logged if "authorization" in l.lower()]
208 assert auth_lines, (
209 f"Expected authorization header in log output; got: {logged!r}"
210 )
211 for line in auth_lines:
212 assert "[REDACTED]" in line, (
213 f"Authorization header must be [REDACTED], got: {line!r}"
214 )
215 ct_lines = [l for l in logged if "content-type" in l.lower()]
216 for line in ct_lines:
217 assert "[REDACTED]" not in line, (
218 f"content-type header must NOT be redacted, got: {line!r}"
219 )
220
221
222# ============================================================
223# ELAPSED TIME
224# ============================================================
225
227 """Elapsed milliseconds appear in the completion log line for HTTP requests."""
228 logged = []
229 old_emit = _ud._emit
230 old_levels = _ud.DEBUG_LEVELS.copy()
231
232 def _capture(line, level, record=None):
233 logged.append(line)
234
235 _ud._emit = _capture
236 _ud.DEBUG_LEVELS["_ELAPSED_TEST_"] = 9
237
238 async def _run():
239 async def mock_app(scope, receive, send):
240 await send({"type": "http.response.start", "status": 200, "headers": []})
241 await send({"type": "http.response.body", "body": b""})
242
243 async def mock_send(message):
244 pass
245
246 middleware = DiagnosticsMiddleware(mock_app, domain="_ELAPSED_TEST_")
247 await middleware(_make_http_scope(path="/timed"), None, mock_send)
248
249 try:
250 asyncio.run(_run())
251 finally:
252 _ud._emit = old_emit
253 _ud.DEBUG_LEVELS.clear()
254 _ud.DEBUG_LEVELS.update(old_levels)
255
256 ms_lines = [l for l in logged if "ms" in l]
257 assert ms_lines, (
258 f"Expected elapsed time in milliseconds in log output, got: {logged!r}"
259 )
_make_http_scope(method="GET", path="/ping", headers=None, client=("127.0.0.1", 9999))