Add game data capture vertical slice
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user