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,505 @@
from __future__ import annotations
import copy
import json
import math
from pathlib import Path
from typing import Any
import pytest
from jsonschema import Draft202012Validator, FormatChecker
from jsonschema.exceptions import ValidationError
from jsonschema.protocols import Validator
from game_data_capture.domain.ports import ResourceBudget
from game_data_capture.domain.state_machines import (
STATE_MACHINES,
InvalidStateTransition,
require_transition,
)
from game_data_capture.domain.taxonomy import RECORD_TYPES_BY_VIEW, SCREEN_VIEWS, Family
from game_data_capture.domain.validation import (
ContractValidationError,
validate_archive_document,
validate_classification,
validate_handoff_document,
)
ROOT = Path(__file__).resolve().parents[2]
CONTRACTS = ROOT / "contracts"
CLASSIFICATION_EXAMPLES = CONTRACTS / "examples" / "classification"
def load_json(path: Path) -> dict[str, Any]:
with path.open(encoding="utf-8") as handle:
value = json.load(handle)
assert isinstance(value, dict)
return value
def schema_validator(schema_name: str) -> Validator:
schema = load_json(CONTRACTS / schema_name)
Draft202012Validator.check_schema(schema)
return Draft202012Validator(schema, format_checker=FormatChecker())
@pytest.mark.parametrize(
"schema_name",
[
"screenshot-classification.schema.json",
"evidence-archive-manifest-v1.schema.json",
"isac-handoff-v1.schema.json",
],
)
def test_schemas_compile_as_portable_draft_2020_12(schema_name: str) -> None:
schema = load_json(CONTRACTS / schema_name)
assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema"
assert not any(key.startswith("x-python") for key in _all_keys(schema))
Draft202012Validator.check_schema(schema)
validator = Draft202012Validator(schema)
assert validator.schema == schema
def _all_keys(value: object) -> list[str]:
keys: list[str] = []
if isinstance(value, dict):
for key, child in value.items():
keys.append(key)
keys.extend(_all_keys(child))
elif isinstance(value, list):
for child in value:
keys.extend(_all_keys(child))
return keys
def test_domain_contract_and_python_taxonomy_cover_every_family_view() -> None:
contract = load_json(CONTRACTS / "domain-contract-v1.json")
families = contract["families"]
for family, views in SCREEN_VIEWS.items():
assert set(families[family.value]["views"]) == set(views)
for view in views:
expected = [record_type.value for record_type in RECORD_TYPES_BY_VIEW[(family, view)]]
assert families[family.value]["views"][view] == expected
def test_domain_contract_and_python_state_machines_match() -> None:
contract_states = load_json(CONTRACTS / "domain-contract-v1.json")["states"]
assert set(contract_states) == set(STATE_MACHINES)
for machine_name, machine in STATE_MACHINES.items():
expected = {state: sorted(targets) for state, targets in machine.transitions.items()}
actual = {
state: sorted(targets) for state, targets in contract_states[machine_name].items()
}
assert actual == expected
@pytest.mark.parametrize("family", [family.value for family in Family])
def test_each_family_has_a_valid_and_invalid_example(family: str) -> None:
validator = schema_validator("screenshot-classification.schema.json")
valid = load_json(CLASSIFICATION_EXAMPLES / f"{family}.valid.json")
invalid = load_json(CLASSIFICATION_EXAMPLES / f"{family}.invalid.json")
validator.validate(valid)
with pytest.raises(ValidationError):
validator.validate(invalid)
validate_classification(valid)
with pytest.raises(ContractValidationError):
validate_classification(invalid)
def test_composite_classification_preserves_multiple_record_types() -> None:
classification = load_json(CLASSIFICATION_EXAMPLES / "weapon.valid.json")
validate_classification(classification)
assert classification["record_types"] == ["weapon", "weapon_talent"]
def test_unknown_enum_value_is_rejected() -> None:
classification = load_json(CLASSIFICATION_EXAMPLES / "gear.valid.json")
classification["gear_slot"] = "Vest"
with pytest.raises(ValidationError):
schema_validator("screenshot-classification.schema.json").validate(classification)
with pytest.raises(ContractValidationError, match="Gear slot"):
validate_classification(classification)
@pytest.mark.parametrize(
("schema_name", "valid_fixture", "invalid_fixture"),
[
(
"evidence-archive-manifest-v1.schema.json",
"archive/manifest.valid.json",
"archive/manifest.invalid.json",
),
(
"isac-handoff-v1.schema.json",
"handoff/isac-handoff.valid.json",
"handoff/isac-handoff.invalid.json",
),
],
)
def test_versioned_schema_fixtures_validate_and_reject(
schema_name: str, valid_fixture: str, invalid_fixture: str
) -> None:
validator = schema_validator(schema_name)
validator.validate(load_json(CONTRACTS / "examples" / valid_fixture))
with pytest.raises(ValidationError):
validator.validate(load_json(CONTRACTS / "examples" / invalid_fixture))
@pytest.mark.parametrize(
("schema_name", "valid_fixture"),
[
("evidence-archive-manifest-v1.schema.json", "archive/manifest.valid.json"),
("isac-handoff-v1.schema.json", "handoff/isac-handoff.valid.json"),
],
)
def test_versioned_schemas_reject_future_versions_from_otherwise_valid_documents(
schema_name: str, valid_fixture: str
) -> None:
document = load_json(CONTRACTS / "examples" / valid_fixture)
document["schema_version"] = 2
with pytest.raises(ValidationError):
schema_validator(schema_name).validate(document)
def test_valid_archive_preserves_observed_values_without_canonicalizing_them() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
validate_archive_document(archive)
observed_paths = {field["path"] for field in archive["observed_instances"][0]["fields"]}
canonical_paths = {field["path"] for field in archive["canonical_candidates"][0]["fields"]}
assert "observed.damage" in observed_paths
assert "observed.damage" not in canonical_paths
assert archive["observed_instances"][0]["observed_context"]["upgrade_state"] == {
"path": "observed.upgrade_state",
"value": None,
"missing_reason": "not_visible",
"evidence_refs": ["evidence-upgrade"],
"uncertainty_note": "The screen does not show an upgrade indicator.",
}
def test_malformed_evidence_bounds_are_rejected() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["evidence_values"][0]["region"] = {
"coordinate_space": "normalized",
"x": 0.8,
"y": 0.1,
"width": 0.3,
"height": 0.1,
}
with pytest.raises(ContractValidationError, match="bounds"):
validate_archive_document(archive)
def test_archive_schema_rejects_nonpositive_evidence_bounds() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["evidence_values"][0]["region"]["width"] = 0
with pytest.raises(ValidationError):
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
def test_null_evidence_without_missing_reason_is_rejected() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
missing = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-upgrade"
)
missing["missing_reason"] = None
with pytest.raises(ValidationError):
schema_validator("evidence-archive-manifest-v1.schema.json").validate(archive)
with pytest.raises(ContractValidationError, match="missing_reason"):
validate_archive_document(archive)
def test_archive_video_limit_matches_the_configured_16_gib_maximum() -> None:
validator = schema_validator("evidence-archive-manifest-v1.schema.json")
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
video = {
"artifact_id": "video-limit",
"sha256": "c" * 64,
"media_type": "video/mp4",
"byte_size": 16 * 1024 * 1024 * 1024,
"sensitivity": "private",
"parent_artifact_id": None,
"video_timestamp_ms": None,
}
archive["artifacts"].append(video)
validator.validate(archive)
video_byte_size = video["byte_size"]
assert isinstance(video_byte_size, int)
oversized_video_bytes = video_byte_size + 1
video["byte_size"] = oversized_video_bytes
with pytest.raises(ValidationError):
validator.validate(archive)
def test_archive_image_limit_is_64_mib() -> None:
validator = schema_validator("evidence-archive-manifest-v1.schema.json")
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
artifact = archive["artifacts"][0]
artifact["byte_size"] = 64 * 1024 * 1024
validator.validate(archive)
artifact["byte_size"] += 1
with pytest.raises(ValidationError):
validator.validate(archive)
def test_image_handoff_limit_is_64_mib() -> None:
validator = schema_validator("isac-handoff-v1.schema.json")
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
artifact = handoff["source_artifacts"][0]
artifact["byte_size"] = 64 * 1024 * 1024
validator.validate(handoff)
artifact["byte_size"] += 1
with pytest.raises(ValidationError):
validator.validate(handoff)
def test_dangling_evidence_reference_is_rejected() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["canonical_candidates"][0]["fields"][0]["evidence_refs"] = ["not-present"]
with pytest.raises(ContractValidationError, match="dangling references"):
validate_archive_document(archive)
@pytest.mark.parametrize(
("collection", "id_field", "new_item"),
[
("artifacts", "artifact_id", None),
("captures", "capture_id", None),
("screens", "screen_id", None),
("jobs", "job_id", {"job_id": "duplicate", "last_error_id": None}),
(
"extractions",
"extraction_id",
{
"extraction_id": "duplicate",
"screen_id": "screen-001",
"evidence_refs": [],
},
),
(
"groups",
"group_id",
{
"group_id": "duplicate",
"screen_ids": ["screen-001"],
"supersedes_group_ids": [],
},
),
("reviews", "review_id", None),
("exports", "export_id", {"export_id": "duplicate", "artifact_id": None}),
("errors", "error_id", {"error_id": "duplicate"}),
("evidence_values", "evidence_id", None),
("observed_instances", "instance_id", None),
("canonical_candidates", "candidate_id", None),
],
)
def test_archive_rejects_duplicate_entity_ids(
collection: str, id_field: str, new_item: dict[str, Any] | None
) -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
item = new_item if new_item is not None else archive[collection][0]
archive[collection] = [copy.deepcopy(item), copy.deepcopy(item)]
with pytest.raises(ContractValidationError, match=f"Duplicate {id_field}"):
validate_archive_document(archive)
def test_archive_rejects_dangling_job_error_reference() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["jobs"].append({"job_id": "job-001", "last_error_id": "missing-error"})
with pytest.raises(ContractValidationError, match=r"job\.last_error_id"):
validate_archive_document(archive)
@pytest.mark.parametrize("supersedes", [["missing-group"], ["group-001"]])
def test_archive_rejects_invalid_group_supersession(supersedes: list[str]) -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["groups"].append(
{
"group_id": "group-001",
"screen_ids": ["screen-001"],
"supersedes_group_ids": supersedes,
}
)
with pytest.raises(ContractValidationError, match="supersedes_group_ids"):
validate_archive_document(archive)
def test_archive_rejects_dangling_export_artifact_reference() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["exports"].append({"export_id": "export-001", "artifact_id": "missing-artifact"})
with pytest.raises(ContractValidationError, match=r"export\.artifact_id"):
validate_archive_document(archive)
def test_archive_rejects_self_referential_artifact_parent() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
archive["artifacts"][0]["parent_artifact_id"] = "image-001"
with pytest.raises(ContractValidationError, match="cannot reference itself"):
validate_archive_document(archive)
def test_correction_history_final_state_must_match_current_evidence() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
evidence = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
)
evidence["normalized_value"] = 12401
with pytest.raises(ContractValidationError, match="final state"):
validate_archive_document(archive)
def test_correction_history_rejects_disconnected_value_states() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
evidence = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
)
evidence["correction_history"].append(
{
"correction_id": "correction-002",
"corrected_at": "2026-07-31T12:01:00Z",
"actor": "local_user",
"reason": "Disconnected fixture.",
"before": {"raw_text": "wrong", "normalized_value": 0, "missing_reason": None},
"after": {
"raw_text": evidence["raw_text"],
"normalized_value": evidence["normalized_value"],
"missing_reason": evidence["missing_reason"],
},
}
)
with pytest.raises(ContractValidationError, match="disconnected"):
validate_archive_document(archive)
def test_correction_history_rejects_out_of_order_timestamps() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
evidence = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
)
current_state = {
"raw_text": evidence["raw_text"],
"normalized_value": evidence["normalized_value"],
"missing_reason": evidence["missing_reason"],
}
evidence["correction_history"].append(
{
"correction_id": "correction-002",
"corrected_at": "2026-07-31T11:59:00Z",
"actor": "local_user",
"reason": "Out-of-order fixture.",
"before": copy.deepcopy(current_state),
"after": copy.deepcopy(current_state),
}
)
with pytest.raises(ContractValidationError, match="strictly ordered"):
validate_archive_document(archive)
def test_correction_ids_are_unique_across_the_archive() -> None:
archive = load_json(CONTRACTS / "examples" / "archive" / "manifest.valid.json")
evidence = next(
value for value in archive["evidence_values"] if value["evidence_id"] == "evidence-damage"
)
duplicate = copy.deepcopy(evidence)
duplicate["evidence_id"] = "evidence-damage-copy"
archive["evidence_values"].append(duplicate)
with pytest.raises(ContractValidationError, match="Duplicate correction_id"):
validate_archive_document(archive)
@pytest.mark.parametrize(
("example", "validator"),
[
("archive/manifest.invalid.json", validate_archive_document),
("handoff/isac-handoff.invalid.json", validate_handoff_document),
],
)
def test_future_schema_versions_fail_closed(example: str, validator: Any) -> None:
with pytest.raises(ContractValidationError, match="Unsupported schema_version"):
validator(load_json(CONTRACTS / "examples" / example))
def test_valid_handoff_has_no_mutation_authority_and_resolves_evidence() -> None:
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
schema_validator("isac-handoff-v1.schema.json").validate(handoff)
validate_handoff_document(handoff)
assert handoff["trust_boundary"] == {
"intended_destination": "authenticated_isac_intake",
"mutation_authority": "none",
"requires_authenticated_action": True,
"preserves_moderation": True,
"creates_audit_events": False,
}
def test_handoff_cannot_claim_mutation_authority() -> None:
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
handoff["trust_boundary"]["mutation_authority"] = "canonical_write"
with pytest.raises(ValidationError):
schema_validator("isac-handoff-v1.schema.json").validate(handoff)
with pytest.raises(ContractValidationError, match="trust boundary"):
validate_handoff_document(handoff)
def test_handoff_dangling_evidence_reference_is_rejected() -> None:
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
broken = copy.deepcopy(handoff)
broken["records"][0]["fields"][0]["evidence_refs"] = ["missing"]
with pytest.raises(ContractValidationError, match="dangling references"):
validate_handoff_document(broken)
def test_handoff_source_archive_provenance_does_not_require_local_entities() -> None:
handoff = load_json(CONTRACTS / "examples" / "handoff" / "isac-handoff.valid.json")
handoff["records"][0]["source_candidate_ids"] = ["archive-candidate-not-embedded"]
handoff["records"][0]["observed_instance_ids"] = ["archive-instance-not-embedded"]
validate_handoff_document(handoff)
@pytest.mark.parametrize("field", ["max_input_bytes", "max_output_bytes", "max_items"])
def test_resource_budget_rejects_negative_limits(field: str) -> None:
with pytest.raises(ValueError, match="non-negative integer"):
ResourceBudget(
deadline_monotonic=1.0,
max_input_bytes=-1 if field == "max_input_bytes" else 1,
max_output_bytes=-1 if field == "max_output_bytes" else 1,
max_items=-1 if field == "max_items" else 1,
)
@pytest.mark.parametrize("deadline", [math.nan, math.inf, -math.inf])
def test_resource_budget_rejects_nonfinite_deadlines(deadline: float) -> None:
with pytest.raises(ValueError, match="finite or null"):
ResourceBudget(
deadline_monotonic=deadline,
max_input_bytes=1,
max_output_bytes=1,
max_items=1,
)
def test_resource_budget_allows_zero_limits_and_no_deadline() -> None:
assert (
ResourceBudget(
deadline_monotonic=None,
max_input_bytes=0,
max_output_bytes=0,
max_items=0,
).deadline_monotonic
is None
)
@pytest.mark.parametrize("machine", sorted(STATE_MACHINES))
def test_each_state_machine_rejects_an_invalid_transition(machine: str) -> None:
first_state = next(iter(STATE_MACHINES[machine].transitions))
with pytest.raises(InvalidStateTransition):
require_transition(machine, first_state, "not-a-state")
def test_terminal_transition_is_rejected_and_recovery_transition_is_allowed() -> None:
with pytest.raises(InvalidStateTransition, match="Invalid review transition"):
require_transition("review", "cancelled", "approved")
require_transition("job", "failed", "queued")
require_transition("error", "retrying", "recovered")
@@ -0,0 +1,458 @@
from __future__ import annotations
import sqlite3
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from game_data_capture.storage import CaptureCorpus, CaptureRepository, ContentAddressedBlobStore
from game_data_capture.storage.blobs import BlobCorruptionError, BlobLockError
from game_data_capture.storage.database import StorageDatabase
from game_data_capture.storage.repository import StorageError
def _label(*, view: str = "detail", records: list[str] | None = None) -> dict[str, object]:
return {
"family": "weapon",
"view": view,
"gear_slot": None,
"record_types": ["weapon"] if records is None else records,
"confidence": 1.0,
"abstention_reason": None,
}
@pytest.fixture
def corpus(tmp_path: Path) -> tuple[StorageDatabase, CaptureRepository, CaptureCorpus]:
root = (tmp_path / "utility").resolve()
database = StorageDatabase(
root,
(Path(__file__).parents[3] / "migrations").resolve(),
)
database.migrate()
blobs = ContentAddressedBlobStore(root)
repository = CaptureRepository(database, blobs)
return database, repository, CaptureCorpus(database, blobs, max_media_bytes=1024)
def _receive(
repository: CaptureRepository, capture_id: str, token: str, payload: bytes = b"png"
) -> None:
repository.receive(capture_id, token, [payload], media_type="image/png", max_bytes=1024)
def _assert_media_unavailable(port: CaptureCorpus, capture_id: str) -> None:
media = port.open_media(capture_id)
assert media is not None
with pytest.raises(BlobCorruptionError):
next(iter(media.chunks))
def _commit_deletion_intent(database: StorageDatabase, capture_id: str) -> None:
with database.connect() as connection:
row = connection.execute(
"SELECT c.blob_sha256, c.state, c.received_at, c.receipt_token, c.failure_reason, "
"c.revision, b.byte_size FROM captures AS c JOIN blobs AS b "
"ON b.sha256 = c.blob_sha256 WHERE c.capture_id = ?",
(capture_id,),
).fetchone()
assert row is not None
connection.execute("BEGIN IMMEDIATE")
connection.execute(
"INSERT INTO capture_deletion_intents(blob_sha256, capture_id, byte_size, "
"capture_state, received_at, receipt_token, failure_reason, revision, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
row["blob_sha256"],
capture_id,
row["byte_size"],
row["state"],
row["received_at"],
row["receipt_token"],
row["failure_reason"],
row["revision"],
"time",
),
)
connection.execute("DELETE FROM captures WHERE capture_id = ?", (capture_id,))
connection.execute("COMMIT")
def test_safe_bounded_listing_detail_and_verified_media(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
_, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
_receive(repository, "capture-0002", "receipt-0002")
records = port.list_captures(limit=1)
assert len(records) == 1 and records[0].capture_id == "capture-0002"
assert port.get_capture("../capture-0001") is None
assert port.open_media("../capture-0001") is None
media = port.open_media("capture-0001")
assert media is not None
assert media.media_type == "image/png" and media.byte_size == 3
chunks = media.chunks
assert next(iter(chunks)) == b"png"
close = getattr(chunks, "close", None)
assert callable(close)
close()
for invalid in (0, 257, True):
with pytest.raises(ValueError):
port.list_captures(limit=invalid)
def test_labels_preserve_prior_values_across_restart_and_reject_is_idempotent(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
first = _label()
second = _label(view="blueprint", records=["weapon_talent"])
with pytest.raises(ValueError):
port.label("capture-0001", {"family": "weapon"})
assert port.label("capture-0001", first) == "updated"
assert port.label("capture-0001", first) == "updated"
assert port.label("capture-0001", second) == "updated"
restarted = CaptureCorpus(
database, ContentAddressedBlobStore(database.root), max_media_bytes=1024
)
record = restarted.get_capture("capture-0001")
assert record is not None and record.classification == second
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM capture_labels").fetchone()[0] == 1
assert restarted.reject_preserving_evidence("capture-0001") == "updated"
assert restarted.reject_preserving_evidence("capture-0001") == "updated"
assert restarted.label("capture-0001", first) == "conflict"
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM capture_rejections").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 1
def test_concurrent_label_writes_serialize_without_losing_prior_value(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
labels = (_label(), _label(view="blueprint", records=["weapon_talent"]))
with ThreadPoolExecutor(max_workers=2) as executor:
outcomes = list(executor.map(lambda value: port.label("capture-0001", value), labels))
assert outcomes == ["updated", "updated"]
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM capture_labels").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 1
def test_canonical_equivalent_labels_do_not_append_history(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
first = _label(records=["weapon", "weapon_talent"])
equivalent = _label(records=["weapon_talent", "weapon"])
equivalent["confidence"] = 1
assert port.label("capture-0001", first) == "updated"
assert port.label("capture-0001", equivalent) == "updated"
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 0
def test_rejected_or_evidence_bearing_captures_are_retained(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
_, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
assert port.reject_preserving_evidence("capture-0001") == "updated"
assert port.delete_with_retention("capture-0001") == "retained"
_receive(repository, "capture-0002", "receipt-0002")
repository.append_ocr_run("run-0002", "capture-0002", "ocr", "1", {})
assert port.delete_with_retention("capture-0002") == "retained"
assert port.delete_with_retention("bad") == "not_found"
def test_label_and_job_evidence_retain_capture(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
assert port.label("capture-0001", _label()) == "updated"
assert port.delete_with_retention("capture-0001") == "retained"
_receive(repository, "capture-0002", "receipt-0002")
with database.connect() as connection:
connection.execute(
"INSERT INTO jobs(job_id, capture_id, kind, state, max_attempts) "
"VALUES (?, ?, ?, ?, ?)",
("job-0002", "capture-0002", "ocr", "queued", 1),
)
assert port.delete_with_retention("capture-0002") == "retained"
def test_receive_translates_put_lock_failure_without_capture(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
monkeypatch: pytest.MonkeyPatch,
) -> None:
database, repository, _ = corpus
def locked(_: object, *, max_bytes: int) -> tuple[str, int]:
raise BlobLockError("locked")
monkeypatch.setattr(repository.blobs, "put", locked)
with pytest.raises(StorageError, match="unavailable"):
_receive(repository, "capture-0001", "receipt-0001")
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
@pytest.mark.parametrize(
("state", "failure_reason"),
(("failed", None), ("cancelled", None), ("stored", "capture failure"), ("unknown", None)),
)
def test_non_disposable_capture_rows_are_retained(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
state: str,
failure_reason: str | None,
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
with database.connect() as connection:
connection.execute(
"UPDATE captures SET state = ?, failure_reason = ? WHERE capture_id = ?",
(state, failure_reason, "capture-0001"),
)
assert port.delete_with_retention("capture-0001") == "retained"
def test_capture_lineage_and_failure_evidence_are_retained(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
repository.append_lineage("lineage-0001", "capture-0001", "parent-0001", "derived")
assert port.delete_with_retention("capture-0001") == "retained"
_receive(repository, "capture-0002", "receipt-0002")
repository.record_failure("failure-0002", "capture", "capture-0002", "fault", "bounded")
assert port.delete_with_retention("capture-0002") == "retained"
_receive(repository, "capture-0003", "receipt-0003")
repository.append_lineage("lineage-0003", "child-0003", "capture-0003", "derived")
assert port.delete_with_retention("capture-0003") == "retained"
def test_shared_blob_deletion_keeps_remaining_capture_media(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
_, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001", b"shared")
_receive(repository, "capture-0002", "receipt-0002", b"shared")
assert port.delete_with_retention("capture-0001") == "updated"
assert port.get_capture("capture-0001") is None
media = port.open_media("capture-0002")
assert media is not None and b"".join(media.chunks) == b"shared"
with repository.database.connect() as connection:
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
def test_unshared_deletion_removes_media_metadata_and_intent(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
row = database.connect().execute("SELECT blob_sha256 FROM captures").fetchone()
assert row is not None
path = repository.blobs._path(str(row["blob_sha256"]))
assert port.delete_with_retention("capture-0001") == "updated"
assert not path.exists()
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 0
assert (
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
)
def test_committed_intent_before_unlink_blocks_replay_then_finalizes(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
row = database.connect().execute("SELECT blob_sha256 FROM captures").fetchone()
assert row is not None
path = repository.blobs._path(str(row["blob_sha256"]))
_commit_deletion_intent(database, "capture-0001")
assert path.exists()
with pytest.raises(StorageError, match="deletion"):
_receive(repository, "capture-0002", "receipt-0001")
restarted = CaptureCorpus(
database, ContentAddressedBlobStore(database.root), max_media_bytes=1024
)
assert restarted.reconcile_deletions() == 1
assert not path.exists()
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 0
assert (
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
)
def test_interleaved_reinstall_waits_for_deletion_finalization(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
monkeypatch: pytest.MonkeyPatch,
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001", b"shared")
unlinked = threading.Event()
release = threading.Event()
original = repository.blobs.delete_verified
outcomes: list[object] = []
def pause(digest: str, *, expected_size: int) -> bool:
result = original(digest, expected_size=expected_size)
unlinked.set()
assert release.wait(0.5)
return result
monkeypatch.setattr(repository.blobs, "delete_verified", pause)
deletion = threading.Thread(
target=lambda: outcomes.append(port.delete_with_retention("capture-0001"))
)
deletion.start()
assert unlinked.wait(0.5)
receipt = threading.Thread(
target=lambda: outcomes.append(
repository.receive(
"capture-0002", "receipt-0002", [b"shared"], media_type="image/png", max_bytes=1024
)
)
)
receipt.start()
release.set()
deletion.join(1)
receipt.join(1)
assert not deletion.is_alive() and not receipt.is_alive()
assert set(outcomes) == {"updated", "capture-0002"}
with database.connect() as connection:
assert (
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
)
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
media = port.open_media("capture-0002")
assert media is not None and b"".join(media.chunks) == b"shared"
def test_unlink_then_sqlite_finalization_failure_recovers(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
row = database.connect().execute("SELECT sha256, byte_size FROM blobs").fetchone()
assert row is not None
path = repository.blobs._path(str(row["sha256"]))
_commit_deletion_intent(database, "capture-0001")
repository.blobs.delete_verified(str(row["sha256"]), expected_size=int(row["byte_size"]))
assert not path.exists()
with database.connect() as connection:
connection.execute(
"CREATE TRIGGER fail_finalization BEFORE DELETE ON capture_deletion_intents "
"BEGIN SELECT RAISE(ABORT, 'fault'); END"
)
with pytest.raises(sqlite3.IntegrityError, match="fault"):
port.reconcile_deletions()
assert port.get_capture("capture-0001") is None
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
assert (
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 1
)
connection.execute("DROP TRIGGER fail_finalization")
restarted = CaptureCorpus(
database, ContentAddressedBlobStore(database.root), max_media_bytes=1024
)
assert restarted.reconcile_deletions() == 1
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 0
assert (
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
)
assert not path.exists()
def test_corrupt_missing_and_symlink_media_fail_closed(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus], tmp_path: Path
) -> None:
_, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
with repository.database.connect() as connection:
row = connection.execute("SELECT blob_sha256 FROM captures").fetchone()
assert row is not None
path = repository.blobs._path(str(row["blob_sha256"]))
path.write_bytes(b"x" * 2048)
_assert_media_unavailable(port, "capture-0001")
path.unlink()
_assert_media_unavailable(port, "capture-0001")
path.symlink_to(tmp_path / "outside")
_assert_media_unavailable(port, "capture-0001")
def test_label_transaction_rolls_back_on_evidence_failure(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
assert port.label("capture-0001", _label()) == "updated"
with database.connect() as connection:
connection.execute(
"CREATE TRIGGER fail_label_evidence BEFORE INSERT ON capture_label_evidence "
"BEGIN SELECT RAISE(ABORT, 'fault'); END"
)
with pytest.raises(sqlite3.IntegrityError, match="fault"):
port.label(
"capture-0001",
_label(view="blueprint", records=["weapon_talent"]),
)
assert port.get_capture("capture-0001") is not None
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM capture_label_evidence").fetchone()[0] == 0
stored = connection.execute("SELECT classification_json FROM capture_labels").fetchone()
assert stored is not None and '"view":"detail"' in stored["classification_json"]
def test_deletion_intent_recovers_after_unlink_failure(
corpus: tuple[StorageDatabase, CaptureRepository, CaptureCorpus],
monkeypatch: pytest.MonkeyPatch,
) -> None:
database, repository, port = corpus
_receive(repository, "capture-0001", "receipt-0001")
with database.connect() as connection:
original = tuple(
connection.execute(
"SELECT capture_id, blob_sha256, state, received_at, receipt_token, "
"failure_reason, revision FROM captures"
).fetchone()
)
def fail_unlink(_: str, *, expected_size: int) -> bool:
raise BlobCorruptionError("injected fault")
monkeypatch.setattr(repository.blobs, "delete_verified", fail_unlink)
assert port.delete_with_retention("capture-0001") == "retained"
with database.connect() as connection:
assert (
connection.execute("SELECT count(*) FROM capture_deletion_intents").fetchone()[0] == 0
)
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
assert (
tuple(
connection.execute(
"SELECT capture_id, blob_sha256, state, received_at, receipt_token, "
"failure_reason, revision FROM captures"
).fetchone()
)
== original
)
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
media = port.open_media("capture-0001")
assert media is not None and b"".join(media.chunks) == b"png"
@@ -0,0 +1,147 @@
from __future__ import annotations
import shutil
import sqlite3
from pathlib import Path
import pytest
from game_data_capture.storage.database import MigrationError, StorageDatabase
def _migrations(tmp_path: Path) -> Path:
source = Path(__file__).parents[3] / "migrations" / "001_initial.sql"
destination = tmp_path / "migrations"
destination.mkdir()
shutil.copy(source, destination / source.name)
return destination
def test_connection_pragmas_and_private_modes(tmp_path: Path) -> None:
root = (tmp_path / "utility").resolve()
database = StorageDatabase(root, _migrations(tmp_path).resolve())
database.migrate()
with database.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
connection.execute("CREATE TABLE IF NOT EXISTS live_wal(value INTEGER)")
assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
assert connection.execute("PRAGMA busy_timeout").fetchone()[0] == 5000
assert connection.execute("PRAGMA synchronous").fetchone()[0] == 2
for path in (
database.path.with_name("capture.sqlite3-wal"),
database.path.with_name("capture.sqlite3-shm"),
):
if path.exists():
assert path.stat().st_mode & 0o077 == 0
connection.execute("COMMIT")
assert root.stat().st_mode & 0o777 == 0o700
assert database.path.stat().st_mode & 0o777 == 0o600
def test_database_symlink_is_rejected(tmp_path: Path) -> None:
root = (tmp_path / "utility").resolve()
root.mkdir()
outside = tmp_path / "outside"
outside.write_text("x")
(root / "capture.sqlite3").symlink_to(outside)
with pytest.raises(MigrationError):
StorageDatabase(root, _migrations(tmp_path).resolve()).connect()
def test_migration_history_and_failure_rollback(tmp_path: Path) -> None:
root = (tmp_path / "utility").resolve()
migrations = _migrations(tmp_path).resolve()
database = StorageDatabase(root, migrations)
database.migrate()
first = migrations / "001_initial.sql"
first.write_text(first.read_text() + "\n-- changed\n")
with pytest.raises(MigrationError):
database.migrate()
first.write_text((Path(__file__).parents[3] / "migrations" / "001_initial.sql").read_text())
bad = migrations / "002_bad.sql"
bad.write_text("CREATE TABLE partial_test(value INTEGER); BAD SQL;")
with pytest.raises(sqlite3.OperationalError):
database.migrate()
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM schema_migrations").fetchone()[0] == 1
assert (
connection.execute(
"SELECT name FROM sqlite_master WHERE name='partial_test'"
).fetchone()
is None
)
bad.write_text("CREATE TABLE partial_test(value INTEGER);")
database.migrate()
database.migrate()
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM schema_migrations").fetchone()[0] == 2
def test_migration_removal_rename_and_duplicate_rejected(tmp_path: Path) -> None:
root = (tmp_path / "utility").resolve()
migrations = _migrations(tmp_path).resolve()
database = StorageDatabase(root, migrations)
database.migrate()
path = migrations / "001_initial.sql"
path.rename(migrations / "001_renamed.sql")
with pytest.raises(MigrationError):
database.migrate()
(migrations / "001_renamed.sql").rename(path)
path.unlink()
with pytest.raises(MigrationError):
database.migrate()
shutil.copy(Path(__file__).parents[3] / "migrations" / "001_initial.sql", path)
shutil.copy(path, migrations / "001_duplicate.sql")
with pytest.raises(MigrationError):
database.migrate()
def test_corpus_migration_preserves_populated_initial_database(tmp_path: Path) -> None:
root = (tmp_path / "utility").resolve()
migrations = _migrations(tmp_path).resolve()
database = StorageDatabase(root, migrations)
database.migrate()
with database.connect() as connection:
connection.execute(
"INSERT INTO blobs(sha256, byte_size, media_type, created_at) VALUES (?, 1, ?, ?)",
("0" * 64, "image/png", "time"),
)
connection.execute(
"INSERT INTO captures(capture_id, blob_sha256, state, received_at, receipt_token) "
"VALUES (?, ?, ?, ?, ?)",
("capture-0001", "0" * 64, "stored", "time", "receipt-0001"),
)
source = Path(__file__).parents[3] / "migrations" / "002_capture_corpus.sql"
shutil.copy(source, migrations / source.name)
database.migrate()
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM capture_labels").fetchone()[0] == 0
assert tuple(
connection.execute(
"SELECT sha256, byte_size, media_type, created_at FROM blobs"
).fetchone()
) == ("0" * 64, 1, "image/png", "time")
assert tuple(
connection.execute(
"SELECT capture_id, blob_sha256, state, received_at, receipt_token, "
"failure_reason, revision FROM captures"
).fetchone()
) == ("capture-0001", "0" * 64, "stored", "time", "receipt-0001", None, 0)
assert connection.execute("SELECT count(*) FROM schema_migrations").fetchone()[0] == 2
migration = connection.execute(
"SELECT filename, checksum FROM schema_migrations WHERE version = 2"
).fetchone()
assert migration is not None
assert migration["filename"] == "002_capture_corpus.sql"
assert isinstance(migration["checksum"], str) and len(migration["checksum"]) == 64
names = {row["name"] for row in connection.execute("SELECT name FROM sqlite_master")}
assert {
"captures_blob_sha256",
"jobs_capture_id",
"ocr_runs_capture_id",
"runtime_failures_capture_subject",
"lineage_child_id",
"lineage_parent_id",
}.issubset(names)
@@ -0,0 +1,117 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from pathlib import Path
import pytest
from game_data_capture.pipeline.jobs import JobLeases
from game_data_capture.storage.database import StorageDatabase
from game_data_capture.storage.repository import StorageError
@pytest.fixture
def database(tmp_path: Path) -> StorageDatabase:
database = StorageDatabase(
(tmp_path / "utility").resolve(), (Path(__file__).parents[3] / "migrations").resolve()
)
database.migrate()
return database
def _expire(database: StorageDatabase, job_id: str) -> None:
with database.connect() as connection:
connection.execute(
"UPDATE jobs SET lease_expires_at = ? WHERE job_id = ?",
((datetime.now(UTC) - timedelta(seconds=1)).isoformat(), job_id),
)
def test_job_validation_and_exclusive_ownership(database: StorageDatabase) -> None:
jobs = JobLeases(database)
for job_id, kind, attempts in (
("!", "ocr", 1),
("job", "bad", 1),
("job", "ocr", True),
("job", "ocr", 0),
):
with pytest.raises(ValueError):
jobs.create(job_id, kind, max_attempts=attempts)
jobs.create("job-1", "ocr")
for duration in (0, 301, True):
with pytest.raises(ValueError):
jobs.acquire("owner-1", duration)
with pytest.raises(ValueError):
jobs.acquire("!", 30)
assert jobs.acquire("owner-1", 30) == "job-1"
assert jobs.acquire("owner-2", 30) is None
with pytest.raises(StorageError):
jobs.renew("job-1", "owner-2", 30)
with pytest.raises(StorageError):
jobs.finish("job-1", "owner-2", "succeeded")
for duration in (0, 301, True):
with pytest.raises(ValueError):
jobs.renew("job-1", "owner-1", duration)
def test_expiry_recovery_exhaustion_and_terminal_jobs(database: StorageDatabase) -> None:
jobs = JobLeases(database)
jobs.create("retry", "ocr", max_attempts=2)
assert jobs.acquire("owner-1", 30) == "retry"
_expire(database, "retry")
with pytest.raises(StorageError):
jobs.renew("retry", "owner-1", 30)
with pytest.raises(StorageError):
jobs.finish("retry", "owner-1", "succeeded")
assert jobs.recover() == 1
with database.connect() as connection:
assert tuple(
connection.execute(
"SELECT state, lease_owner, lease_expires_at FROM jobs WHERE job_id='retry'"
).fetchone()
) == ("queued", None, None)
assert JobLeases(database).acquire("owner-2", 30) == "retry"
with database.connect() as connection:
assert tuple(
connection.execute(
"SELECT attempt, state, lease_owner FROM jobs WHERE job_id='retry'"
).fetchone()
) == (2, "running", "owner-2")
jobs.create("exhaust", "ocr", max_attempts=1)
assert jobs.acquire("owner-3", 30) == "exhaust"
_expire(database, "exhaust")
assert jobs.recover() == 1
with database.connect() as connection:
assert tuple(
connection.execute(
"SELECT state, attempt, lease_owner, lease_expires_at "
"FROM jobs WHERE job_id='exhaust'"
).fetchone()
) == ("failed", 1, None, None)
assert jobs.acquire("owner-4", 30) is None
for job_id, target in (("ok", "succeeded"), ("fail", "failed"), ("cancel", "cancelled")):
jobs.create(job_id, "ocr")
assert jobs.acquire("owner-5", 30) == job_id
jobs.finish(job_id, "owner-5", target)
with database.connect() as connection:
for job_id, target in (("ok", "succeeded"), ("fail", "failed"), ("cancel", "cancelled")):
assert tuple(
connection.execute(
"SELECT state, lease_owner, lease_expires_at FROM jobs WHERE job_id = ?",
(job_id,),
).fetchone()
) == (target, None, None)
def test_retry_wait_finish_rejected_without_changing_lease(database: StorageDatabase) -> None:
jobs = JobLeases(database)
jobs.create("job-1", "ocr")
jobs.acquire("owner-1", 30)
with pytest.raises(ValueError):
jobs.finish("job-1", "owner-1", "retry_wait")
with database.connect() as connection:
assert tuple(
connection.execute(
"SELECT state, lease_owner FROM jobs WHERE job_id='job-1'"
).fetchone()
) == ("running", "owner-1")
@@ -0,0 +1,233 @@
from __future__ import annotations
import hashlib
import json
import sqlite3
from collections.abc import Iterator
from pathlib import Path
import pytest
from game_data_capture.pipeline.jobs import JobLeases
from game_data_capture.storage.blobs import ContentAddressedBlobStore
from game_data_capture.storage.database import StorageDatabase
from game_data_capture.storage.repository import CaptureRepository, StorageError
@pytest.fixture
def storage(tmp_path: Path) -> tuple[StorageDatabase, CaptureRepository]:
root = (tmp_path / "utility").resolve()
migrations = Path(__file__).parents[3] / "migrations"
database = StorageDatabase(root, migrations.resolve())
database.migrate()
return database, CaptureRepository(database, ContentAddressedBlobStore(root))
def test_identical_occurrences_are_deduplicated_and_receipts_survive_restart(
storage: tuple[StorageDatabase, CaptureRepository],
) -> None:
database, repository = storage
assert (
repository.receive(
"capture-1", "receipt-1", [b"same"], media_type="image/png", max_bytes=100
)
== "capture-1"
)
assert (
repository.receive(
"capture-2", "receipt-2", [b"same"], media_type="image/png", max_bytes=100
)
== "capture-2"
)
# A simulated restart uses fresh repository objects and a repeated intake receipt.
restarted = CaptureRepository(database, ContentAddressedBlobStore(database.root))
assert (
restarted.receive(
"capture-other", "receipt-2", [b"same"], media_type="image/png", max_bytes=100
)
== "capture-2"
)
assert restarted.report()["blobs"] == 1
assert restarted.report()["captures"] == 2
def test_capture_never_references_missing_blob_and_cas_rejects_stale(
storage: tuple[StorageDatabase, CaptureRepository],
) -> None:
database, repository = storage
repository.receive(
"capture-1", "receipt-1", [b"complete"], media_type="image/png", max_bytes=100
)
row = database.connect().execute("SELECT blob_sha256 FROM captures").fetchone()
assert row is not None and repository.blobs.verify(str(row["blob_sha256"])) == len(b"complete")
assert repository.transition("capture-1", "queued", 0) == 1
with pytest.raises(StorageError):
repository.transition("capture-1", "failed", 0)
def test_reprocessing_is_append_only_and_leases_enforce_owner(
storage: tuple[StorageDatabase, CaptureRepository],
) -> None:
database, repository = storage
repository.receive(
"capture-1", "receipt-1", [b"capture"], media_type="image/png", max_bytes=100
)
repository.append_ocr_run("run-1", "capture-1", "ocr", "1", {"raw": "one"})
repository.append_ocr_run("run-2", "capture-1", "ocr", "2", {"raw": "two"})
assert database.connect().execute("SELECT count(*) FROM ocr_runs").fetchone()[0] == 2
jobs = JobLeases(database)
jobs.create("job-1", "ocr", "capture-1")
assert jobs.acquire("worker-a", 30) == "job-1"
with pytest.raises(StorageError):
jobs.finish("job-1", "worker-b", "succeeded")
jobs.finish("job-1", "worker-a", "succeeded")
def test_replayed_receipt_does_not_consume_payload(
storage: tuple[StorageDatabase, CaptureRepository],
) -> None:
database, repository = storage
repository.receive(
"capture-1", "receipt-1", [b"capture"], media_type="image/png", max_bytes=100
)
consumed = [False]
class MustNotConsume:
def __iter__(self) -> Iterator[bytes]:
consumed[0] = True
raise AssertionError("receipt replay consumed payload")
assert (
repository.receive(
"capture-2", "receipt-1", MustNotConsume(), media_type="image/png", max_bytes=100
)
== "capture-1"
)
assert not consumed[0]
def test_every_provenance_kind_is_insert_only(
storage: tuple[StorageDatabase, CaptureRepository],
) -> None:
database, repository = storage
repository.receive(
"capture-1", "receipt-1", [b"capture"], media_type="image/png", max_bytes=100
)
repository.append_ocr_run("run-1", "capture-1", "ocr", "1", {})
repository.append_region("region-1", "run-1", {})
repository.append_classification("class-1", "run-1", {})
repository.append_candidate("candidate-1", "run-1", {})
repository.append_routing("route-1", "candidate-1", {})
repository.append_review("review-1", "candidate-1", {})
repository.append_lineage("lineage-1", "child-1", "parent-1", "derived")
repository.append_export("export-1", "ready", {})
with pytest.raises(sqlite3.IntegrityError):
repository.append_ocr_run("run-1", "capture-1", "ocr", "1", {})
payloads = {
"ocr_runs": "payload_json",
"regions": "region_json",
"classifications": "classification_json",
"extraction_candidates": "candidate_json",
"routing_decisions": "decision_json",
"review_events": "event_json",
"exports": "manifest_json",
}
with database.connect() as connection:
for table in (
"ocr_runs",
"regions",
"classifications",
"extraction_candidates",
"routing_decisions",
"review_events",
"lineage",
"exports",
):
assert connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0] == 1
for table, column in payloads.items():
json.loads(connection.execute(f"SELECT {column} FROM {table}").fetchone()[0])
assert (
connection.execute("SELECT count(*) FROM ocr_runs WHERE run_id='run-1'").fetchone()[0]
== 1
)
def test_failure_sanitization_and_provenance_bounds(
storage: tuple[StorageDatabase, CaptureRepository],
) -> None:
database, repository = storage
repository.receive(
"capture-1", "receipt-1", [b"capture"], media_type="image/png", max_bytes=100
)
with pytest.raises(ValueError):
repository.append_ocr_run("run", "capture-1", "", "1", {})
with pytest.raises(ValueError):
repository.append_ocr_run("run", "capture-1", "x" * 101, "1", {})
with pytest.raises(ValueError):
repository.append_lineage("line", "child", "parent", "")
with pytest.raises(ValueError):
repository.append_export("export", "", {})
with pytest.raises(ValueError):
repository.append_candidate("candidate", "run", "x" * 1_000_001)
long_token = "x" * 600
short_message = (
"sk-secret123 Authorization: Bearer auth-secret token=token-secret secret=hidden "
"password=pw-secret api_key=key-secret /home/agent/private.png\n"
)
repository.record_failure("failure-1", "capture", "capture-1", "failure-code", short_message)
repository.record_failure("failure-2", "capture", "capture-1", "failure-code", long_token)
with database.connect() as connection:
rows = list(
connection.execute("SELECT code, message FROM runtime_failures ORDER BY failure_id")
)
assert len(rows) == 2
for row in rows:
assert len(row["message"]) <= 512 and len(row["code"]) <= 64
assert "\n" not in row["message"]
for value in (
"sk-secret123",
"auth-secret",
"token-secret",
"hidden",
"pw-secret",
"key-secret",
"/home/agent/private.png",
"\n",
):
assert value not in rows[0]["message"]
assert long_token not in rows[1]["message"]
with pytest.raises(ValueError):
repository.record_failure("failure-2", "BAD", "capture-1", "code", "message")
def test_complete_blob_after_capture_insert_failure_is_purgeable_orphan(
storage: tuple[StorageDatabase, CaptureRepository],
) -> None:
database, repository = storage
repository.receive(
"capture-1", "receipt-1", [b"payload-a"], media_type="image/png", max_bytes=100
)
payload_b = b"payload-b"
digest_b = hashlib.sha256(payload_b).hexdigest()
with pytest.raises(sqlite3.IntegrityError):
repository.receive(
"capture-1", "receipt-2", [payload_b], media_type="image/png", max_bytes=100
)
assert repository.blobs.verify(digest_b) == len(payload_b)
with database.connect() as connection:
assert (
connection.execute(
"SELECT count(*) FROM blobs WHERE sha256 = ?", (digest_b,)
).fetchone()[0]
== 0
)
assert (
connection.execute(
"SELECT count(*) FROM captures WHERE blob_sha256 = ?", (digest_b,)
).fetchone()[0]
== 0
)
assert repository.blobs._path(digest_b).relative_to(
repository.blobs.root
) in repository.blobs.purge_dry_run(set())
@@ -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
@@ -0,0 +1,42 @@
from __future__ import annotations
import subprocess
import sys
import time
from pathlib import Path
import pytest
from game_data_capture.ingest.clipboard import ClipboardIntake, WatchConfig
def test_shutdown_reaps_real_child_process_group(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
child: subprocess.Popen[bytes] | None = None
real_popen = subprocess.Popen
def fake_popen(args: list[str], **kwargs: object) -> subprocess.Popen[bytes]:
nonlocal child
assert args[0] == "wl-paste" and kwargs["shell"] is False
child = real_popen(
[sys.executable, "-c", "import time; time.sleep(30)"],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return child
monkeypatch.setattr(subprocess, "Popen", fake_popen)
intake = ClipboardIntake(
WatchConfig((tmp_path / "spool").resolve(), 100, 1, "v1", 1, 0.01, 0.2)
)
intake.arm()
assert child is not None and child.poll() is None
intake.shutdown()
for _ in range(20):
if child.poll() is not None:
break
time.sleep(0.01)
assert child.poll() is not None
@@ -0,0 +1,90 @@
from __future__ import annotations
from pathlib import Path
def test_review_interface_marks_keyboard_modal_and_responsive_state_hooks() -> 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 'aria-live="polite"' in template
assert '<dialog class="confirmation-dialog"' in template
assert 'value="cancel"' in template
assert 'type="radio"' not in template
assert "input.type = type" in script
assert 'input.type = "button"' not in script
assert 'input.type = "radio"' not in script
assert 'input.type = "checkbox"' not in script
assert "input.type = type" in script
assert "els.dialog.showModal()" in script
assert 'els.dialog.returnValue = "cancel"' in script
assert 'els.dialog.dataset.action = ""' in script
assert "const focusTarget = returnFocus;" in script
assert "if (focusTarget) focusTarget.focus();" in script
assert "fallbackFocus?.focus();" in script
assert 'els.media.addEventListener("error"' in script
assert ":focus-visible" in stylesheet
assert "min-height: 44px" in stylesheet
assert "@media (max-width: 52rem)" in stylesheet
assert "@media (prefers-reduced-motion: reduce)" in stylesheet
assert "@media (forced-colors: active)" in stylesheet
def test_review_script_guards_session_taxonomy_pending_and_selection_state() -> None:
script = (
Path(__file__).parents[2] / "src" / "game_data_capture" / "web" / "static" / "review.js"
).read_text(encoding="utf-8")
assert 'headers.set("Origin"' not in script
assert "Origin:" not in script
assert 'const bootstrap = await request("/api/bootstrap"' in script
assert 'const session = await request("/api/session")' in script
assert script.index('"/api/bootstrap"') < script.index('"/api/session"')
assert "if (!validSession(session))" in script
assert "family, false, () =>" in script
assert "view, false, renderDependentFields" in script
assert "if (!classification) { renderViews(); renderDependentFields(); return; }" in script
assert "function routeForAction(action)" in script
assert '(action === "reject" || action === "delete") && activeCapture' in script
assert "const routes =" not in script
assert 'document.querySelectorAll("#label-form input")' in script
assert "input.disabled = pending || locked || !activeCapture" in script
assert 'document.querySelectorAll("#capture-list button")' in script
assert "button.disabled = pending || locked;" in script
assert "armed === true" in script
assert "armed !== true" in script
assert "function acceptMutationCsrf(payload)" in script
assert "if (!payload || !acceptMutationCsrf(payload))" in script
assert 'retained ? "RETAINED"' in script
assert "function setCheckedMembers(name, values)" in script
assert "input.checked = selectedValues.has(input.value);" in script
assert 'setCheckedMembers("record_type", classification.record_types || []);' in script
assert 'forEach((record) => setChecked("record_type", record))' not in script
assert (
"async function refreshCaptures({ announce = true, fallbackFocus = null } = {})" in script
)
assert "async function selectCapture(captureId, { announce = true } = {})" in script
assert 'if (announce) notice("success", "QUEUE REFRESHED"' in script
assert 'if (announce) notice("success", "CAPTURE READY"' in script
assert "refreshCaptures({ announce: false })" in script
assert "selectCapture(captureId, { announce: false })" in script
assert script.index("selectCapture(captureId, { announce: false })") < script.index(
'notice("success", "LABEL SAVED"'
)
assert 'if (!activeCapture) els.reviewState.textContent = "READY";' in script
assert '"Capture is ARMED. Stop Session requires explicit confirmation."' in script
assert '"Capture is DISARMED. Start Session requires explicit confirmation."' in script
assert 'action === "start" ? els.stop' in script
assert 'action === "stop" ? els.start' in script
assert 'action === "delete" ? els.refresh' in script
assert "function focusIfAvailable(control)" in script
assert "focusIfAvailable(successFocus);" in script
assert "async function confirmed(action, fallbackFocus)" in script
assert "fallbackFocus?.focus();" in script
assert "function clearActiveDetail()" in script
assert 'if (action === "delete") clearActiveDetail();' in script
assert "captures.some((capture) => capture.capture_id === captureId)" in script
assert "refreshCaptures({ fallbackFocus: els.refresh })" in script
assert "focusIfAvailable(els.save);" in script
@@ -0,0 +1,496 @@
from __future__ import annotations
import io
import subprocess
from collections import deque
from collections.abc import Iterable, Mapping
from contextlib import AbstractContextManager, nullcontext
from pathlib import Path
from threading import Event
import pytest
import uvicorn
import game_data_capture.cli as cli
import game_data_capture.runtime as runtime_module
from game_data_capture.config import AppConfig, LifecycleConfig, ServerConfig
from game_data_capture.ingest.clipboard import (
ClipboardIntake,
FocusedWindowMetadata,
IntakeReceipt,
WatchConfig,
)
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE, receipt_name, spool_png
from game_data_capture.paths import RuntimePaths
from game_data_capture.runtime import CaptureRuntime, InstanceLock, RuntimeFailure, _CaptureControl
from game_data_capture.storage import CaptureRepository, ContentAddressedBlobStore, StorageDatabase
_PNG = PNG_SIGNATURE + b"watch-png"
class FakeIntake:
def __init__(self, cancelled: Event, payloads: list[dict[str, object]]) -> None:
self.cancelled = cancelled
self.payloads = deque(payloads)
self.receipts: deque[IntakeReceipt] = deque()
self.armed = self.reconciled = self.shutdown_called = False
self.opened = 0
self.acknowledged: list[str] = []
self.seen_tokens: set[str] = set()
self._ticks = 0
def reconcile(self) -> list[str]:
self.reconciled = True
return []
def arm(self) -> None:
self.armed = True
def tick(self, now: float | None = None) -> None:
self._ticks += 1
if self._ticks > 1:
self.cancelled.set()
def take_helper_receipt(self) -> dict[str, object] | None:
return self.payloads.popleft() if self.payloads else None
def accept_helper_receipt(
self,
payload: Mapping[str, object],
*,
occurrence_id: str,
processing_version: str,
) -> bool:
token = str(payload["receipt_token"])
if token in self.seen_tokens:
return False
self.seen_tokens.add(token)
self.receipts.append(
IntakeReceipt(
occurrence_id,
token,
"0" * 64,
len(_PNG),
"image/png",
Path("/private/spool.png"),
"capture-v1",
processing_version,
FocusedWindowMetadata(None, None, None),
)
)
return True
def take(self) -> IntakeReceipt | None:
return self.receipts.popleft() if self.receipts else None
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]:
self.opened += 1
return nullcontext([_PNG])
def acknowledge(self, receipt: IntakeReceipt) -> bool:
self.acknowledged.append(receipt.receipt_token)
return True
def shutdown(self) -> None:
self.shutdown_called = True
def _runtime(tmp_path: Path, intake: FakeIntake) -> CaptureRuntime:
root = tmp_path / "xdg"
paths = RuntimePaths(
config_dir=(root / "config").resolve(),
data_dir=(root / "data").resolve(),
cache_dir=(root / "cache").resolve(),
runtime_dir=(root / "runtime").resolve(),
)
return CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
paths,
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
intake_factory=lambda _config: intake,
)
def _payload(token: str) -> dict[str, object]:
return {
"receipt_version": 1,
"receipt_token": token,
"sha256": "0" * 64,
"byte_size": len(_PNG),
"media_type": "image/png",
"spool_name": "ignored.png",
"policy_version": "capture-v1",
}
class PassiveClipboardIntake(ClipboardIntake):
"""Use real private spool sidecars without launching a Wayland watcher."""
def __init__(
self, config: WatchConfig, cancelled: Event, *, acknowledge_result: bool = True
) -> None:
super().__init__(config)
self.cancelled = cancelled
self.acknowledge_result = acknowledge_result
self.acknowledged: list[str] = []
self.reconcile_calls = 0
self._ticks = 0
def reconcile(self) -> list[str]:
self.reconcile_calls += 1
return super().reconcile()
def arm(self) -> None:
self.armed, self.paused = True, False
def tick(self, now: float | None = None) -> None:
self._ticks += 1
if self._ticks > 1:
self.cancelled.set()
def acknowledge(self, receipt: IntakeReceipt) -> bool:
self.acknowledged.append(receipt.receipt_token)
return self.acknowledge_result and super().acknowledge(receipt)
class StopTimeoutReceiptIntake(PassiveClipboardIntake):
def __init__(self, config: WatchConfig, cancelled: Event) -> None:
super().__init__(config, cancelled)
self.tick_entered = Event()
self.release_tick = Event()
def tick(self, now: float | None = None) -> None:
self.tick_entered.set()
self.release_tick.wait()
def shutdown(self) -> None:
self.release_tick.set()
super().shutdown()
def _durable_runtime(
tmp_path: Path, cancelled: Event, *, acknowledge_result: bool = True
) -> tuple[CaptureRuntime, list[PassiveClipboardIntake]]:
created: list[PassiveClipboardIntake] = []
root = tmp_path / "durable-xdg"
paths = RuntimePaths(
config_dir=(root / "config").resolve(),
data_dir=(root / "data").resolve(),
cache_dir=(root / "cache").resolve(),
runtime_dir=(root / "runtime").resolve(),
)
def factory(config: WatchConfig) -> PassiveClipboardIntake:
intake = PassiveClipboardIntake(config, cancelled, acknowledge_result=acknowledge_result)
created.append(intake)
return intake
return (
CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
paths,
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
intake_factory=factory,
),
created,
)
def _durable_receipt(runtime: CaptureRuntime, token: str) -> None:
spool_png(
io.BytesIO(_PNG),
spool=runtime.paths.cache_dir / "spool",
max_bytes=100,
receipt_token=token,
policy_version="capture-v1",
)
def test_watch_streams_verified_receipts_and_replays_exact_tokens_idempotently(
tmp_path: Path,
) -> None:
cancelled = Event()
intake = FakeIntake(cancelled, [_payload("watch-receipt-1"), _payload("watch-receipt-1")])
runtime = _runtime(tmp_path, intake)
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
assert intake.armed and intake.reconciled and intake.shutdown_called and intake.opened == 1
assert intake.acknowledged == ["watch-receipt-1"]
def test_watch_ignores_an_absent_helper_receipt_without_error_noise(tmp_path: Path) -> None:
cancelled = Event()
intake = FakeIntake(cancelled, [])
runtime = _runtime(tmp_path, intake)
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 0
assert intake.shutdown_called and intake.opened == 0
def test_startup_reconciles_partial_blob_storage_before_arming(tmp_path: Path) -> None:
cancelled = Event()
intake = FakeIntake(cancelled, [])
runtime = _runtime(tmp_path, intake)
blobs = ContentAddressedBlobStore(runtime.paths.data_dir)
blobs.temporary.mkdir(parents=True)
partial = blobs.temporary / ".leftover.partial"
partial.write_bytes(b"partial")
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert not partial.exists()
assert intake.reconciled and intake.shutdown_called
def test_durable_receipt_is_acknowledged_once_after_repository_confirmation(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
cancelled = Event()
runtime, created = _durable_runtime(tmp_path, cancelled)
_durable_receipt(runtime, "confirmed")
events: list[str] = []
original_receive = CaptureRepository.receive
original_acknowledge = PassiveClipboardIntake.acknowledge
def tracked_receive(
self: CaptureRepository,
capture_id: str,
receipt_token: str,
chunks: Iterable[bytes],
*,
media_type: str,
max_bytes: int,
) -> str:
events.append("receive")
return original_receive(
self,
capture_id,
receipt_token,
chunks,
media_type=media_type,
max_bytes=max_bytes,
)
monkeypatch.setattr(CaptureRepository, "receive", tracked_receive)
def tracked_acknowledge(self: PassiveClipboardIntake, receipt: IntakeReceipt) -> bool:
events.append("acknowledge")
return original_acknowledge(self, receipt)
monkeypatch.setattr(PassiveClipboardIntake, "acknowledge", tracked_acknowledge)
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert events == ["receive", "acknowledge"]
assert created[0].acknowledged == ["confirmed"]
assert created[0].reconcile_calls == 1
assert not (runtime.paths.cache_dir / "spool" / receipt_name("confirmed")).exists()
def test_storage_failure_leaves_durable_receipt_unacknowledged(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
cancelled = Event()
runtime, created = _durable_runtime(tmp_path, cancelled)
_durable_receipt(runtime, "storage-failure")
def failed_receive(
self: CaptureRepository,
capture_id: str,
receipt_token: str,
chunks: Iterable[bytes],
*,
media_type: str,
max_bytes: int,
) -> str:
raise OSError("storage unavailable")
monkeypatch.setattr(CaptureRepository, "receive", failed_receive)
with pytest.raises(RuntimeFailure, match="capture watch failed"):
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert created[0].acknowledged == []
assert (runtime.paths.cache_dir / "spool" / receipt_name("storage-failure")).exists()
def test_cancellation_after_storage_confirmation_does_not_acknowledge(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
cancelled = Event()
runtime, created = _durable_runtime(tmp_path, cancelled)
_durable_receipt(runtime, "cancelled-after-storage")
original_receive = CaptureRepository.receive
def cancel_after_receive(
self: CaptureRepository,
capture_id: str,
receipt_token: str,
chunks: Iterable[bytes],
*,
media_type: str,
max_bytes: int,
) -> str:
result = original_receive(
self,
capture_id,
receipt_token,
chunks,
media_type=media_type,
max_bytes=max_bytes,
)
cancelled.set()
return result
monkeypatch.setattr(CaptureRepository, "receive", cancel_after_receive)
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert created[0].acknowledged == []
assert (runtime.paths.cache_dir / "spool" / receipt_name("cancelled-after-storage")).exists()
def test_acknowledgement_failure_replays_idempotently_and_releases_lock(tmp_path: Path) -> None:
cancelled = Event()
runtime, failed = _durable_runtime(tmp_path, cancelled, acknowledge_result=False)
_durable_receipt(runtime, "replay-token")
with pytest.raises(RuntimeFailure, match="acknowledgement failed"):
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert failed[0].acknowledged == ["replay-token"]
sidecar = runtime.paths.cache_dir / "spool" / receipt_name("replay-token")
assert sidecar.exists()
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
reusable.release()
replay_cancelled = Event()
replay_runtime, replayed = _durable_runtime(tmp_path, replay_cancelled)
replay_runtime.watch(cancelled=replay_cancelled, poll_seconds=0.001)
database = StorageDatabase(replay_runtime.paths.data_dir, replay_runtime.migrations_dir)
with database.connect() as connection:
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 1
assert replayed[0].acknowledged == ["replay-token"]
assert not sidecar.exists()
def test_service_stop_timeout_preserves_unacknowledged_durable_sidecar(tmp_path: Path) -> None:
cancelled = Event()
created: list[StopTimeoutReceiptIntake] = []
root = tmp_path / "stop-timeout-xdg"
paths = RuntimePaths(
config_dir=(root / "config").resolve(),
data_dir=(root / "data").resolve(),
cache_dir=(root / "cache").resolve(),
runtime_dir=(root / "runtime").resolve(),
)
def factory(config: WatchConfig) -> StopTimeoutReceiptIntake:
intake = StopTimeoutReceiptIntake(config, cancelled)
created.append(intake)
return intake
runtime = CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0, shutdown_timeout_seconds=1)),
paths,
migrations_dir=(Path(__file__).parents[2] / "migrations").resolve(),
intake_factory=factory,
)
_durable_receipt(runtime, "timeout-receipt")
resources = runtime._start()
control = _CaptureControl(runtime, resources.repository)
try:
assert control.start_session() == "started"
assert created[0].tick_entered.wait(0.2)
assert control.stop_session() == "stopped"
assert created[0].acknowledged == []
assert (runtime.paths.cache_dir / "spool" / receipt_name("timeout-receipt")).exists()
finally:
control.shutdown()
resources.close()
def test_cli_bare_watch_is_a_safe_refusal_and_modes_never_start_live_processes(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
root = tmp_path / "fresh-xdg"
for key, name in (
("HOME", "home"),
("XDG_CONFIG_HOME", "config"),
("XDG_DATA_HOME", "data"),
("XDG_CACHE_HOME", "cache"),
("XDG_RUNTIME_DIR", "runtime"),
):
monkeypatch.setenv(key, str(root / name))
def forbidden(*_arguments: object, **_kwargs: object) -> None:
raise AssertionError("test started a process")
monkeypatch.setattr(subprocess, "Popen", forbidden)
assert cli.main(["watch"]) == cli.EXIT_UNIMPLEMENTED
assert "requires gdc-runtime-integration" in capsys.readouterr().err
assert cli.main(["watch", "--dry-run"]) == 0
assert capsys.readouterr().out == "watch dry-run: capture runtime plan is valid\n"
assert not root.exists()
def test_cli_arm_reaches_only_a_mocked_runtime(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[str] = []
class FakeRuntime:
def __init__(self, _config: object, _paths: object) -> None:
calls.append("created")
def watch(self) -> None:
calls.append("watch")
monkeypatch.setattr(cli, "CaptureRuntime", FakeRuntime)
assert cli.main(["watch", "--arm"]) == 0
assert calls == ["created", "watch"]
def test_cli_serve_validates_before_constructing_resources_and_passes_exact_authority(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[str, object]] = []
class FakeRuntime:
def __init__(self, config: AppConfig, _paths: object) -> None:
calls.append(("created", config.server))
def serve(self) -> None:
calls.append(("serve", None))
monkeypatch.setattr(cli, "CaptureRuntime", FakeRuntime)
assert cli.main(["serve", "--host", "127.0.0.1", "--port", "9877"]) == 0
assert calls == [("created", ServerConfig("127.0.0.1", 9877)), ("serve", None)]
calls.clear()
assert cli.main(["serve", "--host", "0.0.0.0"]) == cli.EXIT_CONFIG_ERROR
assert calls == []
def test_cli_serve_converts_uvicorn_startup_exit_without_leaking_its_token(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
root = tmp_path / "serve-xdg"
for key, name in (
("HOME", "home"),
("XDG_CONFIG_HOME", "config"),
("XDG_DATA_HOME", "data"),
("XDG_CACHE_HOME", "cache"),
("XDG_RUNTIME_DIR", "runtime"),
):
monkeypatch.setenv(key, str(root / name))
token = "Z" * 43
monkeypatch.setattr(runtime_module, "new_bootstrap_token", lambda: token)
def failed_server(*_: object, **__: object) -> None:
raise SystemExit(2)
monkeypatch.setattr(uvicorn, "run", failed_server)
assert cli.main(["serve"]) == cli.EXIT_RUNTIME_ERROR
output = capsys.readouterr()
assert output.err == "capture runtime failed\n"
assert token not in output.err
assert "SystemExit" not in output.err
reusable = InstanceLock.acquire(root / "runtime" / "shd-capture" / "instance.lock", 0)
reusable.release()
@@ -0,0 +1,74 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
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
class Control:
def status(self) -> CaptureSessionStatus:
return CaptureSessionStatus(False, 0, 1)
def start_session(self) -> ControlOutcome:
return "started"
def stop_session(self) -> ControlOutcome:
return "stopped"
class EmptyCorpus:
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"
def test_bootstrap_is_one_time_same_origin_and_does_not_enable_permissive_cors() -> None:
token = new_bootstrap_token()
app = create_app(
config=WebConfig(ServerConfig()),
capture_control=Control(),
corpus=EmptyCorpus(),
bootstrap_token=token,
)
client = TestClient(app, base_url="http://127.0.0.1:8765")
cross_origin = client.post(
"/api/bootstrap", headers={"Origin": "http://evil.test"}, json={"token": token}
)
accepted = client.post(
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
)
replay = client.post(
"/api/bootstrap", headers={"Origin": "http://127.0.0.1:8765"}, json={"token": token}
)
assert cross_origin.status_code == 403
assert accepted.status_code == 200
assert replay.status_code == 401
assert "access-control-allow-origin" not in accepted.headers
@@ -0,0 +1,479 @@
from __future__ import annotations
import io
import json
import math
import os
import subprocess
import sys
from pathlib import Path
import pytest
import game_data_capture.ingest.clipboard as clipboard
from game_data_capture.ingest.clipboard import (
ClipboardIntake,
FocusedWindowMetadata,
WatchConfig,
focused_window,
restricted_environment,
)
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE, spool_png
def receipt(spool: Path, token: str = "receipt") -> dict[str, object]:
return spool_png(
__import__("io").BytesIO(PNG_SIGNATURE + b"png"),
spool=spool,
max_bytes=100,
receipt_token=token,
policy_version="v1",
)
def unique_receipt(spool: Path, token: str, value: bytes) -> dict[str, object]:
return spool_png(
io.BytesIO(PNG_SIGNATURE + value),
spool=spool,
max_bytes=100,
receipt_token=token,
policy_version="v1",
)
def config(tmp_path: Path, *, queue: int = 2, retries: int = 2) -> WatchConfig:
return WatchConfig((tmp_path / "spool").resolve(), 100, queue, "v1", retries, 0.01, 0.1)
def test_environment_is_allowlisted_and_secret_free() -> None:
child = restricted_environment(
{
"PATH": "/bin",
"XDG_RUNTIME_DIR": "/run/user/1",
"OPENAI_API_KEY": "sk-secret",
"HTTP_AUTHORIZATION": "Bearer secret",
"PYTHONPATH": "/unsafe",
"UNRELATED": "no",
}
)
assert child == {"PATH": "/bin", "XDG_RUNTIME_DIR": "/run/user/1"}
def test_text_clear_duplicate_and_backpressure_are_silent(tmp_path: Path) -> None:
intake = ClipboardIntake(config(tmp_path, queue=1))
item = receipt(intake.config.spool)
intake.armed = True
metadata = FocusedWindowMetadata(None, None, None)
assert not intake.accept_helper_receipt(
{"media_type": "text/plain"}, occurrence_id="a", processing_version="p"
)
assert not intake.accept_helper_receipt({}, occurrence_id="a", processing_version="p")
assert intake.accept_helper_receipt(
item, occurrence_id="a", processing_version="p", metadata=metadata
)
assert not intake.accept_helper_receipt(
item, occurrence_id="a", processing_version="p", metadata=metadata
)
other = receipt(intake.config.spool, "other")
assert not intake.accept_helper_receipt(
other, occurrence_id="b", processing_version="p", metadata=metadata
)
assert intake.queue_depth == 1
def test_watcher_argv_places_options_before_watch_subcommand(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
launched: list[list[str]] = []
class Fake:
pid = os.getpid()
stdout = None
def poll(self) -> int | None:
return None
def wait(self, timeout: float | None = None) -> int:
return 0
def popen(args: list[str], **kwargs: object) -> Fake:
assert kwargs["shell"] is False
launched.append(args)
return Fake()
monkeypatch.setattr(subprocess, "Popen", popen)
monkeypatch.setattr(os, "killpg", lambda _pid, _signal: None)
intake = ClipboardIntake(config(tmp_path))
intake.arm()
command = launched[0]
watch = command.index("--watch")
assert command[:watch] == ["wl-paste", "--type", "image/png"]
assert command[watch + 1] == sys.executable
assert "--" not in command
assert "game_data_capture.ingest.spool_helper" in command
intake.pause()
assert intake.paused
intake.resume()
assert len(launched) == 2
intake.disarm()
assert not intake.armed
def test_cancellation_disarms_and_clears_queue(tmp_path: Path) -> None:
intake = ClipboardIntake(config(tmp_path))
intake.armed = True
assert intake.accept_helper_receipt(
receipt(intake.config.spool),
occurrence_id="occurrence",
processing_version="processing-v1",
metadata=FocusedWindowMetadata(None, None, None),
)
intake.cancel()
assert not intake.armed and intake.take() is None
def test_receipt_integrity_versions_and_bounded_duplicate_cache(tmp_path: Path) -> None:
intake = ClipboardIntake(config(tmp_path, queue=2))
intake.armed = True
metadata = FocusedWindowMetadata(None, None, None)
first = receipt(intake.config.spool, "one")
future = dict(first, receipt_version=2)
assert not intake.accept_helper_receipt(
future, occurrence_id="one", processing_version="p", metadata=metadata
)
assert intake.accept_helper_receipt(
first, occurrence_id="one", processing_version="p", metadata=metadata
)
assert not intake.accept_helper_receipt(
first, occurrence_id="one", processing_version="p", metadata=metadata
)
assert len(intake._seen_tokens) == 1
intake.shutdown()
assert not intake._seen_tokens and not intake._helper_payloads
def test_durable_receipt_recovery_is_null_metadata_duplicate_safe_and_acknowledged(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
intake = ClipboardIntake(config(tmp_path))
item = receipt(intake.config.spool, "crash-token")
intake.armed = True
monkeypatch.setattr(
clipboard,
"focused_window",
lambda: pytest.fail("recovered receipt must not query the current window"),
)
intake.reconcile()
recovered = intake.take_helper_receipt()
assert recovered == item
assert intake.accept_helper_receipt(
recovered, occurrence_id="recovered", processing_version="p"
)
accepted = intake.take()
assert accepted is not None
assert accepted.focused_window == FocusedWindowMetadata(None, None, None)
assert not intake.accept_helper_receipt(item, occurrence_id="live-copy", processing_version="p")
assert (intake.config.spool / "crash-token.receipt.json").exists()
assert intake.acknowledge(accepted)
assert not intake.acknowledge(accepted)
assert not (intake.config.spool / "crash-token.receipt.json").exists()
assert accepted.spool_path.exists()
def test_reconcile_respects_helper_backpressure_and_retains_durable_receipts(
tmp_path: Path,
) -> None:
intake = ClipboardIntake(config(tmp_path, queue=1))
first = unique_receipt(intake.config.spool, "first", b"first")
second = unique_receipt(intake.config.spool, "second", b"second")
intake.armed = True
intake.reconcile()
queued = intake.take_helper_receipt()
assert queued in (first, second)
assert intake.accept_helper_receipt(
queued,
occurrence_id="first",
processing_version="p",
metadata=FocusedWindowMetadata(None, None, None),
)
remaining = second if queued == first else first
assert (intake.config.spool / f"{remaining['receipt_token']}.receipt.json").exists()
intake.reconcile()
assert intake.take_helper_receipt() == remaining
def test_unacknowledged_receipt_survives_a_fresh_intake(tmp_path: Path) -> None:
initial = ClipboardIntake(config(tmp_path))
item = receipt(initial.config.spool, "retry-token")
restarted = ClipboardIntake(config(tmp_path))
restarted.armed = True
restarted.reconcile()
replay = restarted.take_helper_receipt()
assert replay == item
assert restarted.accept_helper_receipt(replay, occurrence_id="retry", processing_version="p")
accepted = restarted.take()
assert accepted is not None
assert (restarted.config.spool / "retry-token.receipt.json").exists()
@pytest.mark.parametrize("field", ["max_image_bytes", "max_queue_items", "max_restarts"])
def test_bool_limits_are_rejected(tmp_path: Path, field: str) -> None:
values: dict[str, object] = dict(
spool=(tmp_path / "spool").resolve(),
max_image_bytes=100,
max_queue_items=1,
policy_version="v1",
max_restarts=1,
)
values[field] = True
with pytest.raises(ValueError):
WatchConfig(**values) # type: ignore[arg-type]
def test_watcher_crash_uses_backoff_and_restart_cap(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
starts = 0
class Dead:
pid = os.getpid()
stdout = None
def poll(self) -> int:
return 1
def wait(self, timeout: float | None = None) -> int:
return 1
def popen(*_args: object, **_kwargs: object) -> Dead:
nonlocal starts
starts += 1
return Dead()
monkeypatch.setattr(subprocess, "Popen", popen)
intake = ClipboardIntake(config(tmp_path, retries=1))
intake.arm()
intake.tick(0)
assert starts == 1
intake.tick(0.02)
assert starts == 2
intake.tick(0.02)
intake.tick(1)
assert starts == 2
def test_niri_timeout_and_malformed_json_are_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(clipboard, "_bounded_command_output", lambda *_args: None)
assert focused_window() == FocusedWindowMetadata(None, None, None)
monkeypatch.setattr(clipboard, "_bounded_command_output", lambda *_args: b"{broken")
assert focused_window() == FocusedWindowMetadata(None, None, None)
def test_receipt_mutation_matrix_and_file_integrity(tmp_path: Path) -> None:
intake = ClipboardIntake(config(tmp_path))
intake.armed = True
metadata = FocusedWindowMetadata(None, None, None)
base = unique_receipt(intake.config.spool, "token", b"base")
for key, value in (
("receipt_version", 2),
("receipt_token", ""),
("sha256", "0" * 64),
("byte_size", 9),
("media_type", "text/plain"),
("policy_version", "other"),
("spool_name", "wrong.png"),
):
changed = dict(base)
changed[key] = value
assert not intake.accept_helper_receipt(
changed, occurrence_id="ok", processing_version="p", metadata=metadata
)
for value in ("", "bad value"):
assert not intake.accept_helper_receipt(
base, occurrence_id=value, processing_version="p", metadata=metadata
)
assert not intake.accept_helper_receipt(
base, occurrence_id="ok", processing_version=value, metadata=metadata
)
for index, (mode, content) in enumerate(
((0o600, b"changed"), (0o600, PNG_SIGNATURE), (0o644, None))
):
item = unique_receipt(
intake.config.spool, f"token{mode}{index}", bytes(f"{mode}{index}", "ascii")
)
path = intake.config.spool / str(item["spool_name"])
if content is not None:
path.write_bytes(content)
path.chmod(mode)
assert not intake.accept_helper_receipt(
item, occurrence_id=f"o{mode}", processing_version="p", metadata=metadata
)
item = unique_receipt(intake.config.spool, "symlink", b"link")
path = intake.config.spool / str(item["spool_name"])
path.unlink()
path.symlink_to(tmp_path / "outside")
assert not intake.accept_helper_receipt(
item, occurrence_id="symlink", processing_version="p", metadata=metadata
)
def test_open_verified_and_acceptance_close_descriptors(tmp_path: Path) -> None:
intake = ClipboardIntake(config(tmp_path))
intake.armed = True
item = unique_receipt(intake.config.spool, "fd", b"reader")
before = len(list(Path("/proc/self/fd").iterdir()))
assert intake.accept_helper_receipt(
item,
occurrence_id="fd",
processing_version="p",
metadata=FocusedWindowMetadata(None, None, None),
)
accepted = intake.take()
assert accepted is not None and len(list(Path("/proc/self/fd").iterdir())) == before
with intake.open_verified(accepted) as reader:
assert b"".join(reader) == PNG_SIGNATURE + b"reader"
path = accepted.spool_path
path.write_bytes(b"changed")
with pytest.raises(ValueError), intake.open_verified(accepted):
pass
def test_duplicate_cache_eviction_shutdown_and_unreferenced_candidates(tmp_path: Path) -> None:
intake = ClipboardIntake(config(tmp_path, queue=2))
intake.armed = True
metadata = FocusedWindowMetadata(None, None, None)
items = [unique_receipt(intake.config.spool, f"t{i}", bytes([i])) for i in range(3)]
for index, item in enumerate(items):
assert intake.accept_helper_receipt(
item, occurrence_id=f"o{index}", processing_version="p", metadata=metadata
)
intake.take()
assert len(intake._seen_tokens) == 2 and not intake.accept_helper_receipt(
items[2], occurrence_id="latest", processing_version="p", metadata=metadata
)
assert intake.accept_helper_receipt(
items[0], occurrence_id="evicted", processing_version="p", metadata=metadata
)
intake = ClipboardIntake(config(tmp_path / "other", queue=1))
intake.armed = True
queued = unique_receipt(intake.config.spool, "queued", b"queued")
extra = unique_receipt(intake.config.spool, "extra", b"extra")
assert intake.accept_helper_receipt(
queued, occurrence_id="queued", processing_version="p", metadata=metadata
)
assert [path.name for path in intake.unreferenced_spool_candidates()] == [
str(extra["spool_name"])
]
intake._helper_payloads.append({"receipt": "real"})
intake.shutdown()
assert (
not intake._queue
and not intake._helper_payloads
and not intake._seen_tokens
and not intake._seen_order
)
def test_receipt_reader_bounds_spawn_failures_and_config_types(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
class Output:
def __init__(self) -> None:
self.parts = [
b"x" * 1025 + b"\n",
json.dumps({"ok": 1}).encode() + b"\n",
json.dumps({"two": 2}).encode() + b"\n",
]
def readline(self, _size: int) -> bytes:
return self.parts.pop(0) if self.parts else b""
class Process:
stdout = Output()
intake = ClipboardIntake(config(tmp_path, queue=1))
intake._read_receipts(Process()) # type: ignore[arg-type]
assert intake.helper_queue_depth == 1 and intake.take_helper_receipt() == {"ok": 1}
attempts = 0
def failing(*_args: object, **_kwargs: object) -> object:
nonlocal attempts
attempts += 1
raise OSError("missing")
monkeypatch.setattr(subprocess, "Popen", failing)
failed = ClipboardIntake(config(tmp_path, retries=2))
failed.arm()
failed.tick(failed._restart_after + 0.01)
failed.tick(failed._restart_after + 0.01)
failed.tick(failed._restart_after + 1)
assert attempts == 3
for field, value in (
("max_image_bytes", 1.5),
("max_queue_items", "1"),
("max_restarts", 1.5),
("shutdown_timeout_seconds", 0),
("restart_backoff_seconds", math.inf),
):
values: dict[str, object] = dict(
spool=(tmp_path / "x").resolve(),
max_image_bytes=100,
max_queue_items=1,
policy_version="v1",
max_restarts=1,
)
values[field] = value
with pytest.raises(ValueError):
WatchConfig(**values) # type: ignore[arg-type]
def test_bounded_niri_runner_and_valid_metadata(monkeypatch: pytest.MonkeyPatch) -> None:
assert (
clipboard._bounded_command_output([sys.executable, "-c", "print('x'*20000)"], {}, 1) is None
)
assert (
clipboard._bounded_command_output(
[sys.executable, "-c", "import time; time.sleep(2)"], {}, 0.01
)
is None
)
monkeypatch.setattr(
clipboard,
"_bounded_command_output",
lambda *_args: b'{"app_id":"game","title":"title","id":1}',
)
assert focused_window() == FocusedWindowMetadata("game", "title", 1)
def test_exited_watcher_is_waited_stdout_closed_and_reader_retired(tmp_path: Path) -> None:
class Output:
closed = False
def close(self) -> None:
self.closed = True
class Child:
pid = os.getpid()
stdout = Output()
waits = 0
def poll(self) -> int:
return 1
def wait(self, timeout: float | None = None) -> int:
self.waits += 1
return 1
class Reader:
joined = False
def join(self, timeout: float | None = None) -> None:
self.joined = True
intake = ClipboardIntake(config(tmp_path))
child = Child()
reader = Reader()
intake._watcher = child # type: ignore[assignment]
intake._reader = reader # type: ignore[assignment]
intake.pause()
assert child.waits == 1 and child.stdout.closed and reader.joined and intake._reader is None
@@ -0,0 +1,389 @@
from __future__ import annotations
import io
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
import game_data_capture.ingest.spool_helper as spool_helper
from game_data_capture.ingest.spool_helper import (
PNG_SIGNATURE,
SpoolError,
acknowledge_receipt,
durable_receipts,
receipt_name,
reconcile_spool,
spool_png,
)
def png(payload: bytes = b"data") -> bytes:
return PNG_SIGNATURE + payload
def fill_receipt_capacity(spool: Path) -> dict[str, object]:
seed = spool_png(
io.BytesIO(png(b"seed")),
spool=spool,
max_bytes=100,
receipt_token="seed",
policy_version="v1",
)
for index in range(255):
token = f"pre{index}"
sidecar = spool / receipt_name(token)
sidecar.write_text(
json.dumps(dict(seed, receipt_token=token), sort_keys=True, separators=(",", ":"))
)
sidecar.chmod(0o600)
return seed
def test_png_spool_is_private_atomic_and_deduplicated(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
spool = (tmp_path / "spool").resolve()
replaced: list[tuple[Path, Path]] = []
real_replace = os.replace
def replace(source: Path, destination: Path) -> None:
replaced.append((source, destination))
real_replace(source, destination)
monkeypatch.setattr(os, "replace", replace)
first = spool_png(
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="one", policy_version="v1"
)
second = spool_png(
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="two", policy_version="v1"
)
assert first["sha256"] == second["sha256"]
assert (spool.stat().st_mode & 0o777) == 0o700
installed = spool / str(first["spool_name"])
assert installed.read_bytes() == png() and (installed.stat().st_mode & 0o777) == 0o600
assert len(replaced) == 1 and replaced[0][1] == installed
sidecars = sorted(spool.glob("*.receipt.json"))
assert [sidecar.name for sidecar in sidecars] == ["one.receipt.json", "two.receipt.json"]
assert all((sidecar.stat().st_mode & 0o777) == 0o600 for sidecar in sidecars)
assert all(set(json.loads(sidecar.read_text())) == set(first) for sidecar in sidecars)
assert not list(spool.glob("*.partial"))
@pytest.mark.parametrize("value", [b"", b"text", PNG_SIGNATURE[:4]])
def test_empty_text_and_malformed_png_leave_no_partial(tmp_path: Path, value: bytes) -> None:
spool = (tmp_path / "spool").resolve()
with pytest.raises(SpoolError):
spool_png(
io.BytesIO(value), spool=spool, max_bytes=100, receipt_token="one", policy_version="v1"
)
assert not list(spool.glob("*.partial"))
def test_unsupported_mime_leaves_no_partial(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
with pytest.raises(SpoolError):
spool_png(
io.BytesIO(png()),
spool=spool,
max_bytes=100,
receipt_token="one",
policy_version="v1",
media_type="text/plain",
)
assert not spool.exists()
def test_short_reads_accumulate_the_png_signature(tmp_path: Path) -> None:
class ShortReads:
def __init__(self) -> None:
self.parts = [PNG_SIGNATURE[:3], PNG_SIGNATURE[3:] + b"payload", b""]
def read(self, _size: int) -> bytes:
return self.parts.pop(0)
receipt = spool_png(
ShortReads(),
spool=(tmp_path / "spool").resolve(),
max_bytes=100,
receipt_token="one",
policy_version="v1",
)
assert receipt["byte_size"] == len(PNG_SIGNATURE + b"payload")
def test_size_bound_and_crash_reconciliation_remove_partials(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
with pytest.raises(SpoolError):
spool_png(
io.BytesIO(png(b"x" * 100)),
spool=spool,
max_bytes=10,
receipt_token="one",
policy_version="v1",
)
leftover = spool / ".crash.partial"
leftover.write_bytes(b"incomplete")
assert reconcile_spool(spool) == [".crash.partial"]
assert not leftover.exists()
def test_pending_receipt_is_promoted_only_when_its_png_is_verified(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
receipt = spool_png(
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="one", policy_version="v1"
)
final = spool / receipt_name("one")
pending = spool / ".one.receipt.pending"
os.replace(final, pending)
assert reconcile_spool(spool) == []
assert final.exists() and not pending.exists()
final.unlink()
pending.write_text(json.dumps(receipt))
assert reconcile_spool(spool) == [pending.name]
assert not pending.exists()
def test_receipt_token_replay_cannot_replace_the_original_sidecar(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
original = spool_png(
io.BytesIO(png(b"original")),
spool=spool,
max_bytes=100,
receipt_token="same-token",
policy_version="v1",
)
final = spool / receipt_name("same-token")
original_bytes = final.read_bytes()
assert (
spool_png(
io.BytesIO(png(b"original")),
spool=spool,
max_bytes=100,
receipt_token="same-token",
policy_version="v1",
)
== original
)
with pytest.raises(SpoolError, match="receipt token conflict"):
spool_png(
io.BytesIO(png(b"different")),
spool=spool,
max_bytes=100,
receipt_token="same-token",
policy_version="v1",
)
assert final.read_bytes() == original_bytes
conflicting = dict(original, sha256="0" * 64, spool_name=f"{'0' * 64}.png")
pending = spool / ".same-token.receipt.pending"
pending.write_text(json.dumps(conflicting, sort_keys=True, separators=(",", ":")))
pending.chmod(0o600)
assert pending.name in reconcile_spool(spool)
assert final.read_bytes() == original_bytes and not pending.exists()
def test_sidecars_are_bounded_validated_and_acknowledged_exactly_once(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
first = spool_png(
io.BytesIO(png(b"one")),
spool=spool,
max_bytes=100,
receipt_token="one",
policy_version="v1",
)
second = spool_png(
io.BytesIO(png(b"two")),
spool=spool,
max_bytes=100,
receipt_token="two",
policy_version="v1",
)
malformed = spool / "bad.receipt.json"
malformed.write_bytes(b"{" + b"x" * 1024)
malformed.chmod(0o600)
linked = spool / "linked.receipt.json"
linked.symlink_to(malformed)
assert {item["receipt_token"] for item in durable_receipts(spool, max_items=2)} == {
"one",
"two",
}
assert acknowledge_receipt(spool, first)
assert not acknowledge_receipt(spool, first)
assert (spool / receipt_name("two")).exists()
assert not acknowledge_receipt(spool, dict(second, receipt_token="one"))
def test_malformed_sidecars_do_not_starve_valid_receipts_or_partial_cleanup(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
spool.mkdir(mode=0o700)
for index in range(256):
malformed = spool / f"{index:03d}.receipt.json"
malformed.write_bytes(b"{")
malformed.chmod(0o600)
valid = spool_png(
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="valid", policy_version="v1"
)
assert list(durable_receipts(spool, max_items=1)) == [valid]
assert list(spool.glob("*.receipt.json")) == [spool / "valid.receipt.json"]
for index in range(256):
pending = spool / f".pending{index}.receipt.pending"
pending.write_text(
json.dumps(
dict(valid, receipt_token=f"pending{index}"), sort_keys=True, separators=(",", ":")
)
)
pending.chmod(0o600)
partial = spool / ".after-pending.partial"
partial.write_bytes(b"incomplete")
assert partial.name in reconcile_spool(spool)
assert not partial.exists()
def test_receipt_capacity_rejects_new_tokens_but_accepts_exact_replay(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
seed = fill_receipt_capacity(spool)
evidence = (spool / receipt_name("seed")).read_bytes()
png_names = {path.name for path in spool.glob("*.png")}
with pytest.raises(SpoolError, match="capacity"):
spool_png(
io.BytesIO(png(b"new")),
spool=spool,
max_bytes=100,
receipt_token="new",
policy_version="v1",
)
assert (spool / receipt_name("seed")).read_bytes() == evidence
assert {path.name for path in spool.glob("*.png")} == png_names
assert (
spool_png(
io.BytesIO(png(b"seed")),
spool=spool,
max_bytes=100,
receipt_token="seed",
policy_version="v1",
)
== seed
)
def test_receipt_capacity_reconciles_malformed_files_before_counting(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
spool.mkdir(mode=0o700)
for index in range(256):
malformed = spool / f"bad{index}.receipt.json"
malformed.write_bytes(b"{")
malformed.chmod(0o600)
accepted = spool_png(
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="accepted", policy_version="v1"
)
assert accepted["receipt_token"] == "accepted"
assert list(spool.glob("bad*.receipt.json")) == []
def test_receipt_capacity_is_serialized_across_helper_processes(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
fill_receipt_capacity(spool)
for index in range(1):
(spool / receipt_name(f"pre{index}")).unlink()
arguments = [
sys.executable,
"-m",
"game_data_capture.ingest.spool_helper",
"--spool",
str(spool),
"--max-bytes",
"100",
"--policy-version",
"v1",
]
first = subprocess.Popen([*arguments, "--receipt-token", "race-one"], stdin=subprocess.PIPE)
second = subprocess.Popen([*arguments, "--receipt-token", "race-two"], stdin=subprocess.PIPE)
assert first.stdin is not None and second.stdin is not None
first.stdin.write(png(b"one"))
first.stdin.close()
second.stdin.write(png(b"two"))
second.stdin.close()
assert sorted((first.wait(), second.wait())) == [0, 2]
assert len(list(spool.glob("*.receipt.json"))) == 256
def test_spool_lock_is_private_rejects_links_and_releases_on_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
spool = (tmp_path / "spool").resolve()
spool.mkdir(mode=0o700)
(spool / ".spool.lock").symlink_to(tmp_path / "outside")
with pytest.raises(SpoolError, match="unsafe spool lock"):
spool_png(
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="link", policy_version="v1"
)
(spool / ".spool.lock").unlink()
with monkeypatch.context() as context:
context.setattr(
spool_helper,
"_install_pending_sidecar",
lambda *_args: (_ for _ in ()).throw(OSError()),
)
with pytest.raises(OSError):
spool_png(
io.BytesIO(png()),
spool=spool,
max_bytes=100,
receipt_token="failure",
policy_version="v1",
)
accepted = spool_png(
io.BytesIO(png()),
spool=spool,
max_bytes=100,
receipt_token="recovered",
policy_version="v1",
)
assert accepted["receipt_token"] == "recovered"
assert (spool / ".spool.lock").stat().st_mode & 0o777 == 0o600
def test_short_write_removes_partial(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
spool = (tmp_path / "spool").resolve()
monkeypatch.setattr(os, "write", lambda _fd, _data: 0)
with pytest.raises(OSError):
spool_png(
io.BytesIO(png()), spool=spool, max_bytes=100, receipt_token="one", policy_version="v1"
)
assert not list(spool.glob("*.partial"))
def test_helper_source_has_no_service_or_storage_imports() -> None:
source = Path(__file__).parents[3] / "src/game_data_capture/ingest/spool_helper.py"
text = source.read_text(encoding="utf-8")
for forbidden in ("fastapi", "sqlite", "openai", "game_data_capture.storage"):
assert forbidden not in text.lower()
def test_stdin_entrypoint_returns_only_bounded_json_receipt(tmp_path: Path) -> None:
spool = (tmp_path / "spool").resolve()
completed = subprocess.run(
[
sys.executable,
"-m",
"game_data_capture.ingest.spool_helper",
"--spool",
str(spool),
"--max-bytes",
"100",
"--receipt-token",
"receipt",
"--policy-version",
"v1",
],
input=png(),
capture_output=True,
check=False,
env={"PATH": os.environ.get("PATH", "")},
)
assert completed.returncode == 0 and len(completed.stdout) <= 1024 and not completed.stderr
receipt = json.loads(completed.stdout)
assert receipt["media_type"] == "image/png" and receipt["receipt_token"] == "receipt"
@@ -0,0 +1,466 @@
from __future__ import annotations
import sqlite3
from collections.abc import Callable, Iterable, Mapping
from contextlib import AbstractContextManager
from pathlib import Path
from threading import Event, Thread
from typing import cast
import pytest
import uvicorn
from fastapi.testclient import TestClient
from starlette.types import ASGIApp
import game_data_capture.runtime as runtime_module
from game_data_capture.config import AppConfig, CaptureLimits, LifecycleConfig, ServerConfig
from game_data_capture.ingest.clipboard import IntakeReceipt
from game_data_capture.ingest.spool_helper import PNG_SIGNATURE
from game_data_capture.paths import RuntimePaths
from game_data_capture.runtime import (
CaptureRuntime,
InstanceLock,
IntakePort,
RuntimeFailure,
_CaptureControl,
)
from game_data_capture.storage import CaptureCorpus, CaptureRepository, StorageDatabase
_PNG = PNG_SIGNATURE + b"unit-png"
def _paths(tmp_path: Path) -> RuntimePaths:
root = tmp_path / "xdg"
return RuntimePaths(
config_dir=(root / "config").resolve(),
data_dir=(root / "data").resolve(),
cache_dir=(root / "cache").resolve(),
runtime_dir=(root / "runtime").resolve(),
)
def _runtime(tmp_path: Path, *, limits: CaptureLimits | None = None) -> CaptureRuntime:
return CaptureRuntime(
AppConfig(
lifecycle=LifecycleConfig(lock_timeout_seconds=0), limits=limits or CaptureLimits()
),
_paths(tmp_path),
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
)
def test_one_shot_persists_distinct_occurrences_and_one_blob(tmp_path: Path) -> None:
runtime = _runtime(tmp_path)
image = tmp_path / "image.png"
image.write_bytes(_PNG)
first = runtime.ingest_image(image)
second = runtime.ingest_image(image)
database = StorageDatabase(runtime.paths.data_dir, runtime.migrations_dir)
with database.connect() as connection:
assert first != second
assert connection.execute("SELECT count(*) FROM captures").fetchone()[0] == 2
assert connection.execute("SELECT count(*) FROM blobs").fetchone()[0] == 1
@pytest.mark.parametrize("kind", ["malformed", "oversize", "symlink", "broad-mode"])
def test_one_shot_rejects_unsafe_png_inputs(tmp_path: Path, kind: str) -> None:
runtime = _runtime(tmp_path, limits=CaptureLimits(max_image_bytes=len(_PNG)))
image = tmp_path / "image.png"
image.write_bytes(_PNG)
if kind == "malformed":
image.write_bytes(b"not-a-png")
elif kind == "oversize":
image.write_bytes(_PNG + b"x")
elif kind == "symlink":
linked = tmp_path / "linked.png"
linked.symlink_to(image)
image = linked
else:
image.chmod(0o666)
try:
with pytest.raises(RuntimeFailure, match="safe PNG"):
runtime.ingest_image(image)
finally:
if image.exists() and not image.is_symlink():
image.chmod(0o600)
def test_stale_lock_file_is_reused_but_an_active_lock_is_not(tmp_path: Path) -> None:
paths = _paths(tmp_path)
paths.runtime_dir.mkdir(parents=True)
paths.instance_lock_file.write_text("stale", encoding="utf-8")
paths.instance_lock_file.chmod(0o600)
first = InstanceLock.acquire(paths.instance_lock_file, 0)
try:
with pytest.raises(RuntimeFailure, match="another capture instance is active"):
InstanceLock.acquire(paths.instance_lock_file, 0)
finally:
first.release()
second = InstanceLock.acquire(paths.instance_lock_file, 0)
second.release()
assert paths.instance_lock_file.exists()
class InterruptingIntake:
def __init__(self) -> None:
self.shutdown_called = False
def reconcile(self) -> list[str]:
return []
def arm(self) -> None:
return None
def tick(self) -> None:
raise KeyboardInterrupt
def take_helper_receipt(self) -> dict[str, object] | None:
return None
def accept_helper_receipt(
self, payload: Mapping[str, object], *, occurrence_id: str, processing_version: str
) -> bool:
return False
def take(self) -> IntakeReceipt | None:
return None
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]:
raise AssertionError("no receipt should be opened")
def acknowledge(self, receipt: IntakeReceipt) -> bool:
raise AssertionError("no receipt should be acknowledged")
def shutdown(self) -> None:
self.shutdown_called = True
def test_keyboard_interrupt_reaps_intake_and_releases_runtime_lock(tmp_path: Path) -> None:
intake = InterruptingIntake()
runtime = CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
_paths(tmp_path),
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
intake_factory=lambda _config: intake,
)
runtime.watch(poll_seconds=0.001)
assert intake.shutdown_called
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
reusable.release()
def test_cancelled_watch_cleans_up_without_entering_the_tick_loop(tmp_path: Path) -> None:
cancelled = Event()
cancelled.set()
intake = InterruptingIntake()
runtime = CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
_paths(tmp_path),
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
intake_factory=lambda _config: intake,
)
runtime.watch(cancelled=cancelled, poll_seconds=0.001)
assert intake.shutdown_called
def test_one_shot_never_connects_outside_the_utility_data_root(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
runtime = _runtime(tmp_path)
image = tmp_path / "image.png"
image.write_bytes(_PNG)
real_connect = cast(Callable[..., sqlite3.Connection], sqlite3.connect)
connected: list[Path] = []
def checked_connect(database: str | Path, **kwargs: object) -> sqlite3.Connection:
connected.append(Path(database))
return real_connect(database, **kwargs)
monkeypatch.setattr(sqlite3, "connect", checked_connect)
runtime.ingest_image(image)
assert connected
assert all(path.parent == runtime.paths.data_dir for path in connected)
class ControlledIntake:
def __init__(self, *, fail_tick: bool = False) -> None:
self.armed = False
self.reconciled = False
self.shutdown_called = Event()
self.queue_depth = 0
self.helper_queue_depth = 0
self.fail_tick = fail_tick
def reconcile(self) -> list[str]:
self.reconciled = True
return []
def arm(self) -> None:
self.armed = True
def tick(self) -> None:
if self.fail_tick:
raise OSError("helper fault")
def take_helper_receipt(self) -> dict[str, object] | None:
return None
def accept_helper_receipt(
self, payload: Mapping[str, object], *, occurrence_id: str, processing_version: str
) -> bool:
return False
def take(self) -> IntakeReceipt | None:
return None
def open_verified(self, receipt: IntakeReceipt) -> AbstractContextManager[Iterable[bytes]]:
raise AssertionError("no receipt should be opened")
def acknowledge(self, receipt: IntakeReceipt) -> bool:
raise AssertionError("no receipt should be acknowledged")
def shutdown(self) -> None:
self.shutdown_called.set()
class FaultingCleanupIntake(ControlledIntake):
def __init__(self) -> None:
super().__init__(fail_tick=True)
self.cleanup_started = Event()
self.cleanup_release = Event()
self._shutdown_calls = 0
def shutdown(self) -> None:
self._shutdown_calls += 1
self.cleanup_started.set()
if self._shutdown_calls == 1:
self.cleanup_release.wait()
self.shutdown_called.set()
class MalformedQueueIntake:
@property
def queue_depth(self) -> object:
raise OSError("private intake failure")
@property
def helper_queue_depth(self) -> object:
return True
class EmptyRepository:
def receive(
self,
capture_id: str,
receipt_token: str,
chunks: Iterable[bytes],
*,
media_type: str,
max_bytes: int,
) -> str:
raise AssertionError("no receipt should be stored")
def test_control_serializes_repeated_start_stop_and_reports_bounded_queue(tmp_path: Path) -> None:
intake = ControlledIntake()
runtime = CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
_paths(tmp_path),
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
intake_factory=lambda _config: intake,
)
control = _CaptureControl(runtime, cast(CaptureRepository, EmptyRepository()))
outcomes: list[str] = []
def start() -> None:
outcomes.append(control.start_session())
first, second = Thread(target=start), Thread(target=start)
first.start()
second.start()
first.join()
second.join()
assert sorted(outcomes) == ["already_armed", "started"]
assert intake.reconciled and intake.armed
assert control.status().queue_depth == 0
assert control.status().queue_capacity == runtime.config.limits.max_queue_items
assert control.stop_session() in {"stopped", "already_stopped"}
assert control.stop_session() == "already_stopped"
assert intake.shutdown_called.wait(0.1)
def test_control_status_combines_and_bounds_both_intake_queues(tmp_path: Path) -> None:
intake = ControlledIntake()
runtime = CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
_paths(tmp_path),
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
intake_factory=lambda _config: intake,
)
control = _CaptureControl(runtime, cast(CaptureRepository, EmptyRepository()))
intake.queue_depth, intake.helper_queue_depth = 2, 3
control._intake = cast(IntakePort, intake)
assert control.status().queue_depth == 5
intake.queue_depth, intake.helper_queue_depth = 1000, -1
assert control.status().queue_depth == runtime.config.limits.max_queue_items
control._intake = cast(IntakePort, MalformedQueueIntake())
assert control.status().queue_depth == 0
def test_control_turns_background_intake_fault_into_bounded_disarmed_state(tmp_path: Path) -> None:
intake = ControlledIntake(fail_tick=True)
runtime = CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0)),
_paths(tmp_path),
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
intake_factory=lambda _config: intake,
)
control = _CaptureControl(runtime, cast(CaptureRepository, EmptyRepository()))
assert control.start_session() == "started"
assert intake.shutdown_called.wait(0.2)
assert control.stop_session() in {"stopped", "already_stopped"}
assert control.status().armed is False
assert control.status().queue_depth == 0
def test_service_retains_its_lock_until_faulting_worker_cleanup_finishes(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
intake = FaultingCleanupIntake()
runtime = CaptureRuntime(
AppConfig(lifecycle=LifecycleConfig(lock_timeout_seconds=0, shutdown_timeout_seconds=1)),
_paths(tmp_path),
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
intake_factory=lambda _config: intake,
)
captured: dict[str, _CaptureControl] = {}
def factory(**kwargs: object) -> object:
captured["control"] = cast(_CaptureControl, kwargs["capture_control"])
return object()
def runner(_: object, **__: object) -> None:
assert captured["control"].start_session() == "started"
assert intake.cleanup_started.wait(0.2)
monkeypatch.setattr(runtime_module, "create_app", factory)
monkeypatch.setattr(uvicorn, "run", runner)
failure: list[RuntimeFailure] = []
def serve() -> None:
try:
runtime.serve()
except RuntimeFailure as error:
failure.append(error)
service = Thread(target=serve)
service.start()
try:
service.join(timeout=2)
assert not service.is_alive()
assert failure
with pytest.raises(RuntimeFailure, match="another capture instance"):
InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
outcomes: list[str] = []
start = Thread(target=lambda: outcomes.append(captured["control"].start_session()))
stop = Thread(target=lambda: outcomes.append(captured["control"].stop_session()))
start.start()
stop.start()
start.join()
stop.join()
assert sorted(outcomes) == ["conflict", "conflict"]
finally:
intake.cleanup_release.set()
for _ in range(50):
try:
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
except RuntimeFailure:
Event().wait(0.01)
else:
reusable.release()
break
else:
pytest.fail("service lock was not released after worker cleanup")
def test_serve_constructs_durable_corpus_without_auto_arming_and_uses_one_fragment_token(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
created: list[ControlledIntake] = []
token = "A" * 43
def factory(_: object) -> ControlledIntake:
intake = ControlledIntake()
created.append(intake)
return intake
runtime = CaptureRuntime(
AppConfig(
server=ServerConfig(host="127.0.0.1", port=9876),
lifecycle=LifecycleConfig(lock_timeout_seconds=0),
),
_paths(tmp_path),
migrations_dir=(Path(__file__).parents[3] / "migrations").resolve(),
intake_factory=factory,
)
monkeypatch.setattr(runtime_module, "new_bootstrap_token", lambda: token)
received: dict[str, object] = {}
def runner(app: object, **kwargs: object) -> None:
received.update(kwargs)
with TestClient(cast(ASGIApp, app), base_url="http://127.0.0.1:9876") as client:
assert client.get("/health").json() == {"status": "ok"}
assert client.get("/").status_code == 200
assert created == []
bootstrapped = client.post(
"/api/bootstrap",
json={"token": token},
headers={"Origin": "http://127.0.0.1:9876"},
)
csrf = bootstrapped.json()["csrf_token"]
assert client.get("/api/session").json()["armed"] is False
started = client.post(
"/api/session/start",
json={"confirmation": "START"},
headers={"Origin": "http://127.0.0.1:9876", "X-CSRF-Token": csrf},
)
assert started.json()["result"] == "started"
assert created[0].reconciled and created[0].armed
monkeypatch.setattr(uvicorn, "run", runner)
runtime.serve()
output = capsys.readouterr().out
assert output.count(token) == 1
assert f"http://127.0.0.1:9876/#bootstrap={token}" in output
assert received == {
"host": "127.0.0.1",
"port": 9876,
"reload": False,
"workers": 1,
"proxy_headers": False,
}
assert created[0].shutdown_called.wait(0.1)
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
reusable.release()
def test_service_startup_reconciliation_failure_releases_the_instance_lock(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
runtime = _runtime(tmp_path)
def failed_reconciliation(self: CaptureCorpus, *, limit: int = 64) -> int:
raise OSError("private deletion recovery fault")
monkeypatch.setattr(CaptureCorpus, "reconcile_deletions", failed_reconciliation)
with pytest.raises(RuntimeFailure, match="initialization failed"):
runtime.serve()
reusable = InstanceLock.acquire(runtime.paths.instance_lock_file, 0)
reusable.release()
@@ -0,0 +1,200 @@
from __future__ import annotations
import hashlib
import os
import threading
from collections.abc import Iterator
from pathlib import Path
import pytest
from game_data_capture.storage.blobs import (
BlobCorruptionError,
BlobLimitError,
BlobLockError,
ContentAddressedBlobStore,
)
def test_blob_is_verified_deduplicated_and_private(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
payload = b"capture-bytes"
digest, size = store.put([payload], max_bytes=100)
assert digest == hashlib.sha256(payload).hexdigest()
assert store.put([payload], max_bytes=100) == (digest, size)
assert b"".join(store.read(digest, max_bytes=100)) == payload
assert (store._path(digest).stat().st_mode & 0o777) == 0o600
def test_corruption_and_partial_files_are_detected_or_reconciled(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
digest, _ = store.put([b"good"], max_bytes=100)
store._path(digest).write_bytes(b"bad")
with pytest.raises(BlobCorruptionError):
store.verify(digest)
store.temporary.mkdir(parents=True, exist_ok=True)
partial = store.temporary / ".crash.partial"
partial.write_bytes(b"partial")
assert store.reconcile() == [".crash.partial"]
assert not partial.exists()
def test_rejects_hash_and_generator_failures_without_partial_file(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
with pytest.raises(BlobCorruptionError):
store.put([b"value"], max_bytes=100, expected_sha256="0" * 64)
def broken() -> Iterator[bytes]:
yield b"prefix"
raise RuntimeError("source failed")
with pytest.raises(RuntimeError):
store.put(broken(), max_bytes=100)
assert not list(store.temporary.glob("*.partial"))
def test_root_symlinks_are_not_traversed(tmp_path: Path) -> None:
outside = tmp_path / "outside"
outside.mkdir()
root = (tmp_path / "data").resolve()
root.symlink_to(outside, target_is_directory=True)
store = ContentAddressedBlobStore(root)
with pytest.raises(BlobCorruptionError):
store.put([b"x"], max_bytes=1)
def test_limits_and_root_symlink_reconciliation_are_safe(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
for limit in (0, -1, True):
with pytest.raises(BlobLimitError):
store.put([b"x"], max_bytes=limit)
digest, _ = store.put([b"x"], max_bytes=1)
for limit in (0, -1, True):
with pytest.raises(BlobLimitError):
list(store.read(digest, max_bytes=limit))
outside = tmp_path / "outside"
outside.mkdir()
partial = outside / ".outside.partial"
partial.write_bytes(b"outside")
store.temporary.rmdir()
store.temporary.symlink_to(outside, target_is_directory=True)
assert store.reconcile() == []
assert partial.read_bytes() == b"outside"
def test_corrupt_read_rejects_before_yielding(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
digest, _ = store.put([b"good"], max_bytes=10)
store._path(digest).write_bytes(b"bad")
with pytest.raises(BlobCorruptionError):
list(store.read(digest, max_bytes=10))
def test_oversize_and_valid_digest_mismatch_leave_no_partial(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
with pytest.raises(BlobLimitError):
store.put([b"too-large"], max_bytes=1)
assert not list(store.temporary.glob("*.partial"))
with pytest.raises(BlobCorruptionError):
store.put([b"value"], max_bytes=100, expected_sha256="0" * 64)
assert not list(store.temporary.glob("*.partial"))
def test_blob_root_symlink_purge_does_not_inspect_outside(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
store.put([b"inside"], max_bytes=100)
outside = tmp_path / "outside"
outside.mkdir()
outside_file = outside / "outside-blob"
outside_file.write_bytes(b"outside")
for entry in store.blobs.iterdir():
if entry.is_file():
entry.unlink()
else:
for child in entry.iterdir():
child.unlink()
entry.rmdir()
store.blobs.rmdir()
store.blobs.symlink_to(outside, target_is_directory=True)
assert store.purge_dry_run(set()) == []
assert outside_file.read_bytes() == b"outside"
def test_purge_dry_run_does_not_follow_symlink(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
digest, _ = store.put([b"inside"], max_bytes=100)
outside = tmp_path / "outside"
outside.write_bytes(b"outside")
unsafe = store.blobs / "ff"
unsafe.symlink_to(tmp_path)
assert store.purge_dry_run(set()) == [store._path(digest).relative_to(store.root)]
assert outside.read_bytes() == b"outside"
def test_operation_lock_rejects_symlink_and_unsafe_mode(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
store.root.mkdir(mode=0o700)
outside = tmp_path / "outside"
outside.write_bytes(b"x")
store.lock_path.symlink_to(outside)
with pytest.raises(BlobLockError), store.operation_lock():
pass
store.lock_path.unlink()
store.lock_path.write_bytes(b"")
store.lock_path.chmod(0o644)
with pytest.raises(BlobLockError), store.operation_lock():
pass
def test_operation_lock_timeout_and_release_after_exception(tmp_path: Path) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
entered = threading.Event()
outcome: list[type[Exception]] = []
def contender() -> None:
entered.set()
try:
with store.operation_lock(timeout_seconds=0.02):
pass
except BlobLockError as error:
outcome.append(type(error))
with store.operation_lock():
thread = threading.Thread(target=contender)
thread.start()
assert entered.wait(0.2)
thread.join(0.5)
assert not thread.is_alive()
assert outcome == [BlobLockError]
with pytest.raises(RuntimeError), store.operation_lock():
raise RuntimeError("fault")
with store.operation_lock(timeout_seconds=0.02):
pass
for invalid in (True, "1", 0, 6):
with (
pytest.raises(ValueError),
store.operation_lock(
timeout_seconds=invalid # type: ignore[arg-type]
),
):
pass
def test_expected_size_mismatch_rejects_before_blob_read(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
store = ContentAddressedBlobStore((tmp_path / "data").resolve())
digest, _ = store.put([b"value"], max_bytes=100)
store._path(digest).write_bytes(b"x" * 4096)
reads = 0
original = os.read
def forbidden_read(fd: int, size: int) -> bytes:
nonlocal reads
reads += 1
return original(fd, size)
monkeypatch.setattr(os, "read", forbidden_read)
with pytest.raises(BlobCorruptionError):
store.verify(digest, expected_size=5)
assert reads == 0
@@ -0,0 +1,192 @@
from __future__ import annotations
import io
import logging
import subprocess
import sys
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
import pytest
from game_data_capture.cli import EXIT_CONFIG_ERROR, EXIT_UNIMPLEMENTED, main
from game_data_capture.logging import RedactingFormatter, configure_logging, redact_log_data
def test_doctor_reports_required_diagnostics_without_openai_value(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "home"))
monkeypatch.setenv("OPENAI_API_KEY", "sk-this-must-not-appear")
output = io.StringIO()
with redirect_stdout(output):
result = main(["doctor", "--json"])
report = output.getvalue()
assert result == 0
for name in ("niri", "wl-paste", "ffmpeg", "gpu_provider", "storage_permissions", "openai"):
assert f'"name":"{name}"' in report
assert "sk-this-must-not-appear" not in report
def test_serve_rejects_public_binding() -> None:
error = io.StringIO()
with redirect_stderr(error):
result = main(["serve", "--host", "0.0.0.0"])
assert result == EXIT_CONFIG_ERROR
assert "loopback IP literal" in error.getvalue()
def test_serve_rejects_invalid_port_without_echoing_value() -> None:
error = io.StringIO()
with redirect_stderr(error), pytest.raises(SystemExit) as raised:
main(["serve", "--port", "99999-private-value"])
assert raised.value.code == EXIT_CONFIG_ERROR
assert "port must be an integer from 1024 to 65535" in error.getvalue()
assert "99999-private-value" not in error.getvalue()
def test_subsystem_commands_are_explicit_stubs() -> None:
error = io.StringIO()
with redirect_stderr(error):
result = main(["watch"])
assert result == EXIT_UNIMPLEMENTED
assert "requires gdc-runtime-integration" in error.getvalue()
def test_unimplemented_command_validates_config_before_returning_stub_status(
tmp_path: Path,
) -> None:
config_path = tmp_path / "invalid.toml"
config_path.write_text("[server]\nport = 80\n", encoding="utf-8")
error = io.StringIO()
with redirect_stderr(error):
result = main(["watch", "--config", str(config_path)])
assert result == EXIT_CONFIG_ERROR
assert "server.port" in error.getvalue()
assert "subsystem is not implemented" not in error.getvalue()
def test_logging_redacts_sensitive_fields_and_messages() -> None:
redacted = redact_log_data(
{
"authorization": "Bearer secret-value",
"image_payload": "data:image/png;base64,abc",
"ocr_text": "unrestricted source text",
"status": "ok",
}
)
record = logging.LogRecord(
"test", logging.INFO, __file__, 1, "Authorization: Bearer hidden sk-visible123", (), None
)
assert redacted == {
"authorization": "[REDACTED]",
"image_payload": "[REDACTED]",
"ocr_text": "[REDACTED]",
"status": "ok",
}
assert "hidden" not in RedactingFormatter("%(message)s").format(record)
assert "sk-visible123" not in RedactingFormatter("%(message)s").format(record)
def test_configure_logging_replaces_unredacted_handlers_and_redacts_exceptions() -> None:
package_logger = logging.getLogger("game_data_capture")
package_handlers = package_logger.handlers[:]
package_level = package_logger.level
package_propagate = package_logger.propagate
root_logger = logging.getLogger()
root_handler = logging.NullHandler()
root_logger.addHandler(root_handler)
leaked_output = io.StringIO()
existing_handler = logging.StreamHandler(leaked_output)
package_logger.handlers = [existing_handler]
captured_output = io.StringIO()
try:
with redirect_stderr(captured_output):
configured_logger = configure_logging()
configure_logging()
try:
raise RuntimeError("Authorization: Bearer exception-secret; ocr_text=private text")
except RuntimeError:
configured_logger.exception("capture failed")
output = captured_output.getvalue()
assert package_logger.handlers != [existing_handler]
assert len(package_logger.handlers) == 1
assert leaked_output.getvalue() == ""
assert root_handler in root_logger.handlers
assert "exception-secret" not in output
assert "private text" not in output
assert "[REDACTED EXCEPTION]" in output
finally:
for handler in package_logger.handlers[:]:
package_logger.removeHandler(handler)
package_logger.handlers = package_handlers
package_logger.setLevel(package_level)
package_logger.propagate = package_propagate
root_logger.removeHandler(root_handler)
def test_package_import_has_no_runtime_side_effects(tmp_path: Path) -> None:
root = tmp_path / "fresh-xdg"
source_root = Path(__file__).parents[2] / "src"
environment = {
"GDC_IMPORT_ROOT": str(root),
"PYTHONDONTWRITEBYTECODE": "1",
"PYTHONPATH": str(source_root),
}
script = """
import os
import sys
from pathlib import Path
root = Path(os.environ["GDC_IMPORT_ROOT"])
targets = [root / name for name in ("config", "data", "cache", "runtime")]
events = []
def audit(event, arguments):
if event in {"os.mkdir", "os.chmod", "subprocess.Popen", "socket.connect", "sqlite3.connect"}:
events.append(event)
if event == "open":
mode = arguments[1] if len(arguments) > 1 else None
flags = arguments[2] if len(arguments) > 2 else 0
if isinstance(mode, str) and any(marker in mode for marker in ("w", "a", "x", "+")):
events.append("open-write")
if isinstance(flags, int) and flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC):
events.append("open-write")
sys.addaudithook(audit)
os.environ.update({
"HOME": str(root / "home"),
"XDG_CONFIG_HOME": str(targets[0]),
"XDG_DATA_HOME": str(targets[1]),
"XDG_CACHE_HOME": str(targets[2]),
"XDG_RUNTIME_DIR": str(targets[3]),
})
import game_data_capture
if events or any(path.exists() for path in targets):
raise SystemExit(1)
"""
completed = subprocess.run(
[sys.executable, "-B", "-c", script],
capture_output=True,
check=False,
env=environment,
text=True,
timeout=5,
)
assert completed.returncode == 0, completed.stderr
@@ -0,0 +1,81 @@
from __future__ import annotations
from pathlib import Path
from stat import S_IMODE
import pytest
from game_data_capture.config import ConfigError, ServerConfig, parse_config
from game_data_capture.paths import (
PathConfigurationError,
prepare_private_directories,
prepare_private_file,
resolve_runtime_paths,
)
def test_config_uses_typed_defaults() -> None:
config = parse_config({})
assert config.server == ServerConfig(host="127.0.0.1", port=8765)
assert config.lifecycle.instance_name == "shd-capture"
assert config.limits.max_queue_items == 64
@pytest.mark.parametrize("host", ["0.0.0.0", "192.168.1.2", "localhost", "example.test"])
def test_config_rejects_non_loopback_binding(host: str) -> None:
with pytest.raises(ConfigError, match="loopback IP literal"):
parse_config({"server": {"host": host}})
@pytest.mark.parametrize(
("document", "message"),
[
({"server": {"port": 80}}, "server.port"),
({"limits": {"max_queue_items": 257}}, "limits.max_queue_items"),
({"lifecycle": {"instance_name": "SHD Capture"}}, "lifecycle.instance_name"),
({"openai": {"api_key_env": "openai-key"}}, "openai.api_key_env"),
],
)
def test_config_rejects_invalid_bounded_values(document: dict[str, object], message: str) -> None:
with pytest.raises(ConfigError, match=message):
parse_config(document)
def test_xdg_paths_are_resolved_without_writing(tmp_path: Path) -> None:
root = tmp_path / "xdg"
paths = resolve_runtime_paths(
{
"HOME": str(tmp_path / "home"),
"XDG_CONFIG_HOME": str(root / "config"),
"XDG_DATA_HOME": str(root / "data"),
"XDG_CACHE_HOME": str(root / "cache"),
"XDG_RUNTIME_DIR": str(root / "runtime"),
}
)
assert paths.runtime_dir == root / "runtime" / "shd-capture"
assert not root.exists()
def test_xdg_paths_reject_relative_runtime_path() -> None:
with pytest.raises(PathConfigurationError, match="XDG runtime"):
resolve_runtime_paths({"HOME": "/tmp/home", "XDG_RUNTIME_DIR": "relative"})
def test_runtime_directories_and_lock_file_are_owner_only(tmp_path: Path) -> None:
paths = resolve_runtime_paths(
{
"HOME": str(tmp_path),
"XDG_CONFIG_HOME": str(tmp_path / "config"),
"XDG_DATA_HOME": str(tmp_path / "data"),
"XDG_CACHE_HOME": str(tmp_path / "cache"),
"XDG_RUNTIME_DIR": str(tmp_path / "runtime"),
}
)
prepare_private_directories(paths)
prepare_private_file(paths.instance_lock_file)
assert S_IMODE(paths.runtime_dir.stat().st_mode) == 0o700
assert S_IMODE(paths.instance_lock_file.stat().st_mode) == 0o600
@@ -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