fix(evidence): close canonical contract gaps

This commit is contained in:
2026-07-12 03:15:17 +02:00
parent 4424fd3d90
commit 293d96e1a6
5 changed files with 176 additions and 15 deletions
+48 -5
View File
@@ -1,3 +1,4 @@
import hashlib
from datetime import UTC, datetime, timedelta, timezone
import pytest
@@ -7,26 +8,28 @@ from tht.corpus.models import CanonicalChunk, CanonicalDocument, CorpusManifest
def document(source_uri: str = "https://host/a.md") -> CanonicalDocument:
content = "# A"
return CanonicalDocument(
document_id="doc:abc",
source_id="source:a",
source_uri=source_uri,
source_fingerprint="etag:abc",
content_hash=f"sha256:{'d' * 64}",
content_hash=f"sha256:{hashlib.sha256(content.encode()).hexdigest()}",
title="A",
content="# A",
content=content,
media_type="text/markdown",
pipeline_version="evidence-v1",
)
def chunk() -> CanonicalChunk:
content = "# A"
return CanonicalChunk(
chunk_id="chunk:abc:0",
document_id="doc:abc",
ordinal=0,
content="# A",
content_hash=f"sha256:{'e' * 64}",
content=content,
content_hash=f"sha256:{hashlib.sha256(content.encode()).hexdigest()}",
source_uri="https://host/a.md",
pipeline_version="evidence-v1",
)
@@ -156,7 +159,6 @@ def test_vector_generation_requires_embedding_compatibility():
("document_id", "not-namespaced"),
("content_hash", "sha256:not-hex"),
("source_uri", "https://user:pass@host/a"),
("source_uri", "https://host/a?refresh_token=secret"),
],
)
def test_canonical_document_rejects_malformed_or_sensitive_provenance(field, value):
@@ -164,6 +166,47 @@ def test_canonical_document_rejects_malformed_or_sensitive_provenance(field, val
CanonicalDocument.model_validate({**document().model_dump(), field: value})
@pytest.mark.parametrize(
"source_uri",
[
"https://host/a?X-Amz-Credential=abc&X-Amz-Signature=secret#access_token=bad",
"https://host/a?sig=sas-secret&sp=r#section",
],
)
def test_canonical_provenance_strips_query_and_fragment(source_uri):
doc = document(source_uri=source_uri)
canonical_chunk = chunk().model_copy(update={"source_uri": source_uri})
manifest = CorpusManifest(
pipeline_version="evidence-v1", documents=[doc], chunks=[canonical_chunk]
)
assert doc.source_uri == "https://host/a"
assert canonical_chunk.source_uri == "https://host/a"
payload = manifest.model_dump_json()
assert "X-Amz" not in payload
assert "sas-secret" not in payload
assert "access_token" not in payload
@pytest.mark.parametrize("factory", [document, chunk])
def test_content_hash_must_match_exact_canonical_utf8(factory):
record = factory()
with pytest.raises(ValidationError, match="exact canonical UTF-8 content"):
type(record).model_validate({**record.model_dump(), "content": record.content + "\n"})
def test_model_copy_revalidates_records_and_manifests():
with pytest.raises(ValidationError, match="namespaced"):
document().model_copy(update={"document_id": "invalid"})
manifest = CorpusManifest(
pipeline_version="evidence-v1",
embedding_model="embed-v1",
embedding_dimensions=768,
)
with pytest.raises(ValidationError, match="set together"):
manifest.model_copy(update={"embedding_dimensions": None})
def test_manifest_datetimes_are_aware_and_normalized_to_utc():
with pytest.raises(ValidationError, match="timezone-aware"):
CorpusManifest(created_at=datetime(2026, 7, 12), pipeline_version="evidence-v1")
+36 -1
View File
@@ -176,7 +176,7 @@ def test_datetimes_must_be_aware_and_are_normalized_to_utc():
def test_source_errors_are_typed_retryable_and_safe():
transient = EvidenceSourceError(
"remote source unavailable",
"password=hunter2 at https://user:secret@host",
category=EvidenceSourceErrorCategory.TRANSIENT,
details={"status": 503},
)
@@ -188,6 +188,16 @@ def test_source_errors_are_typed_retryable_and_safe():
assert transient.retryable is True
assert permanent.retryable is False
assert transient.details["status"] == 503
assert str(transient) == "evidence source operation failed"
assert transient.args == ("evidence source operation failed",)
assert "hunter2" not in repr(transient)
with pytest.raises(AttributeError):
transient.category = EvidenceSourceErrorCategory.PERMANENT
with pytest.raises(AttributeError):
transient.args = ("leak",)
with pytest.raises(AttributeError):
transient.details = {"unsafe": True}
assert "hunter2" not in repr(transient.__dict__)
with pytest.raises(TypeError):
transient.details["status"] = 200
with pytest.raises(ValueError, match="credential-like"):
@@ -202,3 +212,28 @@ def test_source_errors_are_typed_retryable_and_safe():
category=EvidenceSourceErrorCategory.PERMANENT,
details={"not_json": object()},
)
def test_source_error_preserves_original_only_through_exception_chaining():
cause = RuntimeError("transport diagnostic with password=hunter2")
error = EvidenceSourceError(
"ignored unsafe diagnostic",
category=EvidenceSourceErrorCategory.TRANSIENT,
)
try:
raise error from cause
except EvidenceSourceError as caught:
assert caught.__cause__ is cause
assert "hunter2" not in str(caught)
assert "hunter2" not in caught.args
def test_model_copy_revalidates_source_and_acquired_records():
source = SourceObject(source_id="source:a", uri="file:///a", fingerprint="sha256:a")
acquired = AcquiredDocument(source=source, content=b"a")
with pytest.raises(ValidationError, match="namespaced"):
source.model_copy(update={"source_id": "invalid"})
with pytest.raises(ValidationError, match="timezone-aware"):
acquired.model_copy(update={"acquired_at": datetime(2026, 7, 12)})