Add game data capture vertical slice
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
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
|
||||
|
||||
_ID = "capture-00000000000000000000000000000002"
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaptureControl:
|
||||
armed: bool = False
|
||||
|
||||
def status(self) -> CaptureSessionStatus:
|
||||
return CaptureSessionStatus(self.armed, 1, 64)
|
||||
|
||||
def start_session(self) -> ControlOutcome:
|
||||
self.armed = True
|
||||
return "started"
|
||||
|
||||
def stop_session(self) -> ControlOutcome:
|
||||
self.armed = False
|
||||
return "stopped"
|
||||
|
||||
|
||||
class Corpus:
|
||||
def __init__(self) -> None:
|
||||
self.record = CaptureRecord(_ID, "stored", "2026-07-31T00:00:00Z", None)
|
||||
self.rejected = False
|
||||
self.deleted = False
|
||||
|
||||
def list_captures(self, *, limit: int) -> Sequence[CaptureRecord]:
|
||||
return (self.record,)
|
||||
|
||||
def get_capture(self, capture_id: str) -> CaptureRecord | None:
|
||||
return self.record if capture_id == _ID else None
|
||||
|
||||
def open_media(self, capture_id: str) -> MediaDescriptor | None:
|
||||
return MediaDescriptor("image/png", 3, [b"png"]) if capture_id == _ID else None
|
||||
|
||||
def label(self, capture_id: str, classification: Mapping[str, object]) -> MutationOutcome:
|
||||
if capture_id != _ID:
|
||||
return "not_found"
|
||||
self.record = CaptureRecord(_ID, "stored", "2026-07-31T00:00:00Z", dict(classification))
|
||||
return "updated"
|
||||
|
||||
def reject_preserving_evidence(self, capture_id: str) -> MutationOutcome:
|
||||
if capture_id != _ID:
|
||||
return "not_found"
|
||||
self.rejected = True
|
||||
return "updated"
|
||||
|
||||
def delete_with_retention(self, capture_id: str) -> MutationOutcome:
|
||||
if capture_id != _ID:
|
||||
return "not_found"
|
||||
self.deleted = True
|
||||
return "updated"
|
||||
|
||||
|
||||
def test_private_capture_labeling_flow_preserves_rejection_before_retention_delete() -> None:
|
||||
token = new_bootstrap_token()
|
||||
control = CaptureControl()
|
||||
corpus = Corpus()
|
||||
client = TestClient(
|
||||
create_app(
|
||||
config=WebConfig(ServerConfig()),
|
||||
capture_control=control,
|
||||
corpus=corpus,
|
||||
bootstrap_token=token,
|
||||
),
|
||||
base_url="http://127.0.0.1:8765",
|
||||
)
|
||||
origin = "http://127.0.0.1:8765"
|
||||
csrf = str(
|
||||
client.post("/api/bootstrap", headers={"Origin": origin}, json={"token": token}).json()[
|
||||
"csrf_token"
|
||||
]
|
||||
)
|
||||
label = {
|
||||
"family": "gear",
|
||||
"view": "detail",
|
||||
"gear_slot": "Mask",
|
||||
"record_types": ["gear", "brand_set"],
|
||||
"confidence": 1.0,
|
||||
"abstention_reason": None,
|
||||
}
|
||||
|
||||
response = client.post(
|
||||
f"/api/captures/{_ID}/label", headers={"Origin": origin, "X-CSRF-Token": csrf}, json=label
|
||||
)
|
||||
assert response.status_code == 200
|
||||
csrf = str(response.json()["csrf_token"])
|
||||
assert client.get(f"/api/captures/{_ID}").json()["capture"]["classification"] == label
|
||||
response = client.post(
|
||||
f"/api/captures/{_ID}/reject",
|
||||
headers={"Origin": origin, "X-CSRF-Token": csrf},
|
||||
json={"confirmation": "REJECT"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
csrf = str(response.json()["csrf_token"])
|
||||
assert corpus.rejected is True
|
||||
response = client.post(
|
||||
f"/api/captures/{_ID}/delete",
|
||||
headers={"Origin": origin, "X-CSRF-Token": csrf},
|
||||
json={"confirmation": "DELETE"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert corpus.deleted is True
|
||||
|
||||
|
||||
def test_review_ui_uses_local_external_assets_and_keeps_guardrails_in_static_source() -> None:
|
||||
web_root = Path(__file__).parents[2] / "src" / "game_data_capture" / "web"
|
||||
template = (web_root / "templates" / "index.html").read_text(encoding="utf-8")
|
||||
script = (web_root / "static" / "review.js").read_text(encoding="utf-8")
|
||||
stylesheet = (web_root / "static" / "review.css").read_text(encoding="utf-8")
|
||||
|
||||
assert 'href="/static/review.css"' in template
|
||||
assert 'src="/static/review.js"' in template
|
||||
assert "<style" not in template
|
||||
assert "<script>" not in template
|
||||
assert "http://" not in template + script + stylesheet
|
||||
assert "https://" not in template + script + stylesheet
|
||||
assert "innerHTML" not in script
|
||||
assert "Origin" not in script
|
||||
assert (
|
||||
'history.replaceState(null, "", `${window.location.pathname}${window.location.search}`)'
|
||||
in script
|
||||
)
|
||||
assert '"/api/session/start"' in script
|
||||
assert "JSON.stringify({ confirmation })" in script
|
||||
assert "confidence: 1.0" in script
|
||||
assert 'credentials: "same-origin"' in script
|
||||
assert "localStorage" not in script
|
||||
assert "sessionStorage" not in script
|
||||
for expected in (
|
||||
"weapon",
|
||||
"gear",
|
||||
"skill_chip",
|
||||
"os_protocol",
|
||||
"specialization",
|
||||
"unknown",
|
||||
"Mask",
|
||||
"Gloves",
|
||||
"Backpack",
|
||||
"Body Armor",
|
||||
"Holster",
|
||||
"Knee Pads",
|
||||
"prefers-reduced-motion",
|
||||
):
|
||||
assert expected in script + stylesheet
|
||||
|
||||
|
||||
def test_review_page_and_local_static_assets_serve_with_security_headers() -> None:
|
||||
client = TestClient(
|
||||
create_app(
|
||||
config=WebConfig(ServerConfig()),
|
||||
capture_control=CaptureControl(),
|
||||
corpus=Corpus(),
|
||||
bootstrap_token=new_bootstrap_token(),
|
||||
),
|
||||
base_url="http://127.0.0.1:8765",
|
||||
)
|
||||
|
||||
page = client.get("/")
|
||||
stylesheet = client.get("/static/review.css")
|
||||
script = client.get("/static/review.js")
|
||||
|
||||
for response in (page, stylesheet, script):
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-security-policy"].startswith("default-src 'self'")
|
||||
assert response.headers["referrer-policy"] == "no-referrer"
|
||||
assert response.headers["x-content-type-options"] == "nosniff"
|
||||
assert response.headers["x-frame-options"] == "DENY"
|
||||
assert "http://" not in response.text
|
||||
assert "https://" not in response.text
|
||||
assert 'href="/static/review.css"' in page.text
|
||||
assert 'src="/static/review.js"' in page.text
|
||||
assert stylesheet.headers["content-type"].startswith("text/css")
|
||||
assert "csrfPattern" in script.text
|
||||
assert "setCheckedMembers" in script.text
|
||||
assert "announce: false" in script.text
|
||||
Reference in New Issue
Block a user