This commit is contained in:
1 parent
4db3f45164
commit
d25c04cdd2
175 files changed
+56243
-15
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Application integration tests."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Terminate acceptance at a durable publication boundary for recovery tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from gewu.domain.contracts import ProposalRevision
|
||||
from tests.application.test_acceptance import ACTOR, _service
|
||||
|
||||
|
||||
def main() -> None:
|
||||
data_root = Path(sys.argv[1])
|
||||
proposal_path = Path(sys.argv[2])
|
||||
crash_stage = sys.argv[3]
|
||||
proposal = ProposalRevision.model_validate_json(proposal_path.read_bytes(), strict=True)
|
||||
|
||||
def terminate(stage: str) -> None:
|
||||
if stage == crash_stage:
|
||||
os._exit(73)
|
||||
|
||||
service, *_ = _service(data_root, hook=terminate)
|
||||
service.submit(proposal, actor=ACTOR)
|
||||
raise RuntimeError(f"Acceptance completed without reaching crash stage {crash_stage}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,850 @@
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||||
from gewu.adapters.text_extractor import TextMarkdownExtractor
|
||||
from gewu.application.acceptance import (
|
||||
AcceptanceError,
|
||||
AcceptanceService,
|
||||
ActorContext,
|
||||
)
|
||||
from gewu.application.context import ValidationContext
|
||||
from gewu.application.identity import Principal
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.search import CanonicalSearchService
|
||||
from gewu.application.serialization import safe_yaml_record
|
||||
from gewu.application.uploads import LocalArtifactStore
|
||||
from gewu.domain.contracts import (
|
||||
AcceptanceManifest,
|
||||
EdgeEndpoint,
|
||||
ExtractionRecord,
|
||||
KnowledgeNode,
|
||||
ProposalOperation,
|
||||
ProposalRevision,
|
||||
ReliabilityGrade,
|
||||
Scope,
|
||||
SearchRequest,
|
||||
SourceRevisionRecord,
|
||||
TypedEdge,
|
||||
)
|
||||
from gewu.domain.validation import assertion_digest, evidence_digest, proposal_payload_digest
|
||||
from tests.domain.proposal_fixtures import (
|
||||
EXTRACTION,
|
||||
NODE,
|
||||
POLICIES,
|
||||
PROJECTS,
|
||||
SOURCE_REVISION,
|
||||
proposal,
|
||||
)
|
||||
|
||||
ACTOR = ActorContext(principal_id="agent-pilot", credential_id="agent-key-1")
|
||||
SOURCE_BYTES = b"The installation hosted 400 visitors during the 2026 season."
|
||||
|
||||
|
||||
class SimulatedProcessCrash(BaseException):
|
||||
pass
|
||||
|
||||
|
||||
def _service(
|
||||
data_root: Path,
|
||||
*,
|
||||
hook: Callable[[str], None] = lambda _stage: None,
|
||||
authorized: bool = True,
|
||||
) -> tuple[
|
||||
AcceptanceService,
|
||||
GitCanonicalRepository,
|
||||
LocalArtifactStore,
|
||||
SourceRevisionRecord,
|
||||
ExtractionRecord,
|
||||
]:
|
||||
artifact_store = LocalArtifactStore(data_root / "private-data")
|
||||
upload = artifact_store.begin(
|
||||
filename="pilot.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(SOURCE_BYTES),
|
||||
expected_sha256=hashlib.sha256(SOURCE_BYTES).hexdigest(),
|
||||
now=100,
|
||||
)
|
||||
artifact_store.put_chunk(
|
||||
upload.upload_id,
|
||||
offset=0,
|
||||
content=SOURCE_BYTES,
|
||||
chunk_sha256=hashlib.sha256(SOURCE_BYTES).hexdigest(),
|
||||
now=101,
|
||||
)
|
||||
artifact_store.finalize(upload.upload_id, now=102)
|
||||
source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"artifact_sha256": hashlib.sha256(SOURCE_BYTES).hexdigest(),
|
||||
"byte_length": len(SOURCE_BYTES),
|
||||
}
|
||||
)
|
||||
extraction = EXTRACTION
|
||||
repository = GitCanonicalRepository(data_root / "private-data" / "knowledge.git")
|
||||
context = ValidationContext(
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={},
|
||||
extractions={extraction.extraction_id: extraction},
|
||||
existing_nodes={},
|
||||
)
|
||||
service = AcceptanceService(
|
||||
repository=repository,
|
||||
artifact_store=artifact_store,
|
||||
workflow_root=data_root / "private-data" / "staging",
|
||||
context_provider=lambda: context,
|
||||
actor_is_current=lambda _actor: authorized,
|
||||
failure_hook=hook,
|
||||
)
|
||||
return service, repository, artifact_store, source, extraction
|
||||
|
||||
|
||||
def _proposal(repository: GitCanonicalRepository, source: SourceRevisionRecord) -> ProposalRevision:
|
||||
candidate = proposal(source=source, base_commit=repository.head())
|
||||
return candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
|
||||
|
||||
|
||||
def test_acceptance_commits_records_and_retry_returns_original_receipt(tmp_path: Path) -> None:
|
||||
service, repository, _artifact_store, source, _extraction = _service(tmp_path)
|
||||
candidate = _proposal(repository, source)
|
||||
result = service.submit(candidate, actor=ACTOR)
|
||||
|
||||
assert result.validation.eligible_for_auto_accept
|
||||
assert result.receipt is not None
|
||||
assert result.receipt.state == "accepted"
|
||||
assert result.receipt.commit_id == repository.head()
|
||||
assert result.receipt.accepted_node_ids == (NODE.node_id,)
|
||||
before_retry = repository.head()
|
||||
|
||||
retry = service.submit(candidate, actor=ACTOR)
|
||||
assert retry.receipt == result.receipt
|
||||
assert repository.head() == before_retry
|
||||
status = service.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR)
|
||||
assert status.lifecycle == "accepted"
|
||||
assert status.canonical_revision == result.receipt.commit_id
|
||||
assert status.receipt == result.receipt
|
||||
with pytest.raises(AcceptanceError) as accepted_withdrawal:
|
||||
service.withdraw_proposal(candidate.proposal_id, candidate.revision, actor=ACTOR)
|
||||
assert accepted_withdrawal.value.code == "PROPOSAL_ALREADY_ACCEPTED"
|
||||
|
||||
with pytest.raises(AcceptanceError) as unauthorized:
|
||||
service.proposal_status(
|
||||
candidate.proposal_id,
|
||||
candidate.revision,
|
||||
actor=ActorContext(principal_id="agent-other", credential_id="other-key"),
|
||||
)
|
||||
assert unauthorized.value.code == "PROPOSAL_NOT_FOUND"
|
||||
|
||||
changed_node = NODE.model_copy(update={"title": "Changed after acceptance"})
|
||||
changed = proposal(node=changed_node, source=source, base_commit=candidate.base_commit)
|
||||
with pytest.raises(AcceptanceError) as error:
|
||||
service.submit(changed, actor=ACTOR)
|
||||
assert error.value.code == "IDEMPOTENCY_CONFLICT"
|
||||
|
||||
|
||||
def test_policy_exception_requires_exact_owner_decision_in_manifest(tmp_path: Path) -> None:
|
||||
service, repository, _artifact_store, source, extraction = _service(tmp_path)
|
||||
identities = SQLiteIdentityStore(
|
||||
tmp_path / "owner-identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
secret = pyotp.random_base32()
|
||||
identities.provision_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=secret,
|
||||
project_ids=("dome-performance", "shared"),
|
||||
)
|
||||
projects = {
|
||||
**PROJECTS,
|
||||
"dome-performance": PROJECTS["dome-performance"].model_copy(
|
||||
update={"owners": ("project-owner",)}
|
||||
),
|
||||
"shared": PROJECTS["dome-performance"].model_copy(
|
||||
update={
|
||||
"project_id": "shared",
|
||||
"title": "Shared knowledge",
|
||||
"owners": ("project-owner",),
|
||||
"autonomy_policy_id": "shared",
|
||||
}
|
||||
),
|
||||
}
|
||||
policies = {
|
||||
**POLICIES,
|
||||
"dome-policy": POLICIES["dome-policy"].model_copy(
|
||||
update={"automatic_node_types": ("finding",)}
|
||||
),
|
||||
}
|
||||
context = ValidationContext(
|
||||
projects=projects,
|
||||
policies=policies,
|
||||
sources={},
|
||||
extractions={extraction.extraction_id: extraction},
|
||||
existing_nodes={},
|
||||
)
|
||||
service.context_provider = lambda: context
|
||||
service.owner_challenge_issuer = identities.issue_owner_challenge
|
||||
service.owner_decision_lookup = identities.authorized_owner_challenges
|
||||
candidate = _proposal(repository, source)
|
||||
|
||||
held = service.submit(candidate, actor=ACTOR)
|
||||
assert held.validation.status == "needs-review"
|
||||
assert held.receipt is None
|
||||
assert len(held.owner_challenge_ids) == 1
|
||||
challenge = identities.get_owner_challenge(
|
||||
held.owner_challenge_ids[0], principal_id="project-owner"
|
||||
)
|
||||
assert challenge is not None
|
||||
assert challenge["diff_json"]
|
||||
owner_session = identities.authenticate_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_code=pyotp.TOTP(secret).now(),
|
||||
challenge_id=held.owner_challenge_ids[0],
|
||||
audience="gewu-acceptance-test",
|
||||
)
|
||||
assert owner_session is not None
|
||||
owner_principal = identities.authenticate_owner_session(
|
||||
owner_session.session_token,
|
||||
challenge_id=held.owner_challenge_ids[0],
|
||||
audience="gewu-acceptance-test",
|
||||
)
|
||||
assert owner_principal is not None
|
||||
decision = identities.decide_owner_challenge(
|
||||
challenge_id=held.owner_challenge_ids[0],
|
||||
principal_id="project-owner",
|
||||
proposal_revision=candidate.revision,
|
||||
payload_digest=candidate.payload_digest,
|
||||
diff_digest=challenge["diff_digest"],
|
||||
decision="authorize",
|
||||
rationale="Reviewed the exact file diff and approve this policy exception.",
|
||||
owner_session_id=owner_principal.credential_id.rpartition(":")[2],
|
||||
audience="gewu-acceptance-test",
|
||||
)
|
||||
assert decision["status"] == "authorized"
|
||||
|
||||
original_context = context
|
||||
changed_policies = {
|
||||
**policies,
|
||||
"dome-policy": policies["dome-policy"].model_copy(update={"policy_version": 2}),
|
||||
}
|
||||
context = ValidationContext(
|
||||
projects=projects,
|
||||
policies=changed_policies,
|
||||
sources={},
|
||||
extractions={extraction.extraction_id: extraction},
|
||||
existing_nodes={},
|
||||
)
|
||||
stale_approval = service.submit(candidate, actor=ACTOR)
|
||||
assert stale_approval.receipt is None
|
||||
assert stale_approval.validation.status == "needs-review"
|
||||
assert repository.head() == candidate.base_commit
|
||||
context = original_context
|
||||
|
||||
accepted = service.submit(candidate, actor=ACTOR)
|
||||
assert accepted.receipt is not None
|
||||
assert accepted.validation.status == "needs-review"
|
||||
raw_manifest = repository.read_file(
|
||||
accepted.receipt.commit_id,
|
||||
service._manifest_path(candidate.proposal_id, candidate.revision),
|
||||
)
|
||||
assert raw_manifest is not None
|
||||
manifest = safe_yaml_record(raw_manifest, AcceptanceManifest)
|
||||
assert len(manifest.owner_decisions) == 1
|
||||
assert manifest.owner_decisions[0].challenge_id == held.owner_challenge_ids[0]
|
||||
|
||||
|
||||
def test_contradiction_bundle_publishes_both_claims_and_their_typed_edge(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
content = (
|
||||
b"Field report: The seasonal audience count was 400.\n\n"
|
||||
b"Vendor report: The seasonal audience count was 450.\n"
|
||||
)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
artifact_store = LocalArtifactStore(tmp_path / "private-data")
|
||||
upload = artifact_store.begin(
|
||||
filename="conflicting-reports.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(content),
|
||||
expected_sha256=digest,
|
||||
now=100,
|
||||
)
|
||||
artifact_store.put_chunk(
|
||||
upload.upload_id,
|
||||
offset=0,
|
||||
content=content,
|
||||
chunk_sha256=digest,
|
||||
now=101,
|
||||
)
|
||||
artifact_store.finalize(upload.upload_id, now=102)
|
||||
source = SOURCE_REVISION.model_copy(
|
||||
update={"artifact_sha256": digest, "byte_length": len(content)}
|
||||
)
|
||||
extraction = TextMarkdownExtractor().extract(source.revision_id, content, "text/markdown")
|
||||
|
||||
def supported_node(
|
||||
node_id: str, claim_id: str, quote: str, title: str, value: int
|
||||
) -> KnowledgeNode:
|
||||
block = next(item for item in extraction.blocks if quote in item.normalized_text)
|
||||
start = block.normalized_text.index(quote)
|
||||
evidence = (
|
||||
NODE.claims[0]
|
||||
.evidence[0]
|
||||
.model_copy(
|
||||
update={
|
||||
"source_revision_id": source.revision_id,
|
||||
"extraction_id": extraction.extraction_id,
|
||||
"assertion_digest": assertion_digest(quote),
|
||||
"quote": quote,
|
||||
"locator": NODE.claims[0]
|
||||
.evidence[0]
|
||||
.locator.model_copy(
|
||||
update={
|
||||
"block_id": block.block_id,
|
||||
"start": start,
|
||||
"end": start + len(quote),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
assessment = NODE.claims[0].assessment.model_copy(
|
||||
update={
|
||||
"assertion_digest": assertion_digest(quote),
|
||||
"evidence_digests": (evidence_digest(evidence),),
|
||||
}
|
||||
)
|
||||
claim = NODE.claims[0].model_copy(
|
||||
update={
|
||||
"claim_id": claim_id,
|
||||
"text": quote,
|
||||
"evidence": (evidence,),
|
||||
"assessment": assessment,
|
||||
}
|
||||
)
|
||||
return NODE.model_copy(
|
||||
update={
|
||||
"node_id": node_id,
|
||||
"title": title,
|
||||
"summary": quote,
|
||||
"claims": (claim,),
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
|
||||
first = supported_node(
|
||||
"dome:count-field-report",
|
||||
"dome:claim-count-400",
|
||||
"The seasonal audience count was 400.",
|
||||
"Field count",
|
||||
400,
|
||||
)
|
||||
second = supported_node(
|
||||
"dome:count-vendor-report",
|
||||
"dome:claim-count-450",
|
||||
"The seasonal audience count was 450.",
|
||||
"Vendor count",
|
||||
450,
|
||||
)
|
||||
edge = TypedEdge(
|
||||
edge_id="dome:counts-conflict",
|
||||
edge_type="contradicts",
|
||||
source=EdgeEndpoint(kind="node", id=first.node_id),
|
||||
target=EdgeEndpoint(kind="node", id=second.node_id),
|
||||
conditions="Both reports refer to the same seasonal audience count.",
|
||||
interpretation_rationale="The source-backed counts differ for the same reporting period.",
|
||||
)
|
||||
policies = {
|
||||
**POLICIES,
|
||||
"dome-policy": POLICIES["dome-policy"].model_copy(
|
||||
update={"automatic_edge_types": ("contradicts",)}
|
||||
),
|
||||
}
|
||||
repository = GitCanonicalRepository(tmp_path / "private-data" / "knowledge.git")
|
||||
context = ValidationContext(
|
||||
projects=PROJECTS,
|
||||
policies=policies,
|
||||
sources={},
|
||||
extractions={extraction.extraction_id: extraction},
|
||||
existing_nodes={},
|
||||
)
|
||||
service = AcceptanceService(
|
||||
repository=repository,
|
||||
artifact_store=artifact_store,
|
||||
workflow_root=tmp_path / "private-data" / "staging",
|
||||
context_provider=lambda: context,
|
||||
actor_is_current=lambda _actor: True,
|
||||
)
|
||||
base = proposal(node=None, source=source, base_commit=repository.head())
|
||||
candidate = base.model_copy(
|
||||
update={
|
||||
"operations": (
|
||||
*base.operations,
|
||||
ProposalOperation(operation="upsert_node", node=first),
|
||||
ProposalOperation(operation="upsert_node", node=second),
|
||||
ProposalOperation(operation="add_edge", edge=edge),
|
||||
),
|
||||
"payload_digest": "0" * 64,
|
||||
}
|
||||
)
|
||||
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
|
||||
|
||||
result = service.submit(candidate, actor=ACTOR)
|
||||
|
||||
assert result.validation.status == "eligible", result.validation.findings
|
||||
assert result.receipt is not None
|
||||
assert result.receipt.accepted_node_ids == (first.node_id, second.node_id)
|
||||
for node in (first, second):
|
||||
stored = repository.read_file(
|
||||
result.receipt.commit_id,
|
||||
f"projects/{hashlib.sha256(node.project_id.encode()).hexdigest()}/nodes/"
|
||||
f"{hashlib.sha256(node.node_id.encode()).hexdigest()}.yaml",
|
||||
)
|
||||
assert stored is not None
|
||||
assert safe_yaml_record(stored, KnowledgeNode).claims[0].text == node.claims[0].text
|
||||
stored_edge = repository.read_file(
|
||||
result.receipt.commit_id,
|
||||
f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml",
|
||||
)
|
||||
assert stored_edge is not None
|
||||
assert safe_yaml_record(stored_edge, TypedEdge) == edge
|
||||
|
||||
|
||||
def test_independent_acceptance_services_exclude_a_concurrent_writer(tmp_path: Path) -> None:
|
||||
entered_commit = threading.Event()
|
||||
release_commit = threading.Event()
|
||||
|
||||
def hold_before_commit(stage: str) -> None:
|
||||
if stage == "before_commit":
|
||||
entered_commit.set()
|
||||
assert release_commit.wait(timeout=5)
|
||||
|
||||
service, repository, artifact_store, source, _extraction = _service(
|
||||
tmp_path, hook=hold_before_commit
|
||||
)
|
||||
second_context = ValidationContext(
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
)
|
||||
second_service = AcceptanceService(
|
||||
repository=repository,
|
||||
artifact_store=artifact_store,
|
||||
workflow_root=tmp_path / "private-data" / "staging",
|
||||
context_provider=lambda: second_context,
|
||||
actor_is_current=lambda _actor: True,
|
||||
)
|
||||
first = _proposal(repository, source).model_copy(update={"proposal_id": "proposal-writer-a"})
|
||||
second = _proposal(repository, source).model_copy(update={"proposal_id": "proposal-writer-b"})
|
||||
|
||||
with ThreadPoolExecutor(max_workers=1) as workers:
|
||||
pending = workers.submit(service.submit, first, actor=ACTOR)
|
||||
try:
|
||||
assert entered_commit.wait(timeout=5)
|
||||
with pytest.raises(AcceptanceError) as busy:
|
||||
second_service.submit(second, actor=ACTOR)
|
||||
assert busy.value.code == "BUSY"
|
||||
finally:
|
||||
release_commit.set()
|
||||
accepted = pending.result(timeout=5)
|
||||
|
||||
assert accepted.receipt is not None
|
||||
assert repository.head() == accepted.receipt.commit_id
|
||||
assert second_service.receipt(second.proposal_id, second.revision) is None
|
||||
|
||||
|
||||
def test_acceptance_publishes_and_reads_evidence_backed_assumptions(tmp_path: Path) -> None:
|
||||
service, repository, _artifact_store, source, extraction = _service(tmp_path)
|
||||
original_claim = NODE.claims[0]
|
||||
statement = "The installation's seasonal count is suitable for comparison."
|
||||
statement_digest = assertion_digest(statement)
|
||||
evidence = original_claim.evidence[0].model_copy(
|
||||
update={
|
||||
"assertion_path": "assumptions[0].text",
|
||||
"assertion_digest": statement_digest,
|
||||
}
|
||||
)
|
||||
claim = original_claim.model_copy(
|
||||
update={
|
||||
"claim_id": "dome:assumption-seasonal-comparison",
|
||||
"text": statement,
|
||||
"evidence": (evidence,),
|
||||
"assessment": original_claim.assessment.model_copy(
|
||||
update={
|
||||
"assertion_digest": statement_digest,
|
||||
"evidence_digests": (evidence_digest(evidence),),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
node = NODE.model_copy(update={"assumptions": (claim,)})
|
||||
candidate = proposal(node=node, source=source, base_commit=repository.head())
|
||||
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
|
||||
|
||||
result = service.submit(
|
||||
candidate,
|
||||
actor=ACTOR,
|
||||
staged_extractions={extraction.extraction_id: extraction},
|
||||
)
|
||||
|
||||
assert result.receipt is not None
|
||||
assert (
|
||||
repository.read_file(
|
||||
result.receipt.commit_id, f"extractions/{extraction.extraction_id}.json"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
reader = CanonicalNodeReader(repository)
|
||||
principal = Principal(
|
||||
"reader", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||||
)
|
||||
read = reader.get_node(node.project_id, node.node_id, principal=principal)
|
||||
stored = read.data[0]
|
||||
assert stored["assumptions"][0]["text"] == statement
|
||||
assert any(item.assertion_digest == statement_digest for item in read.citations)
|
||||
|
||||
|
||||
def test_reference_only_source_registers_without_an_artifact(tmp_path: Path) -> None:
|
||||
service, repository, _artifacts, _source, _extraction = _service(tmp_path)
|
||||
reference = SourceRevisionRecord(
|
||||
source_id="dome:missing-paper",
|
||||
revision_id="dome:missing-paper:r1",
|
||||
media_type="application/octet-stream",
|
||||
byte_length=0,
|
||||
captured_at=SOURCE_REVISION.captured_at,
|
||||
original_url="https://research.example/missing-paper",
|
||||
title="Paper whose archive is unavailable",
|
||||
license="unspecified",
|
||||
source_type="paper",
|
||||
reliability_grade=ReliabilityGrade.MEDIUM,
|
||||
ingested_for=("dome-performance",),
|
||||
archive_status="capture-failed",
|
||||
)
|
||||
candidate = ProposalRevision(
|
||||
proposal_id="proposal-reference-only",
|
||||
revision=1,
|
||||
initiating_project_id="dome-performance",
|
||||
scope=proposal().scope,
|
||||
base_commit=repository.head(),
|
||||
payload_digest="0" * 64,
|
||||
proposer_id=ACTOR.principal_id,
|
||||
run_id="run-reference-only",
|
||||
operations=(ProposalOperation(operation="register_source", source=reference),),
|
||||
)
|
||||
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
|
||||
|
||||
result = service.submit(candidate, actor=ACTOR)
|
||||
|
||||
assert result.receipt is not None
|
||||
assert result.receipt.state == "accepted"
|
||||
paths = repository.list_files(repository.head(), "sources")
|
||||
stored = next(repository.read_file(repository.head(), path) for path in paths)
|
||||
assert stored is not None
|
||||
assert b"capture-failed" in stored
|
||||
assert b"artifact_sha256: null" in stored
|
||||
|
||||
|
||||
@pytest.mark.parametrize("crash_stage", ("after_ref_update", "before_receipt"))
|
||||
def test_ref_update_before_receipt_is_reconciled_without_republishing(
|
||||
tmp_path: Path, crash_stage: str
|
||||
) -> None:
|
||||
def fail_after_ref_update(stage: str) -> None:
|
||||
if stage == crash_stage:
|
||||
raise SimulatedProcessCrash
|
||||
|
||||
service, repository, _store, source, _extraction = _service(
|
||||
tmp_path, hook=fail_after_ref_update
|
||||
)
|
||||
candidate = _proposal(repository, source)
|
||||
with pytest.raises(SimulatedProcessCrash):
|
||||
service.submit(candidate, actor=ACTOR)
|
||||
accepted_head = repository.head()
|
||||
|
||||
recovered, _same_repo, _same_store, _same_source, _same_extraction = (
|
||||
_service_without_new_artifact(service, repository, tmp_path, source)
|
||||
)
|
||||
recovered.recover()
|
||||
receipt = recovered.receipt(candidate.proposal_id, candidate.revision)
|
||||
assert receipt is not None
|
||||
assert receipt.commit_id == accepted_head
|
||||
assert repository.head() == accepted_head
|
||||
status = recovered.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR)
|
||||
assert status.lifecycle == "accepted"
|
||||
assert status.receipt == receipt
|
||||
retry = recovered.submit(candidate, actor=ACTOR)
|
||||
assert retry.receipt == receipt
|
||||
|
||||
|
||||
@pytest.mark.parametrize("crash_stage", ("after_ref_update", "before_receipt"))
|
||||
def test_os_process_termination_recovers_publication_after_restart(
|
||||
tmp_path: Path, crash_stage: str
|
||||
) -> None:
|
||||
service, _repository, _store, source, _extraction = _service(tmp_path)
|
||||
candidate = _proposal(service.repository, source)
|
||||
proposal_path = tmp_path / "candidate.json"
|
||||
proposal_path.write_bytes(candidate.model_dump_json().encode("utf-8"))
|
||||
|
||||
crashed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"tests.application.crash_acceptance_worker",
|
||||
str(tmp_path),
|
||||
str(proposal_path),
|
||||
crash_stage,
|
||||
],
|
||||
cwd=Path(__file__).parents[2],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=60,
|
||||
)
|
||||
assert crashed.returncode == 73, crashed.stderr.decode("utf-8", errors="replace")
|
||||
|
||||
# Construct fresh adapters as a new service process would, then recover from durable state.
|
||||
recovered, repository, _store, _source, _extraction = _service(tmp_path)
|
||||
committed_head = repository.head()
|
||||
recovered.recover()
|
||||
receipt = recovered.receipt(candidate.proposal_id, candidate.revision)
|
||||
assert receipt is not None
|
||||
assert receipt.commit_id == committed_head
|
||||
status = recovered.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR)
|
||||
assert status.lifecycle == "accepted"
|
||||
assert status.receipt == receipt
|
||||
|
||||
# The derived index was absent at the crash. Its first read after recovery must
|
||||
# rebuild from the committed revision and retain the accepted claim's citation.
|
||||
index = SQLiteSearchIndex(tmp_path / "private-data" / "search.sqlite3", repository)
|
||||
assert index.diagnostic_status()[0] is None
|
||||
reader = CanonicalSearchService(
|
||||
index=index,
|
||||
project_ids_provider=lambda: PROJECTS.keys(),
|
||||
canonical_head=repository.head,
|
||||
)
|
||||
search_result = reader.search(
|
||||
SearchRequest(
|
||||
query="visitors",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
budget_tokens=5000,
|
||||
),
|
||||
principal=Principal(
|
||||
"recovery-reader",
|
||||
"recovery-reader-key",
|
||||
frozenset({"viewer"}),
|
||||
frozenset({"dome-performance"}),
|
||||
),
|
||||
)
|
||||
assert search_result.data
|
||||
indexed_node = search_result.data[0]["node"]
|
||||
assert isinstance(indexed_node, dict)
|
||||
assert indexed_node["node_id"] == NODE.node_id
|
||||
assert search_result.citations
|
||||
|
||||
retry = recovered.submit(candidate, actor=ACTOR)
|
||||
assert retry.receipt == receipt
|
||||
assert repository.head() == committed_head
|
||||
|
||||
|
||||
def test_prepublication_crash_leaves_proposal_staged_and_retryable(tmp_path: Path) -> None:
|
||||
def fail_before_commit(stage: str) -> None:
|
||||
if stage == "before_commit":
|
||||
raise SimulatedProcessCrash
|
||||
|
||||
service, repository, _store, source, _extraction = _service(tmp_path, hook=fail_before_commit)
|
||||
candidate = _proposal(repository, source)
|
||||
base = repository.head()
|
||||
with pytest.raises(SimulatedProcessCrash):
|
||||
service.submit(candidate, actor=ACTOR)
|
||||
assert repository.head() == base
|
||||
|
||||
recovered, *_ = _service_without_new_artifact(service, repository, tmp_path, source)
|
||||
recovered.recover()
|
||||
status = recovered.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR)
|
||||
assert status.lifecycle == "staged"
|
||||
retry = recovered.submit(candidate, actor=ACTOR)
|
||||
assert retry.receipt is not None
|
||||
assert repository.head() == retry.receipt.commit_id
|
||||
|
||||
|
||||
def test_unrelated_submission_waits_until_pending_publication_is_reconciled(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
crash_pending_writer = True
|
||||
|
||||
def fail_after_ref_update(stage: str) -> None:
|
||||
nonlocal crash_pending_writer
|
||||
if stage == "after_ref_update" and crash_pending_writer:
|
||||
crash_pending_writer = False
|
||||
raise SimulatedProcessCrash
|
||||
|
||||
service, repository, _store, source, _extraction = _service(
|
||||
tmp_path, hook=fail_after_ref_update
|
||||
)
|
||||
pending = _proposal(repository, source)
|
||||
with pytest.raises(SimulatedProcessCrash):
|
||||
service.submit(pending, actor=ACTOR)
|
||||
committed_head = repository.head()
|
||||
|
||||
journal_path = sorted((tmp_path / "private-data/staging/journal").glob("*/*.json"))[-1]
|
||||
journal_entry = json.loads(journal_path.read_text(encoding="utf-8"))
|
||||
expected_manifest_hash = journal_entry["manifest_sha256"]
|
||||
journal_entry["manifest_sha256"] = "0" * 64
|
||||
journal_path.write_text(json.dumps(journal_entry), encoding="utf-8")
|
||||
|
||||
followup_node = NODE.model_copy(
|
||||
update={"node_id": "dome:follow-up-finding", "title": "Follow-up visitor finding"}
|
||||
)
|
||||
followup = proposal(
|
||||
node=followup_node,
|
||||
source=source,
|
||||
base_commit=committed_head,
|
||||
).model_copy(update={"proposal_id": "proposal-pilot-2"})
|
||||
followup = followup.model_copy(update={"payload_digest": proposal_payload_digest(followup)})
|
||||
|
||||
with pytest.raises(AcceptanceError) as blocked:
|
||||
service.submit(followup, actor=ACTOR)
|
||||
assert blocked.value.code == "RECOVERY_REQUIRED"
|
||||
assert repository.head() == committed_head
|
||||
assert not service._proposal_path(followup.proposal_id, followup.revision).exists()
|
||||
|
||||
journal_entry["manifest_sha256"] = expected_manifest_hash
|
||||
journal_path.write_text(json.dumps(journal_entry), encoding="utf-8")
|
||||
service.recover()
|
||||
pending_receipt = service.receipt(pending.proposal_id, pending.revision)
|
||||
assert pending_receipt is not None
|
||||
assert pending_receipt.commit_id == committed_head
|
||||
|
||||
completed = service.submit(followup, actor=ACTOR)
|
||||
assert completed.receipt is not None
|
||||
assert completed.receipt.commit_id == repository.head()
|
||||
assert repository.head() != committed_head
|
||||
|
||||
|
||||
def test_proposal_withdrawal_is_idempotent_and_prevents_later_submission(tmp_path: Path) -> None:
|
||||
service, repository, _store, source, _extraction = _service(tmp_path)
|
||||
initial_head = repository.head()
|
||||
candidate = _proposal(repository, source).model_copy(update={"base_commit": "f" * 40})
|
||||
candidate = candidate.model_copy(update={"payload_digest": "0" * 64})
|
||||
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
|
||||
evaluated = service.submit(candidate, actor=ACTOR)
|
||||
assert evaluated.validation.status == "conflict"
|
||||
assert evaluated.receipt is None
|
||||
|
||||
withdrawn = service.withdraw_proposal(candidate.proposal_id, candidate.revision, actor=ACTOR)
|
||||
assert withdrawn.lifecycle == "withdrawn"
|
||||
assert (
|
||||
service.withdraw_proposal(candidate.proposal_id, candidate.revision, actor=ACTOR)
|
||||
== withdrawn
|
||||
)
|
||||
assert (
|
||||
service.proposal_status(candidate.proposal_id, candidate.revision, actor=ACTOR) == withdrawn
|
||||
)
|
||||
with pytest.raises(AcceptanceError) as error:
|
||||
service.submit(candidate, actor=ACTOR)
|
||||
assert error.value.code == "PROPOSAL_WITHDRAWN"
|
||||
assert repository.head() == initial_head
|
||||
|
||||
|
||||
def _service_without_new_artifact(
|
||||
old_service: AcceptanceService,
|
||||
repository: GitCanonicalRepository,
|
||||
data_root: Path,
|
||||
source: SourceRevisionRecord,
|
||||
) -> tuple[
|
||||
AcceptanceService,
|
||||
GitCanonicalRepository,
|
||||
LocalArtifactStore,
|
||||
SourceRevisionRecord,
|
||||
ExtractionRecord,
|
||||
]:
|
||||
from tests.domain.proposal_fixtures import EXTRACTION
|
||||
|
||||
store = old_service.artifact_store
|
||||
context = ValidationContext(
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
)
|
||||
service = AcceptanceService(
|
||||
repository=repository,
|
||||
artifact_store=store,
|
||||
workflow_root=data_root / "private-data" / "staging",
|
||||
context_provider=lambda: context,
|
||||
actor_is_current=lambda _actor: True,
|
||||
)
|
||||
return service, repository, store, source, EXTRACTION
|
||||
|
||||
|
||||
def test_changed_authority_blocks_publication(tmp_path: Path) -> None:
|
||||
service, repository, _store, source, _extraction = _service(tmp_path, authorized=False)
|
||||
candidate = _proposal(repository, source)
|
||||
with pytest.raises(AcceptanceError) as error:
|
||||
service.submit(candidate, actor=ACTOR)
|
||||
assert error.value.code == "UNAUTHORIZED"
|
||||
assert repository.head() == candidate.base_commit
|
||||
|
||||
|
||||
def test_revoked_key_during_validation_is_rechecked_before_publication(tmp_path: Path) -> None:
|
||||
service, repository, _store, source, _extraction = _service(tmp_path)
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = identities.provision(
|
||||
credential_id="agent-key-1",
|
||||
principal_id="agent-pilot",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance", "shared"),
|
||||
)
|
||||
principal = identities.authenticate(token)
|
||||
assert principal is not None
|
||||
actor = ActorContext(
|
||||
principal_id=principal.principal_id,
|
||||
credential_id=principal.credential_id,
|
||||
roles=principal.roles,
|
||||
project_ids=principal.project_ids,
|
||||
)
|
||||
context = ValidationContext(
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={},
|
||||
extractions={_extraction.extraction_id: _extraction},
|
||||
existing_nodes={},
|
||||
)
|
||||
context_reads = 0
|
||||
|
||||
def context_provider() -> ValidationContext:
|
||||
nonlocal context_reads
|
||||
context_reads += 1
|
||||
if context_reads == 2:
|
||||
assert identities.revoke("agent-key-1")
|
||||
return context
|
||||
|
||||
service.context_provider = context_provider
|
||||
service.actor_is_current = lambda candidate: identities.is_current(
|
||||
candidate.principal_id,
|
||||
candidate.credential_id,
|
||||
roles=candidate.roles,
|
||||
project_ids=candidate.project_ids,
|
||||
)
|
||||
candidate = _proposal(repository, source)
|
||||
|
||||
with pytest.raises(AcceptanceError) as error:
|
||||
service.submit(candidate, actor=actor)
|
||||
assert error.value.code == "POLICY_CHANGED"
|
||||
assert repository.head() == candidate.base_commit
|
||||
status = service.proposal_status(candidate.proposal_id, candidate.revision, actor=actor)
|
||||
assert status.lifecycle == "evaluated"
|
||||
assert status.receipt is None
|
||||
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_search import SearchIndexError, SQLiteSearchIndex
|
||||
from gewu.adapters.sqlite_vectors import SQLiteVectorIndex
|
||||
from gewu.application.identity import Principal
|
||||
from gewu.application.search import CanonicalSearchService, HybridSearchIndex, SearchError
|
||||
from gewu.domain.contracts import (
|
||||
Confidence,
|
||||
EdgeEndpoint,
|
||||
NodeType,
|
||||
Scope,
|
||||
SearchRequest,
|
||||
TypedEdge,
|
||||
)
|
||||
from tests.domain.proposal_fixtures import NODE
|
||||
|
||||
|
||||
class FixtureEmbeddings:
|
||||
dimensions = 2
|
||||
model_id = "fixture-e5@v1"
|
||||
artifact_sha256 = "b" * 64
|
||||
|
||||
def embed_queries(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]:
|
||||
return tuple((1.0, 0.0) for _ in texts)
|
||||
|
||||
def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]:
|
||||
return tuple(
|
||||
(1.0, 0.0) if text.startswith("Semantic match") else (0.0, 1.0) for text in texts
|
||||
)
|
||||
|
||||
|
||||
def _record_path(node_id: str, project_id: str) -> str:
|
||||
project_key = hashlib.sha256(project_id.encode()).hexdigest()
|
||||
node_key = hashlib.sha256(node_id.encode()).hexdigest()
|
||||
return f"projects/{project_key}/nodes/{node_key}.yaml"
|
||||
|
||||
|
||||
def _repository(tmp_path: Path) -> GitCanonicalRepository:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
nodes = [
|
||||
NODE.model_copy(
|
||||
update={
|
||||
"node_id": f"dome:alpha-{number:02}",
|
||||
"title": f"Alpha candidate {number:02}",
|
||||
"summary": f"Alpha lexical candidate {number:02}.",
|
||||
"node_type": NodeType.FINDING,
|
||||
"confidence": Confidence.MEDIUM,
|
||||
}
|
||||
)
|
||||
for number in range(12)
|
||||
]
|
||||
nodes.extend(
|
||||
(
|
||||
NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:semantic-match",
|
||||
"title": "Semantic match for the intent",
|
||||
"summary": "A close meaning with no shared query word.",
|
||||
"node_type": NodeType.TECHNIQUE,
|
||||
"confidence": Confidence.HIGH,
|
||||
}
|
||||
),
|
||||
NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:graph-related",
|
||||
"title": "Connected nonlexical technique",
|
||||
"summary": "A related result found through one typed edge.",
|
||||
"node_type": NodeType.TECHNIQUE,
|
||||
"confidence": Confidence.MEDIUM,
|
||||
}
|
||||
),
|
||||
NODE.model_copy(
|
||||
update={
|
||||
"node_id": "video:semantic-match",
|
||||
"project_id": "interactive-video",
|
||||
"title": "Semantic match in another project",
|
||||
"summary": "Must remain outside the requested project scope.",
|
||||
"node_type": NodeType.TECHNIQUE,
|
||||
"confidence": Confidence.HIGH,
|
||||
}
|
||||
),
|
||||
)
|
||||
)
|
||||
edges = (
|
||||
TypedEdge(
|
||||
edge_id="dome:search-expansion-edge",
|
||||
edge_type="compared_with",
|
||||
source=EdgeEndpoint(kind="node", id="dome:alpha-00"),
|
||||
target=EdgeEndpoint(kind="node", id="dome:graph-related"),
|
||||
interpretation_rationale="Both notes inform the same audience-attendance question.",
|
||||
),
|
||||
TypedEdge(
|
||||
edge_id="dome:search-cross-project-edge",
|
||||
edge_type="compared_with",
|
||||
source=EdgeEndpoint(kind="node", id="dome:alpha-00"),
|
||||
target=EdgeEndpoint(kind="node", id="video:semantic-match"),
|
||||
interpretation_rationale="Cross-project edge must not widen search scope.",
|
||||
),
|
||||
)
|
||||
files = {
|
||||
_record_path(node.node_id, node.project_id): yaml.safe_dump(
|
||||
node.model_dump(mode="json")
|
||||
).encode()
|
||||
for node in nodes
|
||||
}
|
||||
files.update(
|
||||
{
|
||||
f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||||
edge.model_dump(mode="json")
|
||||
).encode()
|
||||
for edge in edges
|
||||
}
|
||||
)
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files=files,
|
||||
message="Add hybrid search fixtures",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
return repository
|
||||
|
||||
|
||||
def test_hybrid_search_fuses_ranks_and_preserves_scope_and_filters(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
from gewu.application import search as search_module
|
||||
|
||||
monkeypatch.setattr(search_module, "_VECTOR_CANDIDATE_LIMIT", 1)
|
||||
repository = _repository(tmp_path)
|
||||
search_path = tmp_path / "search.sqlite3"
|
||||
lexical = SQLiteSearchIndex(search_path, repository)
|
||||
vectors = SQLiteVectorIndex(search_path)
|
||||
hybrid = HybridSearchIndex(
|
||||
lexical=lexical,
|
||||
vectors=vectors,
|
||||
embeddings=FixtureEmbeddings(),
|
||||
)
|
||||
lexical.configure_vector_generation(
|
||||
extension_loader=vectors.load_extension,
|
||||
builder=hybrid.build_vector_generation,
|
||||
is_current=hybrid.vector_generation_is_current,
|
||||
)
|
||||
service = CanonicalSearchService(
|
||||
index=hybrid,
|
||||
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
|
||||
canonical_head=repository.head,
|
||||
)
|
||||
principal = Principal(
|
||||
"reader", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||||
)
|
||||
request = SearchRequest(
|
||||
query="alpha",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
limit=20,
|
||||
budget_tokens=50_000,
|
||||
)
|
||||
|
||||
response = service.search(request, principal=principal)
|
||||
first_generation = vectors.active_generation()
|
||||
assert first_generation is not None
|
||||
assert lexical.path == vectors.path
|
||||
assert first_generation.revision == response.index_revision
|
||||
assert first_generation.node_count == 15
|
||||
result_ids = [item["node"]["node_id"] for item in response.data]
|
||||
assert response.retrieval_mode == "hybrid"
|
||||
assert result_ids.index("dome:semantic-match") < result_ids.index("dome:alpha-11")
|
||||
graph_hit = next(
|
||||
item for item in response.data if item["node"]["node_id"] == "dome:graph-related"
|
||||
)
|
||||
assert graph_hit["retrieval_path"] == "graph_expansion"
|
||||
assert graph_hit["expanded_from"] == "dome:alpha-00"
|
||||
assert graph_hit["expansion_edge"]["edge_type"] == "compared_with"
|
||||
assert not any(node_id.startswith("video:") for node_id in result_ids)
|
||||
assert all("retrieval_score" in item for item in response.data)
|
||||
|
||||
first_page = service.search(request.model_copy(update={"limit": 5}), principal=principal)
|
||||
assert first_page.next_cursor is not None
|
||||
second_page = service.search(
|
||||
request.model_copy(update={"limit": 5, "cursor": first_page.next_cursor}),
|
||||
principal=principal,
|
||||
)
|
||||
first_ids = {item["node"]["node_id"] for item in first_page.data}
|
||||
second_ids = {item["node"]["node_id"] for item in second_page.data}
|
||||
assert len(first_ids) == len(second_ids) == 5
|
||||
assert first_ids.isdisjoint(second_ids)
|
||||
|
||||
class ChangedModelEmbeddings(FixtureEmbeddings):
|
||||
model_id = "fixture-e5@v2"
|
||||
|
||||
changed_hybrid = HybridSearchIndex(
|
||||
lexical=lexical,
|
||||
vectors=vectors,
|
||||
embeddings=ChangedModelEmbeddings(),
|
||||
)
|
||||
changed_profile_service = CanonicalSearchService(
|
||||
index=changed_hybrid,
|
||||
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
|
||||
canonical_head=repository.head,
|
||||
)
|
||||
with pytest.raises(SearchError, match="cursor is invalid"):
|
||||
changed_profile_service.search(
|
||||
request.model_copy(update={"limit": 5, "cursor": first_page.next_cursor}),
|
||||
principal=principal,
|
||||
)
|
||||
|
||||
filtered = service.search(
|
||||
request.model_copy(
|
||||
update={"node_types": (NodeType.TECHNIQUE,), "confidences": (Confidence.HIGH,)}
|
||||
),
|
||||
principal=principal,
|
||||
)
|
||||
assert [item["node"]["node_id"] for item in filtered.data] == ["dome:semantic-match"]
|
||||
assert filtered.retrieval_mode == "hybrid"
|
||||
|
||||
lexical.configure_vector_generation(
|
||||
extension_loader=vectors.load_extension,
|
||||
builder=changed_hybrid.build_vector_generation,
|
||||
is_current=changed_hybrid.vector_generation_is_current,
|
||||
)
|
||||
changed_response = changed_profile_service.search(
|
||||
request.model_copy(update={"limit": 5}), principal=principal
|
||||
)
|
||||
changed_generation = vectors.active_generation()
|
||||
assert changed_response.retrieval_mode == "hybrid"
|
||||
assert changed_generation is not None and changed_generation.model_id == "fixture-e5@v2"
|
||||
assert vectors.generation(first_generation.generation_id) is not None
|
||||
|
||||
class FailingEmbeddings(FixtureEmbeddings):
|
||||
model_id = "fixture-e5@failed-build"
|
||||
|
||||
def embed_passages(self, texts: tuple[str, ...]) -> tuple[tuple[float, ...], ...]:
|
||||
raise RuntimeError("fixture model failure")
|
||||
|
||||
failing_hybrid = HybridSearchIndex(
|
||||
lexical=lexical,
|
||||
vectors=vectors,
|
||||
embeddings=FailingEmbeddings(),
|
||||
)
|
||||
lexical.configure_vector_generation(
|
||||
extension_loader=vectors.load_extension,
|
||||
builder=failing_hybrid.build_vector_generation,
|
||||
is_current=failing_hybrid.vector_generation_is_current,
|
||||
)
|
||||
degraded = CanonicalSearchService(
|
||||
index=failing_hybrid,
|
||||
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
|
||||
canonical_head=repository.head,
|
||||
).search(request.model_copy(update={"limit": 5}), principal=principal)
|
||||
assert degraded.retrieval_mode == "keyword"
|
||||
assert any(item.code == "HYBRID_RETRIEVAL_DEGRADED" for item in degraded.warnings)
|
||||
assert degraded.next_cursor is None
|
||||
|
||||
|
||||
def test_fts_and_vector_publication_roll_back_as_one_transaction(tmp_path: Path) -> None:
|
||||
repository = _repository(tmp_path)
|
||||
search_path = tmp_path / "search.sqlite3"
|
||||
lexical = SQLiteSearchIndex(search_path, repository)
|
||||
vectors = SQLiteVectorIndex(search_path)
|
||||
hybrid = HybridSearchIndex(
|
||||
lexical=lexical,
|
||||
vectors=vectors,
|
||||
embeddings=FixtureEmbeddings(),
|
||||
)
|
||||
|
||||
def fail_after_vector_publish(connection, revision, nodes) -> None:
|
||||
hybrid.build_vector_generation(connection, revision, nodes)
|
||||
raise sqlite3.OperationalError("simulated termination before index commit")
|
||||
|
||||
lexical.configure_vector_generation(
|
||||
extension_loader=vectors.load_extension,
|
||||
builder=fail_after_vector_publish,
|
||||
is_current=hybrid.vector_generation_is_current,
|
||||
)
|
||||
with pytest.raises(SearchIndexError):
|
||||
lexical.search(
|
||||
query="alpha",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=5,
|
||||
)
|
||||
assert lexical.diagnostic_status()[0] is None
|
||||
assert vectors.active_generation() is None
|
||||
|
||||
lexical.configure_vector_generation(
|
||||
extension_loader=vectors.load_extension,
|
||||
builder=hybrid.build_vector_generation,
|
||||
is_current=hybrid.vector_generation_is_current,
|
||||
)
|
||||
recovered = hybrid.search(
|
||||
query="alpha",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=5,
|
||||
)
|
||||
active = vectors.active_generation()
|
||||
assert recovered.retrieval_mode == "hybrid"
|
||||
assert active is not None and active.revision == recovered.index_revision
|
||||
@@ -0,0 +1,165 @@
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import portalocker
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.application.registry import LocalContextProvider, LocalProjectRegistry
|
||||
from gewu.domain.contracts import AutoAcceptPolicy, ProjectRecord, ProjectStatus
|
||||
from gewu.domain.validation import extraction_digest
|
||||
from tests.domain.proposal_fixtures import EXTRACTION, NODE, POLICIES, PROJECTS
|
||||
|
||||
|
||||
def test_project_registry_create_and_archive_are_atomic_and_idempotent(tmp_path: Path) -> None:
|
||||
registry_file = tmp_path / "control" / "registry.json"
|
||||
registry = LocalProjectRegistry(
|
||||
registry_file,
|
||||
repository=GitCanonicalRepository(tmp_path / "knowledge.git"),
|
||||
index_directory=tmp_path / "publishing",
|
||||
authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock",
|
||||
)
|
||||
project = ProjectRecord(
|
||||
project_id="new-research",
|
||||
title="New research",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
owners=("owner-one",),
|
||||
north_star="Investigate a new research area.",
|
||||
key_questions=("What is known?",),
|
||||
autonomy_policy_id="new-policy",
|
||||
default_includes=("shared",),
|
||||
)
|
||||
policy = AutoAcceptPolicy(
|
||||
policy_id="new-policy",
|
||||
policy_version=1,
|
||||
project_id="new-research",
|
||||
require_supported_assessment=True,
|
||||
)
|
||||
|
||||
assert registry.create(project, policy) == project
|
||||
assert registry.repository is not None
|
||||
revision_after_create = registry.repository.head()
|
||||
assert (
|
||||
registry.repository.read_file(revision_after_create, "projects/new-research/project.md")
|
||||
is not None
|
||||
)
|
||||
assert registry.repository.list_files(revision_after_create, "policies")
|
||||
canonical_context = LocalContextProvider(registry.repository, registry_file)()
|
||||
assert canonical_context.projects["new-research"] == project
|
||||
assert canonical_context.policies["new-policy"] == policy
|
||||
assert registry.records() == (project,)
|
||||
assert "new-research" in registry()
|
||||
lock_path = tmp_path / "workflow" / "locks" / "canonical-writer.lock"
|
||||
with portalocker.Lock(lock_path, mode="a", timeout=0):
|
||||
with pytest.raises(portalocker.exceptions.LockException):
|
||||
registry.archive("new-research")
|
||||
assert registry.archive("new-research").status == ProjectStatus.ARCHIVED
|
||||
assert registry.repository.head() != revision_after_create
|
||||
assert registry.archive("new-research").status == ProjectStatus.ARCHIVED
|
||||
assert registry.records()[0].status == ProjectStatus.ARCHIVED
|
||||
assert (
|
||||
LocalContextProvider(registry.repository, registry_file)().projects["new-research"].status
|
||||
== ProjectStatus.ARCHIVED
|
||||
)
|
||||
|
||||
|
||||
def test_archive_migrates_legacy_project_to_canonical_registry(tmp_path: Path) -> None:
|
||||
registry_file = tmp_path / "control" / "registry.json"
|
||||
registry_file.parent.mkdir(parents=True)
|
||||
registry_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"projects": [project.model_dump(mode="json") for project in PROJECTS.values()],
|
||||
"policies": [policy.model_dump(mode="json") for policy in POLICIES.values()],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
registry = LocalProjectRegistry(
|
||||
registry_file,
|
||||
repository=repository,
|
||||
index_directory=tmp_path / "publishing",
|
||||
authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock",
|
||||
)
|
||||
|
||||
archived = registry.archive("dome-performance")
|
||||
context = LocalContextProvider(repository, registry_file)()
|
||||
|
||||
assert archived.status == ProjectStatus.ARCHIVED
|
||||
assert context.projects["dome-performance"].status == ProjectStatus.ARCHIVED
|
||||
assert context.projects["interactive-video"] == PROJECTS["interactive-video"]
|
||||
assert context.policies == POLICIES
|
||||
|
||||
|
||||
def test_canonical_registry_retains_prior_extractions_after_parser_replacement(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
prior = EXTRACTION
|
||||
replacement_candidate = prior.model_copy(
|
||||
update={"extraction_id": "0" * 64, "parser_version": "replacement-parser-1"}
|
||||
)
|
||||
replacement = replacement_candidate.model_copy(
|
||||
update={"extraction_id": extraction_digest(replacement_candidate)}
|
||||
)
|
||||
|
||||
node_path = (
|
||||
f"projects/{hashlib.sha256(NODE.project_id.encode()).hexdigest()}/nodes/"
|
||||
f"{hashlib.sha256(NODE.node_id.encode()).hexdigest()}.yaml"
|
||||
)
|
||||
first_revision = repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
f"extractions/{prior.extraction_id}.json": prior.model_dump_json().encode(),
|
||||
node_path: yaml.safe_dump(NODE.model_dump(mode="json")).encode(),
|
||||
},
|
||||
message="Publish a cited node using the original parser extraction",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
replacement_revision = repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
f"extractions/{replacement.extraction_id}.json": replacement.model_dump_json().encode()
|
||||
},
|
||||
message="Add replacement parser extraction without deleting history",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
|
||||
context = LocalContextProvider(repository, tmp_path / "control/registry.json")()
|
||||
assert context.extractions[prior.extraction_id] == prior
|
||||
assert context.extractions[replacement.extraction_id] == replacement
|
||||
assert context.existing_nodes[NODE.node_id] == NODE
|
||||
assert (
|
||||
context.existing_nodes[NODE.node_id].claims[0].evidence[0].extraction_id
|
||||
== prior.extraction_id
|
||||
)
|
||||
assert (
|
||||
repository.read_file(first_revision, f"extractions/{prior.extraction_id}.json") is not None
|
||||
)
|
||||
assert (
|
||||
repository.read_file(replacement_revision, f"extractions/{prior.extraction_id}.json")
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def test_disposable_registry_seed_loads_with_versioned_contracts(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("GEWU_DATA_ROOT", str(tmp_path))
|
||||
result = subprocess.run(
|
||||
[sys.executable, "scripts/seed_test_registry.py"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
context = LocalContextProvider(repository, tmp_path / "control/registry.json")()
|
||||
assert set(context.projects) == {"dome-performance", "interactive-video"}
|
||||
assert set(context.policies) == {"dome-pilot-policy", "video-pilot-policy", "shared"}
|
||||
@@ -0,0 +1,106 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from gewu.application.acceptance import ActorContext
|
||||
from gewu.application.runs import LocalRunService, RunError
|
||||
from gewu.domain.contracts import (
|
||||
RunCheckpointRequest,
|
||||
RunCreateRequest,
|
||||
RunModulePin,
|
||||
RunState,
|
||||
Scope,
|
||||
)
|
||||
|
||||
|
||||
def _create(run_id: str = "run-pilot") -> RunCreateRequest:
|
||||
return RunCreateRequest(
|
||||
run_id=run_id,
|
||||
objective="Compare audience response",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
lens="Audience behavior",
|
||||
pack_id="gewu",
|
||||
pack_version="0.3.0",
|
||||
pack_snapshot_digest="a" * 64,
|
||||
module_pins=(
|
||||
RunModulePin(
|
||||
module_id="profile",
|
||||
implementation_version="1.0.0",
|
||||
sha256="b" * 64,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _checkpoint(run_id: str = "run-pilot", **overrides: object) -> RunCheckpointRequest:
|
||||
values: dict[str, object] = {
|
||||
"run_id": run_id,
|
||||
"expected_revision": 0,
|
||||
"stage": "source-review",
|
||||
"state": RunState.ACTIVE,
|
||||
"budgets": {"source_count": 3},
|
||||
}
|
||||
values.update(overrides)
|
||||
return RunCheckpointRequest.model_validate(values)
|
||||
|
||||
|
||||
def test_run_create_and_checkpoint_are_idempotent_and_revision_checked(tmp_path: Path) -> None:
|
||||
current = True
|
||||
actor = ActorContext("agent-pilot", "pilot-key")
|
||||
service = LocalRunService(
|
||||
workflow_root=tmp_path / "workflow",
|
||||
actor_is_current=lambda _: current,
|
||||
)
|
||||
|
||||
created = service.create(_create(), actor=actor)
|
||||
assert created.revision == 0
|
||||
assert service.create(_create(), actor=actor) == created
|
||||
with pytest.raises(RunError, match="different input"):
|
||||
service.create(_create().model_copy(update={"objective": "Different"}), actor=actor)
|
||||
|
||||
checkpoint = _checkpoint()
|
||||
updated = service.checkpoint(checkpoint, actor=actor)
|
||||
assert updated.revision == 1
|
||||
assert service.checkpoint(checkpoint, actor=actor) == updated
|
||||
with pytest.raises(RunError, match="changed content"):
|
||||
service.checkpoint(_checkpoint(stage="different-stage"), actor=actor)
|
||||
service.checkpoint(_checkpoint(expected_revision=1, stage="next-stage"), actor=actor)
|
||||
with pytest.raises(RunError, match="stale"):
|
||||
service.checkpoint(_checkpoint(expected_revision=0, stage="different-stage"), actor=actor)
|
||||
assert service.status("run-pilot", actor=actor).revision == 2
|
||||
|
||||
|
||||
def test_run_owner_revocation_and_terminal_state_are_enforced(tmp_path: Path) -> None:
|
||||
active = True
|
||||
owner = ActorContext("agent-pilot", "pilot-key")
|
||||
other = ActorContext("agent-other", "other-key")
|
||||
service = LocalRunService(
|
||||
workflow_root=tmp_path / "workflow",
|
||||
actor_is_current=lambda actor: active and actor.credential_id in {"pilot-key", "other-key"},
|
||||
)
|
||||
service.create(_create(), actor=owner)
|
||||
with pytest.raises(RunError, match="unavailable"):
|
||||
service.status("run-pilot", actor=other)
|
||||
|
||||
completed = service.checkpoint(_checkpoint(state=RunState.COMPLETED), actor=owner)
|
||||
assert completed.state is RunState.COMPLETED
|
||||
with pytest.raises(RunError, match="cannot be updated"):
|
||||
service.checkpoint(_checkpoint(expected_revision=1), actor=owner)
|
||||
|
||||
active = False
|
||||
with pytest.raises(RunError, match="no longer active"):
|
||||
service.checkpoint(_checkpoint(expected_revision=1), actor=owner)
|
||||
with pytest.raises(RunError, match="no longer active"):
|
||||
service.status("run-pilot", actor=owner)
|
||||
|
||||
|
||||
def test_run_contract_rejects_global_scope_and_negative_budgets() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
RunCreateRequest.model_validate(
|
||||
_create().model_dump() | {"scope": Scope(projects=("all",), all_projects=True)}
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
RunCheckpointRequest.model_validate(
|
||||
{"run_id": "run-pilot", "expected_revision": 0, "stage": "x", "budgets": {"tokens": -1}}
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from gewu.application.identity import Principal
|
||||
from gewu.application.registry import LocalProjectRegistry
|
||||
from gewu.application.search import CanonicalSearchService, SearchError, SearchIndex
|
||||
from gewu.domain.contracts import ProjectRecord, ProjectStatus, Scope
|
||||
|
||||
|
||||
def _service() -> CanonicalSearchService:
|
||||
return CanonicalSearchService(
|
||||
index=cast(SearchIndex, object()),
|
||||
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
|
||||
canonical_head=lambda: "a" * 40,
|
||||
default_includes_provider=lambda project_id: (
|
||||
("interactive-video", "shared") if project_id == "dome-performance" else ()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _principal(*projects: str) -> Principal:
|
||||
return Principal("reader", "reader-key", frozenset({"viewer"}), frozenset(projects))
|
||||
|
||||
|
||||
def test_single_project_scope_resolves_defaults_and_shared() -> None:
|
||||
resolved = _service()._resolve_scope(
|
||||
Scope(projects=("dome-performance",)),
|
||||
_principal("dome-performance", "interactive-video", "shared"),
|
||||
)
|
||||
|
||||
assert resolved == ("dome-performance", "interactive-video")
|
||||
|
||||
|
||||
def test_explicit_empty_includes_suppresses_project_defaults() -> None:
|
||||
resolved = _service()._resolve_scope(
|
||||
Scope(projects=("dome-performance",), include_projects=(), include_shared=False),
|
||||
_principal("dome-performance", "interactive-video", "shared"),
|
||||
)
|
||||
|
||||
assert resolved == ("dome-performance",)
|
||||
|
||||
|
||||
def test_default_includes_must_be_authorized() -> None:
|
||||
with pytest.raises(SearchError, match="cannot read every requested project") as error:
|
||||
_service()._resolve_scope(
|
||||
Scope(projects=("dome-performance",)), _principal("dome-performance", "shared")
|
||||
)
|
||||
|
||||
assert error.value.status_code == 403
|
||||
|
||||
|
||||
def test_explicit_include_projects_override_defaults() -> None:
|
||||
resolved = _service()._resolve_scope(
|
||||
Scope(projects=("dome-performance",), include_projects=("interactive-video",)),
|
||||
_principal("dome-performance", "interactive-video", "shared"),
|
||||
)
|
||||
|
||||
assert resolved == ("dome-performance", "interactive-video")
|
||||
|
||||
|
||||
def test_local_project_registry_exposes_validated_default_includes(tmp_path: Path) -> None:
|
||||
project = ProjectRecord(
|
||||
project_id="dome-performance",
|
||||
title="Dome",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
north_star="Build useful dome experiences.",
|
||||
key_questions=("What works?",),
|
||||
default_includes=("interactive-video", "shared"),
|
||||
autonomy_policy_id="dome-policy",
|
||||
)
|
||||
registry_file = tmp_path / "registry.json"
|
||||
registry_file.write_text(
|
||||
json.dumps(
|
||||
{"schema_version": 1, "projects": [project.model_dump(mode="json")], "policies": []}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registry = LocalProjectRegistry(registry_file)
|
||||
|
||||
assert registry.default_includes("dome-performance") == ("interactive-video",)
|
||||
@@ -0,0 +1,395 @@
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gewu.application.uploads import CHUNK_BYTES, LocalArtifactStore, UploadError
|
||||
from tests.domain.proposal_fixtures import EXTRACTION
|
||||
|
||||
|
||||
def _begin(store: LocalArtifactStore, content: bytes, *, now: int = 1_000) -> str:
|
||||
session = store.begin(
|
||||
filename="selected.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(content),
|
||||
expected_sha256=hashlib.sha256(content).hexdigest(),
|
||||
now=now,
|
||||
)
|
||||
return session.upload_id
|
||||
|
||||
|
||||
def test_matching_chunk_retry_and_restart_are_idempotent(tmp_path: Path) -> None:
|
||||
content = b"# Evidence\nA source block.\n"
|
||||
first_store = LocalArtifactStore(tmp_path)
|
||||
upload_id = _begin(first_store, content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
assert first_store.put_chunk(
|
||||
upload_id, offset=0, content=content, chunk_sha256=digest, now=1_001
|
||||
) == (0,)
|
||||
|
||||
restarted = LocalArtifactStore(tmp_path)
|
||||
assert restarted.verified_offsets(upload_id, now=1_002) == (0,)
|
||||
assert restarted.put_chunk(
|
||||
upload_id, offset=0, content=content, chunk_sha256=digest, now=1_002
|
||||
) == (0,)
|
||||
receipt = restarted.finalize(upload_id, now=1_003)
|
||||
assert receipt.sha256 == digest
|
||||
assert receipt.status == "staged"
|
||||
with restarted.open_artifact(receipt.sha256) as artifact:
|
||||
assert artifact.read() == content
|
||||
|
||||
|
||||
def test_pasted_capture_receipt_survives_restart_and_conflicts_on_changed_input(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
first = LocalArtifactStore(tmp_path)
|
||||
result = {"sha256": "a" * 64, "source_candidate": {"revision_id": "paste:r1"}}
|
||||
with first.capture_receipt_guard():
|
||||
first.save_capture_receipt(
|
||||
"capture-1",
|
||||
owner_id="agent-a",
|
||||
project_id="project-a",
|
||||
request_sha256="b" * 64,
|
||||
result=result,
|
||||
)
|
||||
|
||||
restarted = LocalArtifactStore(tmp_path)
|
||||
with restarted.capture_receipt_guard():
|
||||
assert (
|
||||
restarted.get_capture_receipt(
|
||||
"capture-1",
|
||||
owner_id="agent-a",
|
||||
project_id="project-a",
|
||||
request_sha256="b" * 64,
|
||||
)
|
||||
== result
|
||||
)
|
||||
with pytest.raises(UploadError) as error:
|
||||
restarted.get_capture_receipt(
|
||||
"capture-1",
|
||||
owner_id="agent-a",
|
||||
project_id="project-a",
|
||||
request_sha256="c" * 64,
|
||||
)
|
||||
assert error.value.code == "CAPTURE_CONFLICT"
|
||||
with pytest.raises(UploadError) as error:
|
||||
restarted.get_capture_receipt(
|
||||
"capture-1",
|
||||
owner_id="agent-a",
|
||||
project_id="project-b",
|
||||
request_sha256="b" * 64,
|
||||
)
|
||||
assert error.value.code == "CAPTURE_CONFLICT"
|
||||
assert (
|
||||
restarted.get_capture_receipt(
|
||||
"capture-1",
|
||||
owner_id="agent-b",
|
||||
project_id="project-a",
|
||||
request_sha256="b" * 64,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_upload_session_is_private_to_its_principal(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
content = b"# Scoped upload\n"
|
||||
upload_id = store.begin(
|
||||
filename="scoped.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(content),
|
||||
expected_sha256=hashlib.sha256(content).hexdigest(),
|
||||
owner_id="agent-a",
|
||||
).upload_id
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.verified_offsets(upload_id, owner_id="agent-b")
|
||||
assert error.value.code == "UPLOAD_NOT_FOUND"
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.put_chunk(
|
||||
upload_id,
|
||||
offset=0,
|
||||
content=content,
|
||||
chunk_sha256=hashlib.sha256(content).hexdigest(),
|
||||
owner_id="agent-b",
|
||||
)
|
||||
assert error.value.code == "UPLOAD_NOT_FOUND"
|
||||
|
||||
|
||||
def test_staged_extraction_is_immutable_and_principal_scoped(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
store.save_extraction(EXTRACTION, owner_id="agent-a")
|
||||
assert store.get_extraction(EXTRACTION.extraction_id, owner_id="agent-a") == EXTRACTION
|
||||
assert store.get_extraction(EXTRACTION.extraction_id, owner_id="agent-b") is None
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.save_extraction(EXTRACTION, owner_id="agent-b")
|
||||
assert error.value.code == "EXTRACTION_STORE_CONFLICT"
|
||||
|
||||
|
||||
def test_artifact_access_is_scoped_to_principal_and_project(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
content = b"private project source"
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
upload_id = store.begin(
|
||||
filename="source.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(content),
|
||||
expected_sha256=digest,
|
||||
owner_id="agent-a",
|
||||
project_id="project-a",
|
||||
).upload_id
|
||||
store.put_chunk(
|
||||
upload_id,
|
||||
offset=0,
|
||||
content=content,
|
||||
chunk_sha256=digest,
|
||||
owner_id="agent-a",
|
||||
)
|
||||
store.finalize(upload_id, owner_id="agent-a")
|
||||
assert (
|
||||
store.artifact_media_type(digest, owner_id="agent-a", project_id="project-a")
|
||||
== "text/markdown"
|
||||
)
|
||||
with store.open_artifact(digest, owner_id="agent-a", project_id="project-a") as stream:
|
||||
assert stream.read() == content
|
||||
with pytest.raises(UploadError):
|
||||
store.open_artifact(digest, owner_id="agent-b", project_id="project-a")
|
||||
with pytest.raises(UploadError):
|
||||
store.open_artifact(digest, owner_id="agent-a", project_id="project-b")
|
||||
|
||||
|
||||
def test_changed_retry_at_same_offset_is_rejected(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
upload_id = _begin(store, b"abc")
|
||||
store.put_chunk(
|
||||
upload_id,
|
||||
offset=0,
|
||||
content=b"abc",
|
||||
chunk_sha256=hashlib.sha256(b"abc").hexdigest(),
|
||||
now=1_001,
|
||||
)
|
||||
with pytest.raises(UploadError, match="different bytes") as error:
|
||||
store.put_chunk(
|
||||
upload_id,
|
||||
offset=0,
|
||||
content=b"xyz",
|
||||
chunk_sha256=hashlib.sha256(b"xyz").hexdigest(),
|
||||
now=1_002,
|
||||
)
|
||||
assert error.value.code == "IDEMPOTENCY_CONFLICT"
|
||||
|
||||
|
||||
def test_chunk_and_complete_artifact_digests_are_verified(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
content = b"verified bytes"
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
upload_id = store.begin(
|
||||
filename="verified.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(content),
|
||||
expected_sha256="f" * 64,
|
||||
).upload_id
|
||||
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.put_chunk(
|
||||
upload_id,
|
||||
offset=0,
|
||||
content=content,
|
||||
chunk_sha256="0" * 64,
|
||||
)
|
||||
assert error.value.code == "CHUNK_DIGEST_MISMATCH"
|
||||
|
||||
store.put_chunk(upload_id, offset=0, content=content, chunk_sha256=digest)
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.finalize(upload_id)
|
||||
assert error.value.code == "ARTIFACT_DIGEST_MISMATCH"
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.open_artifact("f" * 64)
|
||||
assert error.value.code == "ARTIFACT_NOT_FOUND"
|
||||
|
||||
|
||||
def test_interrupted_upload_does_not_publish_an_artifact(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
content = b"a" * (CHUNK_BYTES + 1)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
upload_id = store.begin(
|
||||
filename="partial.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(content),
|
||||
expected_sha256=digest,
|
||||
owner_id="agent-a",
|
||||
project_id="project-a",
|
||||
).upload_id
|
||||
first_chunk = content[:CHUNK_BYTES]
|
||||
store.put_chunk(
|
||||
upload_id,
|
||||
offset=0,
|
||||
content=first_chunk,
|
||||
chunk_sha256=hashlib.sha256(first_chunk).hexdigest(),
|
||||
owner_id="agent-a",
|
||||
)
|
||||
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.finalize(upload_id, owner_id="agent-a")
|
||||
assert error.value.code == "UPLOAD_INCOMPLETE"
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.open_artifact(digest, owner_id="agent-a", project_id="project-a")
|
||||
assert error.value.code == "ARTIFACT_NOT_FOUND"
|
||||
|
||||
|
||||
def test_symlinked_upload_paths_are_not_followed(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
sensitive = outside / "chunk"
|
||||
sensitive.write_bytes(b"data")
|
||||
|
||||
forged_id = "A" * 24
|
||||
try:
|
||||
(store.upload_root / forged_id).symlink_to(outside, target_is_directory=True)
|
||||
except OSError as error:
|
||||
pytest.skip(f"directory symlinks are unavailable: {error}")
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.verified_offsets(forged_id)
|
||||
assert error.value.code == "UPLOAD_NOT_FOUND"
|
||||
|
||||
upload_id = _begin(store, b"data")
|
||||
chunk_link = store.upload_root / upload_id / f"{0:020d}.chunk"
|
||||
chunk_link.symlink_to(sensitive)
|
||||
assert store.verified_offsets(upload_id, now=1_001) == ()
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.finalize(upload_id, now=1_001)
|
||||
assert error.value.code == "UPLOAD_INCOMPLETE"
|
||||
assert sensitive.read_bytes() == b"data"
|
||||
|
||||
|
||||
def test_upload_requires_order_complete_digest_and_utf8(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
content = b"a" * (CHUNK_BYTES + 1)
|
||||
upload_id = _begin(store, content)
|
||||
final_chunk = content[CHUNK_BYTES:]
|
||||
with pytest.raises(UploadError, match="in order"):
|
||||
store.put_chunk(
|
||||
upload_id,
|
||||
offset=CHUNK_BYTES,
|
||||
content=final_chunk,
|
||||
chunk_sha256=hashlib.sha256(final_chunk).hexdigest(),
|
||||
now=1_001,
|
||||
)
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.finalize(upload_id, now=1_002)
|
||||
assert error.value.code == "UPLOAD_INCOMPLETE"
|
||||
|
||||
invalid_bytes = b"\xff"
|
||||
invalid_id = _begin(store, invalid_bytes)
|
||||
store.put_chunk(
|
||||
invalid_id,
|
||||
offset=0,
|
||||
content=invalid_bytes,
|
||||
chunk_sha256=hashlib.sha256(invalid_bytes).hexdigest(),
|
||||
now=1_001,
|
||||
)
|
||||
with pytest.raises(UploadError, match="UTF-8") as error:
|
||||
store.finalize(invalid_id, now=1_002)
|
||||
assert error.value.code == "TEXT_ENCODING_INVALID"
|
||||
|
||||
|
||||
def test_rejects_unsupported_types_expiry_and_malicious_ids(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.begin(
|
||||
filename="payload.exe",
|
||||
media_type="application/octet-stream",
|
||||
byte_length=4,
|
||||
expected_sha256=hashlib.sha256(b"data").hexdigest(),
|
||||
now=1_000,
|
||||
)
|
||||
assert error.value.code == "UNSUPPORTED_MEDIA_TYPE"
|
||||
|
||||
upload_id = _begin(store, b"data")
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.verified_offsets(upload_id, now=1_601)
|
||||
assert error.value.code == "UPLOAD_EXPIRED"
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.verified_offsets("../../outside", now=1_001)
|
||||
assert error.value.code == "UPLOAD_NOT_FOUND"
|
||||
|
||||
|
||||
def test_pdf_upload_requires_matching_suffix_and_pdf_signature(tmp_path: Path) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
payload = b"%PDF-fixture"
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.begin(
|
||||
filename="source.txt",
|
||||
media_type="application/pdf",
|
||||
byte_length=len(payload),
|
||||
expected_sha256=digest,
|
||||
)
|
||||
assert error.value.code == "UNSUPPORTED_MEDIA_TYPE"
|
||||
upload_id = store.begin(
|
||||
filename="source.pdf",
|
||||
media_type="application/pdf",
|
||||
byte_length=len(payload),
|
||||
expected_sha256=digest,
|
||||
).upload_id
|
||||
store.put_chunk(upload_id, offset=0, content=payload, chunk_sha256=digest)
|
||||
assert store.finalize(upload_id).media_type == "application/pdf"
|
||||
|
||||
invalid = b"not pdf"
|
||||
invalid_digest = hashlib.sha256(invalid).hexdigest()
|
||||
invalid_id = store.begin(
|
||||
filename="invalid.pdf",
|
||||
media_type="application/pdf",
|
||||
byte_length=len(invalid),
|
||||
expected_sha256=invalid_digest,
|
||||
).upload_id
|
||||
store.put_chunk(invalid_id, offset=0, content=invalid, chunk_sha256=invalid_digest)
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.finalize(invalid_id)
|
||||
assert error.value.code == "PDF_INVALID"
|
||||
|
||||
|
||||
def test_identical_artifact_bytes_dedupe_without_collapsing_upload_receipts(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = LocalArtifactStore(tmp_path)
|
||||
content = b"Shared source content."
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
first_id = store.begin(
|
||||
filename="shared.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(content),
|
||||
expected_sha256=digest,
|
||||
owner_id="agent-shared",
|
||||
project_id="project-a",
|
||||
).upload_id
|
||||
second_session = store.begin(
|
||||
filename="second-copy.txt",
|
||||
media_type="text/plain",
|
||||
byte_length=len(content),
|
||||
expected_sha256=digest,
|
||||
owner_id="agent-shared",
|
||||
project_id="project-b",
|
||||
now=1_000,
|
||||
)
|
||||
for upload_id in (first_id, second_session.upload_id):
|
||||
store.put_chunk(
|
||||
upload_id,
|
||||
offset=0,
|
||||
content=content,
|
||||
chunk_sha256=digest,
|
||||
owner_id="agent-shared",
|
||||
now=1_001,
|
||||
)
|
||||
first = store.finalize(first_id, owner_id="agent-shared", now=1_002)
|
||||
second = store.finalize(second_session.upload_id, owner_id="agent-shared", now=1_002)
|
||||
assert first.sha256 == second.sha256
|
||||
assert first.byte_length == second.byte_length == len(content)
|
||||
for project_id in ("project-a", "project-b"):
|
||||
with store.open_artifact(
|
||||
digest, owner_id="agent-shared", project_id=project_id
|
||||
) as artifact:
|
||||
assert artifact.read() == content
|
||||
with pytest.raises(UploadError) as error:
|
||||
store.open_artifact(digest, owner_id="agent-shared", project_id="project-c")
|
||||
assert error.value.code == "ARTIFACT_NOT_FOUND"
|
||||
Reference in new issue
Block a user