Files
ducoterra 6fd83446ec phase: 01_runnable_service_scaffold
**Phase 01 final verification pass — all criteria green**

- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
2026-10-06 01:51:20 -04:00

106 lines
3.6 KiB
Python

"""Unit tests for the security headers (PLAN.md §11, A12).
These assert the *values*, not just their presence: a header that silently
weakened (a `unsafe-inline`, a `*`, a reporting endpoint) is a security
regression dressed up as a passing test.
"""
from __future__ import annotations
import pytest
from starlette.responses import JSONResponse, PlainTextResponse, Response
from undercooked.server.headers import (
CONTENT_SECURITY_POLICY,
SECURITY_HEADERS,
SecurityHeadersMiddleware,
apply_security_headers,
)
pytestmark = pytest.mark.unit
EXPECTED = {
"Content-Security-Policy": CONTENT_SECURITY_POLICY,
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "no-referrer",
"X-Frame-Options": "DENY",
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
def test_the_five_documented_headers_are_defined() -> None:
assert dict(SECURITY_HEADERS) == EXPECTED
def test_apply_adds_every_header_to_a_response() -> None:
response = PlainTextResponse("hi")
apply_security_headers(response)
for header, value in EXPECTED.items():
assert response.headers[header] == value
def test_apply_works_on_json_and_empty_bodies() -> None:
payload = JSONResponse({"a": 1})
apply_security_headers(payload)
assert payload.headers["content-security-policy"] == CONTENT_SECURITY_POLICY
empty = Response(status_code=204)
apply_security_headers(empty)
assert empty.headers["x-content-type-options"] == "nosniff"
def test_apply_is_idempotent_and_overrides_weaker_values() -> None:
response = PlainTextResponse("hi", headers={"X-Frame-Options": "SAMEORIGIN"})
apply_security_headers(response)
apply_security_headers(response)
assert response.headers["x-frame-options"] == "DENY"
def _csp() -> dict[str, str]:
"""The CSP as `directive -> whole clause`, so assertions read like the header."""
return {part.split(" ", 1)[0]: part for part in CONTENT_SECURITY_POLICY.split("; ")}
def test_csp_locks_scripts_and_styles_to_self() -> None:
directives = _csp()
assert directives["default-src"] == "default-src 'self'"
assert directives["script-src"] == "script-src 'self'"
assert directives["style-src"] == "style-src 'self'"
assert directives["font-src"] == "font-src 'self'"
assert directives["base-uri"] == "base-uri 'none'"
assert directives["frame-ancestors"] == "frame-ancestors 'none'"
def test_csp_allows_only_same_origin_websockets_and_data_images() -> None:
directives = _csp()
assert directives["connect-src"] == "connect-src 'self' ws: wss:"
assert directives["img-src"] == "img-src 'self' data:"
@pytest.mark.parametrize(
"forbidden", ["unsafe-inline", "unsafe-eval", " https://", "http://", "*", "report-uri"]
)
def test_csp_contains_no_unsafe_source_or_remote_endpoint(forbidden: str) -> None:
assert forbidden not in CONTENT_SECURITY_POLICY
def test_no_header_points_at_a_remote_reporting_service() -> None:
"""`Report-To`/`report-uri` would phone a third party: A12 forbids it."""
assert not any(name.lower() in {"report-to", "nel"} for name in SECURITY_HEADERS)
for value in SECURITY_HEADERS.values():
assert "://" not in value
assert "report" not in value.lower()
def test_headers_are_immutable() -> None:
with pytest.raises(TypeError):
SECURITY_HEADERS["X-Frame-Options"] = "ALLOWALL" # type: ignore[index]
def test_middleware_is_constructible_with_an_app_stub() -> None:
async def stub_app(scope: object, receive: object, send: object) -> None: # pragma: no cover
return None
middleware = SecurityHeadersMiddleware(stub_app)
assert middleware.app is stub_app