Add game data capture vertical slice

This commit is contained in:
jb
2026-07-31 14:34:24 -04:00
parent 5859f84034
commit fee4dc3a78
75 changed files with 12654 additions and 5 deletions
@@ -0,0 +1,337 @@
from __future__ import annotations
from collections.abc import Iterable, Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import cast
from fastapi.testclient import TestClient
from starlette.routing import Mount
from starlette.staticfiles import StaticFiles
from starlette.types import Scope
from game_data_capture.config import ServerConfig
from game_data_capture.web.app import (
CaptureRecord,
CaptureSessionStatus,
ControlOutcome,
MediaDescriptor,
MutationOutcome,
WebConfig,
create_app,
)
from game_data_capture.web.security import new_bootstrap_token
_CAPTURE_ID = "capture-00000000000000000000000000000001"
_LABEL: dict[str, object] = {
"family": "weapon",
"view": "detail",
"gear_slot": None,
"record_types": ["weapon", "weapon_talent"],
"confidence": 1.0,
"abstention_reason": None,
}
@dataclass
class FakeControl:
armed: bool = False
starts: int = 0
stops: int = 0
def status(self) -> CaptureSessionStatus:
return CaptureSessionStatus(self.armed, 0, 64)
def start_session(self) -> ControlOutcome:
if self.armed:
return "already_armed"
self.starts += 1
self.armed = True
return "started"
def stop_session(self) -> ControlOutcome:
if not self.armed:
return "already_stopped"
self.stops += 1
self.armed = False
return "stopped"
@dataclass
class FakeCorpus:
records: dict[str, CaptureRecord] = field(
default_factory=lambda: {
_CAPTURE_ID: CaptureRecord(_CAPTURE_ID, "stored", "2026-07-31T00:00:00Z", None)
}
)
calls: list[str] = field(default_factory=list)
media: MediaDescriptor | None = None
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
self.calls.append("list")
return tuple(self.records.values())[:limit]
def get_capture(self, capture_id: str) -> CaptureRecord | None:
self.calls.append("get")
return self.records.get(capture_id)
def open_media(self, capture_id: str) -> MediaDescriptor | None:
self.calls.append("media")
if capture_id not in self.records:
return None
if self.media is not None:
return self.media
return MediaDescriptor("image/png", 4, [b"data"])
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
self.calls.append("label")
if capture_id not in self.records:
return "not_found"
old = self.records[capture_id]
self.records[capture_id] = CaptureRecord(
old.capture_id, old.state, old.received_at, dict(classification)
)
return "updated"
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
self.calls.append("reject")
return "updated" if capture_id in self.records else "not_found"
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
self.calls.append("delete")
return "retained" if capture_id in self.records else "not_found"
def _client(control: FakeControl, corpus: FakeCorpus) -> tuple[TestClient, str]:
token = new_bootstrap_token()
app = create_app(
config=WebConfig(ServerConfig()),
capture_control=control,
corpus=corpus,
bootstrap_token=token,
)
return TestClient(app, base_url="http://127.0.0.1:8765"), token
def _bootstrap(client: TestClient, token: str) -> str:
response = client.post(
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
)
assert response.status_code == 200
return str(response.json()["csrf_token"])
def _mutation_headers(csrf: str) -> dict[str, str]:
return {"Origin": "http://127.0.0.1:8765", "X-CSRF-Token": csrf}
def test_factory_and_authenticated_gets_never_arm_capture() -> None:
control = FakeControl()
corpus = FakeCorpus()
client, token = _client(control, corpus)
assert control.starts == 0
assert client.get("/health").json() == {"status": "ok"}
csrf = _bootstrap(client, token)
assert client.get("/api/session").status_code == 200
assert client.get("/api/captures").status_code == 200
assert client.get(f"/api/captures/{_CAPTURE_ID}").status_code == 200
assert control.starts == 0
assert csrf
def test_static_contract_is_package_local_and_does_not_accept_a_filesystem_root() -> None:
control = FakeControl()
corpus = FakeCorpus()
app = create_app(
config=WebConfig(ServerConfig()),
capture_control=control,
corpus=corpus,
bootstrap_token=new_bootstrap_token(),
)
static_mount = next(
route for route in app.routes if isinstance(route, Mount) and route.path == "/static"
)
assert isinstance(static_mount.app, StaticFiles)
assert static_mount.app.follow_symlink is False
assert static_mount.app.directory is not None
assert Path(static_mount.app.directory).name == "static"
assert not Path(
static_mount.app.get_path(
cast(Scope, {"path": "/static/../../etc/passwd", "root_path": ""})
)
).is_absolute()
def test_bootstrap_cookie_is_host_only_http_only_and_never_echoes_bootstrap_token() -> None:
control = FakeControl()
corpus = FakeCorpus()
client, token = _client(control, corpus)
response = client.post(
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
)
assert response.status_code == 200
assert token not in response.text
cookie = response.headers["set-cookie"].lower()
assert "httponly" in cookie
assert "samesite=strict" in cookie
assert "domain=" not in cookie
assert token not in cookie
assert response.json().keys() == {"csrf_token"}
def test_start_is_explicit_csrf_protected_idempotent_and_rotates_session() -> None:
control = FakeControl()
corpus = FakeCorpus()
client, token = _client(control, corpus)
csrf = _bootstrap(client, token)
old_session = str(client.cookies["capture_session"])
response = client.post(
"/api/session/start", headers=_mutation_headers(csrf), json={"confirmation": "START"}
)
assert response.status_code == 200
assert response.json()["result"] == "started"
assert control.starts == 1
next_csrf = str(response.json()["csrf_token"])
stale = client.post(
"/api/session/start", headers=_mutation_headers(csrf), json={"confirmation": "START"}
)
assert stale.status_code == 403
assert control.starts == 1
expired_session = client.post(
"/api/session/start",
headers={**_mutation_headers(next_csrf), "Cookie": f"capture_session={old_session}"},
json={"confirmation": "START"},
)
assert expired_session.status_code == 401
assert control.starts == 1
duplicate = client.post(
"/api/session/start", headers=_mutation_headers(next_csrf), json={"confirmation": "START"}
)
assert duplicate.status_code == 200
assert duplicate.json()["result"] == "already_armed"
assert control.starts == 1
def test_label_reject_and_retention_deletion_use_closed_confirmed_contracts() -> None:
control = FakeControl()
corpus = FakeCorpus()
client, token = _client(control, corpus)
csrf = _bootstrap(client, token)
labeled = client.post(
f"/api/captures/{_CAPTURE_ID}/label", headers=_mutation_headers(csrf), json=_LABEL
)
assert labeled.status_code == 200
csrf = str(labeled.json()["csrf_token"])
detail = client.get(f"/api/captures/{_CAPTURE_ID}")
assert detail.json()["capture"]["classification"] == _LABEL
rejected = client.post(
f"/api/captures/{_CAPTURE_ID}/reject",
headers=_mutation_headers(csrf),
json={"confirmation": "REJECT"},
)
assert rejected.status_code == 200
csrf = str(rejected.json()["csrf_token"])
denied = client.post(
f"/api/captures/{_CAPTURE_ID}/delete",
headers=_mutation_headers(csrf),
json={"confirmation": "delete"},
)
assert denied.status_code == 403
assert "delete" not in corpus.calls
retained = client.post(
f"/api/captures/{_CAPTURE_ID}/delete",
headers=_mutation_headers(csrf),
json={"confirmation": "DELETE"},
)
assert retained.status_code == 409
assert retained.json() == {"error": "retained"}
assert corpus.calls.count("label") == 1
assert corpus.calls.count("reject") == 1
assert corpus.calls.count("delete") == 1
def test_opaque_media_route_has_private_nosniff_response_and_no_path_parameter() -> None:
control = FakeControl()
corpus = FakeCorpus()
client, token = _client(control, corpus)
_bootstrap(client, token)
response = client.get(f"/api/captures/{_CAPTURE_ID}/media")
assert response.status_code == 200
assert response.content == b"data"
assert response.headers["content-type"] == "image/png"
assert response.headers["cache-control"] == "private, no-store"
assert response.headers["x-content-type-options"] == "nosniff"
assert client.get("/api/captures/../../etc/passwd/media").status_code == 404
assert corpus.calls.count("media") == 1
class RaisingChunks:
def __iter__(self) -> Iterator[bytes]:
raise RuntimeError("raw-secret /private/capture/path")
def test_malformed_media_streams_fail_before_success_headers_or_content() -> None:
descriptors = (
MediaDescriptor("image/png", 4, [b"abc"]),
MediaDescriptor("image/png", 3, [b"abcd"]),
MediaDescriptor("image/png", 3, [b""]),
MediaDescriptor("image/png", 3, cast(Iterable[bytes], ["bad"])),
MediaDescriptor("image/png", 3, RaisingChunks()),
)
for descriptor in descriptors:
control = FakeControl()
corpus = FakeCorpus(media=descriptor)
client, token = _client(control, corpus)
_bootstrap(client, token)
response = client.get(f"/api/captures/{_CAPTURE_ID}/media")
assert response.status_code == 503
assert response.json() == {"error": "service_unavailable"}
assert "raw-secret" not in response.text
assert "/private/capture/path" not in response.text
class FailingCorpus(FakeCorpus):
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
raise RuntimeError("raw-secret /private/capture/path")
def test_port_exceptions_are_sanitized_without_an_uncaught_client_error() -> None:
control = FakeControl()
corpus = FailingCorpus()
client, token = _client(control, corpus)
_bootstrap(client, token)
response = client.get("/api/captures")
assert response.status_code == 503
assert response.json() == {"error": "service_unavailable"}
assert "raw-secret" not in response.text
assert "/private/capture/path" not in response.text
def test_taxonomy_payload_rejects_unknown_and_ambiguous_values_before_corpus_mutation() -> None:
control = FakeControl()
corpus = FakeCorpus()
client, token = _client(control, corpus)
csrf = _bootstrap(client, token)
unsupported = dict(_LABEL)
unsupported["view"] = "not-a-view"
unsupported["record_types"] = ["gear"]
response = client.post(
f"/api/captures/{_CAPTURE_ID}/label", headers=_mutation_headers(csrf), json=unsupported
)
assert response.status_code == 403
assert "label" not in corpus.calls
@@ -0,0 +1,321 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from game_data_capture.config import ServerConfig
from game_data_capture.web.app import (
CaptureCorpusPort,
CaptureRecord,
CaptureSessionStatus,
ControlOutcome,
MediaDescriptor,
MutationOutcome,
WebConfig,
create_app,
)
from game_data_capture.web.security import (
LocalAuthority,
LocalRequestLimitsMiddleware,
RequestLimits,
new_bootstrap_token,
)
@dataclass
class Control:
start_calls: int = 0
def status(self) -> CaptureSessionStatus:
return CaptureSessionStatus(False, 0, 1)
def start_session(self) -> ControlOutcome:
self.start_calls += 1
return "started"
def stop_session(self) -> ControlOutcome:
return "already_stopped"
class Corpus(CaptureCorpusPort):
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
return ()
def get_capture(self, capture_id: str) -> CaptureRecord | None:
return None
def open_media(self, capture_id: str) -> MediaDescriptor | None:
return None
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
return "not_found"
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
return "not_found"
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
return "not_found"
class Clock:
def __init__(self) -> None:
self.value = 0.0
def __call__(self) -> float:
return self.value
def _app(
control: Control, *, now: Callable[[], float] | None = None, max_body: int = 16 * 1024
) -> tuple[FastAPI, str]:
token = new_bootstrap_token()
config = WebConfig(ServerConfig(), request_limits=RequestLimits(max_body_bytes=max_body))
if now is None:
app = create_app(
config=config,
capture_control=control,
corpus=Corpus(),
bootstrap_token=token,
)
else:
app = create_app(
config=config,
capture_control=control,
corpus=Corpus(),
bootstrap_token=token,
now=now,
)
return app, token
def _bootstrap(client: TestClient, token: str) -> str:
result = client.post(
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
)
assert result.status_code == 200
return str(result.json()["csrf_token"])
def test_host_origin_and_csrf_fail_before_start_side_effect() -> None:
control = Control()
app, token = _app(control)
client = TestClient(app, base_url="http://127.0.0.1:8765")
csrf = _bootstrap(client, token)
bad_host = client.post(
"/api/session/start",
headers={"Host": "evil.test:8765", "Origin": "http://127.0.0.1:8765", "X-CSRF-Token": csrf},
json={"confirmation": "START"},
)
missing_origin = client.post(
"/api/session/start", headers={"X-CSRF-Token": csrf}, json={"confirmation": "START"}
)
cross_origin = client.post(
"/api/session/start",
headers={"Origin": "http://evil.test", "X-CSRF-Token": csrf},
json={"confirmation": "START"},
)
bad_csrf = client.post(
"/api/session/start",
headers={"Origin": "http://127.0.0.1:8765", "X-CSRF-Token": "x" * 43},
json={"confirmation": "START"},
)
assert bad_host.status_code == 400
assert missing_origin.status_code == 403
assert cross_origin.status_code == 403
assert bad_csrf.status_code == 403
assert control.start_calls == 0
def test_session_expiry_and_process_restart_invalidate_cookie() -> None:
control = Control()
clock = Clock()
app, token = _app(control, now=clock)
client = TestClient(app, base_url="http://127.0.0.1:8765")
_bootstrap(client, token)
assert client.get("/api/session").status_code == 200
clock.value = 901
assert client.get("/api/session").status_code == 401
new_app, _ = _app(control)
restarted = TestClient(new_app, base_url="http://127.0.0.1:8765")
restarted.cookies.update(client.cookies)
assert restarted.get("/api/session").status_code == 401
def test_duplicate_json_and_form_payloads_fail_before_start_side_effect() -> None:
control = Control()
app, token = _app(control)
client = TestClient(app, base_url="http://127.0.0.1:8765")
csrf = _bootstrap(client, token)
headers = {"Origin": "http://127.0.0.1:8765", "X-CSRF-Token": csrf}
duplicate_json = client.post(
"/api/session/start",
headers={**headers, "Content-Type": "application/json"},
content=b'{"confirmation":"START","confirmation":"START"}',
)
form = client.post(
"/api/session/start",
headers={**headers, "Content-Type": "application/x-www-form-urlencoded"},
content=b"confirmation=START&confirmation=START",
)
assert duplicate_json.status_code == 403
assert form.status_code == 403
assert control.start_calls == 0
def test_ipv6_host_requires_bracketed_exact_authority() -> None:
control = Control()
token = new_bootstrap_token()
app = create_app(
config=WebConfig(ServerConfig(host="::1", port=8765)),
capture_control=control,
corpus=Corpus(),
bootstrap_token=token,
)
client = TestClient(app, base_url="http://127.0.0.1:8765")
accepted = client.get("/health", headers={"Host": "[::1]:8765"})
rejected = client.get("/health", headers={"Host": "::1:8765"})
assert accepted.status_code == 200
assert rejected.status_code == 400
def test_all_success_and_error_responses_have_local_only_security_headers() -> None:
control = Control()
app, _ = _app(control)
client = TestClient(app, base_url="http://127.0.0.1:8765")
responses = (
client.get("/health"),
client.get("/health", headers={"Host": "evil.test:8765"}),
client.get("/api/session"),
)
for response in responses:
csp = response.headers["content-security-policy"]
assert "default-src 'self'" in csp
assert "script-src 'self'" in csp
assert "style-src 'self'" in csp
assert "font-src 'self'" in csp
assert "img-src 'self'" in csp
assert "media-src 'self'" in csp
assert "connect-src 'self'" in csp
assert "object-src 'none'" in csp
assert "base-uri 'none'" in csp
assert "frame-ancestors 'none'" in csp
assert response.headers["referrer-policy"] == "no-referrer"
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "DENY"
async def _call_raw(app: ASGIApp, scope: Scope, messages: list[Message]) -> list[Message]:
sent: list[Message] = []
async def receive() -> Message:
return messages.pop(0)
async def send(message: Message) -> None:
sent.append(message)
await app(scope, receive, send)
return sent
def _scope(path: str, headers: list[tuple[bytes, bytes]], *, method: str = "POST") -> Scope:
return {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": method,
"scheme": "http",
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": headers,
"client": ("127.0.0.1", 50000),
"server": ("127.0.0.1", 8765),
}
def test_declared_streamed_duplicate_and_disconnect_requests_fail_before_start() -> None:
control = Control()
app, _ = _app(control, max_body=32)
base_headers = [(b"host", b"127.0.0.1:8765"), (b"content-type", b"application/json")]
declared = asyncio.run(
_call_raw(
app,
_scope("/api/session/start", [*base_headers, (b"content-length", b"33")]),
[{"type": "http.request", "body": b"", "more_body": False}],
)
)
duplicated = asyncio.run(
_call_raw(
app,
_scope("/api/session/start", [*base_headers, (b"host", b"127.0.0.1:8765")]),
[{"type": "http.request", "body": b"{}", "more_body": False}],
)
)
streamed = asyncio.run(
_call_raw(
app,
_scope(
"/api/bootstrap",
[*base_headers, (b"origin", b"http://127.0.0.1:8765")],
),
[
{"type": "http.request", "body": b'{"token":"', "more_body": True},
{"type": "http.request", "body": b"x" * 24, "more_body": False},
],
)
)
disconnected = asyncio.run(
_call_raw(
app,
_scope(
"/api/bootstrap",
[*base_headers, (b"origin", b"http://127.0.0.1:8765")],
),
[{"type": "http.disconnect"}],
)
)
assert declared[0]["status"] == 413
assert duplicated[0]["status"] == 400
assert streamed[0]["status"] == 413
assert disconnected[0]["status"] == 400
assert control.start_calls == 0
def test_post_completion_disconnect_is_normal_for_streaming_response_lifecycle() -> None:
async def streaming_lifecycle(scope: Scope, receive: Receive, send: Send) -> None:
await receive()
assert (await receive())["type"] == "http.disconnect"
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok"})
guarded = LocalRequestLimitsMiddleware(
streaming_lifecycle,
authority=LocalAuthority("127.0.0.1", 8765),
limits=RequestLimits(),
)
sent = asyncio.run(
_call_raw(
guarded,
_scope("/stream", [(b"host", b"127.0.0.1:8765")], method="GET"),
[
{"type": "http.request", "body": b"", "more_body": False},
{"type": "http.disconnect"},
],
)
)
assert sent[0]["status"] == 200