196 lines
6.4 KiB
Python
196 lines
6.4 KiB
Python
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
|