All gates green. Final report: **Verified (phase 167 final pass — all 6 tasks already in `complete/`)** - Live re-measure of the headline criterion on the **1,000-doc live-replica corpus** (restored `data/bor_live_kb_replica.dump.sql` into a scratch DB `bor_audit`, alembic 0011→0027, dropped after; the shared dev DB was never touched): `GET /api/docs/tree` default depth=1 = **8,474 identity → 1,128 B with `Content-Encoding: gzip`** (< 40 KB bar; ~2 MB defect fixed), repeat load **304 / 0 bytes**; depth=8 265,147→23,515; node drill 20,844→1,065; `/api/docs` 246,366→45,104; `/api/documents/content` 892,124→60,776 (`LOOK`, accepted-with-why). Reproduces `AUDIT.md` to within 1 byte. - Transport contracts proven live: `POST /api/chat` **no** `Content-Encoding` + frames arriving live + budget line `… enc=identity duration_ms=7380 stream=1`; HTML still `no-cache` with the `?v=` rewrite (and gzipped); A20 headers on 200 **and** 304; ETag identical across identity/gzip; `depth=0|9|abc`→422; anonymous tree/node→403; unknown folder/source→404 `unknown node`. **Gates (all 100% green)** - `uv run pytest` → **3980 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (`compression.py`, `http_cache.py`, `response_budget.py` 100%; `api/docs.py` 99%, one defensive dedupe line). - `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors. - Phase + regression E2E **in isolation** (`--junitxml`, exit codes): `test_kb_tree_lazy_load` 11, `test_kb_tree` 8, `test_kb_tree_nav` 4, `test_navbar_refresh` 7, `test_document_dates` 6, `test_git_source_dates` 4, `test_sync_summary_visibility` 3, `test_table_ux_polish` 6, `test_sources_midstream_bug` 6, `test_ls_tree_drilldown` 3, `test_smoke` 3 — **0 failures/errors** (the earlier `-q -q` run had simply suppressed pytest's summary line). **Documentation** — README.md + AGENTS.md reviewed in full (1,436 / 80 lines): - Fixed (pre-existing, phase-167-exposed): README *Caching & deploys* claimed "One Starlette middleware (`app/core/caching.py`) applies the rule" while the section now describes three transport layers → rewritten to name caching/compression/http_cache, the real outermost→innermost order, and why `bytes=` is uncompressed. - AGENTS.md rule 5 (UI Structure Check) gained the phase-167 clause: a lazily-drilled level keeps the current level rendered, its in-flight `role="status"` / `role="alert"`+Retry row lives in the same bounded scroll panel, and stat cards read server `totals` (never a walk of a depth-limited tree). - Cross-checked and consistent: every `BOR_*` in `.env.example` is in the README table, every `Settings` field is documented, all 15 README-cited API routes exist in the app, no stale "one fetch / drill client-side" claim remains anywhere. **Notable decisions / deviations** - `compression.py` adds a documented `_RebufferSmallResponses` seam beyond the D1 sketch (the caching layer's `BaseHTTPMiddleware` re-framing otherwise defeats Starlette's `minimum_size` exemption and inflates tiny responses) — integration-pinned, math still Starlette's. - A node-route **404 = vanished folder → honest reset to top level** (per `00_phase.md` D4/D6), not task-06's "alert + Retry"; 500 shows alert+Retry with the level preserved — both pinned, nothing weakened. - Retrieval battery not measurable (shared E2E DB holds no corpus), but `git diff --name-only -- app/rag` is empty, so retrieval is unchanged by construction. `AUDIT.md` left byte-identical (it lives under `todo/`, off-limits to this pass); no git index/history commands run. **Next pending phase:** none — `167_api_payload_diet` is the only entry in `.agents/phases/todo/`; the harness moves it to `complete/` and commits.
498 lines
20 KiB
Python
498 lines
20 KiB
Python
"""Unit tests: ``app.core.compression.SelectiveGZipMiddleware`` (phase 167, D1, LOCKED A1).
|
|
|
|
Pins the three contracts of the selective-gzip layer on a tiny ASGI app —
|
|
|
|
* a ~50 KB JSON route compresses (``Content-Encoding: gzip``, the body
|
|
``gzip.decompress``es to the original, ``Vary`` mentions
|
|
``accept-encoding``), an ``Accept-Encoding: identity`` client gets
|
|
identity bytes, a ~50-byte route stays under the floor, and a response
|
|
that already set ``content-encoding`` is forwarded byte-identical (never
|
|
double-encoded — the fronting-nginx contract);
|
|
|
|
* the EXACT-path skip: a route named ``/api/chat`` is NOT compressed and
|
|
streams frame-by-frame (the raw-ASGI interleaving proof: every frame
|
|
reaches the client's ``send`` BEFORE the app yields the next one — a
|
|
buffering middleware fails this deterministically — plus the
|
|
``list(stream)`` client idiom the existing SSE tests use), while
|
|
``/api/chat-images/x`` shares the prefix and MUST compress;
|
|
|
|
* non-HTTP scopes (``lifespan``/``websocket``) pass straight through.
|
|
|
|
Plus the config kill switch: ``compress_min_bytes=0`` → ``create_app``
|
|
adds no gzip layer at all; ``-1`` → the validator refuses startup naming
|
|
the field; the middleware sits INSIDE ``SecurityHeadersMiddleware`` (A20)
|
|
and OUTSIDE ``CachingMiddleware``.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import gzip
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import ValidationError
|
|
from starlette.applications import Starlette
|
|
from starlette.middleware.gzip import GZipMiddleware
|
|
from starlette.requests import Request
|
|
from starlette.responses import Response, StreamingResponse
|
|
from starlette.routing import Route
|
|
from starlette.types import Message, Receive, Scope, Send
|
|
|
|
import app.main as main_mod
|
|
from app.config import Settings
|
|
from app.core.compression import DEFAULT_SKIP_PATHS, SelectiveGZipMiddleware
|
|
|
|
|
|
def _settings(**kwargs: Any) -> Settings:
|
|
"""Build Settings without reading a .env file (the test_config pattern)."""
|
|
kwargs.setdefault("_env_file", None)
|
|
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
|
|
|
|
|
# ~50 KB of highly compressible JSON — the /api/docs/tree shape class.
|
|
_BIG_BODY = json.dumps(
|
|
{
|
|
"items": [
|
|
{"n": i, "path": f"source/docs/file-{i}.md", "title": f"Doc {i}"}
|
|
for i in range(1000)
|
|
]
|
|
}
|
|
).encode()
|
|
|
|
# ~50 bytes — comfortably under the 860-byte floor (compression would inflate).
|
|
_SMALL_BODY = json.dumps({"ok": True, "note": "intentionally tiny body"}).encode("utf-8")
|
|
|
|
# Five SSE frames (~2 KB total — ABOVE the floor, so a missing skip cannot
|
|
# hide behind the minimum_size exemption).
|
|
_SSE_FRAMES = [
|
|
f"event: thinking\ndata: {'x' * 400}\n\n".encode() for _ in range(5)
|
|
]
|
|
|
|
_PRE_ENCODED_BODY = gzip.compress(_BIG_BODY)
|
|
|
|
|
|
def _inner_app() -> Starlette:
|
|
async def big(request: Request) -> Response:
|
|
return Response(_BIG_BODY, media_type="application/json")
|
|
|
|
async def small(request: Request) -> Response:
|
|
return Response(_SMALL_BODY, media_type="application/json")
|
|
|
|
async def chat(request: Request) -> StreamingResponse:
|
|
async def frames():
|
|
for frame in _SSE_FRAMES:
|
|
yield frame
|
|
|
|
return StreamingResponse(frames(), media_type="text/event-stream")
|
|
|
|
async def chat_images(request: Request) -> Response:
|
|
# Shares the /api/chat PREFIX but is a different endpoint — the
|
|
# skip must be EXACT, so this one compresses.
|
|
return Response(_BIG_BODY, media_type="application/json")
|
|
|
|
async def pre_encoded(request: Request) -> Response:
|
|
# A body that arrived already encoded (e.g. from a future CDN
|
|
# layer): the responder skips it, never double-encodes.
|
|
return Response(
|
|
_PRE_ENCODED_BODY,
|
|
media_type="application/json",
|
|
headers={"Content-Encoding": "gzip"},
|
|
)
|
|
|
|
return Starlette(
|
|
routes=[
|
|
Route("/big", big),
|
|
Route("/small", small),
|
|
Route("/api/chat", chat, methods=["POST"]),
|
|
Route("/api/chat-images/x", chat_images),
|
|
Route("/pre-encoded", pre_encoded),
|
|
]
|
|
)
|
|
|
|
|
|
def _client(minimum_size: int = 860) -> TestClient:
|
|
return TestClient(
|
|
SelectiveGZipMiddleware(_inner_app(), minimum_size=minimum_size)
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The gzip path (Accept-Encoding negotiation, the floor, the pre-encoded skip)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_large_json_compresses_decompresses_and_sets_vary() -> None:
|
|
client = _client()
|
|
response = client.get("/big", headers={"Accept-Encoding": "gzip"})
|
|
assert response.status_code == 200
|
|
assert response.headers["content-encoding"] == "gzip"
|
|
assert "accept-encoding" in response.headers.get("vary", "").lower()
|
|
# httpx transparently decodes: the documented JSON arrives whole.
|
|
assert response.content == _BIG_BODY
|
|
assert len(response.content) > 860 # the seed really is above the floor
|
|
|
|
# The RAW wire bytes are a real gzip stream of the original body.
|
|
with client.stream("GET", "/big", headers={"Accept-Encoding": "gzip"}) as streamed:
|
|
raw = b"".join(list(streamed.iter_raw()))
|
|
assert gzip.decompress(raw) == _BIG_BODY
|
|
assert len(raw) < len(_BIG_BODY) # ~8-12x on this JSON; order of magnitude suffices
|
|
|
|
|
|
def test_identity_client_is_not_compressed() -> None:
|
|
client = _client()
|
|
response = client.get("/big", headers={"Accept-Encoding": "identity"})
|
|
assert response.status_code == 200
|
|
assert "content-encoding" not in response.headers
|
|
assert response.content == _BIG_BODY
|
|
|
|
|
|
def test_small_response_stays_under_the_floor() -> None:
|
|
client = _client()
|
|
response = client.get("/small", headers={"Accept-Encoding": "gzip"})
|
|
assert response.status_code == 200
|
|
assert "content-encoding" not in response.headers
|
|
assert response.content == _SMALL_BODY
|
|
|
|
|
|
def test_pre_encoded_body_is_never_double_encoded() -> None:
|
|
client = _client()
|
|
with client.stream("GET", "/pre-encoded", headers={"Accept-Encoding": "gzip"}) as streamed:
|
|
assert streamed.headers["content-encoding"] == "gzip"
|
|
raw = b"".join(list(streamed.iter_raw()))
|
|
# Byte-identical passthrough of the already-encoded body (Starlette's
|
|
# "skip when content-encoding is set" rule — the nginx coexistence pin).
|
|
assert raw == _PRE_ENCODED_BODY
|
|
assert gzip.decompress(raw) == _BIG_BODY
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The EXACT-path skip (A15: the SSE stream is never compressed, never buffered)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_default_skip_paths_constant() -> None:
|
|
assert DEFAULT_SKIP_PATHS == ("/api/chat",)
|
|
|
|
|
|
def test_chat_route_is_not_compressed_and_streams_whole() -> None:
|
|
"""The client-level pin (the ``list(stream)`` idiom the SSE tests use):
|
|
a route named /api/chat carries NO Content-Encoding and its raw bytes
|
|
are exactly the SSE frames, concatenated, above the size floor."""
|
|
client = _client()
|
|
with client.stream(
|
|
"POST", "/api/chat", json={"message": "hi"}, headers={"Accept-Encoding": "gzip"}
|
|
) as streamed:
|
|
assert streamed.status_code == 200
|
|
assert "content-encoding" not in streamed.headers
|
|
chunks = list(streamed.iter_raw())
|
|
assert b"".join(chunks) == b"".join(_SSE_FRAMES)
|
|
assert sum(len(c) for c in chunks) > 860 # the skip beats the floor, not size
|
|
|
|
|
|
def test_skipped_stream_arrives_frame_by_frame_uncompressed() -> None:
|
|
"""The frame-by-frame proof (deterministic, no timing): every frame
|
|
reaches the client's ``send`` BEFORE the app produces the next one.
|
|
A buffering middleware (BaseHTTPMiddleware, or a gzip pass) yields
|
|
all ``app:*`` events first — this strict interleaving fails then."""
|
|
events: list[str] = [] # shared: the app logs a yield, send logs delivery
|
|
sent: list[Message] = []
|
|
|
|
async def sse_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [(b"content-type", b"text/event-stream")],
|
|
}
|
|
)
|
|
for i, frame in enumerate(_SSE_FRAMES):
|
|
events.append(f"app:frame-{i}")
|
|
await send({"type": "http.response.body", "body": frame, "more_body": True})
|
|
events.append("app:final")
|
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
|
|
async def recording_send(message: Message) -> None:
|
|
events.append(f"client:{message['type']}")
|
|
sent.append(message)
|
|
|
|
async def receive() -> Message:
|
|
return {"type": "http.disconnect"}
|
|
|
|
middleware = SelectiveGZipMiddleware(sse_app, minimum_size=860)
|
|
asyncio.run(
|
|
middleware(
|
|
{"type": "http", "path": "/api/chat", "headers": [(b"accept-encoding", b"gzip")]},
|
|
receive,
|
|
recording_send,
|
|
)
|
|
)
|
|
|
|
expected = ["client:http.response.start"]
|
|
for i in range(len(_SSE_FRAMES)):
|
|
expected += [f"app:frame-{i}", "client:http.response.body"]
|
|
expected += ["app:final", "client:http.response.body"]
|
|
assert events == expected
|
|
|
|
start = sent[0]
|
|
headers = {k.decode().lower() for k, _ in start["headers"]} # pyright: ignore[reportGeneralTypeIssues]
|
|
assert "content-encoding" not in headers
|
|
body = b"".join(m.get("body", b"") for m in sent[1:]) # pyright: ignore[reportGeneralTypeIssues]
|
|
assert body == b"".join(_SSE_FRAMES)
|
|
|
|
|
|
def _raw_harness(
|
|
*,
|
|
path: str,
|
|
app: Any,
|
|
) -> list[Message]:
|
|
"""Drive the middleware at the raw ASGI level; return the exact
|
|
messages that reached the client's ``send``."""
|
|
sent: list[Message] = []
|
|
|
|
async def recording_send(message: Message) -> None:
|
|
sent.append(message)
|
|
|
|
async def receive() -> Message:
|
|
return {"type": "http.disconnect"}
|
|
|
|
scope: Scope = {
|
|
"type": "http",
|
|
"path": path,
|
|
"headers": [(b"accept-encoding", b"gzip")],
|
|
}
|
|
middleware = SelectiveGZipMiddleware(app, minimum_size=860)
|
|
asyncio.run(middleware(scope, receive, recording_send))
|
|
return sent
|
|
|
|
|
|
def test_same_stream_at_a_non_skipped_path_goes_through_gzip() -> None:
|
|
"""The skip is PATH-driven: the identical streaming app mounted at a
|
|
non-skipped path is compressed by the composed GZipMiddleware."""
|
|
|
|
async def json_stream_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [(b"content-type", b"application/json")],
|
|
}
|
|
)
|
|
for i, frame in enumerate(_SSE_FRAMES):
|
|
await send(
|
|
{"type": "http.response.body", "body": frame, "more_body": i < len(_SSE_FRAMES) - 1}
|
|
)
|
|
|
|
sent = _raw_harness(path="/api/other-stream", app=json_stream_app)
|
|
start_headers = {
|
|
k.decode().lower(): v.decode() for k, v in sent[0]["headers"] # pyright: ignore[reportGeneralTypeIssues]
|
|
}
|
|
assert start_headers.get("content-encoding") == "gzip"
|
|
raw = b"".join(m.get("body", b"") for m in sent[1:]) # pyright: ignore[reportGeneralTypeIssues]
|
|
assert gzip.decompress(raw) == b"".join(_SSE_FRAMES)
|
|
|
|
|
|
def test_reframed_small_body_stays_identity_under_the_floor() -> None:
|
|
"""The CachingMiddleware seam (starlette 1.x ``BaseHTTPMiddleware``
|
|
re-frames every response it passes up: chunk with ``more_body=True``
|
|
+ empty terminator): a body whose ``content-length`` is below the
|
|
floor is re-buffered into ONE buffered message so Starlette's own
|
|
small-response exemption fires — identity bytes, no ``Vary`` append."""
|
|
payload = b'{"status":"ok","version":"0.1.0"}' # 33 bytes — the /api/health class
|
|
|
|
async def reframed_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [
|
|
(b"content-type", b"application/json"),
|
|
(b"content-length", str(len(payload)).encode()),
|
|
],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": payload, "more_body": True})
|
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
|
|
sent = _raw_harness(path="/api/health", app=reframed_app)
|
|
start_headers = {
|
|
k.decode().lower(): v.decode() for k, v in sent[0]["headers"] # pyright: ignore[reportGeneralTypeIssues]
|
|
}
|
|
assert "content-encoding" not in start_headers
|
|
assert "vary" not in start_headers # the exemption adds nothing
|
|
body = b"".join(m.get("body", b"") for m in sent[1:]) # pyright: ignore[reportGeneralTypeIssues]
|
|
assert body == payload
|
|
|
|
|
|
def test_lying_content_length_that_outgrows_the_floor_compresses() -> None:
|
|
"""A body that outgrows the floor despite a small ``content-length``
|
|
stops the hold and streams through the normal compressing path — the
|
|
buffer is bounded by the floor and correctness never depends on the
|
|
header being honest."""
|
|
|
|
async def lying_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [
|
|
(b"content-type", b"application/json"),
|
|
(b"content-length", b"5"), # a lie
|
|
],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": _BIG_BODY, "more_body": True})
|
|
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
|
|
|
sent = _raw_harness(path="/api/lias", app=lying_app)
|
|
start_headers = {
|
|
k.decode().lower(): v.decode() for k, v in sent[0]["headers"] # pyright: ignore[reportGeneralTypeIssues]
|
|
}
|
|
assert start_headers.get("content-encoding") == "gzip"
|
|
raw = b"".join(m.get("body", b"") for m in sent[1:]) # pyright: ignore[reportGeneralTypeIssues]
|
|
assert gzip.decompress(raw) == _BIG_BODY
|
|
|
|
|
|
def test_non_body_message_while_holding_flushes_untouched() -> None:
|
|
"""``http.response.pathsend`` (or anything unexpected) arriving while
|
|
the hold is active: the held messages and the message itself are
|
|
forwarded byte-for-byte — the hold never swallows a frame."""
|
|
|
|
async def pathsend_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 200,
|
|
"headers": [
|
|
(b"content-type", b"text/plain"),
|
|
(b"content-length", b"4"),
|
|
],
|
|
}
|
|
)
|
|
await send({"type": "http.response.pathsend", "path": "/tmp/file"})
|
|
|
|
sent = _raw_harness(path="/assets/file", app=pathsend_app)
|
|
assert [m["type"] for m in sent] == ["http.response.start", "http.response.pathsend"]
|
|
start_headers = {k.decode().lower() for k, _ in sent[0]["headers"]} # pyright: ignore[reportGeneralTypeIssues]
|
|
assert "content-encoding" not in start_headers
|
|
|
|
|
|
def test_chat_images_prefix_still_compresses() -> None:
|
|
"""The exact-match pin: /api/chat-images/x shares the /api/chat
|
|
prefix and MUST go through the gzip path."""
|
|
client = _client()
|
|
response = client.get("/api/chat-images/x", headers={"Accept-Encoding": "gzip"})
|
|
assert response.status_code == 200
|
|
assert response.headers["content-encoding"] == "gzip"
|
|
assert response.content == _BIG_BODY
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Non-HTTP scopes pass straight through
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_non_http_scopes_pass_through_untouched() -> None:
|
|
seen: list[str] = []
|
|
|
|
async def probe_app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
seen.append(scope["type"])
|
|
|
|
async def receive() -> Message:
|
|
return {"type": "http.disconnect"}
|
|
|
|
async def send(message: Message) -> None: # pragma: no cover - app sends nothing
|
|
raise AssertionError("no ASGI messages expected")
|
|
|
|
middleware = SelectiveGZipMiddleware(probe_app, minimum_size=860)
|
|
for scope_type in ("lifespan", "websocket"):
|
|
asyncio.run(
|
|
middleware({"type": scope_type, "path": "/api/anything"}, receive, send)
|
|
)
|
|
assert seen == ["lifespan", "websocket"]
|
|
|
|
|
|
def test_composes_starlette_gzip_with_minimum_size() -> None:
|
|
"""The middleware reuses Starlette's GZipMiddleware, it does not fork
|
|
the compression math (D1)."""
|
|
|
|
async def app(scope: Scope, receive: Receive, send: Send) -> None: ...
|
|
|
|
middleware = SelectiveGZipMiddleware(app, minimum_size=1234)
|
|
assert isinstance(middleware.gzip, GZipMiddleware)
|
|
assert middleware.gzip.minimum_size == 1234
|
|
assert middleware.skip_paths == DEFAULT_SKIP_PATHS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config: the BOR_COMPRESS_MIN_BYTES kill switch and the loud negative
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _middleware_names(app: Any) -> list[str]:
|
|
"""Middleware class names, outermost first (``user_middleware`` order)."""
|
|
return [getattr(mw.cls, "__name__", "") for mw in app.user_middleware]
|
|
|
|
|
|
def test_compress_min_bytes_defaults_to_the_nginx_floor(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("BOR_COMPRESS_MIN_BYTES", raising=False)
|
|
assert _settings().compress_min_bytes == 860
|
|
|
|
|
|
def test_compress_min_bytes_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("BOR_COMPRESS_MIN_BYTES", "2048")
|
|
assert _settings().compress_min_bytes == 2048
|
|
monkeypatch.setenv("BOR_COMPRESS_MIN_BYTES", "0") # the kill switch is legal
|
|
assert _settings().compress_min_bytes == 0
|
|
|
|
|
|
def test_compress_min_bytes_negative_refuses_startup_naming_the_field() -> None:
|
|
with pytest.raises(ValidationError, match="compress_min_bytes must be >= 0"):
|
|
_settings(compress_min_bytes=-1)
|
|
|
|
|
|
def test_create_app_wires_gzip_inside_security_headers(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A20 holds: SecurityHeaders stays outermost (index 0 — starlette's
|
|
last add_middleware is the outermost layer), gzip sits between it and
|
|
the caching layer, so an already-rewritten HTML page compresses and
|
|
the /api/* caching pass-through happens before compression.
|
|
|
|
Phase 167 task 05 adds ``ResponseBudgetMiddleware`` between gzip and
|
|
caching — INSIDE the compressor on purpose, so its ``bytes=`` counts the
|
|
body the ROUTE produced while its ``enc=`` still reads the encoding the
|
|
compressor put on the wire (see tests/unit/test_response_budget.py)."""
|
|
monkeypatch.setattr(main_mod.settings, "compress_min_bytes", 860)
|
|
app2 = main_mod.create_app()
|
|
assert _middleware_names(app2) == [
|
|
"SecurityHeadersMiddleware",
|
|
"SelectiveGZipMiddleware",
|
|
"ResponseBudgetMiddleware",
|
|
"CachingMiddleware",
|
|
"SessionMiddleware",
|
|
]
|
|
# The app still serves.
|
|
assert TestClient(app2).get("/api/health").status_code == 200
|
|
|
|
|
|
def test_create_app_adds_no_gzip_layer_when_disabled(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""BOR_COMPRESS_MIN_BYTES=0 removes the middleware entirely (the kill
|
|
switch): the gzip class is absent from app.user_middleware."""
|
|
monkeypatch.setattr(main_mod.settings, "compress_min_bytes", 0)
|
|
app2 = main_mod.create_app()
|
|
names = _middleware_names(app2)
|
|
assert "SelectiveGZipMiddleware" not in names
|
|
assert names == [
|
|
"SecurityHeadersMiddleware",
|
|
# Phase 167 task 05: the byte-budget line is not optional.
|
|
"ResponseBudgetMiddleware",
|
|
"CachingMiddleware",
|
|
"SessionMiddleware",
|
|
]
|