This commit is contained in:
1 parent
4db3f45164
commit
d25c04cdd2
175 files changed
+56243
-15
No files matched your search
@@ -0,0 +1 @@
|
||||
"""GEWU test suite."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Infrastructure adapter tests."""
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Exit abruptly from inside a SQLite FTS rebuild transaction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||||
|
||||
|
||||
class CrashConnection(sqlite3.Connection):
|
||||
def __init__(self, *args: object, **kwargs: object) -> None:
|
||||
super().__init__(*args, **kwargs) # type: ignore[arg-type]
|
||||
self._fts_inserts = 0
|
||||
|
||||
def execute(
|
||||
self,
|
||||
sql: str,
|
||||
parameters: tuple[object, ...] = (),
|
||||
/,
|
||||
) -> sqlite3.Cursor:
|
||||
cursor = super().execute(sql, parameters)
|
||||
if sql.lstrip().upper().startswith("INSERT INTO NODE_FTS"):
|
||||
self._fts_inserts += 1
|
||||
if self._fts_inserts == 5:
|
||||
os._exit(74)
|
||||
return cursor
|
||||
|
||||
|
||||
class CrashDuringIndexRebuild(SQLiteSearchIndex):
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(
|
||||
self.path,
|
||||
timeout=5,
|
||||
isolation_level=None,
|
||||
factory=CrashConnection,
|
||||
)
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=5000")
|
||||
connection.execute("PRAGMA synchronous=NORMAL")
|
||||
return connection
|
||||
|
||||
|
||||
def main() -> None:
|
||||
repository = GitCanonicalRepository(Path(sys.argv[1]))
|
||||
index = CrashDuringIndexRebuild(Path(sys.argv[2]), repository)
|
||||
index.search(
|
||||
query="visitors",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=20,
|
||||
)
|
||||
raise RuntimeError("SQLite rebuild did not reach the requested crash point")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,92 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from gewu.adapters.embeddings import (
|
||||
EmbeddingProviderError,
|
||||
LocalSentenceTransformer,
|
||||
model_artifact_sha256,
|
||||
)
|
||||
|
||||
|
||||
class FakeEncoder:
|
||||
def __init__(self, rows: list[list[float]]) -> None:
|
||||
self.rows = rows
|
||||
self.seen: list[str] = []
|
||||
self.calls = 0
|
||||
|
||||
def encode(
|
||||
self,
|
||||
sentences: list[str],
|
||||
*,
|
||||
normalize_embeddings: bool,
|
||||
convert_to_numpy: bool,
|
||||
show_progress_bar: bool,
|
||||
) -> Any:
|
||||
assert normalize_embeddings and convert_to_numpy and not show_progress_bar
|
||||
self.seen.extend(sentences)
|
||||
result = self.rows[self.calls : self.calls + len(sentences)]
|
||||
self.calls += len(sentences)
|
||||
return result
|
||||
|
||||
|
||||
def _model_path(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "model"
|
||||
path.mkdir()
|
||||
(path / "config.json").write_text('{"dim":2}', encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_local_sentence_transformer_applies_e5_prefixes_and_metadata(tmp_path: Path) -> None:
|
||||
path = _model_path(tmp_path)
|
||||
encoder = FakeEncoder([[0.6, 0.8], [1.0, 0.0]])
|
||||
artifact = model_artifact_sha256(path)
|
||||
provider = LocalSentenceTransformer(
|
||||
path,
|
||||
model_id="intfloat/multilingual-e5-small@pinned",
|
||||
expected_sha256=artifact,
|
||||
dimensions=2,
|
||||
encoder=encoder,
|
||||
)
|
||||
|
||||
assert provider.embed_queries(("question",)) == ((0.6, 0.8),)
|
||||
assert provider.embed_passages(("evidence",)) == ((1.0, 0.0),)
|
||||
assert encoder.seen == ["query: question", "passage: evidence"]
|
||||
assert provider.model_id == "intfloat/multilingual-e5-small@pinned"
|
||||
assert provider.artifact_sha256 == artifact
|
||||
assert len(provider.artifact_sha256) == 64
|
||||
|
||||
|
||||
def test_local_sentence_transformer_rejects_changed_artifact(tmp_path: Path) -> None:
|
||||
path = _model_path(tmp_path)
|
||||
(path / "config.json").write_text("changed", encoding="utf-8")
|
||||
|
||||
with pytest.raises(EmbeddingProviderError, match="digest"):
|
||||
LocalSentenceTransformer(
|
||||
path,
|
||||
model_id="model",
|
||||
expected_sha256=hashlib.sha256(b"wrong").hexdigest(),
|
||||
dimensions=2,
|
||||
encoder=FakeEncoder([[1.0, 0.0]]),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rows", [[[2.0, 0.0]], [[float("nan"), 0.0]], [[1.0]]])
|
||||
def test_local_sentence_transformer_rejects_invalid_vectors(
|
||||
tmp_path: Path, rows: list[list[float]]
|
||||
) -> None:
|
||||
path = _model_path(tmp_path)
|
||||
provider = LocalSentenceTransformer(
|
||||
path,
|
||||
model_id="model",
|
||||
expected_sha256=model_artifact_sha256(path),
|
||||
dimensions=2,
|
||||
encoder=FakeEncoder(rows),
|
||||
)
|
||||
|
||||
with pytest.raises(EmbeddingProviderError):
|
||||
provider.embed_passages(("text",))
|
||||
@@ -0,0 +1,92 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
|
||||
from gewu.adapters.git_repository import (
|
||||
GitCanonicalRepository,
|
||||
GitConflictError,
|
||||
GitRepositoryError,
|
||||
)
|
||||
|
||||
|
||||
def test_bare_repository_commits_only_explicit_files_and_checks_expected_head(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "private" / "knowledge.git")
|
||||
base = repository.head()
|
||||
record_path = "projects/project-a/nodes/node-a.yaml"
|
||||
commit = repository.publish_files(
|
||||
expected_head=base,
|
||||
files={record_path: b"title: accepted\n"},
|
||||
message="Accept fixture",
|
||||
index_directory=tmp_path / "staging" / "publishing",
|
||||
)
|
||||
assert repository.read_file(commit, record_path) == b"title: accepted\n"
|
||||
assert repository.list_files(commit, "projects") == (record_path,)
|
||||
assert repository.list_files(commit, "sources") == ()
|
||||
assert repository.read_file(commit, "unrelated/user-file.txt") is None
|
||||
assert repository.introducing_commit(commit, record_path) == commit
|
||||
|
||||
with pytest.raises(GitConflictError, match="Canonical branch changed"):
|
||||
repository.publish_files(
|
||||
expected_head=base,
|
||||
files={"projects/project-b/nodes/node-b.yaml": b"title: stale\n"},
|
||||
message="Stale writer",
|
||||
index_directory=tmp_path / "staging" / "publishing",
|
||||
)
|
||||
|
||||
|
||||
def test_record_paths_reject_traversal_and_noncanonical_separators(tmp_path: Path) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
for unsafe in ("../escape", "/absolute", "projects//node", "projects/./node", "a\\b"):
|
||||
with pytest.raises(GitRepositoryError, match="record path is invalid"):
|
||||
repository.read_file(repository.head(), unsafe)
|
||||
|
||||
|
||||
def test_independent_repository_adapters_allow_one_compare_and_swap_winner(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository_path = tmp_path / "knowledge.git"
|
||||
first_adapter = GitCanonicalRepository(repository_path)
|
||||
second_adapter = GitCanonicalRepository(repository_path)
|
||||
base = first_adapter.head()
|
||||
start = Barrier(2)
|
||||
index_directory = tmp_path / "staging" / "publishing"
|
||||
|
||||
def publish(repository: GitCanonicalRepository, name: str) -> tuple[str, str | None]:
|
||||
path = f"projects/{name}/nodes/node.yaml"
|
||||
start.wait(timeout=5)
|
||||
try:
|
||||
revision = repository.publish_files(
|
||||
expected_head=base,
|
||||
files={path: f"title: {name}\n".encode()},
|
||||
message=f"Publish {name}",
|
||||
index_directory=index_directory,
|
||||
)
|
||||
except GitConflictError:
|
||||
return name, None
|
||||
return name, revision
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as workers:
|
||||
results = list(
|
||||
workers.map(
|
||||
lambda args: publish(*args),
|
||||
((first_adapter, "project-a"), (second_adapter, "project-b")),
|
||||
)
|
||||
)
|
||||
|
||||
winners = [(name, revision) for name, revision in results if revision is not None]
|
||||
losers = [name for name, revision in results if revision is None]
|
||||
assert len(winners) == len(losers) == 1
|
||||
winner, winning_revision = winners[0]
|
||||
assert winning_revision is not None
|
||||
assert first_adapter.head() == winning_revision
|
||||
assert second_adapter.head() == winning_revision
|
||||
assert (
|
||||
first_adapter.read_file(winning_revision, f"projects/{winner}/nodes/node.yaml")
|
||||
== f"title: {winner}\n".encode()
|
||||
)
|
||||
loser = losers[0]
|
||||
assert first_adapter.read_file(winning_revision, f"projects/{loser}/nodes/node.yaml") is None
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gewu.adapters.pdf_extractor import PdfTextExtractor
|
||||
|
||||
|
||||
def _pdf(pages: tuple[str, ...]) -> bytes:
|
||||
objects: list[bytes] = []
|
||||
page_ids = [4 + 2 * index for index in range(len(pages))]
|
||||
objects.extend(
|
||||
[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
(
|
||||
f"<< /Type /Pages /Kids [{' '.join(f'{page_id} 0 R' for page_id in page_ids)}] "
|
||||
f"/Count {len(pages)} >>"
|
||||
).encode(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
]
|
||||
)
|
||||
for page_id, text in zip(page_ids, pages, strict=True):
|
||||
content_id = page_id + 1
|
||||
escaped = text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||
stream = f"BT /F1 12 Tf 72 720 Td ({escaped}) Tj ET".encode()
|
||||
page = (
|
||||
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
||||
f"/Resources << /Font << /F1 3 0 R >> >> /Contents {content_id} 0 R >>"
|
||||
)
|
||||
objects.append(page.encode())
|
||||
objects.append(
|
||||
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream"
|
||||
)
|
||||
output = bytearray(b"%PDF-1.4\n")
|
||||
offsets = [0]
|
||||
for number, obj in enumerate(objects, 1):
|
||||
offsets.append(len(output))
|
||||
output.extend(f"{number} 0 obj\n".encode() + obj + b"\nendobj\n")
|
||||
xref = len(output)
|
||||
output.extend(f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode())
|
||||
for offset in offsets[1:]:
|
||||
output.extend(f"{offset:010d} 00000 n \n".encode())
|
||||
output.extend(
|
||||
f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
|
||||
)
|
||||
return bytes(output)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not all(shutil.which(item) for item in ("bwrap", "pdftotext", "prlimit")),
|
||||
reason="Linux PDF sandbox tools unavailable",
|
||||
)
|
||||
def test_pdf_extraction_preserves_page_locations_and_parser_identity() -> None:
|
||||
record = PdfTextExtractor().extract(
|
||||
"project:source:r1",
|
||||
_pdf(("First page evidence.", "Second page evidence.")),
|
||||
"application/pdf",
|
||||
)
|
||||
assert record.status == "complete"
|
||||
assert record.parser_name == "gewu-poppler-pdftotext"
|
||||
assert [block.page for block in record.blocks] == [1, 2]
|
||||
assert [block.original_text.strip() for block in record.blocks] == [
|
||||
"First page evidence.",
|
||||
"Second page evidence.",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not all(shutil.which(item) for item in ("bwrap", "pdftotext", "prlimit")),
|
||||
reason="Linux PDF sandbox tools unavailable",
|
||||
)
|
||||
def test_empty_pdf_page_is_reported_as_partial() -> None:
|
||||
record = PdfTextExtractor().extract(
|
||||
"project:source:r1", _pdf(("Text page.", "")), "application/pdf"
|
||||
)
|
||||
assert record.status == "partial"
|
||||
assert record.blocks[0].page == 1
|
||||
assert record.warnings
|
||||
|
||||
|
||||
def test_pdf_parser_rejects_bad_signature_without_launching_parser() -> None:
|
||||
with pytest.raises(ValueError, match="signature"):
|
||||
PdfTextExtractor().extract("project:source:r1", b"not a pdf", "application/pdf")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not shutil.which("bwrap") or not shutil.which("prlimit"), reason="bubblewrap unavailable"
|
||||
)
|
||||
def test_sandbox_cannot_read_host_temporary_file(tmp_path: Path) -> None:
|
||||
sentinel = tmp_path / "gewu-host-secret-fixture"
|
||||
sentinel.write_text("must-not-be-visible", encoding="utf-8")
|
||||
extractor = PdfTextExtractor()
|
||||
if not extractor._pdftotext:
|
||||
pytest.skip("pdftotext unavailable")
|
||||
command = extractor._command()
|
||||
command[-6:] = ["/usr/bin/cat", str(sentinel)]
|
||||
with tempfile.TemporaryFile() as output, tempfile.TemporaryFile() as errors:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=output,
|
||||
stderr=errors,
|
||||
env={},
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode != 0
|
||||
output.seek(0)
|
||||
assert b"must-not-be-visible" not in output.read()
|
||||
@@ -0,0 +1,513 @@
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pyotp
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
|
||||
|
||||
def issue_challenge(
|
||||
store: SQLiteIdentityStore,
|
||||
*,
|
||||
principal_id: str,
|
||||
challenge_id: str,
|
||||
domain: str,
|
||||
now: int,
|
||||
) -> None:
|
||||
store.issue_owner_challenge(
|
||||
challenge_id=challenge_id,
|
||||
owner_principal_id=principal_id,
|
||||
proposal_id="proposal-one",
|
||||
proposal_revision=1,
|
||||
payload_digest="a" * 64,
|
||||
diff_digest="b" * 64,
|
||||
domains=(domain,),
|
||||
expires_at=now + 5 * 60,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def test_credentials_are_hashed_revocable_and_project_scoped(tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "private" / "identity.sqlite3")
|
||||
token = store.provision(
|
||||
credential_id="agent-key-1",
|
||||
principal_id="agent-pilot",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
|
||||
principal = store.authenticate(token)
|
||||
assert principal is not None
|
||||
assert principal.principal_id == "agent-pilot"
|
||||
assert principal.credential_id == "agent-key-1"
|
||||
assert principal.can_read_project("dome-performance")
|
||||
assert not principal.can_read_project("interactive-video")
|
||||
assert token.encode() not in (tmp_path / "private" / "identity.sqlite3").read_bytes()
|
||||
assert store.revoke("agent-key-1")
|
||||
assert store.authenticate(token) is None
|
||||
assert not store.revoke("agent-key-1")
|
||||
|
||||
|
||||
def test_admin_role_can_read_without_project_grant(tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = store.provision(
|
||||
credential_id="admin-key",
|
||||
principal_id="operator-1",
|
||||
roles=("admin",),
|
||||
)
|
||||
principal = store.authenticate(token)
|
||||
assert principal is not None
|
||||
assert principal.can_read_project("dome-performance")
|
||||
|
||||
|
||||
def test_publication_authority_snapshot_detects_grant_or_role_change(tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = store.provision(
|
||||
credential_id="agent-key",
|
||||
principal_id="agent-one",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance", "shared"),
|
||||
)
|
||||
principal = store.authenticate(token)
|
||||
assert principal is not None
|
||||
assert store.is_current(
|
||||
principal.principal_id,
|
||||
principal.credential_id,
|
||||
roles=principal.roles,
|
||||
project_ids=principal.project_ids,
|
||||
)
|
||||
assert not store.is_current(
|
||||
principal.principal_id,
|
||||
principal.credential_id,
|
||||
roles=principal.roles,
|
||||
project_ids=frozenset({"dome-performance"}),
|
||||
)
|
||||
|
||||
|
||||
def test_rotation_revokes_old_key_and_preserves_attribution_and_grants(tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
old_token = store.provision(
|
||||
credential_id="agent-key-old",
|
||||
principal_id="agent-pilot",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
new_token = store.rotate("agent-key-old", "agent-key-new")
|
||||
assert store.authenticate(old_token) is None
|
||||
principal = store.authenticate(new_token)
|
||||
assert principal is not None
|
||||
assert principal.principal_id == "agent-pilot"
|
||||
assert principal.credential_id == "agent-key-new"
|
||||
assert principal.can_read_project("dome-performance")
|
||||
|
||||
|
||||
def test_browser_sessions_are_human_only_csrf_protected_and_revocable(tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
viewer_key = store.provision(
|
||||
credential_id="viewer-key",
|
||||
principal_id="viewer-one",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
agent_key = store.provision(
|
||||
credential_id="agent-key",
|
||||
principal_id="agent-one",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
with pytest.raises(PermissionError, match="human browser identity"):
|
||||
store.create_browser_session(agent_key)
|
||||
|
||||
session = store.create_browser_session(viewer_key)
|
||||
principal = store.authenticate_browser_session(session.session_token)
|
||||
assert principal is not None
|
||||
assert principal.auth_method == "browser_session"
|
||||
assert principal.can_read_project("dome-performance")
|
||||
assert not principal.can_submit_project("dome-performance")
|
||||
assert store.browser_csrf_valid(session.session_token, session.csrf_token)
|
||||
assert not store.browser_csrf_valid(session.session_token, "gwc_invalid")
|
||||
assert store.revoke_browser_session(session.session_token)
|
||||
assert store.authenticate_browser_session(session.session_token) is None
|
||||
assert not store.browser_csrf_valid(session.session_token, session.csrf_token)
|
||||
|
||||
admin_key = store.provision(
|
||||
credential_id="human-admin-key",
|
||||
principal_id="human-admin",
|
||||
roles=("admin",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
admin_session = store.create_browser_session(admin_key)
|
||||
browser_admin = store.authenticate_browser_session(admin_session.session_token)
|
||||
assert browser_admin is not None and not browser_admin.can_submit_project("dome-performance")
|
||||
|
||||
|
||||
def test_revoking_browser_credential_invalidates_its_sessions(tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = store.provision(
|
||||
credential_id="human-key",
|
||||
principal_id="human-one",
|
||||
roles=("editor",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
session = store.create_browser_session(token)
|
||||
assert store.authenticate_browser_session(session.session_token) is not None
|
||||
assert store.revoke("human-key")
|
||||
assert store.authenticate_browser_session(session.session_token) is None
|
||||
|
||||
|
||||
def test_identity_store_rejects_symlinked_database_path(tmp_path: Path) -> None:
|
||||
target = tmp_path / "real"
|
||||
target.mkdir()
|
||||
link = tmp_path / "linked"
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
with pytest.raises(ValueError, match="symbolic links"):
|
||||
SQLiteIdentityStore(link / "identity.sqlite3")
|
||||
|
||||
|
||||
def test_owner_session_is_challenge_bound_short_lived_and_separate_from_agent_auth(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
secret = pyotp.random_base32()
|
||||
store.provision_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=secret,
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
database_bytes = (tmp_path / "identity.sqlite3").read_bytes()
|
||||
assert secret.encode() not in database_bytes
|
||||
assert b"correct horse battery staple" not in database_bytes
|
||||
now = int(time.time())
|
||||
issue_challenge(
|
||||
store,
|
||||
principal_id="project-owner",
|
||||
challenge_id="challenge-1",
|
||||
domain="dome-performance",
|
||||
now=now,
|
||||
)
|
||||
session = store.authenticate_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_code=pyotp.TOTP(secret).at(now),
|
||||
challenge_id="challenge-1",
|
||||
audience="https://gewu.example",
|
||||
now=now,
|
||||
)
|
||||
assert session is not None
|
||||
assert session.expires_at.timestamp() == now + 5 * 60
|
||||
principal = store.authenticate_owner_session(
|
||||
session.session_token,
|
||||
challenge_id="challenge-1",
|
||||
audience="https://gewu.example",
|
||||
)
|
||||
assert principal is not None
|
||||
assert principal.roles == frozenset({"owner"})
|
||||
assert principal.auth_method == "owner_session"
|
||||
assert principal.can_read_project("dome-performance")
|
||||
assert not principal.can_submit_project("dome-performance")
|
||||
assert store.authenticate(session.session_token) is None
|
||||
assert (
|
||||
store.authenticate_owner_session(
|
||||
session.session_token,
|
||||
challenge_id="challenge-2",
|
||||
audience="https://gewu.example",
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert (
|
||||
store.authenticate_owner_session(
|
||||
session.session_token,
|
||||
challenge_id="challenge-1",
|
||||
audience="https://other.example",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_owner_totp_cannot_be_replayed_and_login_failures_are_rate_limited(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
secret = pyotp.random_base32()
|
||||
store.provision_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=secret,
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
now = int(time.time())
|
||||
issue_challenge(
|
||||
store,
|
||||
principal_id="project-owner",
|
||||
challenge_id="challenge-1",
|
||||
domain="dome-performance",
|
||||
now=now,
|
||||
)
|
||||
code = pyotp.TOTP(secret).at(now)
|
||||
args = {
|
||||
"principal_id": "project-owner",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": code,
|
||||
"challenge_id": "challenge-1",
|
||||
"audience": "https://gewu.example",
|
||||
"now": now,
|
||||
}
|
||||
assert store.authenticate_owner(**args) is not None
|
||||
assert store.authenticate_owner(**args) is None
|
||||
|
||||
for attempt in range(5):
|
||||
challenge_id = f"challenge-bad-{attempt}"
|
||||
issue_challenge(
|
||||
store,
|
||||
principal_id="project-owner",
|
||||
challenge_id=challenge_id,
|
||||
domain="dome-performance",
|
||||
now=now,
|
||||
)
|
||||
assert (
|
||||
store.authenticate_owner(
|
||||
principal_id="project-owner",
|
||||
password="incorrect password",
|
||||
totp_code="000000",
|
||||
challenge_id=challenge_id,
|
||||
audience="https://gewu.example",
|
||||
now=now,
|
||||
)
|
||||
is None
|
||||
)
|
||||
issue_challenge(
|
||||
store,
|
||||
principal_id="project-owner",
|
||||
challenge_id="challenge-final",
|
||||
domain="dome-performance",
|
||||
now=now,
|
||||
)
|
||||
assert (
|
||||
store.authenticate_owner(
|
||||
principal_id="project-owner",
|
||||
password="incorrect password",
|
||||
totp_code="000000",
|
||||
challenge_id="challenge-final",
|
||||
audience="https://gewu.example",
|
||||
now=now,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_owner_operations_stay_disabled_without_encryption_key(tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
with pytest.raises(RuntimeError, match="encryption key is not configured"):
|
||||
store.provision_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=pyotp.random_base32(),
|
||||
project_ids=(),
|
||||
)
|
||||
assert (
|
||||
store.authenticate_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_code="000000",
|
||||
challenge_id="challenge-1",
|
||||
audience="https://gewu.example",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_owner_challenge_retry_is_bound_to_payload_diff_and_granted_domains(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
store = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
store.provision_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=pyotp.random_base32(),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
now = int(time.time())
|
||||
issue_challenge(
|
||||
store,
|
||||
principal_id="project-owner",
|
||||
challenge_id="challenge-1",
|
||||
domain="dome-performance",
|
||||
now=now,
|
||||
)
|
||||
issue_challenge(
|
||||
store,
|
||||
principal_id="project-owner",
|
||||
challenge_id="challenge-1",
|
||||
domain="dome-performance",
|
||||
now=now,
|
||||
)
|
||||
with pytest.raises(ValueError, match="different content"):
|
||||
store.issue_owner_challenge(
|
||||
challenge_id="challenge-1",
|
||||
owner_principal_id="project-owner",
|
||||
proposal_id="proposal-one",
|
||||
proposal_revision=1,
|
||||
payload_digest="c" * 64,
|
||||
diff_digest="b" * 64,
|
||||
domains=("dome-performance",),
|
||||
expires_at=now + 300,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def test_owner_decision_is_exact_diff_bound_and_idempotent(tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
secret = pyotp.random_base32()
|
||||
store.provision_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=secret,
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
now = int(time.time())
|
||||
diff_json = json.dumps({"files": [{"path": "projects/dome/nodes/a.yaml"}]})
|
||||
diff_digest = hashlib.sha256(diff_json.encode()).hexdigest()
|
||||
store.issue_owner_challenge(
|
||||
challenge_id="challenge-bound",
|
||||
owner_principal_id="project-owner",
|
||||
proposal_id="proposal-one",
|
||||
proposal_revision=1,
|
||||
payload_digest="a" * 64,
|
||||
diff_digest=diff_digest,
|
||||
domains=("dome-performance",),
|
||||
expires_at=now + 300,
|
||||
diff_json=diff_json,
|
||||
now=now,
|
||||
)
|
||||
token = store.authenticate_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_code=pyotp.TOTP(secret).at(now),
|
||||
challenge_id="challenge-bound",
|
||||
audience="gewu-test",
|
||||
now=now,
|
||||
)
|
||||
assert token is not None
|
||||
assert (
|
||||
store.authenticate_owner_session(
|
||||
token.session_token, challenge_id="challenge-bound", audience="gewu-test"
|
||||
)
|
||||
is not None
|
||||
)
|
||||
challenge = store.get_owner_challenge("challenge-bound", principal_id="project-owner")
|
||||
assert challenge is not None
|
||||
assert challenge["diff_json"] == diff_json
|
||||
|
||||
arguments = {
|
||||
"challenge_id": "challenge-bound",
|
||||
"principal_id": "project-owner",
|
||||
"proposal_revision": 1,
|
||||
"payload_digest": "a" * 64,
|
||||
"diff_digest": diff_digest,
|
||||
"decision": "authorize",
|
||||
"rationale": "Reviewed exact proposed files and approve the policy exception.",
|
||||
"owner_session_id": hashlib.sha256(token.session_token.encode()).hexdigest(),
|
||||
"audience": "gewu-test",
|
||||
"now": now,
|
||||
}
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
store.decide_owner_challenge(**{**arguments, "diff_digest": "b" * 64})
|
||||
with pytest.raises(PermissionError, match="authority is unavailable"):
|
||||
store.decide_owner_challenge(**{**arguments, "owner_session_id": "0" * 64})
|
||||
first = store.decide_owner_challenge(**arguments)
|
||||
assert first["status"] == "authorized"
|
||||
assert store.decide_owner_challenge(**arguments) == first
|
||||
with pytest.raises(ValueError, match="different decision"):
|
||||
store.decide_owner_challenge(**{**arguments, "decision": "reject"})
|
||||
with pytest.raises(PermissionError, match="every affected domain"):
|
||||
store.issue_owner_challenge(
|
||||
challenge_id="challenge-out-of-scope",
|
||||
owner_principal_id="project-owner",
|
||||
proposal_id="proposal-one",
|
||||
proposal_revision=1,
|
||||
payload_digest="a" * 64,
|
||||
diff_digest="b" * 64,
|
||||
domains=("interactive-video",),
|
||||
expires_at=now + 300,
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
def test_owner_grant_change_and_revocation_invalidate_sessions(tmp_path: Path) -> None:
|
||||
store = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
secret = pyotp.random_base32()
|
||||
store.provision_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=secret,
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
now = int(time.time())
|
||||
issue_challenge(
|
||||
store,
|
||||
principal_id="project-owner",
|
||||
challenge_id="challenge-1",
|
||||
domain="dome-performance",
|
||||
now=now,
|
||||
)
|
||||
first = store.authenticate_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_code=pyotp.TOTP(secret).at(now),
|
||||
challenge_id="challenge-1",
|
||||
audience="https://gewu.example",
|
||||
now=now,
|
||||
)
|
||||
assert first is not None
|
||||
assert store.update_owner_grants("project-owner", ("interactive-video",))
|
||||
assert (
|
||||
store.authenticate_owner_session(
|
||||
first.session_token,
|
||||
challenge_id="challenge-1",
|
||||
audience="https://gewu.example",
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
second_counter = now // 30 + 1
|
||||
second_now = second_counter * 30
|
||||
issue_challenge(
|
||||
store,
|
||||
principal_id="project-owner",
|
||||
challenge_id="challenge-2",
|
||||
domain="interactive-video",
|
||||
now=second_now,
|
||||
)
|
||||
second = store.authenticate_owner(
|
||||
principal_id="project-owner",
|
||||
password="correct horse battery staple",
|
||||
totp_code=pyotp.TOTP(secret).generate_otp(second_counter),
|
||||
challenge_id="challenge-2",
|
||||
audience="https://gewu.example",
|
||||
now=second_now,
|
||||
)
|
||||
assert second is not None
|
||||
assert store.revoke_owner("project-owner")
|
||||
assert (
|
||||
store.authenticate_owner_session(
|
||||
second.session_token,
|
||||
challenge_id="challenge-2",
|
||||
audience="https://gewu.example",
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -0,0 +1,702 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, date, datetime
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||||
from gewu.application.identity import Principal
|
||||
from gewu.application.search import CanonicalSearchService, SearchError
|
||||
from gewu.domain.contracts import (
|
||||
Confidence,
|
||||
EdgeEndpoint,
|
||||
KnowledgeNode,
|
||||
NeighborRequest,
|
||||
NodeType,
|
||||
Scope,
|
||||
SearchRequest,
|
||||
TypedEdge,
|
||||
)
|
||||
from gewu.domain.validation import assertion_digest, evidence_digest
|
||||
from tests.domain.proposal_fixtures import NODE, PROJECTS, SOURCE_REVISION
|
||||
|
||||
|
||||
def _node_path(node: KnowledgeNode) -> str:
|
||||
project_key = hashlib.sha256(node.project_id.encode()).hexdigest()
|
||||
node_key = hashlib.sha256(node.node_id.encode()).hexdigest()
|
||||
return f"projects/{project_key}/nodes/{node_key}.yaml"
|
||||
|
||||
|
||||
def _publish_nodes(
|
||||
repository: GitCanonicalRepository, directory: Path, nodes: tuple[KnowledgeNode, ...]
|
||||
) -> str:
|
||||
return repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
_node_path(node): yaml.safe_dump(
|
||||
node.model_dump(mode="json"), allow_unicode=True
|
||||
).encode()
|
||||
for node in nodes
|
||||
},
|
||||
message="Publish search fixtures",
|
||||
index_directory=directory,
|
||||
)
|
||||
|
||||
|
||||
def _service(
|
||||
repository: GitCanonicalRepository,
|
||||
index_path: Path,
|
||||
*,
|
||||
head: Callable[[], str] | None = None,
|
||||
) -> CanonicalSearchService:
|
||||
return CanonicalSearchService(
|
||||
index=SQLiteSearchIndex(index_path, repository),
|
||||
project_ids_provider=lambda: PROJECTS.keys(),
|
||||
canonical_head=head or repository.head,
|
||||
)
|
||||
|
||||
|
||||
def test_fts_search_enforces_explicit_project_and_shared_scope(tmp_path: Path) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
stale = NODE.model_copy(
|
||||
update={
|
||||
"as_of": datetime(2024, 1, 1, tzinfo=UTC),
|
||||
"stale_after": datetime(2025, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
)
|
||||
video_node = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "video:branching-workflow",
|
||||
"project_id": "interactive-video",
|
||||
"title": "Interactive branching workflow",
|
||||
"summary": "Video authors control branching and state growth.",
|
||||
}
|
||||
)
|
||||
shared_node = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "shared:visitor-benchmark",
|
||||
"project_id": "shared",
|
||||
"title": "Shared visitor benchmark",
|
||||
}
|
||||
)
|
||||
_publish_nodes(repository, tmp_path / "publishing", (stale, video_node, shared_node))
|
||||
service = _service(repository, tmp_path / "search.sqlite3")
|
||||
dome_reader = Principal(
|
||||
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||||
)
|
||||
dome_result = service.search(
|
||||
SearchRequest(
|
||||
query="visitors",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
budget_tokens=5000,
|
||||
),
|
||||
principal=dome_reader,
|
||||
)
|
||||
assert [cast(dict[str, object], row["node"])["node_id"] for row in dome_result.data] == [
|
||||
NODE.node_id
|
||||
]
|
||||
assert any(warning.code == "STALE_NODE" for warning in dome_result.warnings)
|
||||
assert dome_result.retrieval_mode == "keyword"
|
||||
assert dome_result.canonical_revision == dome_result.index_revision
|
||||
assert dome_result.tokens_used > 0
|
||||
bounded = service.search(
|
||||
SearchRequest(
|
||||
query="visitors",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
budget_tokens=1500,
|
||||
),
|
||||
principal=dome_reader,
|
||||
)
|
||||
assert bounded.data == ()
|
||||
assert bounded.citations == ()
|
||||
assert bounded.omissions
|
||||
assert bounded.warnings
|
||||
assert bounded.next_cursor is not None
|
||||
recovered = service.search(
|
||||
SearchRequest(
|
||||
query="visitors",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
cursor=bounded.next_cursor,
|
||||
budget_tokens=5000,
|
||||
),
|
||||
principal=dome_reader,
|
||||
)
|
||||
recovered_node = cast(dict[str, object], recovered.data[0]["node"])
|
||||
assert recovered_node["node_id"] == NODE.node_id
|
||||
with pytest.raises(SearchError, match="too small"):
|
||||
service.search(
|
||||
SearchRequest(
|
||||
query="visitors",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
budget_tokens=256,
|
||||
),
|
||||
principal=dome_reader,
|
||||
)
|
||||
with pytest.raises(SearchError, match="shared knowledge"):
|
||||
service.search(
|
||||
SearchRequest(
|
||||
query="visitors",
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
budget_tokens=5000,
|
||||
),
|
||||
principal=dome_reader,
|
||||
)
|
||||
with pytest.raises(SearchError, match="every requested project"):
|
||||
service.search(
|
||||
SearchRequest(
|
||||
query='visitors" OR *',
|
||||
scope=Scope(
|
||||
projects=("dome-performance", "interactive-video"), include_shared=False
|
||||
),
|
||||
budget_tokens=5000,
|
||||
),
|
||||
principal=dome_reader,
|
||||
)
|
||||
|
||||
|
||||
def test_assumption_and_limitation_claims_are_searchable_and_cited(tmp_path: Path) -> None:
|
||||
original_claim = NODE.claims[0]
|
||||
statement = "The assumptionspecific condition affects seasonal visitor counts."
|
||||
statement_digest = assertion_digest(statement)
|
||||
evidence = original_claim.evidence[0].model_copy(
|
||||
update={
|
||||
"assertion_path": "assumptions[0].text",
|
||||
"assertion_digest": statement_digest,
|
||||
}
|
||||
)
|
||||
assessment = original_claim.assessment.model_copy(
|
||||
update={
|
||||
"assertion_digest": statement_digest,
|
||||
"evidence_digests": (evidence_digest(evidence),),
|
||||
}
|
||||
)
|
||||
context_claim = original_claim.model_copy(
|
||||
update={
|
||||
"claim_id": "dome:assumption-search-test",
|
||||
"text": statement,
|
||||
"evidence": (evidence,),
|
||||
"assessment": assessment,
|
||||
}
|
||||
)
|
||||
limitation_evidence = evidence.model_copy(update={"assertion_path": "limitations[0].text"})
|
||||
limitation_claim = context_claim.model_copy(
|
||||
update={
|
||||
"claim_id": "dome:limitation-search-test",
|
||||
"evidence": (limitation_evidence,),
|
||||
"assessment": assessment.model_copy(
|
||||
update={"evidence_digests": (evidence_digest(limitation_evidence),)}
|
||||
),
|
||||
}
|
||||
)
|
||||
contextual_node = NODE.model_copy(
|
||||
update={"assumptions": (context_claim,), "limitations": (limitation_claim,)}
|
||||
)
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
_publish_nodes(repository, tmp_path / "publishing", (contextual_node,))
|
||||
service = _service(repository, tmp_path / "search.sqlite3")
|
||||
reader = Principal(
|
||||
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||||
)
|
||||
|
||||
result = service.search(
|
||||
SearchRequest(
|
||||
query="assumptionspecific",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
budget_tokens=50_000,
|
||||
),
|
||||
principal=reader,
|
||||
)
|
||||
found = cast(dict[str, object], result.data[0]["node"])
|
||||
assert found["assumptions"][0]["text"] == statement
|
||||
assert found["limitations"][0]["text"] == statement
|
||||
assert any(item.quote == evidence.quote for item in result.citations)
|
||||
|
||||
|
||||
def test_fts_search_indexes_entity_aliases(tmp_path: Path) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
entity = NODE.model_copy(
|
||||
update={
|
||||
"node_type": NodeType.ENTITY,
|
||||
"node_id": "dome:planetarium-entity",
|
||||
"title": "Dome installation",
|
||||
"aliases": ("Planetarium",),
|
||||
}
|
||||
)
|
||||
_publish_nodes(repository, tmp_path / "publishing", (entity,))
|
||||
service = _service(repository, tmp_path / "search.sqlite3")
|
||||
reader = Principal(
|
||||
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||||
)
|
||||
result = service.search(
|
||||
SearchRequest(
|
||||
query="Planetarium",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
budget_tokens=5000,
|
||||
),
|
||||
principal=reader,
|
||||
)
|
||||
assert [cast(dict[str, object], row["node"])["node_id"] for row in result.data] == [
|
||||
entity.node_id
|
||||
]
|
||||
|
||||
|
||||
def test_fts_search_applies_node_type_and_freshness_facets_in_sql(tmp_path: Path) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
stale_problem = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:stale-visitor-problem",
|
||||
"node_type": NodeType.PROBLEM,
|
||||
"title": "Visitor count reporting problem",
|
||||
"as_of": datetime(2023, 1, 1, tzinfo=UTC),
|
||||
"stale_after": datetime(2024, 1, 1, tzinfo=UTC),
|
||||
"value": None,
|
||||
"unit": None,
|
||||
}
|
||||
)
|
||||
fresh_finding = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:fresh-visitor-finding",
|
||||
"node_type": NodeType.FINDING,
|
||||
"title": "Visitor count finding",
|
||||
"as_of": None,
|
||||
"stale_after": None,
|
||||
"value": None,
|
||||
"unit": None,
|
||||
"sample_size": None,
|
||||
"reporting_year": None,
|
||||
"tags": ("demo-only",),
|
||||
"audience": ("sales",),
|
||||
"use_for": ("demo",),
|
||||
"domain": ("video-production",),
|
||||
}
|
||||
)
|
||||
_publish_nodes(repository, tmp_path / "publishing", (stale_problem, fresh_finding))
|
||||
index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository)
|
||||
|
||||
problem_hits = index.search(
|
||||
query="visitor",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
node_types=("problem",),
|
||||
)
|
||||
assert [item.node.node_id for item in problem_hits.nodes] == [stale_problem.node_id]
|
||||
|
||||
browse_hits = index.search(
|
||||
query="",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
node_types=("problem",),
|
||||
)
|
||||
assert [item.node.node_id for item in browse_hits.nodes] == [stale_problem.node_id]
|
||||
|
||||
stale_hits = index.search(
|
||||
query="visitor",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
stale_only=True,
|
||||
)
|
||||
assert [item.node.node_id for item in stale_hits.nodes] == [stale_problem.node_id]
|
||||
|
||||
scoped_facet_hits = index.search(
|
||||
query="visitor",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
tags=("attendance",),
|
||||
audience=("product",),
|
||||
use_for=("comparison",),
|
||||
domain=("production",),
|
||||
)
|
||||
assert [item.node.node_id for item in scoped_facet_hits.nodes] == [stale_problem.node_id]
|
||||
|
||||
or_facet_hits = index.search(
|
||||
query="visitor",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
tags=("attendance", "demo-only"),
|
||||
)
|
||||
assert {item.node.node_id for item in or_facet_hits.nodes} == {
|
||||
stale_problem.node_id,
|
||||
fresh_finding.node_id,
|
||||
}
|
||||
|
||||
confidence_date_hits = index.search(
|
||||
query="visitor",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
confidences=(Confidence.MEDIUM,),
|
||||
as_of_from=date(2023, 1, 1),
|
||||
as_of_to=date(2023, 12, 31),
|
||||
)
|
||||
assert [item.node.node_id for item in confidence_date_hits.nodes] == [stale_problem.node_id]
|
||||
|
||||
mismatched_confidence_hits = index.search(
|
||||
query="visitor",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
confidences=(Confidence.LOW,),
|
||||
as_of_from=date(2023, 1, 1),
|
||||
as_of_to=date(2023, 12, 31),
|
||||
)
|
||||
assert not mismatched_confidence_hits.nodes
|
||||
|
||||
|
||||
def test_typed_browse_uses_scoped_non_overlapping_cursor_pages(tmp_path: Path) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
first = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:problem-a",
|
||||
"node_type": NodeType.PROBLEM,
|
||||
"title": "Problem A",
|
||||
"value": None,
|
||||
"unit": None,
|
||||
}
|
||||
)
|
||||
second = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:problem-b",
|
||||
"node_type": NodeType.PROBLEM,
|
||||
"title": "Problem B",
|
||||
"value": None,
|
||||
"unit": None,
|
||||
}
|
||||
)
|
||||
other_project = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "video:problem-c",
|
||||
"project_id": "interactive-video",
|
||||
"node_type": NodeType.PROBLEM,
|
||||
"title": "Problem C",
|
||||
"value": None,
|
||||
"unit": None,
|
||||
}
|
||||
)
|
||||
_publish_nodes(repository, tmp_path / "publishing", (first, second, other_project))
|
||||
service = _service(repository, tmp_path / "search.sqlite3")
|
||||
reader = Principal(
|
||||
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||||
)
|
||||
request = SearchRequest(
|
||||
query="",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
node_types=(NodeType.PROBLEM,),
|
||||
limit=1,
|
||||
budget_tokens=5000,
|
||||
)
|
||||
|
||||
page_one = service.search(request, principal=reader)
|
||||
page_two = service.search(
|
||||
request.model_copy(update={"cursor": page_one.next_cursor}), principal=reader
|
||||
)
|
||||
|
||||
ids = [
|
||||
cast(dict[str, object], row["node"])["node_id"] for row in (*page_one.data, *page_two.data)
|
||||
]
|
||||
assert ids == [first.node_id, second.node_id]
|
||||
assert page_one.next_cursor is not None
|
||||
assert page_two.next_cursor is None
|
||||
|
||||
|
||||
def test_search_index_rebuilds_from_git_after_index_deletion_and_new_revision(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
_publish_nodes(repository, tmp_path / "publishing", (NODE,))
|
||||
index_path = tmp_path / "search.sqlite3"
|
||||
index = SQLiteSearchIndex(index_path, repository)
|
||||
first = index.search(
|
||||
query="visitors",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
assert [item.node.node_id for item in first.nodes] == [NODE.node_id]
|
||||
|
||||
video_node = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "video:branching-workflow",
|
||||
"project_id": "interactive-video",
|
||||
"title": "Interactive video branches",
|
||||
"summary": "Branching is costly for video authors.",
|
||||
}
|
||||
)
|
||||
new_revision = _publish_nodes(repository, tmp_path / "publishing", (video_node,))
|
||||
second = index.search(
|
||||
query="branches",
|
||||
project_ids=("interactive-video",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
assert second.index_revision == new_revision
|
||||
assert [item.node.node_id for item in second.nodes] == [video_node.node_id]
|
||||
|
||||
rebuilt = SQLiteSearchIndex(tmp_path / "rebuilt-search.sqlite3", repository).search(
|
||||
query="visitors",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
assert rebuilt.index_revision == new_revision
|
||||
assert [item.node.node_id for item in rebuilt.nodes] == [NODE.node_id]
|
||||
|
||||
|
||||
def test_os_termination_inside_index_rebuild_rolls_back_before_retrying(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
nodes = tuple(
|
||||
NODE.model_copy(
|
||||
update={
|
||||
"node_id": f"dome:rebuild-{index:02d}",
|
||||
"title": f"Visitor evidence {index:02d}",
|
||||
"summary": f"The installation hosted visitors in fixture record {index:02d}.",
|
||||
}
|
||||
)
|
||||
for index in range(8)
|
||||
)
|
||||
first_revision = _publish_nodes(repository, tmp_path / "publishing", nodes)
|
||||
index_path = tmp_path / "search.sqlite3"
|
||||
index = SQLiteSearchIndex(index_path, repository)
|
||||
initial = index.search(
|
||||
query="visitors",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=20,
|
||||
)
|
||||
assert len(initial.nodes) == len(nodes)
|
||||
|
||||
added_nodes = tuple(
|
||||
NODE.model_copy(
|
||||
update={
|
||||
"node_id": f"dome:rebuild-{index:02d}",
|
||||
"title": f"Visitor evidence {index:02d}",
|
||||
"summary": f"The installation hosted visitors in fixture record {index:02d}.",
|
||||
}
|
||||
)
|
||||
for index in range(8, 12)
|
||||
)
|
||||
all_nodes = (*nodes, *added_nodes)
|
||||
latest_revision = _publish_nodes(repository, tmp_path / "publishing", added_nodes)
|
||||
|
||||
crashed = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"tests.adapters.crash_sqlite_rebuild_worker",
|
||||
str(repository.path),
|
||||
str(index_path),
|
||||
],
|
||||
cwd=Path(__file__).parents[2],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=60,
|
||||
)
|
||||
assert crashed.returncode == 74, crashed.stderr.decode("utf-8", errors="replace")
|
||||
|
||||
with sqlite3.connect(index_path) as connection:
|
||||
assert connection.execute("SELECT count(*) FROM indexed_nodes").fetchone() == (len(nodes),)
|
||||
assert connection.execute("SELECT count(*) FROM node_fts").fetchone() == (len(nodes),)
|
||||
prior_revision = connection.execute("SELECT revision FROM index_state").fetchone()
|
||||
assert prior_revision == (first_revision,)
|
||||
|
||||
recovered = SQLiteSearchIndex(index_path, repository)
|
||||
result = recovered.search(
|
||||
query="visitors",
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=20,
|
||||
)
|
||||
assert {hit.node.node_id for hit in result.nodes} == {node.node_id for node in all_nodes}
|
||||
assert result.index_revision == latest_revision
|
||||
assert recovered.diagnostic_status()[0] == latest_revision
|
||||
|
||||
|
||||
def test_search_discloses_canonical_revision_advancing_during_the_query(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
_publish_nodes(repository, tmp_path / "publishing", (NODE,))
|
||||
video_node = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "video:branching-workflow",
|
||||
"project_id": "interactive-video",
|
||||
"title": "Interactive video branching",
|
||||
"summary": "Authors control video branches.",
|
||||
}
|
||||
)
|
||||
advanced = False
|
||||
|
||||
def canonical_head_after_index() -> str:
|
||||
nonlocal advanced
|
||||
if not advanced:
|
||||
advanced = True
|
||||
_publish_nodes(repository, tmp_path / "publishing", (video_node,))
|
||||
return repository.head()
|
||||
|
||||
service = _service(
|
||||
repository,
|
||||
tmp_path / "search.sqlite3",
|
||||
head=canonical_head_after_index,
|
||||
)
|
||||
principal = Principal(
|
||||
"reader-dome", "reader-key", frozenset({"viewer"}), frozenset({"dome-performance"})
|
||||
)
|
||||
response = service.search(
|
||||
SearchRequest(
|
||||
query="visitors",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
budget_tokens=5000,
|
||||
),
|
||||
principal=principal,
|
||||
)
|
||||
assert response.index_revision != response.canonical_revision
|
||||
assert any(item.code == "INDEX_LAG" for item in response.warnings)
|
||||
|
||||
|
||||
def test_search_cursor_expires_when_the_index_revision_changes(tmp_path: Path) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
second = NODE.model_copy(update={"node_id": "z:season-visitors"})
|
||||
_publish_nodes(repository, tmp_path / "publishing", (NODE, second))
|
||||
service = _service(repository, tmp_path / "search.sqlite3")
|
||||
reader = Principal("reader", "key", frozenset({"viewer"}), frozenset({NODE.project_id}))
|
||||
request = SearchRequest(
|
||||
query="visitors",
|
||||
scope=Scope(projects=(NODE.project_id,), include_shared=False),
|
||||
limit=1,
|
||||
budget_tokens=5000,
|
||||
)
|
||||
first_page = service.search(request, principal=reader)
|
||||
assert first_page.next_cursor is not None
|
||||
|
||||
third = NODE.model_copy(update={"node_id": "zz:season-visitors"})
|
||||
_publish_nodes(repository, tmp_path / "publishing", (third,))
|
||||
with pytest.raises(SearchError, match="cursor expired") as error:
|
||||
service.search(
|
||||
request.model_copy(update={"cursor": first_page.next_cursor}), principal=reader
|
||||
)
|
||||
assert error.value.status_code == 409
|
||||
|
||||
|
||||
def test_neighbors_are_rebuilt_and_hide_cross_project_nodes(tmp_path: Path) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
other = NODE.model_copy(update={"node_id": "video:other", "project_id": "interactive-video"})
|
||||
edge = TypedEdge(
|
||||
edge_id="edge:cross-project",
|
||||
edge_type="compared_with",
|
||||
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||||
target=EdgeEndpoint(kind="node", id=other.node_id),
|
||||
)
|
||||
files = {
|
||||
_node_path(node): yaml.safe_dump(node.model_dump(mode="json")).encode()
|
||||
for node in (NODE, other)
|
||||
}
|
||||
files[f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml"] = yaml.safe_dump(
|
||||
edge.model_dump(mode="json")
|
||||
).encode()
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files=files,
|
||||
message="Publish typed relationship",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository)
|
||||
hits = index.neighbors(
|
||||
node_id=NODE.node_id,
|
||||
edge_types=(),
|
||||
project_ids=(NODE.project_id, other.project_id),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
assert hits.origin is not None and hits.origin.node_id == NODE.node_id
|
||||
assert len(hits.edges) == 1
|
||||
assert hits.edges[0].neighbor_node is not None
|
||||
|
||||
service = _service(repository, tmp_path / "search2.sqlite3")
|
||||
reader = Principal("reader", "key", frozenset({"viewer"}), frozenset({NODE.project_id}))
|
||||
response = service.neighbors(
|
||||
NeighborRequest(
|
||||
node_id=NODE.node_id,
|
||||
scope=Scope(projects=(NODE.project_id,), include_shared=False),
|
||||
budget_tokens=5000,
|
||||
),
|
||||
principal=reader,
|
||||
)
|
||||
assert response.data == ()
|
||||
assert response.citations == ()
|
||||
|
||||
|
||||
def test_neighbor_source_metadata_requires_an_ingested_for_scope(tmp_path: Path) -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
edge = TypedEdge(
|
||||
edge_id="edge:zzz-visible-source",
|
||||
edge_type="derived_from",
|
||||
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||||
target=EdgeEndpoint(kind="source", id=SOURCE_REVISION.revision_id),
|
||||
)
|
||||
private_source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"source_id": "video:private",
|
||||
"revision_id": "video:private:r1",
|
||||
"ingested_for": ("interactive-video",),
|
||||
}
|
||||
)
|
||||
private_edge = TypedEdge(
|
||||
edge_id="edge:aaa-private-source",
|
||||
edge_type="derived_from",
|
||||
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||||
target=EdgeEndpoint(kind="source", id=private_source.revision_id),
|
||||
)
|
||||
source_key = hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()
|
||||
revision_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()
|
||||
private_source_key = hashlib.sha256(private_source.source_id.encode()).hexdigest()
|
||||
private_revision_key = hashlib.sha256(private_source.revision_id.encode()).hexdigest()
|
||||
files = {
|
||||
_node_path(NODE): yaml.safe_dump(NODE.model_dump(mode="json")).encode(),
|
||||
f"sources/{source_key}/{revision_key}.yaml": yaml.safe_dump(
|
||||
SOURCE_REVISION.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"sources/{private_source_key}/{private_revision_key}.yaml": yaml.safe_dump(
|
||||
private_source.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||||
edge.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"edges/{hashlib.sha256(private_edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||||
private_edge.model_dump(mode="json")
|
||||
).encode(),
|
||||
}
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files=files,
|
||||
message="Publish source relationship",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
service = _service(repository, tmp_path / "search.sqlite3")
|
||||
reader = Principal("reader", "key", frozenset({"viewer"}), frozenset({NODE.project_id}))
|
||||
response = service.neighbors(
|
||||
NeighborRequest(
|
||||
node_id=NODE.node_id,
|
||||
scope=Scope(projects=(NODE.project_id,), include_shared=False),
|
||||
limit=1,
|
||||
budget_tokens=5000,
|
||||
),
|
||||
principal=reader,
|
||||
)
|
||||
assert len(response.data) == 1
|
||||
assert response.next_cursor is None
|
||||
neighbor = cast(dict[str, object], response.data[0]["neighbor"])
|
||||
assert neighbor["kind"] == "source"
|
||||
assert cast(dict[str, object], neighbor["record"])["revision_id"] == SOURCE_REVISION.revision_id
|
||||
@@ -0,0 +1,108 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gewu.adapters.sqlite_vectors import SQLiteVectorIndex, VectorIndexError
|
||||
|
||||
|
||||
def _vectors() -> tuple[tuple[str, str, tuple[float, ...]], ...]:
|
||||
return (
|
||||
("dome-performance", "dome:closest", (1.0, 0.0, 0.0)),
|
||||
("interactive-video", "video:closest", (0.0, 1.0, 0.0)),
|
||||
("shared", "shared:closest", (0.9701425001, 0.242535625, 0.0)),
|
||||
)
|
||||
|
||||
|
||||
def _index(path: Path) -> SQLiteVectorIndex:
|
||||
pytest.importorskip("sqlite_vec")
|
||||
return SQLiteVectorIndex(path)
|
||||
|
||||
|
||||
def _build(index: SQLiteVectorIndex, generation_id: str, vectors=None) -> None:
|
||||
index.build_generation(
|
||||
generation_id=generation_id,
|
||||
revision="a" * 40,
|
||||
model_id="test-model@revision",
|
||||
artifact_sha256="b" * 64,
|
||||
dimensions=3,
|
||||
vectors=_vectors() if vectors is None else vectors,
|
||||
)
|
||||
|
||||
|
||||
def test_vector_generation_is_scoped_and_switches_atomically(tmp_path: Path) -> None:
|
||||
index = _index(tmp_path / "vectors.sqlite3")
|
||||
_build(index, "generation-1")
|
||||
|
||||
assert index.activate("generation-1", expected_active=None) is None
|
||||
active = index.active_generation()
|
||||
assert active is not None
|
||||
assert active.generation_id == "generation-1"
|
||||
assert active.revision == "a" * 40
|
||||
assert active.model_id == "test-model@revision"
|
||||
assert active.artifact_sha256 == "b" * 64
|
||||
assert active.dimensions == 3
|
||||
assert active.node_count == 3
|
||||
scoped = index.search(
|
||||
query=(1.0, 0.0, 0.0),
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
assert [hit.node_id for hit in scoped] == ["dome:closest"]
|
||||
with_shared = index.search(
|
||||
query=(1.0, 0.0, 0.0),
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=True,
|
||||
limit=10,
|
||||
)
|
||||
assert {hit.node_id for hit in with_shared} == {"dome:closest", "shared:closest"}
|
||||
|
||||
_build(
|
||||
index, "generation-2", vectors=(("dome-performance", "dome:replacement", (1.0, 0.0, 0.0)),)
|
||||
)
|
||||
with pytest.raises(VectorIndexError, match="changed"):
|
||||
index.activate("generation-2", expected_active=None)
|
||||
active = index.active_generation()
|
||||
assert active is not None and active.generation_id == "generation-1"
|
||||
assert index.activate("generation-2", expected_active="generation-1") == "generation-1"
|
||||
assert [
|
||||
hit.node_id
|
||||
for hit in index.search(
|
||||
query=(1.0, 0.0, 0.0),
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
] == ["dome:replacement"]
|
||||
|
||||
assert index.activate("generation-1", expected_active="generation-2") == "generation-2"
|
||||
assert [
|
||||
hit.node_id
|
||||
for hit in index.search(
|
||||
query=(1.0, 0.0, 0.0),
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
] == ["dome:closest"]
|
||||
with pytest.raises(VectorIndexError, match="Active vector generation"):
|
||||
index.delete_generation("generation-1")
|
||||
index.delete_generation("generation-2")
|
||||
_build(index, "generation-2")
|
||||
|
||||
|
||||
def test_vector_generation_rejects_invalid_vectors_before_publish(tmp_path: Path) -> None:
|
||||
index = _index(tmp_path / "vectors.sqlite3")
|
||||
with pytest.raises(VectorIndexError, match="normalized"):
|
||||
_build(index, "bad", vectors=(("dome-performance", "dome:bad", (2.0, 0.0, 0.0)),))
|
||||
|
||||
assert index.active_generation() is None
|
||||
assert (
|
||||
index.search(
|
||||
query=(1.0, 0.0, 0.0),
|
||||
project_ids=("dome-performance",),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
== ()
|
||||
)
|
||||
@@ -0,0 +1,117 @@
|
||||
import pytest
|
||||
|
||||
from gewu.adapters.text_extractor import TextExtractionError, TextMarkdownExtractor
|
||||
from gewu.domain.validation import extraction_digest
|
||||
|
||||
|
||||
def test_markdown_extraction_preserves_unicode_and_original_location_maps() -> None:
|
||||
source = "# Evidence\r\nCafe\u0301 has 400 visitors.\r\n"
|
||||
extraction = TextMarkdownExtractor().extract(
|
||||
"shared:source:r1", source.encode("utf-8"), "text/markdown"
|
||||
)
|
||||
assert extraction.extraction_id == extraction_digest(extraction)
|
||||
block = extraction.blocks[0]
|
||||
assert "Café has 400 visitors." in block.normalized_text
|
||||
normalized_start = block.normalized_text.index("Café")
|
||||
normalized_end = normalized_start + len("Café")
|
||||
original_spans = block.normalization_map[normalized_start:normalized_end]
|
||||
original_quote = block.original_text[
|
||||
min(span[0] for span in original_spans) : max(span[1] for span in original_spans)
|
||||
]
|
||||
assert original_quote == "Cafe\u0301"
|
||||
|
||||
|
||||
def test_html_extraction_keeps_visible_text_and_excludes_active_content() -> None:
|
||||
source = (
|
||||
"<html><head><title>Fixture title</title>"
|
||||
"<style>.hide { display:none }</style></head><body>"
|
||||
"<h1>Evidence</h1><p>Café has 400 visitors.</p>"
|
||||
"<script>ignoreThisSecret()</script><noscript>fallback text</noscript>"
|
||||
"<p>Second paragraph.</p></body></html>"
|
||||
)
|
||||
extraction = TextMarkdownExtractor().extract(
|
||||
"shared:html:r1", source.encode("utf-8"), "text/html"
|
||||
)
|
||||
assert extraction.parser_version == "0.3.0"
|
||||
text = "\n".join(block.original_text for block in extraction.blocks)
|
||||
assert "Evidence" in text
|
||||
assert "Café has 400 visitors." in "\n".join(
|
||||
block.normalized_text for block in extraction.blocks
|
||||
)
|
||||
assert "ignoreThisSecret" not in text
|
||||
assert "fallback text" not in text
|
||||
assert "display:none" not in text
|
||||
block = extraction.blocks[0]
|
||||
normalized_start = block.normalized_text.index("Café")
|
||||
original_spans = block.normalization_map[normalized_start : normalized_start + 4]
|
||||
original_quote = block.original_text[
|
||||
min(span[0] for span in original_spans) : max(span[1] for span in original_spans)
|
||||
]
|
||||
assert original_quote == "Cafe\u0301"
|
||||
|
||||
|
||||
def test_extraction_splits_bounded_blocks_without_losing_text() -> None:
|
||||
source = ("a" * 80_000 + "\n" + "b" * 200).encode("utf-8")
|
||||
extraction = TextMarkdownExtractor().extract("shared:large:r1", source, "text/plain")
|
||||
assert len(extraction.blocks) == 2
|
||||
assert "".join(block.original_text for block in extraction.blocks) == source.decode("utf-8")
|
||||
assert all(len(block.normalized_text) <= 80_000 for block in extraction.blocks)
|
||||
|
||||
|
||||
def test_webvtt_extraction_records_exact_cue_timestamps_and_text_offsets() -> None:
|
||||
source = (
|
||||
"WEBVTT\n\n"
|
||||
"cue-1\n00:14:32.000 --> 00:14:36.500 align:start\n"
|
||||
"The audience entered the room.\n\n"
|
||||
"cue-2\n00:14:40.000 --> 00:14:42.000\n"
|
||||
"The host introduced the film.\n"
|
||||
)
|
||||
extraction = TextMarkdownExtractor().extract(
|
||||
"shared:talk-transcript:r1", source.encode(), "text/vtt"
|
||||
)
|
||||
cues = [block for block in extraction.blocks if block.time_start_ms is not None]
|
||||
assert [(cue.time_start_ms, cue.time_end_ms) for cue in cues] == [
|
||||
(872_000, 876_500),
|
||||
(880_000, 882_000),
|
||||
]
|
||||
quote = "The audience entered the room."
|
||||
assert quote in cues[0].normalized_text
|
||||
start = cues[0].normalized_text.index(quote)
|
||||
location = cues[0].normalization_map[start : start + len(quote)]
|
||||
original = cues[0].original_text[
|
||||
min(span[0] for span in location) : max(span[1] for span in location)
|
||||
]
|
||||
assert original == quote
|
||||
|
||||
|
||||
def test_subrip_transcript_extracts_comma_millisecond_timestamps() -> None:
|
||||
extraction = TextMarkdownExtractor().extract(
|
||||
"shared:interview:r1",
|
||||
b"1\n00:01:02,250 --> 00:01:03,750\nA precise quote.\n",
|
||||
"application/x-subrip",
|
||||
)
|
||||
cue = next(block for block in extraction.blocks if block.time_start_ms is not None)
|
||||
assert (cue.time_start_ms, cue.time_end_ms) == (62_250, 63_750)
|
||||
assert "A precise quote." in cue.normalized_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
b"WEBVTT\n\n00:00:05.000 --> 00:00:02.000\nReversed time.\n",
|
||||
b"WEBVTT\n\n00:00:01.000 --> invalid\nMalformed time.\n",
|
||||
b"WEBVTT\n\nNo timestamped cue.\n",
|
||||
],
|
||||
)
|
||||
def test_transcript_extraction_rejects_invalid_or_missing_timestamps(content: bytes) -> None:
|
||||
with pytest.raises(TextExtractionError):
|
||||
TextMarkdownExtractor().extract("shared:bad-transcript:r1", content, "text/vtt")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "media_type"),
|
||||
[(b"\xff", "text/plain"), (b"payload", "application/pdf"), (b"", "text/plain")],
|
||||
)
|
||||
def test_extractor_rejects_invalid_or_unsupported_sources(content: bytes, media_type: str) -> None:
|
||||
with pytest.raises(TextExtractionError):
|
||||
TextMarkdownExtractor().extract("shared:source:r1", content, media_type)
|
||||
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import time
|
||||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import gewu.adapters.url_fetch as url_fetch
|
||||
from gewu.adapters.url_fetch import (
|
||||
MAX_CAPTURE_BYTES,
|
||||
PublicUrlFetcher,
|
||||
UrlCaptureError,
|
||||
)
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status: int, headers: dict[str, str], body: bytes = b"") -> None:
|
||||
self.status = status
|
||||
self.headers = headers
|
||||
self.body = body
|
||||
self.read_calls = 0
|
||||
|
||||
def getheader(self, name: str) -> str | None:
|
||||
return self.headers.get(name)
|
||||
|
||||
def read(self, amount: int) -> bytes:
|
||||
self.read_calls += 1
|
||||
block, self.body = self.body[:amount], self.body[amount:]
|
||||
return block
|
||||
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self, response: FakeResponse) -> None:
|
||||
self.response = response
|
||||
self.request_data: tuple[str, str, dict[str, str]] | None = None
|
||||
self.closed = False
|
||||
|
||||
def request(self, method: str, path: str, headers: dict[str, str]) -> None:
|
||||
self.request_data = (method, path, headers)
|
||||
|
||||
def getresponse(self) -> FakeResponse:
|
||||
return self.response
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def public_resolver(host: str, port: int, **_kwargs: Any) -> list[tuple[Any, ...]]:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port))]
|
||||
|
||||
|
||||
def test_capture_requires_exact_host_allowlist_and_pins_validated_dns_address() -> None:
|
||||
response = FakeResponse(
|
||||
200,
|
||||
{"Content-Type": "text/plain; charset=utf-8", "X-Source-Title": "Fixture title"},
|
||||
b"fixture source",
|
||||
)
|
||||
connections: list[FakeConnection] = []
|
||||
pins: list[tuple[str, str, int, str, int]] = []
|
||||
|
||||
def factory(scheme: str, host: str, port: int, address: str, timeout: int) -> FakeConnection:
|
||||
pins.append((scheme, host, port, address, timeout))
|
||||
connection = FakeConnection(response)
|
||||
connections.append(connection)
|
||||
return connection
|
||||
|
||||
fetcher = PublicUrlFetcher(
|
||||
{"research.example"}, resolver=public_resolver, connection_factory=factory
|
||||
)
|
||||
captured = fetcher.fetch("https://research.example/reference?q=1#ignored")
|
||||
|
||||
assert captured.original_url == "https://research.example/reference?q=1#ignored"
|
||||
assert captured.final_url == "https://research.example/reference?q=1#ignored"
|
||||
assert captured.media_type == "text/plain"
|
||||
assert captured.title == "Fixture title"
|
||||
assert captured.content == b"fixture source"
|
||||
assert pins[0][1:4] == ("research.example", 443, "93.184.216.34")
|
||||
assert connections[0].request_data == (
|
||||
"GET",
|
||||
"/reference?q=1",
|
||||
{
|
||||
"Host": "research.example",
|
||||
"User-Agent": "GEWU-source-capture/1.0",
|
||||
"Accept": "application/pdf, text/html, text/plain, text/markdown",
|
||||
"Accept-Encoding": "identity",
|
||||
"Connection": "close",
|
||||
},
|
||||
)
|
||||
assert connections[0].closed
|
||||
|
||||
|
||||
def test_private_dns_result_is_rejected_before_opening_a_connection() -> None:
|
||||
factory_called = False
|
||||
|
||||
def resolver(host: str, port: int, **_kwargs: Any) -> list[tuple[Any, ...]]:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", port))]
|
||||
|
||||
def factory(*_args: Any) -> FakeConnection:
|
||||
nonlocal factory_called
|
||||
factory_called = True
|
||||
return FakeConnection(FakeResponse(200, {"Content-Type": "text/plain"}))
|
||||
|
||||
fetcher = PublicUrlFetcher({"research.example"}, resolver=resolver, connection_factory=factory)
|
||||
with pytest.raises(UrlCaptureError) as error:
|
||||
fetcher.fetch("http://research.example/source")
|
||||
assert error.value.code == "FETCH_BLOCKED"
|
||||
assert not factory_called
|
||||
|
||||
|
||||
def test_dns_resolution_has_a_bounded_wait(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def slow_resolver(host: str, port: int, **_kwargs: Any) -> list[tuple[Any, ...]]:
|
||||
time.sleep(0.03)
|
||||
return public_resolver(host, port)
|
||||
|
||||
monkeypatch.setattr(url_fetch, "DNS_TIMEOUT_SECONDS", 0.001)
|
||||
fetcher = PublicUrlFetcher({"research.example"}, resolver=slow_resolver)
|
||||
with pytest.raises(UrlCaptureError) as error:
|
||||
fetcher.fetch("https://research.example/source")
|
||||
assert error.value.code == "FETCH_TIMEOUT"
|
||||
|
||||
|
||||
def test_redirect_is_revalidated_against_allowlist_before_next_connection() -> None:
|
||||
first = FakeResponse(302, {"Location": "http://127.0.0.1/metadata"})
|
||||
connection_count = 0
|
||||
|
||||
def factory(*_args: Any) -> FakeConnection:
|
||||
nonlocal connection_count
|
||||
connection_count += 1
|
||||
return FakeConnection(first)
|
||||
|
||||
fetcher = PublicUrlFetcher(
|
||||
{"research.example"}, resolver=public_resolver, connection_factory=factory
|
||||
)
|
||||
with pytest.raises(UrlCaptureError) as error:
|
||||
fetcher.fetch("http://research.example/start")
|
||||
assert error.value.code == "FETCH_BLOCKED"
|
||||
assert connection_count == 1
|
||||
|
||||
|
||||
def test_capture_rejects_unsupported_types_encoding_and_oversize() -> None:
|
||||
responses = deque(
|
||||
[
|
||||
FakeResponse(200, {"Content-Type": "application/zip"}),
|
||||
FakeResponse(
|
||||
200,
|
||||
{"Content-Type": "text/plain", "Content-Encoding": "gzip"},
|
||||
),
|
||||
FakeResponse(
|
||||
200,
|
||||
{
|
||||
"Content-Type": "text/plain",
|
||||
"Content-Length": str(MAX_CAPTURE_BYTES + 1),
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def factory(*_args: Any) -> FakeConnection:
|
||||
return FakeConnection(responses.popleft())
|
||||
|
||||
fetcher = PublicUrlFetcher(
|
||||
{"research.example"}, resolver=public_resolver, connection_factory=factory
|
||||
)
|
||||
for expected_code in (
|
||||
"UNSUPPORTED_MEDIA_TYPE",
|
||||
"UNSUPPORTED_ENCODING",
|
||||
"CAPTURE_TOO_LARGE",
|
||||
):
|
||||
with pytest.raises(UrlCaptureError) as error:
|
||||
fetcher.fetch("http://research.example/source")
|
||||
assert error.value.code == expected_code
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
"file:///etc/passwd",
|
||||
"http://user:password@research.example/source",
|
||||
"http://research.example:8080/source",
|
||||
"http://research.example/line\nInjected: header",
|
||||
],
|
||||
)
|
||||
def test_capture_rejects_unsafe_url_forms(url: str) -> None:
|
||||
fetcher = PublicUrlFetcher({"research.example"}, resolver=public_resolver)
|
||||
with pytest.raises(UrlCaptureError) as error:
|
||||
fetcher.fetch(url)
|
||||
assert error.value.code == "URL_INVALID"
|
||||
@@ -0,0 +1 @@
|
||||
"""Transport boundary tests."""
|
||||
@@ -0,0 +1,264 @@
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.bootstrap.app import create_app
|
||||
|
||||
PASSWORD = "initial-password-fixture"
|
||||
|
||||
|
||||
def test_password_accounts_owned_agent_keys_and_session_lifecycle(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
operator = store.provision(
|
||||
credential_id="operator", principal_id="operator", roles=("admin",)
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=store,
|
||||
account_identity=store,
|
||||
credential_administration=store,
|
||||
node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")),
|
||||
)
|
||||
transport = httpx2.ASGITransport(app=app)
|
||||
operator_headers = {"Authorization": f"Bearer {operator}"}
|
||||
async with httpx2.AsyncClient(transport=transport, base_url="https://gewu.test") as admin:
|
||||
body = {
|
||||
"username": "alice",
|
||||
"password": PASSWORD,
|
||||
"role": "editor",
|
||||
"project_ids": ["pilot"],
|
||||
}
|
||||
assert (await admin.post("/v1/admin/users", json=body)).status_code == 401
|
||||
created = await admin.post("/v1/admin/users", json=body, headers=operator_headers)
|
||||
assert created.status_code == 201 and PASSWORD not in created.text
|
||||
assert (
|
||||
await admin.post("/v1/admin/users", json=body, headers=operator_headers)
|
||||
).status_code == 409
|
||||
for username, role in (("bob", "editor"), ("viewer", "viewer"), ("admin", "admin")):
|
||||
assert (
|
||||
await admin.post(
|
||||
"/v1/admin/users",
|
||||
headers=operator_headers,
|
||||
json={**body, "username": username, "role": role},
|
||||
)
|
||||
).status_code == 201
|
||||
short_secret = "shortsecret"
|
||||
invalid = await admin.post(
|
||||
"/v1/admin/users",
|
||||
headers=operator_headers,
|
||||
json={**body, "username": "invalid", "password": short_secret},
|
||||
)
|
||||
assert invalid.status_code == 422 and short_secret not in invalid.text
|
||||
listing = await admin.get("/v1/admin/users", headers=operator_headers)
|
||||
assert "password_hash" not in listing.text and PASSWORD not in listing.text
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport, base_url="https://gewu.test"
|
||||
) as alice:
|
||||
login = await alice.post(
|
||||
"/v1/users/session",
|
||||
json={"username": "alice", "password": PASSWORD},
|
||||
headers={"Origin": "https://gewu.test"},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
assert "secure" in login.headers["set-cookie"].lower()
|
||||
csrf = {"X-GEWU-CSRF": login.json()["csrf_token"]}
|
||||
assert (await alice.get("/v1/me")).json()["principal_id"] == "user:alice"
|
||||
assert (await alice.get("/v1/admin/users")).status_code == 403
|
||||
assert (
|
||||
await alice.post("/v1/admin/users", headers=csrf, json=body)
|
||||
).status_code == 403
|
||||
agent_body = {"name": "codex-laptop", "project_ids": ["pilot"]}
|
||||
assert (
|
||||
await alice.post("/v1/me/agent-credentials", json=agent_body)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await alice.post(
|
||||
"/v1/me/agent-credentials",
|
||||
headers={**csrf, "Origin": "https://attacker"},
|
||||
json=agent_body,
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await alice.post(
|
||||
"/v1/me/agent-credentials",
|
||||
headers=csrf,
|
||||
json={**agent_body, "project_ids": ["other"]},
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await alice.post(
|
||||
"/v1/me/agent-credentials",
|
||||
headers=csrf,
|
||||
json={**agent_body, "role": "admin"},
|
||||
)
|
||||
).status_code == 422
|
||||
agent = await alice.post("/v1/me/agent-credentials", headers=csrf, json=agent_body)
|
||||
assert agent.status_code == 201
|
||||
key_id, token = agent.json()["credential_id"], agent.json()["token"]
|
||||
agent_principal = store.authenticate(token)
|
||||
assert agent_principal and agent_principal.can_submit_project("pilot")
|
||||
assert not agent_principal.can_submit_project("other")
|
||||
assert (await alice.get("/v1/me/agent-credentials")).json()[0][
|
||||
"credential_id"
|
||||
] == key_id
|
||||
assert token not in (await alice.get("/v1/me/agent-credentials")).text
|
||||
assert (
|
||||
await alice.get("/v1/me", headers={"Authorization": f"Bearer {token}"})
|
||||
).status_code == 403
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport, base_url="https://gewu.test"
|
||||
) as bob:
|
||||
bob_login = await bob.post(
|
||||
"/v1/users/session", json={"username": "bob", "password": PASSWORD}
|
||||
)
|
||||
bob_csrf = {"X-GEWU-CSRF": bob_login.json()["csrf_token"]}
|
||||
assert (await bob.get("/v1/me/agent-credentials")).json() == []
|
||||
assert (
|
||||
await bob.delete(f"/v1/me/agent-credentials/{key_id}", headers=bob_csrf)
|
||||
).status_code == 404
|
||||
assert store.authenticate(token) is not None
|
||||
bob_agent = await bob.post(
|
||||
"/v1/me/agent-credentials", headers=bob_csrf, json=agent_body
|
||||
)
|
||||
rotated = store.rotate(bob_agent.json()["credential_id"], "rotated-bob-key")
|
||||
assert (await bob.get("/v1/me/agent-credentials")).json()[-1][
|
||||
"owner_principal_id"
|
||||
] == "user:bob"
|
||||
assert (
|
||||
await admin.delete(
|
||||
"/v1/admin/credentials/account:bob", headers=operator_headers
|
||||
)
|
||||
).status_code == 200
|
||||
assert store.authenticate(rotated) is None
|
||||
assert (await bob.get("/v1/me")).status_code == 401
|
||||
|
||||
password_body = {
|
||||
"current_password": PASSWORD,
|
||||
"new_password": "changed-password-fixture",
|
||||
}
|
||||
assert (await alice.post("/v1/me/password", json=password_body)).status_code == 403
|
||||
assert (
|
||||
await alice.post(
|
||||
"/v1/me/password",
|
||||
headers=csrf,
|
||||
json={**password_body, "current_password": "wrong"},
|
||||
)
|
||||
).status_code == 403
|
||||
second_session = store.login_account("alice", PASSWORD)
|
||||
assert second_session
|
||||
changed = await alice.post("/v1/me/password", headers=csrf, json=password_body)
|
||||
assert changed.status_code == 200
|
||||
assert store.authenticate_browser_session(second_session.session_token) is None
|
||||
assert (await alice.get("/v1/me")).status_code == 401
|
||||
assert store.login_account("alice", PASSWORD) is None
|
||||
assert store.authenticate(token) is not None
|
||||
relogin = await alice.post(
|
||||
"/v1/users/session",
|
||||
json={"username": "alice", "password": password_body["new_password"]},
|
||||
)
|
||||
csrf = {"X-GEWU-CSRF": relogin.json()["csrf_token"]}
|
||||
assert (
|
||||
await alice.delete(f"/v1/me/agent-credentials/{key_id}", headers=csrf)
|
||||
).status_code == 200
|
||||
assert store.authenticate(token) is None
|
||||
another = await alice.post(
|
||||
"/v1/me/agent-credentials", headers=csrf, json=agent_body
|
||||
)
|
||||
assert (
|
||||
await admin.delete("/v1/admin/users/alice", headers=operator_headers)
|
||||
).status_code == 200
|
||||
assert store.authenticate(another.json()["token"]) is None
|
||||
assert (await alice.get("/v1/me")).status_code == 401
|
||||
assert store.login_account("alice", password_body["new_password"]) is None
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport, base_url="https://gewu.test"
|
||||
) as viewer:
|
||||
login = await viewer.post(
|
||||
"/v1/users/session", json={"username": "viewer", "password": PASSWORD}
|
||||
)
|
||||
csrf = {"X-GEWU-CSRF": login.json()["csrf_token"]}
|
||||
assert (
|
||||
await viewer.post(
|
||||
"/v1/me/agent-credentials",
|
||||
headers=csrf,
|
||||
json={"name": "agent", "project_ids": ["pilot"]},
|
||||
)
|
||||
).status_code == 403
|
||||
async with httpx2.AsyncClient(
|
||||
transport=transport, base_url="https://gewu.test"
|
||||
) as human_admin:
|
||||
login = await human_admin.post(
|
||||
"/v1/users/session", json={"username": "admin", "password": PASSWORD}
|
||||
)
|
||||
csrf = {"X-GEWU-CSRF": login.json()["csrf_token"]}
|
||||
assert (
|
||||
await human_admin.delete("/v1/admin/users/admin", headers=csrf)
|
||||
).status_code == 403
|
||||
with sqlite3.connect(store.database) as db:
|
||||
assert (
|
||||
db.execute("SELECT password_hash FROM user_accounts WHERE username='bob'")
|
||||
.fetchone()[0]
|
||||
.startswith("$argon2id$")
|
||||
)
|
||||
assert (
|
||||
PASSWORD not in (await admin.get("/v1/admin/events", headers=operator_headers)).text
|
||||
)
|
||||
assert len(SQLiteIdentityStore(store.database).list_accounts(limit=50, offset=0)) == 4
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_password_login_origin_tls_size_and_throttle(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
store.create_account(
|
||||
username="alice",
|
||||
password=PASSWORD,
|
||||
role="editor",
|
||||
project_ids=("pilot",),
|
||||
actor="operator",
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=store,
|
||||
account_identity=store,
|
||||
node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://gewu.test"
|
||||
) as http:
|
||||
body = {"username": "alice", "password": PASSWORD}
|
||||
assert (await http.post("/v1/users/session", json=body)).status_code == 403
|
||||
assert (
|
||||
await http.post(
|
||||
"https://gewu.test/v1/users/session",
|
||||
json=body,
|
||||
headers={"Origin": "https://attacker.invalid"},
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await http.post("https://gewu.test/v1/users/session", content=b" " * 4097)
|
||||
).status_code == 413
|
||||
for _ in range(5):
|
||||
assert (
|
||||
await http.post(
|
||||
"https://gewu.test/v1/users/session", json={**body, "password": "wrong"}
|
||||
)
|
||||
).status_code == 401
|
||||
assert (
|
||||
await http.post("https://gewu.test/v1/users/session", json=body)
|
||||
).status_code == 401
|
||||
with store._connect() as db:
|
||||
db.execute("UPDATE account_login_failures SET failed_at=0")
|
||||
assert (
|
||||
await http.post("https://gewu.test/v1/users/session", json=body)
|
||||
).status_code == 200
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,31 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.export_agent_contract import PACK, build_contract
|
||||
|
||||
|
||||
def test_pack_mcp_contract_matches_the_live_server_and_generated_schemas(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
snapshot_path = PACK / "contracts" / "service-v1.json"
|
||||
snapshot = json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||||
actual = build_contract(tmp_path)
|
||||
assert actual == snapshot
|
||||
assert (PACK / "contracts" / "catalog-v1.json").read_bytes() == (
|
||||
Path(__file__).resolve().parents[2] / "schema" / "v1" / "catalog.json"
|
||||
).read_bytes()
|
||||
|
||||
packaged_schemas = PACK / "contracts"
|
||||
for schema in snapshot["record_schemas"]:
|
||||
schema_path = packaged_schemas / schema["path"]
|
||||
assert schema_path.is_file()
|
||||
assert (
|
||||
schema_path.read_bytes()
|
||||
== (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "schema"
|
||||
/ "v1"
|
||||
/ "records"
|
||||
/ Path(schema["path"]).name
|
||||
).read_bytes()
|
||||
)
|
||||
@@ -0,0 +1,273 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.text_extractor import TextMarkdownExtractor
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.uploads import LocalArtifactStore
|
||||
from gewu.bootstrap.app import create_app
|
||||
from tests.domain.proposal_fixtures import NODE, SOURCE_REVISION
|
||||
|
||||
|
||||
def test_authenticated_scoped_canonical_node_read(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest()
|
||||
node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest()
|
||||
node_path = f"projects/{project_key}/nodes/{node_key}.yaml"
|
||||
source_path = (
|
||||
f"sources/{hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()}.yaml"
|
||||
)
|
||||
artifact_store = LocalArtifactStore(tmp_path / "artifacts")
|
||||
source_bytes = b"# Archived source\nA precise citation.\n"
|
||||
source_digest = hashlib.sha256(source_bytes).hexdigest()
|
||||
upload_id = artifact_store.begin(
|
||||
filename="source.md",
|
||||
media_type="text/markdown",
|
||||
byte_length=len(source_bytes),
|
||||
expected_sha256=source_digest,
|
||||
owner_id="agent-pilot",
|
||||
project_id=NODE.project_id,
|
||||
).upload_id
|
||||
artifact_store.put_chunk(
|
||||
upload_id,
|
||||
offset=0,
|
||||
content=source_bytes,
|
||||
chunk_sha256=source_digest,
|
||||
owner_id="agent-pilot",
|
||||
)
|
||||
artifact_store.finalize(upload_id, owner_id="agent-pilot")
|
||||
extraction = TextMarkdownExtractor().extract(
|
||||
SOURCE_REVISION.revision_id, source_bytes, "text/markdown"
|
||||
)
|
||||
artifact_store.save_extraction(
|
||||
extraction, owner_id="agent-pilot", project_id=NODE.project_id
|
||||
)
|
||||
archived_source = SOURCE_REVISION.model_copy(
|
||||
update={"artifact_sha256": source_digest, "byte_length": len(source_bytes)}
|
||||
)
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={node_path: yaml.safe_dump(NODE.model_dump(mode="json")).encode()},
|
||||
message="Publish test node",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
updated_node = NODE.model_copy(update={"summary": "A revised accepted summary."})
|
||||
restricted_source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"source_id": "video:private",
|
||||
"revision_id": "video:private:r1",
|
||||
"ingested_for": ("interactive-video",),
|
||||
}
|
||||
)
|
||||
restricted_source_path = (
|
||||
f"sources/{hashlib.sha256(restricted_source.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(restricted_source.revision_id.encode()).hexdigest()}.yaml"
|
||||
)
|
||||
reference_source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"source_id": "dome:reference-only",
|
||||
"revision_id": "dome:reference-only:r1",
|
||||
"artifact_sha256": None,
|
||||
"media_type": "application/octet-stream",
|
||||
"byte_length": 0,
|
||||
"original_url": "https://research.example/unavailable-paper",
|
||||
"ingested_for": (NODE.project_id,),
|
||||
"archive_status": "reference-only",
|
||||
}
|
||||
)
|
||||
reference_source_path = (
|
||||
f"sources/{hashlib.sha256(reference_source.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(reference_source.revision_id.encode()).hexdigest()}.yaml"
|
||||
)
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
node_path: yaml.safe_dump(updated_node.model_dump(mode="json")).encode(),
|
||||
source_path: yaml.safe_dump(archived_source.model_dump(mode="json")).encode(),
|
||||
restricted_source_path: yaml.safe_dump(
|
||||
restricted_source.model_dump(mode="json")
|
||||
).encode(),
|
||||
reference_source_path: yaml.safe_dump(
|
||||
reference_source.model_dump(mode="json")
|
||||
).encode(),
|
||||
},
|
||||
message="Revise node and register source",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={"metadata/unrelated.yaml": b"accepted but unrelated\n"},
|
||||
message="Advance repository without changing the node",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = identities.provision(
|
||||
credential_id="agent-key",
|
||||
principal_id="agent-pilot",
|
||||
roles=("agent",),
|
||||
project_ids=(NODE.project_id,),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
artifact_store=artifact_store,
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://gewu.test"
|
||||
) as client:
|
||||
path = f"/v1/projects/{NODE.project_id}/nodes/{NODE.node_id}"
|
||||
assert (await client.get(path)).status_code == 401
|
||||
malformed_id = await client.get(
|
||||
f"/v1/projects/{NODE.project_id}/nodes/not%20a%20record-id",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert malformed_id.status_code == 422
|
||||
denied = await client.get(
|
||||
f"/v1/projects/interactive-video/nodes/{NODE.node_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
accepted = await client.get(path, headers={"Authorization": f"Bearer {token}"})
|
||||
assert accepted.status_code == 200
|
||||
envelope = accepted.json()
|
||||
assert envelope["retrieval_mode"] == "canonical"
|
||||
assert envelope["canonical_revision"] == repository.head()
|
||||
assert envelope["data"][0]["node_id"] == NODE.node_id
|
||||
assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
|
||||
history = await client.get(
|
||||
f"{path}/history", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert history.status_code == 200
|
||||
history_data = history.json()["data"]
|
||||
assert len(history_data) == 2
|
||||
assert history_data[0]["node"]["summary"] == updated_node.summary
|
||||
latest_node_revision = history_data[0]["revision"]
|
||||
assert latest_node_revision != repository.head()
|
||||
latest_file_version = await client.get(
|
||||
f"{path}?revision={latest_node_revision}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert latest_file_version.status_code == 200
|
||||
assert latest_file_version.json()["canonical_revision"] == latest_node_revision
|
||||
assert latest_file_version.json()["data"][0]["summary"] == updated_node.summary
|
||||
historical_revision = history_data[-1]["revision"]
|
||||
historical = await client.get(
|
||||
f"{path}?revision={historical_revision}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert historical.status_code == 200, historical.text
|
||||
assert historical.json()["canonical_revision"] == historical_revision
|
||||
assert historical.json()["data"][0]["summary"] == NODE.summary
|
||||
assert historical.json()["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
|
||||
malformed_revision = await client.get(
|
||||
f"{path}?revision=not-a-commit",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert malformed_revision.status_code == 422
|
||||
unknown_revision = await client.get(
|
||||
f"{path}?revision={'a' * 40}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert unknown_revision.status_code == 404
|
||||
source = await client.get(
|
||||
f"/v1/projects/{NODE.project_id}/sources/{SOURCE_REVISION.revision_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert source.status_code == 200
|
||||
assert source.json()["data"][0]["revision_id"] == SOURCE_REVISION.revision_id
|
||||
reference_path = (
|
||||
f"/v1/projects/{NODE.project_id}/sources/{reference_source.revision_id}"
|
||||
)
|
||||
reference = await client.get(
|
||||
reference_path, headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert reference.status_code == 200
|
||||
assert reference.json()["data"][0]["artifact_sha256"] is None
|
||||
diagnostics_path = "/v1/diagnostics?projects=dome-performance"
|
||||
assert (await client.get("/v1/diagnostics")).status_code == 401
|
||||
diagnostic_response = await client.get(
|
||||
diagnostics_path, headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert diagnostic_response.status_code == 200
|
||||
diagnostic_body = diagnostic_response.json()
|
||||
catalog_path = Path(__file__).resolve().parents[2] / "schema/v1/catalog.json"
|
||||
assert (
|
||||
diagnostic_body["catalog_sha256"]
|
||||
== hashlib.sha256(catalog_path.read_bytes()).hexdigest()
|
||||
)
|
||||
assert diagnostic_body["selected_scope"]["projects"] == [NODE.project_id]
|
||||
assert diagnostic_body["index_state"] == "not_configured"
|
||||
assert diagnostic_body["max_upload_bytes"] == 50 * 1024 * 1024
|
||||
assert "get_source_artifact" in diagnostic_body["authorized_operations"]
|
||||
assert token not in diagnostic_response.text
|
||||
assert "principal_id" not in diagnostic_body
|
||||
forbidden_diagnostic_scope = await client.get(
|
||||
"/v1/diagnostics?projects=interactive-video",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert forbidden_diagnostic_scope.status_code == 403
|
||||
forbidden_shared_scope = await client.get(
|
||||
diagnostics_path + "&include_shared=true",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert forbidden_shared_scope.status_code == 403
|
||||
artifact_path = (
|
||||
f"/v1/projects/{NODE.project_id}/sources/{SOURCE_REVISION.revision_id}/artifact"
|
||||
)
|
||||
assert (await client.get(artifact_path)).status_code == 401
|
||||
archived = await client.get(artifact_path, headers={"Authorization": f"Bearer {token}"})
|
||||
assert archived.status_code == 200
|
||||
assert archived.content == source_bytes
|
||||
assert archived.headers["etag"] == f'"{source_digest}"'
|
||||
assert archived.headers["x-content-type-options"] == "nosniff"
|
||||
assert archived.headers["cache-control"] == "private, no-store"
|
||||
assert archived.headers["content-security-policy"] == "default-src 'none'; sandbox"
|
||||
missing_artifact = await client.get(
|
||||
f"{reference_path}/artifact", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert missing_artifact.status_code == 404
|
||||
block = extraction.blocks[0]
|
||||
offset = block.normalized_text.index("precise")
|
||||
location_path = (
|
||||
f"/v1/projects/{NODE.project_id}/sources/{SOURCE_REVISION.revision_id}"
|
||||
f"/extractions/{extraction.extraction_id}/blocks/{block.block_id}"
|
||||
)
|
||||
location = await client.get(
|
||||
location_path,
|
||||
params={"offset": offset, "length": len("precise")},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert location.status_code == 200
|
||||
assert location.json()["normalized_text"] == "precise"
|
||||
assert location.json()["original_text"] == "precise"
|
||||
denied_location = await client.get(
|
||||
location_path,
|
||||
params={"offset": offset, "length": len("precise")},
|
||||
)
|
||||
assert denied_location.status_code == 401
|
||||
restricted = await client.get(
|
||||
f"/v1/projects/{NODE.project_id}/sources/{restricted_source.revision_id}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert restricted.status_code == 404
|
||||
restricted_artifact = await client.get(
|
||||
f"/v1/projects/{NODE.project_id}/sources/{restricted_source.revision_id}/artifact",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert restricted_artifact.status_code == 404
|
||||
assert identities.revoke("agent-key")
|
||||
assert (
|
||||
await client.get(path, headers={"Authorization": f"Bearer {token}"})
|
||||
).status_code == 401
|
||||
assert (
|
||||
await client.get(artifact_path, headers={"Authorization": f"Bearer {token}"})
|
||||
).status_code == 401
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,189 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.search import CanonicalSearchService
|
||||
from gewu.bootstrap.app import create_app
|
||||
from tests.domain.proposal_fixtures import PROJECTS
|
||||
|
||||
|
||||
def test_browser_session_is_http_only_human_only_and_csrf_protected(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
viewer_key = identities.provision(
|
||||
credential_id="browser-viewer-key",
|
||||
principal_id="browser-viewer",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
agent_key = identities.provision(
|
||||
credential_id="browser-agent-key",
|
||||
principal_id="browser-agent",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
search = CanonicalSearchService(
|
||||
index=SQLiteSearchIndex(tmp_path / "search.sqlite3", repository),
|
||||
project_ids_provider=lambda: PROJECTS.keys(),
|
||||
canonical_head=repository.head,
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
search_service=search,
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://gewu.test",
|
||||
) as client:
|
||||
agent_session = await client.post(
|
||||
"/v1/session",
|
||||
headers={
|
||||
"Authorization": f"Bearer {agent_key}",
|
||||
"Origin": "http://gewu.test",
|
||||
},
|
||||
)
|
||||
assert agent_session.status_code == 403
|
||||
|
||||
cross_origin = await client.post(
|
||||
"/v1/session",
|
||||
headers={
|
||||
"Authorization": f"Bearer {viewer_key}",
|
||||
"Origin": "https://attacker.invalid",
|
||||
},
|
||||
)
|
||||
assert cross_origin.status_code == 403
|
||||
|
||||
created = await client.post(
|
||||
"/v1/session",
|
||||
headers={
|
||||
"Authorization": f"Bearer {viewer_key}",
|
||||
"Origin": "http://gewu.test",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
session = created.json()
|
||||
cookie = created.cookies.get("gewu_session")
|
||||
assert cookie is not None
|
||||
assert "httponly" in created.headers["set-cookie"].lower()
|
||||
assert "samesite=strict" in created.headers["set-cookie"].lower()
|
||||
assert "max-age" not in created.headers["set-cookie"].lower()
|
||||
|
||||
projects = await client.get("/v1/projects")
|
||||
assert projects.status_code == 200
|
||||
assert projects.json() == ["dome-performance"]
|
||||
denied_csrf = await client.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert denied_csrf.status_code == 403
|
||||
|
||||
searched = await client.post(
|
||||
"/v1/search",
|
||||
headers={"X-GEWU-CSRF": session["csrf_token"]},
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert searched.status_code == 200, searched.text
|
||||
|
||||
signed_out = await client.delete(
|
||||
"/v1/session", headers={"X-GEWU-CSRF": session["csrf_token"]}
|
||||
)
|
||||
assert signed_out.status_code == 200
|
||||
assert (await client.get("/v1/projects")).status_code == 401
|
||||
assert identities.authenticate_browser_session(cookie) is None
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_session_restoration_preserves_other_tabs_and_original_expiry(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = identities.provision(
|
||||
credential_id="admin", principal_id="operator", roles=("admin",)
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="https://gewu.test"
|
||||
) as http:
|
||||
headers = {"X-GEWU-Session": "restore", "Origin": "https://gewu.test"}
|
||||
assert (await http.get("/v1/session", headers=headers)).status_code == 401
|
||||
login = await http.post("/v1/session", headers={"Authorization": f"Bearer {token}"})
|
||||
original_csrf = login.json()["csrf_token"]
|
||||
cookie = login.cookies.get("gewu_session")
|
||||
assert cookie
|
||||
assert (await http.get("/v1/session")).status_code == 403
|
||||
assert (
|
||||
await http.get(
|
||||
"/v1/session", headers={**headers, "Origin": "https://attacker.invalid"}
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await http.get("/v1/session", headers={**headers, "Sec-Fetch-Site": "cross-site"})
|
||||
).status_code == 403
|
||||
restored = await http.get("/v1/session", headers=headers)
|
||||
assert restored.status_code == 200
|
||||
assert restored.json()["expires_at"] == login.json()["expires_at"]
|
||||
assert restored.json()["password_account"] is False
|
||||
assert restored.headers["cache-control"] == "private, no-store"
|
||||
assert "set-cookie" not in restored.headers
|
||||
assert cookie not in restored.text and token not in restored.text
|
||||
restored_csrf = restored.json()["csrf_token"]
|
||||
assert identities.browser_csrf_valid(cookie, original_csrf)
|
||||
assert identities.browser_csrf_valid(cookie, restored_csrf)
|
||||
second = await http.get("/v1/session", headers=headers)
|
||||
assert second.json()["csrf_token"] == restored_csrf
|
||||
assert identities.browser_csrf_valid(cookie, original_csrf)
|
||||
assert not identities.browser_csrf_valid(cookie, "gwc_invalid")
|
||||
signed_out = await http.delete("/v1/session", headers={"X-GEWU-CSRF": restored_csrf})
|
||||
assert signed_out.status_code == 200
|
||||
http.cookies.set("gewu_session", cookie)
|
||||
assert (await http.get("/v1/session", headers=headers)).status_code == 401
|
||||
|
||||
next_login = identities.create_browser_session(token)
|
||||
http.cookies.clear()
|
||||
http.cookies.set("gewu_session", next_login.session_token)
|
||||
identities.revoke("admin")
|
||||
assert (await http.get("/v1/session", headers=headers)).status_code == 401
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_password_session_restore_and_pre_upgrade_session_migration(tmp_path: Path) -> None:
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
identities.create_account(
|
||||
username="admin",
|
||||
password="fixture-password",
|
||||
role="admin",
|
||||
project_ids=(),
|
||||
actor="operator",
|
||||
)
|
||||
session = identities.login_account("admin", "fixture-password")
|
||||
assert session
|
||||
# Reconstruct the pre-upgrade schema with an issued session, then run the migration.
|
||||
with identities._connect() as db:
|
||||
db.execute("ALTER TABLE browser_sessions DROP COLUMN restored_csrf_digest")
|
||||
reopened = SQLiteIdentityStore(identities.database)
|
||||
restored = reopened.restore_browser_session(session.session_token)
|
||||
assert restored and restored.expires_at == session.expires_at
|
||||
assert reopened.browser_csrf_valid(session.session_token, session.csrf_token)
|
||||
assert reopened.browser_csrf_valid(session.session_token, restored.csrf_token)
|
||||
with reopened._connect() as db:
|
||||
db.execute("UPDATE browser_sessions SET expires_at=0")
|
||||
assert reopened.restore_browser_session(session.session_token) is None
|
||||
@@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.bootstrap.app import create_app
|
||||
|
||||
|
||||
def test_admin_credentials_sessions_and_secret_free_audit(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
store = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
admin = store.provision(credential_id="admin", principal_id="operator", roles=("admin",))
|
||||
viewer = store.provision(credential_id="viewer", principal_id="reader", roles=("viewer",))
|
||||
mixed = store.provision(
|
||||
credential_id="mixed", principal_id="agent", roles=("agent", "admin")
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=store,
|
||||
credential_administration=store,
|
||||
node_reader=CanonicalNodeReader(GitCanonicalRepository(tmp_path / "knowledge.git")),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://test"
|
||||
) as http:
|
||||
assert (await http.get("/v1/admin/credentials")).status_code == 401
|
||||
for token in (viewer, mixed):
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
assert (await http.get("/v1/admin/credentials", headers=headers)).status_code == 403
|
||||
assert (await http.get("/v1/admin/events", headers=headers)).status_code == 403
|
||||
assert (
|
||||
await http.post(
|
||||
"/v1/admin/credentials",
|
||||
headers=headers,
|
||||
json={
|
||||
"credential_id": "denied",
|
||||
"principal_id": "denied",
|
||||
"role": "admin",
|
||||
},
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await http.delete("/v1/admin/credentials/viewer", headers=headers)
|
||||
).status_code == 403
|
||||
session = await http.post("/v1/session", headers={"Authorization": f"Bearer {admin}"})
|
||||
csrf = {"X-GEWU-CSRF": session.json()["csrf_token"]}
|
||||
body = {
|
||||
"credential_id": "new",
|
||||
"principal_id": "researcher",
|
||||
"role": "agent",
|
||||
"project_ids": ["pilot"],
|
||||
}
|
||||
assert (await http.post("/v1/admin/credentials", json=body)).status_code == 403
|
||||
assert (
|
||||
await http.post(
|
||||
"/v1/admin/credentials", json=body, headers={**csrf, "Origin": "https://other"}
|
||||
)
|
||||
).status_code == 403
|
||||
assert (
|
||||
await http.post("/v1/admin/credentials", headers=csrf, content=b" " * 4097)
|
||||
).status_code == 413
|
||||
assert (
|
||||
await http.post(
|
||||
"/v1/admin/credentials", headers=csrf, json={**body, "project_ids": []}
|
||||
)
|
||||
).status_code == 422
|
||||
created = await http.post("/v1/admin/credentials", headers=csrf, json=body)
|
||||
assert created.status_code == 201
|
||||
token = created.json()["token"]
|
||||
principal = store.authenticate(token)
|
||||
assert principal and principal.project_ids == frozenset({"pilot"})
|
||||
assert (
|
||||
await http.post("/v1/admin/credentials", headers=csrf, json=body)
|
||||
).status_code == 409
|
||||
listing = await http.get("/v1/admin/credentials")
|
||||
assert listing.headers["cache-control"] == "private, no-store"
|
||||
assert token not in listing.text and "token_digest" not in listing.text
|
||||
assert (
|
||||
await http.delete("/v1/admin/credentials/admin", headers=csrf)
|
||||
).status_code == 409
|
||||
assert (await http.delete("/v1/admin/credentials/new")).status_code == 403
|
||||
assert (await http.delete("/v1/admin/credentials/new", headers=csrf)).status_code == 200
|
||||
assert store.authenticate(token) is None
|
||||
assert (await http.delete("/v1/admin/credentials/new", headers=csrf)).status_code == 404
|
||||
# Revocation invalidates already-issued browser sessions as well as bearer keys.
|
||||
viewer_session = store.create_browser_session(viewer)
|
||||
assert (
|
||||
await http.delete("/v1/admin/credentials/viewer", headers=csrf)
|
||||
).status_code == 200
|
||||
assert store.authenticate_browser_session(viewer_session.session_token) is None
|
||||
events = await http.get("/v1/admin/events")
|
||||
assert [e["action"] for e in events.json()] == [
|
||||
"credential.revoked",
|
||||
"credential.revoked",
|
||||
"credential.created",
|
||||
]
|
||||
assert token not in events.text and admin not in events.text
|
||||
reopened = SQLiteIdentityStore(store.database)
|
||||
assert len(reopened.list_admin_events(limit=50)) == 3
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,82 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from _pytest.monkeypatch import MonkeyPatch
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from gewu.bootstrap.app import create_app, create_local_app
|
||||
|
||||
|
||||
def test_health_is_available_without_exposing_mutation_routes() -> None:
|
||||
async def request_health() -> httpx.Response:
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=create_app()),
|
||||
base_url="http://gewu.test",
|
||||
) as client:
|
||||
return await client.get("/health")
|
||||
|
||||
response = asyncio.run(request_health())
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok", "service": "gewu"}
|
||||
routes = {
|
||||
f"{method} {route.path}"
|
||||
for route in create_app().routes
|
||||
if isinstance(route, APIRoute)
|
||||
for method in route.methods or ()
|
||||
}
|
||||
assert routes == {"GET /health"}
|
||||
|
||||
|
||||
def test_local_factory_composes_private_persistent_read_service(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
data_root = tmp_path / "private-gewu"
|
||||
monkeypatch.setenv("GEWU_DATA_ROOT", str(data_root))
|
||||
app = create_local_app()
|
||||
route_paths = {route.path for route in app.routes if isinstance(route, APIRoute)}
|
||||
assert route_paths == {
|
||||
"/health",
|
||||
"/v1/diagnostics",
|
||||
"/v1/admin/projects",
|
||||
"/v1/admin/credentials",
|
||||
"/v1/admin/credentials/{credential_id}",
|
||||
"/v1/admin/events",
|
||||
"/v1/admin/users",
|
||||
"/v1/admin/users/{username}",
|
||||
"/v1/users/session",
|
||||
"/v1/me",
|
||||
"/v1/me/password",
|
||||
"/v1/me/agent-credentials",
|
||||
"/v1/me/agent-credentials/{credential_id}",
|
||||
"/v1/admin/projects/{project_id}/archive",
|
||||
"/v1/projects/{project_id}/nodes/{node_id}",
|
||||
"/v1/projects/{project_id}/nodes/{node_id}/history",
|
||||
"/v1/projects/{project_id}/sources/{revision_id}",
|
||||
"/v1/projects/{project_id}/sources/{revision_id}/artifact",
|
||||
"/v1/projects/{project_id}/sources/{revision_id}/extractions/{extraction_id}/blocks/{block_id}",
|
||||
"/v1/neighbors",
|
||||
"/v1/projects",
|
||||
"/v1/search",
|
||||
"/v1/sources",
|
||||
"/v1/session",
|
||||
"/auth/owner-session",
|
||||
}
|
||||
assert (data_root / "knowledge.git" / "HEAD").is_file()
|
||||
assert (data_root / "identity.sqlite3").is_file()
|
||||
assert (data_root / "search.sqlite3").is_file()
|
||||
assert (data_root / "search.sqlite3").stat().st_mode & 0o077 == 0
|
||||
assert (data_root.stat().st_mode & 0o777) == 0o700
|
||||
|
||||
|
||||
def test_local_factory_rejects_symlinked_data_root(
|
||||
monkeypatch: MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
target = tmp_path / "real-root"
|
||||
target.mkdir()
|
||||
link = tmp_path / "linked-root"
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
monkeypatch.setenv("GEWU_DATA_ROOT", str(link))
|
||||
with pytest.raises(ValueError, match="symbolic links"):
|
||||
create_local_app()
|
||||
@@ -0,0 +1,951 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import socket
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx2
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import TextContent
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.source_extractor import LocalSourceExtractor
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.url_fetch import PublicUrlFetcher
|
||||
from gewu.application.acceptance import AcceptanceService
|
||||
from gewu.application.context import ValidationContext
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.uploads import LocalArtifactStore
|
||||
from gewu.bootstrap.app import create_app
|
||||
from gewu.domain.contracts import ProposalOperation, ProposalRevision, Scope, SourceRevisionRecord
|
||||
from gewu.domain.validation import evidence_digest, proposal_payload_digest
|
||||
from gewu.transports.stdio_connector import SelectedFile, create_connector_server
|
||||
from tests.domain.proposal_fixtures import (
|
||||
BLOCK_TEXT,
|
||||
NODE,
|
||||
POLICIES,
|
||||
PROJECTS,
|
||||
SOURCE_REVISION,
|
||||
proposal,
|
||||
)
|
||||
|
||||
|
||||
class _FixtureResponse:
|
||||
def __init__(self, media_type: str, content: bytes, status: int = 200) -> None:
|
||||
self.media_type = media_type
|
||||
self.content = content
|
||||
self.status = status
|
||||
self.offset = 0
|
||||
|
||||
def getheader(self, name: str) -> str | None:
|
||||
return {
|
||||
"Content-Type": f"{self.media_type}; charset=utf-8",
|
||||
"X-Source-Title": "Allowlisted fixture",
|
||||
}.get(name)
|
||||
|
||||
def read(self, amount: int) -> bytes:
|
||||
result = self.content[self.offset : self.offset + amount]
|
||||
self.offset += len(result)
|
||||
return result
|
||||
|
||||
|
||||
class _FixtureConnection:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self.path = ""
|
||||
|
||||
def request(self, _method: str, path: str, headers: dict[str, str]) -> None:
|
||||
self.path = path
|
||||
assert headers["Accept-Encoding"] == "identity"
|
||||
return None
|
||||
|
||||
def getresponse(self) -> _FixtureResponse:
|
||||
if self.path.startswith("/missing"):
|
||||
return _FixtureResponse("text/plain", b"unavailable", status=404)
|
||||
if self.path.startswith("/source.md?rev=2"):
|
||||
return _FixtureResponse("text/markdown", b"# Captured fixture\nRevised evidence.\n")
|
||||
if self.path.startswith("/page.html"):
|
||||
return _FixtureResponse(
|
||||
"text/html",
|
||||
b"<article><h1>Web evidence</h1><p>Exact HTML statement.</p>"
|
||||
b"<script>ignoreInjection()</script></article>",
|
||||
)
|
||||
if self.path.startswith("/paper.pdf"):
|
||||
return _FixtureResponse("application/pdf", _pdf_fixture())
|
||||
if self.path.startswith("/malformed.pdf"):
|
||||
return _FixtureResponse("application/pdf", b"%PDF-1.4\nnot a valid PDF")
|
||||
return _FixtureResponse(
|
||||
"text/markdown", b"# Captured fixture\nAn exact source statement.\n"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _fixture_fetcher() -> PublicUrlFetcher:
|
||||
def resolver(host: str, port: int, **_kwargs: Any) -> list[tuple[Any, ...]]:
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port))]
|
||||
|
||||
return PublicUrlFetcher(
|
||||
{"research.example"},
|
||||
resolver=resolver,
|
||||
connection_factory=lambda *_args: _FixtureConnection(),
|
||||
)
|
||||
|
||||
|
||||
def _text_payload(result: Any) -> str:
|
||||
assert result.content and isinstance(result.content[0], TextContent)
|
||||
return result.content[0].text
|
||||
|
||||
|
||||
def _pdf_fixture() -> bytes:
|
||||
stream = b"BT /F1 12 Tf 72 720 Td (Archived PDF evidence.) Tj ET"
|
||||
objects = [
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>",
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
|
||||
b"/Resources << /Font << /F1 3 0 R >> >> /Contents 5 0 R >>",
|
||||
b"<< /Length " + str(len(stream)).encode() + b" >>\nstream\n" + stream + b"\nendstream",
|
||||
]
|
||||
result = bytearray(b"%PDF-1.4\n")
|
||||
offsets = [0]
|
||||
for number, item in enumerate(objects, start=1):
|
||||
offsets.append(len(result))
|
||||
result.extend(f"{number} 0 obj\n".encode() + item + b"\nendobj\n")
|
||||
xref = len(result)
|
||||
result.extend(f"xref\n0 {len(offsets)}\n0000000000 65535 f \n".encode())
|
||||
for offset in offsets[1:]:
|
||||
result.extend(f"{offset:010d} 00000 n \n".encode())
|
||||
result.extend(
|
||||
f"trailer\n<< /Size {len(offsets)} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n".encode()
|
||||
)
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def test_mcp_source_to_citation_vertical_slice(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
artifacts = LocalArtifactStore(tmp_path)
|
||||
extractor = LocalSourceExtractor()
|
||||
context = ValidationContext(
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={},
|
||||
extractions={},
|
||||
existing_nodes={},
|
||||
)
|
||||
identities = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3",
|
||||
authority_lock_path=tmp_path / "workflow/locks/canonical-writer.lock",
|
||||
)
|
||||
token = identities.provision(
|
||||
credential_id="pilot-agent-key",
|
||||
principal_id="agent-pilot",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance", "shared"),
|
||||
)
|
||||
viewer_token = identities.provision(
|
||||
credential_id="pilot-viewer-key",
|
||||
principal_id="pilot-viewer",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance", "shared"),
|
||||
)
|
||||
acceptance = AcceptanceService(
|
||||
repository=repository,
|
||||
artifact_store=artifacts,
|
||||
workflow_root=tmp_path / "workflow",
|
||||
context_provider=lambda: context,
|
||||
actor_is_current=lambda actor: identities.is_current(
|
||||
actor.principal_id,
|
||||
actor.credential_id,
|
||||
roles=actor.roles,
|
||||
project_ids=actor.project_ids,
|
||||
),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
artifact_store=artifacts,
|
||||
extractor=extractor,
|
||||
acceptance_service=acceptance,
|
||||
context_provider=lambda: context,
|
||||
url_fetcher=_fixture_fetcher(),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
tools = await mcp.list_tools()
|
||||
assert {tool.name for tool in tools.tools} == {
|
||||
"diagnostics",
|
||||
"get_node",
|
||||
"node_history",
|
||||
"get_project_context",
|
||||
"get_source",
|
||||
"source_upload_begin",
|
||||
"source_capture_text",
|
||||
"source_capture_url",
|
||||
"source_upload_status",
|
||||
"source_upload_chunk",
|
||||
"source_upload_finalize",
|
||||
"source_extract",
|
||||
"source_extraction_block",
|
||||
"proposal_evaluate",
|
||||
"proposal_submit",
|
||||
"proposal_status",
|
||||
"proposal_withdraw",
|
||||
"run_create",
|
||||
"run_checkpoint",
|
||||
"run_status",
|
||||
}
|
||||
project_context = await mcp.call_tool(
|
||||
"get_project_context", {"project_id": "dome-performance"}
|
||||
)
|
||||
assert not project_context.is_error
|
||||
lens_result = json.loads(_text_payload(project_context))
|
||||
assert (
|
||||
lens_result["lens"]["north_star"] == PROJECTS["dome-performance"].north_star
|
||||
)
|
||||
assert lens_result["lens"]["key_questions"] == list(
|
||||
PROJECTS["dome-performance"].key_questions
|
||||
)
|
||||
assert "autonomy_policy_id" not in lens_result["lens"]
|
||||
denied_lens = await mcp.call_tool(
|
||||
"get_project_context", {"project_id": "interactive-video"}
|
||||
)
|
||||
assert denied_lens.is_error
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
) as viewer_http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=viewer_http)
|
||||
) as viewer_mcp:
|
||||
viewer_proposal = await viewer_mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": proposal().model_dump_json()},
|
||||
)
|
||||
assert viewer_proposal.is_error
|
||||
viewer_upload = await viewer_mcp.call_tool(
|
||||
"source_upload_begin",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"filename": "viewer.md",
|
||||
"media_type": "text/markdown",
|
||||
"byte_length": 4,
|
||||
"expected_sha256": hashlib.sha256(b"data").hexdigest(),
|
||||
},
|
||||
)
|
||||
assert viewer_upload.is_error
|
||||
viewer_paste = await viewer_mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "viewer-capture-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "viewer-paste",
|
||||
"text": "Viewer supplied text",
|
||||
},
|
||||
)
|
||||
assert viewer_paste.is_error
|
||||
diagnostics_result = await mcp.call_tool(
|
||||
"diagnostics",
|
||||
{"projects": ["dome-performance"], "include_shared": True},
|
||||
)
|
||||
assert not diagnostics_result.is_error
|
||||
diagnostics = json.loads(_text_payload(diagnostics_result))
|
||||
assert diagnostics["workflow_status"] == "not_verified"
|
||||
assert diagnostics["selected_scope"]["include_shared"] is True
|
||||
assert "proposal_submit" in diagnostics["authorized_operations"]
|
||||
assert "run_checkpoint" in diagnostics["authorized_operations"]
|
||||
assert token not in _text_payload(diagnostics_result)
|
||||
run_request = {
|
||||
"run_id": "mcp-run-pilot",
|
||||
"objective": "Compare audience response",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
},
|
||||
"lens": "Audience behavior",
|
||||
"pack_id": "gewu",
|
||||
"pack_version": "0.3.0",
|
||||
"pack_snapshot_digest": "a" * 64,
|
||||
"module_pins": [
|
||||
{
|
||||
"module_id": "profile",
|
||||
"implementation_version": "1.0.0",
|
||||
"sha256": "b" * 64,
|
||||
}
|
||||
],
|
||||
}
|
||||
created_run = await mcp.call_tool(
|
||||
"run_create", {"request_json": json.dumps(run_request)}
|
||||
)
|
||||
assert not created_run.is_error
|
||||
run_record = json.loads(_text_payload(created_run))
|
||||
assert run_record["revision"] == 0
|
||||
checkpoint = {
|
||||
"run_id": "mcp-run-pilot",
|
||||
"expected_revision": 0,
|
||||
"stage": "source-review",
|
||||
}
|
||||
saved_run = await mcp.call_tool(
|
||||
"run_checkpoint", {"request_json": json.dumps(checkpoint)}
|
||||
)
|
||||
assert not saved_run.is_error
|
||||
assert json.loads(_text_payload(saved_run))["revision"] == 1
|
||||
read_run = await mcp.call_tool("run_status", {"run_id": "mcp-run-pilot"})
|
||||
assert not read_run.is_error
|
||||
assert json.loads(_text_payload(read_run))["revision"] == 1
|
||||
denied = await mcp.call_tool(
|
||||
"source_upload_begin",
|
||||
{
|
||||
"project_id": "interactive-video",
|
||||
"filename": "private.md",
|
||||
"media_type": "text/markdown",
|
||||
"byte_length": 4,
|
||||
"expected_sha256": "0" * 64,
|
||||
},
|
||||
)
|
||||
assert denied.is_error
|
||||
forbidden_capture = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "interactive-video",
|
||||
"url": "https://research.example/source.md",
|
||||
},
|
||||
)
|
||||
assert forbidden_capture.is_error
|
||||
forbidden_paste = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "out-of-scope-paste-1",
|
||||
"project_id": "interactive-video",
|
||||
"source_key": "out-of-scope-paste",
|
||||
"text": "Must not be uploaded",
|
||||
},
|
||||
)
|
||||
assert forbidden_paste.is_error
|
||||
unallowlisted_capture = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://internal.example/private",
|
||||
},
|
||||
)
|
||||
assert unallowlisted_capture.is_error
|
||||
captured = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/source.md?token=do-not-echo",
|
||||
},
|
||||
)
|
||||
assert not captured.is_error
|
||||
capture_result = json.loads(_text_payload(captured))
|
||||
assert capture_result["title"] == "Allowlisted fixture"
|
||||
assert capture_result["media_type"] == "text/markdown"
|
||||
assert "do-not-echo" not in _text_payload(captured)
|
||||
assert capture_result["original_url"] == ("https://research.example/source.md")
|
||||
captured_source = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(capture_result["source_candidate"]), strict=True
|
||||
)
|
||||
assert captured_source.archive_status == "archived"
|
||||
assert captured_source.final_url == "https://research.example/source.md"
|
||||
assert captured_source.artifact_sha256 == capture_result["sha256"]
|
||||
assert captured_source.capture_method == "url-fetch"
|
||||
assert f":r:{captured_source.artifact_sha256}:" in captured_source.revision_id
|
||||
pasted_text = "## Meeting note\nPasted evidence: 400 visitors.\n"
|
||||
pasted_arguments = {
|
||||
"capture_id": "interview-paste-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "research-interview-2026-10-14",
|
||||
"text": pasted_text,
|
||||
"title": "Research interview notes",
|
||||
"original_url": "https://research.example/interview?token=secret",
|
||||
"media_type": "text/markdown",
|
||||
"source_type": "internal",
|
||||
}
|
||||
pasted = await mcp.call_tool("source_capture_text", pasted_arguments)
|
||||
assert not pasted.is_error
|
||||
pasted_retry = await mcp.call_tool("source_capture_text", pasted_arguments)
|
||||
assert not pasted_retry.is_error
|
||||
assert json.loads(_text_payload(pasted_retry)) == json.loads(
|
||||
_text_payload(pasted)
|
||||
)
|
||||
changed_paste_retry = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{**pasted_arguments, "text": pasted_text + "changed"},
|
||||
)
|
||||
assert changed_paste_retry.is_error
|
||||
invalid_capture_id = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{**pasted_arguments, "capture_id": "invalid capture id"},
|
||||
)
|
||||
assert invalid_capture_id.is_error
|
||||
pasted_result = json.loads(_text_payload(pasted))
|
||||
pasted_source = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(pasted_result["source_candidate"]), strict=True
|
||||
)
|
||||
assert pasted_source.capture_method == "pasted-text"
|
||||
assert pasted_source.original_url == "https://research.example/interview"
|
||||
assert "secret" not in _text_payload(pasted)
|
||||
capture_journal = tmp_path / "staging" / "capture-receipts"
|
||||
assert all(
|
||||
pasted_text.encode() not in path.read_bytes()
|
||||
for path in capture_journal.glob("*.json")
|
||||
)
|
||||
assert (
|
||||
pasted_source.artifact_sha256
|
||||
== hashlib.sha256(pasted_text.encode()).hexdigest()
|
||||
)
|
||||
assert pasted_result["byte_length"] == len(pasted_text.encode())
|
||||
assert pasted_result["extraction_id"]
|
||||
pasted_extraction = artifacts.get_extraction(
|
||||
pasted_result["extraction_id"],
|
||||
owner_id="agent-pilot",
|
||||
project_id="dome-performance",
|
||||
)
|
||||
assert pasted_extraction is not None
|
||||
assert pasted_extraction.source_revision_id == pasted_source.revision_id
|
||||
assert (
|
||||
"Pasted evidence: 400 visitors."
|
||||
in pasted_extraction.blocks[0].original_text
|
||||
)
|
||||
oversized_paste = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "oversized-paste-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "oversized-paste",
|
||||
"text": "x" * 1_000_001,
|
||||
},
|
||||
)
|
||||
assert oversized_paste.is_error
|
||||
control_character_paste = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "invalid-paste-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "invalid-paste",
|
||||
"text": "unsafe\x00content",
|
||||
},
|
||||
)
|
||||
assert control_character_paste.is_error
|
||||
oversized_author_paste = await mcp.call_tool(
|
||||
"source_capture_text",
|
||||
{
|
||||
"capture_id": "invalid-author-paste-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_key": "invalid-author-paste",
|
||||
"text": "valid body",
|
||||
"authors": ["a" * 301],
|
||||
},
|
||||
)
|
||||
assert oversized_author_paste.is_error
|
||||
changed_capture = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/source.md?rev=2",
|
||||
},
|
||||
)
|
||||
assert not changed_capture.is_error
|
||||
changed_source = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(json.loads(_text_payload(changed_capture))["source_candidate"]),
|
||||
strict=True,
|
||||
)
|
||||
assert changed_source.source_id == captured_source.source_id
|
||||
assert changed_source.revision_id != captured_source.revision_id
|
||||
with artifacts.open_artifact(
|
||||
capture_result["sha256"], project_id="dome-performance"
|
||||
) as artifact:
|
||||
assert artifact.read() == (
|
||||
b"# Captured fixture\nAn exact source statement.\n"
|
||||
)
|
||||
failed_capture = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/missing?access-token=must-not-leak",
|
||||
"title": "Temporarily unavailable paper",
|
||||
"source_type": "paper",
|
||||
},
|
||||
)
|
||||
assert not failed_capture.is_error
|
||||
failed_result = json.loads(_text_payload(failed_capture))
|
||||
failed_source = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(failed_result["source_candidate"]), strict=True
|
||||
)
|
||||
assert failed_result["status"] == "capture_failed"
|
||||
assert failed_source.archive_status == "capture-failed"
|
||||
assert failed_source.artifact_sha256 is None
|
||||
assert "access-token" not in _text_payload(failed_capture)
|
||||
failed_proposal = ProposalRevision(
|
||||
proposal_id="proposal-failed-url-capture",
|
||||
revision=1,
|
||||
initiating_project_id="dome-performance",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=True),
|
||||
base_commit=repository.head(),
|
||||
payload_digest="0" * 64,
|
||||
proposer_id="agent-pilot",
|
||||
run_id="run-failed-url-capture",
|
||||
operations=(
|
||||
ProposalOperation(operation="register_source", source=failed_source),
|
||||
),
|
||||
)
|
||||
failed_proposal = failed_proposal.model_copy(
|
||||
update={"payload_digest": proposal_payload_digest(failed_proposal)}
|
||||
)
|
||||
registered_failure = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": failed_proposal.model_dump_json()},
|
||||
)
|
||||
assert not registered_failure.is_error
|
||||
assert (
|
||||
json.loads(_text_payload(registered_failure))["receipt"]["state"]
|
||||
== "accepted"
|
||||
)
|
||||
captured_html = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/page.html",
|
||||
},
|
||||
)
|
||||
assert not captured_html.is_error
|
||||
html_result = json.loads(_text_payload(captured_html))
|
||||
assert html_result["media_type"] == "text/html"
|
||||
html_extraction_result = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": html_result["sha256"],
|
||||
"source_revision_id": "shared:web-page:r1",
|
||||
},
|
||||
)
|
||||
assert not html_extraction_result.is_error
|
||||
html_extraction = json.loads(_text_payload(html_extraction_result))
|
||||
html_block_result = await mcp.call_tool(
|
||||
"source_extraction_block",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"extraction_id": html_extraction["extraction_id"],
|
||||
"block_id": html_extraction["blocks"][0]["block_id"],
|
||||
"offset": 0,
|
||||
"length": 500,
|
||||
},
|
||||
)
|
||||
assert not html_block_result.is_error
|
||||
html_block = _text_payload(html_block_result)
|
||||
assert "Exact HTML statement." in html_block
|
||||
assert "ignoreInjection" not in html_block
|
||||
captured_pdf = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/paper.pdf",
|
||||
},
|
||||
)
|
||||
assert not captured_pdf.is_error
|
||||
pdf_result = json.loads(_text_payload(captured_pdf))
|
||||
assert pdf_result["media_type"] == "application/pdf"
|
||||
with artifacts.open_artifact(
|
||||
pdf_result["sha256"], project_id="dome-performance"
|
||||
) as artifact:
|
||||
assert artifact.read() == _pdf_fixture()
|
||||
pdf_extracted = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": pdf_result["sha256"],
|
||||
"source_revision_id": "shared:pdf:r1",
|
||||
},
|
||||
)
|
||||
assert not pdf_extracted.is_error
|
||||
pdf_record = json.loads(_text_payload(pdf_extracted))
|
||||
assert pdf_record["blocks"][0]["page"] == 1
|
||||
pdf_block_result = await mcp.call_tool(
|
||||
"source_extraction_block",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"extraction_id": pdf_record["extraction_id"],
|
||||
"block_id": pdf_record["blocks"][0]["block_id"],
|
||||
"offset": 0,
|
||||
"length": 500,
|
||||
},
|
||||
)
|
||||
assert not pdf_block_result.is_error
|
||||
pdf_block = json.loads(_text_payload(pdf_block_result))
|
||||
assert pdf_block["page"] == 1
|
||||
assert "Archived PDF evidence." in pdf_block["normalized_text"]
|
||||
malformed_pdf = await mcp.call_tool(
|
||||
"source_capture_url",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"url": "https://research.example/malformed.pdf",
|
||||
},
|
||||
)
|
||||
assert not malformed_pdf.is_error
|
||||
malformed_result = json.loads(_text_payload(malformed_pdf))
|
||||
malformed_extraction = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": malformed_result["sha256"],
|
||||
"source_revision_id": malformed_result["source_candidate"][
|
||||
"revision_id"
|
||||
],
|
||||
},
|
||||
)
|
||||
assert not malformed_extraction.is_error
|
||||
unavailable = json.loads(_text_payload(malformed_extraction))
|
||||
assert unavailable["status"] == "unavailable"
|
||||
assert unavailable["error_code"] == "PDF_UNSUPPORTED"
|
||||
content = BLOCK_TEXT.encode("utf-8")
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
interrupted_begin = await mcp.call_tool(
|
||||
"source_upload_begin",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"filename": "interrupted.md",
|
||||
"media_type": "text/markdown",
|
||||
"byte_length": len(content),
|
||||
"expected_sha256": digest,
|
||||
},
|
||||
)
|
||||
assert not interrupted_begin.is_error
|
||||
interrupted_upload = json.loads(_text_payload(interrupted_begin))
|
||||
interrupted_finalize = await mcp.call_tool(
|
||||
"source_upload_finalize",
|
||||
{"upload_id": interrupted_upload["upload_id"]},
|
||||
)
|
||||
assert interrupted_finalize.is_error
|
||||
uncitable = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": digest,
|
||||
"source_revision_id": SOURCE_REVISION.revision_id,
|
||||
},
|
||||
)
|
||||
assert uncitable.is_error
|
||||
|
||||
begun = await mcp.call_tool(
|
||||
"source_upload_begin",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"filename": "pilot.md",
|
||||
"media_type": "text/markdown",
|
||||
"byte_length": len(content),
|
||||
"expected_sha256": digest,
|
||||
},
|
||||
)
|
||||
assert not begun.is_error
|
||||
upload = json.loads(_text_payload(begun))
|
||||
wrong_digest_chunk = await mcp.call_tool(
|
||||
"source_upload_chunk",
|
||||
{
|
||||
"upload_id": upload["upload_id"],
|
||||
"offset": 0,
|
||||
"content_base64": base64.b64encode(content).decode(),
|
||||
"chunk_sha256": "0" * 64,
|
||||
},
|
||||
)
|
||||
assert wrong_digest_chunk.is_error
|
||||
chunked = await mcp.call_tool(
|
||||
"source_upload_chunk",
|
||||
{
|
||||
"upload_id": upload["upload_id"],
|
||||
"offset": 0,
|
||||
"content_base64": base64.b64encode(content).decode(),
|
||||
"chunk_sha256": digest,
|
||||
},
|
||||
)
|
||||
assert not chunked.is_error
|
||||
finalized = await mcp.call_tool(
|
||||
"source_upload_finalize", {"upload_id": upload["upload_id"]}
|
||||
)
|
||||
assert not finalized.is_error
|
||||
artifact = json.loads(_text_payload(finalized))
|
||||
extracted = await mcp.call_tool(
|
||||
"source_extract",
|
||||
{
|
||||
"project_id": "dome-performance",
|
||||
"artifact_sha256": artifact["sha256"],
|
||||
"source_revision_id": SOURCE_REVISION.revision_id,
|
||||
},
|
||||
)
|
||||
assert not extracted.is_error
|
||||
extraction_info = json.loads(_text_payload(extracted))
|
||||
record = extractor.extract(
|
||||
SOURCE_REVISION.revision_id, content, "text/markdown"
|
||||
)
|
||||
assert extraction_info["extraction_id"] == record.extraction_id
|
||||
|
||||
evidence = (
|
||||
NODE.claims[0]
|
||||
.evidence[0]
|
||||
.model_copy(
|
||||
update={
|
||||
"extraction_id": record.extraction_id,
|
||||
"locator": NODE.claims[0]
|
||||
.evidence[0]
|
||||
.locator.model_copy(update={"block_id": record.blocks[0].block_id}),
|
||||
}
|
||||
)
|
||||
)
|
||||
claim = NODE.claims[0].model_copy(
|
||||
update={
|
||||
"evidence": (evidence,),
|
||||
"assessment": NODE.claims[0].assessment.model_copy(
|
||||
update={"evidence_digests": (evidence_digest(evidence),)}
|
||||
),
|
||||
}
|
||||
)
|
||||
node = NODE.model_copy(update={"claims": (claim,)})
|
||||
source = SOURCE_REVISION.model_copy(
|
||||
update={"artifact_sha256": artifact["sha256"], "byte_length": len(content)}
|
||||
)
|
||||
candidate = proposal(node=node, source=source, base_commit=repository.head())
|
||||
candidate = candidate.model_copy(update={"payload_digest": "0" * 64})
|
||||
candidate = candidate.model_copy(
|
||||
update={"payload_digest": proposal_payload_digest(candidate)}
|
||||
)
|
||||
|
||||
wrong_location_evidence = evidence.model_copy(
|
||||
update={
|
||||
"quote": "400 visitors",
|
||||
"locator": evidence.locator.model_copy(update={"start": 0, "end": 13}),
|
||||
}
|
||||
)
|
||||
wrong_location_claim = claim.model_copy(
|
||||
update={
|
||||
"evidence": (wrong_location_evidence,),
|
||||
"assessment": claim.assessment.model_copy(
|
||||
update={
|
||||
"evidence_digests": (evidence_digest(wrong_location_evidence),)
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
wrong_location_node = node.model_copy(
|
||||
update={"claims": (wrong_location_claim,)}
|
||||
)
|
||||
wrong_location = proposal(
|
||||
node=wrong_location_node,
|
||||
source=source,
|
||||
base_commit=repository.head(),
|
||||
)
|
||||
wrong_location = wrong_location.model_copy(
|
||||
update={
|
||||
"proposal_id": "proposal-wrong-location",
|
||||
"payload_digest": "0" * 64,
|
||||
}
|
||||
)
|
||||
wrong_location = wrong_location.model_copy(
|
||||
update={"payload_digest": proposal_payload_digest(wrong_location)}
|
||||
)
|
||||
head_before_evaluate = repository.head()
|
||||
invalid_evaluation = await mcp.call_tool(
|
||||
"proposal_evaluate",
|
||||
{"proposal_json": wrong_location.model_dump_json()},
|
||||
)
|
||||
assert not invalid_evaluation.is_error, invalid_evaluation.content
|
||||
invalid_evaluation_payload = json.loads(_text_payload(invalid_evaluation))
|
||||
assert invalid_evaluation_payload["status"] == "invalid"
|
||||
assert "EVIDENCE_MISMATCH" in {
|
||||
finding["code"] for finding in invalid_evaluation_payload["findings"]
|
||||
}
|
||||
assert repository.head() == head_before_evaluate
|
||||
rejected = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": wrong_location.model_dump_json()},
|
||||
)
|
||||
assert not rejected.is_error, rejected.content
|
||||
rejected_outcome = json.loads(_text_payload(rejected))
|
||||
assert rejected_outcome["status"] == "invalid"
|
||||
assert "EVIDENCE_MISMATCH" in {
|
||||
finding["code"] for finding in rejected_outcome["findings"]
|
||||
}
|
||||
rejected_status = await mcp.call_tool(
|
||||
"proposal_status",
|
||||
{"proposal_id": "proposal-wrong-location", "revision": 1},
|
||||
)
|
||||
assert not rejected_status.is_error, rejected_status.content
|
||||
rejected_status_payload = json.loads(_text_payload(rejected_status))
|
||||
assert rejected_status_payload["lifecycle"] == "evaluated"
|
||||
assert rejected_status_payload["validation_status"] == "invalid"
|
||||
assert rejected_status_payload["receipt"] is None
|
||||
assert "quote" not in _text_payload(rejected_status)
|
||||
withdrawn = await mcp.call_tool(
|
||||
"proposal_withdraw",
|
||||
{"proposal_id": "proposal-wrong-location", "revision": 1},
|
||||
)
|
||||
assert not withdrawn.is_error, withdrawn.content
|
||||
assert json.loads(_text_payload(withdrawn))["lifecycle"] == "withdrawn"
|
||||
resubmitted = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": wrong_location.model_dump_json()},
|
||||
)
|
||||
assert resubmitted.is_error
|
||||
|
||||
candidate_head = repository.head()
|
||||
valid_evaluation = await mcp.call_tool(
|
||||
"proposal_evaluate",
|
||||
{"proposal_json": candidate.model_dump_json()},
|
||||
)
|
||||
assert not valid_evaluation.is_error, valid_evaluation.content
|
||||
valid_evaluation_payload = json.loads(_text_payload(valid_evaluation))
|
||||
assert valid_evaluation_payload["status"] == "eligible"
|
||||
assert valid_evaluation_payload["eligible_for_auto_accept"]
|
||||
assert repository.head() == candidate_head
|
||||
spoofed_assessment = claim.assessment.model_copy(
|
||||
update={"assessor_id": "agent-other"}
|
||||
)
|
||||
spoofed_claim = claim.model_copy(update={"assessment": spoofed_assessment})
|
||||
spoofed_node = node.model_copy(update={"claims": (spoofed_claim,)})
|
||||
spoofed = proposal(
|
||||
node=spoofed_node,
|
||||
source=source,
|
||||
base_commit=repository.head(),
|
||||
)
|
||||
spoofed = spoofed.model_copy(update={"payload_digest": "0" * 64})
|
||||
spoofed = spoofed.model_copy(
|
||||
update={"payload_digest": proposal_payload_digest(spoofed)}
|
||||
)
|
||||
spoofed_result = await mcp.call_tool(
|
||||
"proposal_evaluate",
|
||||
{"proposal_json": spoofed.model_dump_json()},
|
||||
)
|
||||
assert not spoofed_result.is_error, spoofed_result.content
|
||||
assert "ASSESSOR_ATTRIBUTION_MISMATCH" in {
|
||||
finding["code"]
|
||||
for finding in json.loads(_text_payload(spoofed_result))["findings"]
|
||||
}
|
||||
unstored = await mcp.call_tool(
|
||||
"proposal_status",
|
||||
{"proposal_id": candidate.proposal_id, "revision": candidate.revision},
|
||||
)
|
||||
assert unstored.is_error
|
||||
|
||||
submitted = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": candidate.model_dump_json()},
|
||||
)
|
||||
assert not submitted.is_error, submitted.content
|
||||
outcome = json.loads(_text_payload(submitted))
|
||||
assert outcome["status"] == "accepted"
|
||||
assert outcome["validation_status"] == "eligible"
|
||||
assert outcome["receipt"] is not None
|
||||
accepted_head = repository.head()
|
||||
retried = await mcp.call_tool(
|
||||
"proposal_submit",
|
||||
{"proposal_json": candidate.model_dump_json()},
|
||||
)
|
||||
assert not retried.is_error, retried.content
|
||||
retry_outcome = json.loads(_text_payload(retried))
|
||||
assert retry_outcome["receipt"] == outcome["receipt"]
|
||||
assert repository.head() == accepted_head
|
||||
accepted_status = await mcp.call_tool(
|
||||
"proposal_status",
|
||||
{"proposal_id": candidate.proposal_id, "revision": candidate.revision},
|
||||
)
|
||||
assert not accepted_status.is_error, accepted_status.content
|
||||
accepted_status_payload = json.loads(_text_payload(accepted_status))
|
||||
assert accepted_status_payload["lifecycle"] == "accepted"
|
||||
assert accepted_status_payload["receipt"] == outcome["receipt"]
|
||||
other_token = identities.provision(
|
||||
credential_id="other-agent-key",
|
||||
principal_id="agent-other",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {other_token}"},
|
||||
) as other_http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=other_http)
|
||||
) as other_mcp:
|
||||
private_status = await other_mcp.call_tool(
|
||||
"proposal_status",
|
||||
{
|
||||
"proposal_id": candidate.proposal_id,
|
||||
"revision": candidate.revision,
|
||||
},
|
||||
)
|
||||
assert private_status.is_error
|
||||
impersonated_evaluation = await other_mcp.call_tool(
|
||||
"proposal_evaluate",
|
||||
{"proposal_json": candidate.model_dump_json()},
|
||||
)
|
||||
assert impersonated_evaluation.is_error
|
||||
unauthorized_withdrawal = await other_mcp.call_tool(
|
||||
"proposal_withdraw",
|
||||
{
|
||||
"proposal_id": candidate.proposal_id,
|
||||
"revision": candidate.revision,
|
||||
},
|
||||
)
|
||||
assert unauthorized_withdrawal.is_error
|
||||
read = await mcp.call_tool(
|
||||
"get_node",
|
||||
{"project_id": node.project_id, "node_id": node.node_id},
|
||||
)
|
||||
assert not read.is_error
|
||||
envelope = json.loads(_text_payload(read))
|
||||
assert envelope["canonical_revision"] == outcome["receipt"]["commit_id"]
|
||||
assert envelope["citations"][0]["quote"] == BLOCK_TEXT
|
||||
|
||||
selected_path = tmp_path / "operator-selected.md"
|
||||
selected_bytes = b"A separately selected source for the stdio bridge.\n"
|
||||
selected_path.write_bytes(selected_bytes)
|
||||
|
||||
@asynccontextmanager
|
||||
async def remote_client() -> AsyncIterator[Client]:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as remote:
|
||||
yield remote
|
||||
|
||||
connector = create_connector_server(
|
||||
(SelectedFile("source-1", selected_path),), remote_client
|
||||
)
|
||||
async with Client(connector) as local:
|
||||
connector_tools = await local.list_tools()
|
||||
assert {tool.name for tool in connector_tools.tools} == {
|
||||
"list_selected_files",
|
||||
"upload_selected_file",
|
||||
}
|
||||
connector_result = await local.call_tool(
|
||||
"upload_selected_file",
|
||||
{
|
||||
"file_id": "source-1",
|
||||
"project_id": "dome-performance",
|
||||
"source_revision_id": "dome:connector-probe:r1",
|
||||
},
|
||||
)
|
||||
assert not connector_result.is_error
|
||||
connector_text = _text_payload(connector_result)
|
||||
connector_receipt = json.loads(connector_text)
|
||||
assert (
|
||||
connector_receipt["sha256"]
|
||||
== hashlib.sha256(selected_bytes).hexdigest()
|
||||
)
|
||||
assert selected_bytes.decode() not in connector_text
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,139 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import yaml
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import TextContent
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.bootstrap.app import create_app
|
||||
from tests.domain.proposal_fixtures import NODE, SOURCE_REVISION
|
||||
|
||||
|
||||
def test_mcp_exposes_only_authenticated_canonical_read(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest()
|
||||
node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest()
|
||||
source_key = hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()
|
||||
source_revision_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump(
|
||||
NODE.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"sources/{source_key}/{source_revision_key}.yaml": yaml.safe_dump(
|
||||
SOURCE_REVISION.model_dump(mode="json")
|
||||
).encode(),
|
||||
},
|
||||
message="Publish test node",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = identities.provision(
|
||||
credential_id="reader-key",
|
||||
principal_id="research-agent",
|
||||
roles=("agent",),
|
||||
project_ids=(NODE.project_id,),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
tools = await mcp.list_tools()
|
||||
assert {tool.name for tool in tools.tools} == {
|
||||
"diagnostics",
|
||||
"get_node",
|
||||
"node_history",
|
||||
"get_source",
|
||||
}
|
||||
diagnostic_result = await mcp.call_tool(
|
||||
"diagnostics", {"projects": [NODE.project_id]}
|
||||
)
|
||||
assert not diagnostic_result.is_error
|
||||
diagnostic_text = diagnostic_result.content[0]
|
||||
assert isinstance(diagnostic_text, TextContent)
|
||||
diagnostics = json.loads(diagnostic_text.text)
|
||||
assert diagnostics["transport"] == "mcp"
|
||||
assert diagnostics["selected_scope"]["projects"] == [NODE.project_id]
|
||||
assert diagnostics["index_state"] == "not_configured"
|
||||
assert "get_node" in diagnostics["authorized_operations"]
|
||||
assert "proposal_submit" not in diagnostics["authorized_operations"]
|
||||
assert token not in diagnostic_text.text
|
||||
denied_diagnostics = await mcp.call_tool(
|
||||
"diagnostics", {"projects": ["interactive-video"]}
|
||||
)
|
||||
assert denied_diagnostics.is_error
|
||||
result = await mcp.call_tool(
|
||||
"get_node",
|
||||
{"project_id": NODE.project_id, "node_id": NODE.node_id},
|
||||
)
|
||||
assert not result.is_error
|
||||
content = result.content[0]
|
||||
assert content.type == "text"
|
||||
envelope = json.loads(content.text)
|
||||
assert envelope["canonical_revision"] == repository.head()
|
||||
assert envelope["data"][0]["node_id"] == NODE.node_id
|
||||
assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
|
||||
history = await mcp.call_tool(
|
||||
"node_history",
|
||||
{"project_id": NODE.project_id, "node_id": NODE.node_id},
|
||||
)
|
||||
assert not history.is_error
|
||||
assert isinstance(history.content[0], TextContent)
|
||||
assert len(json.loads(history.content[0].text)["data"]) == 1
|
||||
source = await mcp.call_tool(
|
||||
"get_source",
|
||||
{
|
||||
"project_id": NODE.project_id,
|
||||
"revision_id": SOURCE_REVISION.revision_id,
|
||||
},
|
||||
)
|
||||
assert not source.is_error
|
||||
assert isinstance(source.content[0], TextContent)
|
||||
assert json.loads(source.content[0].text)["data"][0]["revision_id"] == (
|
||||
SOURCE_REVISION.revision_id
|
||||
)
|
||||
assert identities.revoke("reader-key")
|
||||
revoked = await mcp.call_tool(
|
||||
"get_node",
|
||||
{"project_id": NODE.project_id, "node_id": NODE.node_id},
|
||||
)
|
||||
assert revoked.is_error
|
||||
revoked_message = revoked.content[0]
|
||||
assert isinstance(revoked_message, TextContent)
|
||||
assert NODE.node_id not in revoked_message.text
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
) as http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
unauthenticated = await mcp.call_tool(
|
||||
"get_node",
|
||||
{"project_id": NODE.project_id, "node_id": NODE.node_id},
|
||||
)
|
||||
assert unauthenticated.is_error
|
||||
error_message = unauthenticated.content[0]
|
||||
assert isinstance(error_message, TextContent)
|
||||
assert NODE.node_id not in error_message.text
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,30 @@
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gewu.bootstrap.app import create_local_app
|
||||
from gewu.domain.operations import OPERATIONS, Transport, operations_for
|
||||
|
||||
|
||||
def test_http_openapi_routes_match_the_explicit_operation_allowlist(
|
||||
tmp_path: Path, monkeypatch: Any
|
||||
) -> None:
|
||||
monkeypatch.setenv("GEWU_DATA_ROOT", str(tmp_path / "gewu-data"))
|
||||
app = create_local_app()
|
||||
schema = app.openapi()
|
||||
exposed = {
|
||||
operation["operationId"]: operation
|
||||
for methods in schema["paths"].values()
|
||||
for method, operation in methods.items()
|
||||
if method in {"get", "post", "put", "delete", "patch"}
|
||||
}
|
||||
http_allowlist = {item.name for item in operations_for(Transport.HTTP)}
|
||||
operation_index = {item.name: item for item in OPERATIONS}
|
||||
|
||||
assert set(exposed) == http_allowlist
|
||||
assert "owner_decide" not in exposed
|
||||
assert "proposal_submit" not in exposed
|
||||
assert all(
|
||||
not operation_index[name].mutates_knowledge
|
||||
for name, route in exposed.items()
|
||||
if "knowledge" in route.get("tags", [])
|
||||
)
|
||||
@@ -0,0 +1,227 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
|
||||
import httpx2
|
||||
import pyotp
|
||||
from cryptography.fernet import Fernet
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.bootstrap.app import create_app
|
||||
|
||||
|
||||
def test_owner_login_returns_a_short_lived_non_agent_token(tmp_path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
identities = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
secret = pyotp.random_base32()
|
||||
identities.provision_owner(
|
||||
principal_id="owner-one",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=secret,
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
now = int(time.time())
|
||||
identities.issue_owner_challenge(
|
||||
challenge_id="challenge-one",
|
||||
owner_principal_id="owner-one",
|
||||
proposal_id="proposal-one",
|
||||
proposal_revision=1,
|
||||
payload_digest="a" * 64,
|
||||
diff_digest="b" * 64,
|
||||
domains=("dome-performance",),
|
||||
expires_at=now + 300,
|
||||
now=now,
|
||||
)
|
||||
agent_token = identities.provision(
|
||||
credential_id="agent-key",
|
||||
principal_id="agent-one",
|
||||
roles=("agent",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
owner_identity_provider=identities,
|
||||
owner_service_audience="gewu-local-test",
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/auth/owner-session",
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": pyotp.TOTP(secret).now(),
|
||||
"challenge_id": "challenge-one",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
token = response.json()["session_token"]
|
||||
assert token.startswith("gwo_")
|
||||
assert identities.authenticate(token) is None
|
||||
assert identities.authenticate(agent_token) is not None
|
||||
assert (
|
||||
identities.authenticate_owner_session(
|
||||
token,
|
||||
challenge_id="challenge-one",
|
||||
audience="gewu-local-test",
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
wrong_origin = await client.post(
|
||||
"/auth/owner-session",
|
||||
headers={"Origin": "https://attacker.invalid"},
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": pyotp.TOTP(secret).now(),
|
||||
"challenge_id": "challenge-two",
|
||||
},
|
||||
)
|
||||
assert wrong_origin.status_code == 403
|
||||
|
||||
oversized = await client.post(
|
||||
"/auth/owner-session",
|
||||
content='{"padding":"' + ("x" * 5000) + '"}',
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert oversized.status_code == 413
|
||||
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://gewu.example"
|
||||
) as remote_http:
|
||||
insecure = await remote_http.post(
|
||||
"/auth/owner-session",
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": "000000",
|
||||
"challenge_id": "challenge-three",
|
||||
},
|
||||
)
|
||||
assert insecure.status_code == 403
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_owner_login_is_fail_closed_without_encryption_key_or_audience(tmp_path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
owner_identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
)
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1"
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/auth/owner-session",
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": "000000",
|
||||
"challenge_id": "challenge-one",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 503
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_dedicated_owner_mcp_reads_diff_and_records_decision(tmp_path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
identities = SQLiteIdentityStore(
|
||||
tmp_path / "identity.sqlite3", owner_encryption_key=Fernet.generate_key()
|
||||
)
|
||||
secret = pyotp.random_base32()
|
||||
identities.provision_owner(
|
||||
principal_id="owner-one",
|
||||
password="correct horse battery staple",
|
||||
totp_secret=secret,
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
diff_json = '{"files":[{"path":"projects/dome/nodes/a.yaml"}]}'
|
||||
diff_digest = hashlib.sha256(diff_json.encode()).hexdigest()
|
||||
identities.issue_owner_challenge(
|
||||
challenge_id="challenge-review",
|
||||
owner_principal_id="owner-one",
|
||||
proposal_id="proposal-one",
|
||||
proposal_revision=1,
|
||||
payload_digest="a" * 64,
|
||||
diff_digest=diff_digest,
|
||||
domains=("dome-performance",),
|
||||
expires_at=int(time.time()) + 300,
|
||||
diff_json=diff_json,
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
owner_identity_provider=identities,
|
||||
owner_service_audience="gewu-owner-test",
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://127.0.0.1"
|
||||
) as login_http:
|
||||
login = await login_http.post(
|
||||
"/auth/owner-session",
|
||||
json={
|
||||
"principal_id": "owner-one",
|
||||
"password": "correct horse battery staple",
|
||||
"totp_code": pyotp.TOTP(secret).now(),
|
||||
"challenge_id": "challenge-review",
|
||||
},
|
||||
)
|
||||
assert login.status_code == 200, login.text
|
||||
token = login.json()["session_token"]
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as owner_http:
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/owner-mcp", http_client=owner_http)
|
||||
) as owner:
|
||||
names = {tool.name for tool in (await owner.list_tools()).tools}
|
||||
assert names == {
|
||||
"owner_challenge_get",
|
||||
"owner_diff_get",
|
||||
"owner_decide",
|
||||
}
|
||||
challenge = await owner.call_tool(
|
||||
"owner_challenge_get", {"challenge_id": "challenge-review"}
|
||||
)
|
||||
assert not challenge.is_error
|
||||
diff = await owner.call_tool(
|
||||
"owner_diff_get", {"challenge_id": "challenge-review"}
|
||||
)
|
||||
assert not diff.is_error
|
||||
decision = await owner.call_tool(
|
||||
"owner_decide",
|
||||
{
|
||||
"challenge_id": "challenge-review",
|
||||
"expected_proposal_revision": 1,
|
||||
"expected_payload_digest": "a" * 64,
|
||||
"expected_diff_digest": diff_digest,
|
||||
"decision": "authorize",
|
||||
"rationale": "Reviewed this exact change and approve it.",
|
||||
},
|
||||
)
|
||||
assert not decision.is_error
|
||||
assert json.loads(decision.content[0].text)["status"] == "authorized"
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,153 @@
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.registry import LocalProjectRegistry
|
||||
from gewu.bootstrap.app import create_app
|
||||
from gewu.domain.contracts import AutoAcceptPolicy, ProjectRecord, ProjectStatus
|
||||
|
||||
|
||||
def test_project_registry_lifecycle_is_admin_only_and_durable(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
admin_token = identities.provision(
|
||||
credential_id="project-admin",
|
||||
principal_id="owner-operator",
|
||||
roles=("admin",),
|
||||
)
|
||||
viewer_token = identities.provision(
|
||||
credential_id="project-viewer",
|
||||
principal_id="research-reader",
|
||||
roles=("viewer",),
|
||||
)
|
||||
mixed_agent_admin_token = identities.provision(
|
||||
credential_id="mixed-agent-admin",
|
||||
principal_id="agent-with-admin-role",
|
||||
roles=("admin", "agent"),
|
||||
)
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
registry = LocalProjectRegistry(
|
||||
tmp_path / "control" / "registry.json",
|
||||
repository=repository,
|
||||
index_directory=tmp_path / "publishing",
|
||||
authority_lock_path=tmp_path / "workflow" / "locks" / "canonical-writer.lock",
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
project_registry=registry,
|
||||
)
|
||||
project = ProjectRecord(
|
||||
project_id="new-research",
|
||||
title="New research",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
description="An administrator-owned research lens.",
|
||||
owners=("owner-operator",),
|
||||
north_star="Investigate a new research area.",
|
||||
key_questions=("What is known?",),
|
||||
autonomy_policy_id="new-policy",
|
||||
)
|
||||
policy = AutoAcceptPolicy(
|
||||
policy_id="new-policy",
|
||||
policy_version=1,
|
||||
project_id="new-research",
|
||||
require_supported_assessment=True,
|
||||
)
|
||||
body = {
|
||||
"project": project.model_dump(mode="json"),
|
||||
"policy": policy.model_dump(mode="json"),
|
||||
}
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://test"
|
||||
) as http:
|
||||
assert (await http.get("/v1/admin/projects")).status_code == 401
|
||||
denied = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {viewer_token}"},
|
||||
)
|
||||
assert denied.status_code == 403
|
||||
mixed_agent_denied = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {mixed_agent_admin_token}"},
|
||||
)
|
||||
assert mixed_agent_denied.status_code == 403
|
||||
oversized = await http.post(
|
||||
"/v1/admin/projects",
|
||||
content=b" " * (64 * 1024 + 1),
|
||||
headers={
|
||||
"Authorization": f"Bearer {admin_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
assert oversized.status_code == 413
|
||||
|
||||
headers = {"Authorization": f"Bearer {admin_token}"}
|
||||
diagnostics = await http.get("/v1/diagnostics", headers=headers)
|
||||
assert diagnostics.status_code == 200
|
||||
assert {
|
||||
"admin_list_projects",
|
||||
"project_create",
|
||||
"project_archive",
|
||||
}.issubset(diagnostics.json()["authorized_operations"])
|
||||
created = await http.post("/v1/admin/projects", json=body, headers=headers)
|
||||
assert created.status_code == 201, created.text
|
||||
assert created.json()["project_id"] == "new-research"
|
||||
listing = await http.get("/v1/admin/projects", headers=headers)
|
||||
assert [item["project_id"] for item in listing.json()] == ["new-research"]
|
||||
duplicate = await http.post("/v1/admin/projects", json=body, headers=headers)
|
||||
assert duplicate.status_code == 409
|
||||
archived = await http.post("/v1/admin/projects/new-research/archive", headers=headers)
|
||||
assert archived.status_code == 200
|
||||
assert archived.json()["status"] == "archived"
|
||||
assert registry.records()[0].status == ProjectStatus.ARCHIVED
|
||||
|
||||
session = identities.create_browser_session(admin_token)
|
||||
browser_project = project.model_copy(
|
||||
update={
|
||||
"project_id": "browser-created",
|
||||
"title": "Browser created",
|
||||
"autonomy_policy_id": "browser-policy",
|
||||
}
|
||||
)
|
||||
browser_policy = policy.model_copy(
|
||||
update={"policy_id": "browser-policy", "project_id": "browser-created"}
|
||||
)
|
||||
browser_body = {
|
||||
"project": browser_project.model_dump(mode="json"),
|
||||
"policy": browser_policy.model_dump(mode="json"),
|
||||
}
|
||||
cookie = {"Cookie": f"gewu_session={session.session_token}"}
|
||||
without_csrf = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=browser_body,
|
||||
headers={**cookie, "Origin": "http://test"},
|
||||
)
|
||||
assert without_csrf.status_code == 403
|
||||
wrong_origin = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=browser_body,
|
||||
headers={
|
||||
**cookie,
|
||||
"Origin": "https://attacker.example",
|
||||
"X-GEWU-CSRF": session.csrf_token,
|
||||
},
|
||||
)
|
||||
assert wrong_origin.status_code == 403
|
||||
browser_created = await http.post(
|
||||
"/v1/admin/projects",
|
||||
json=browser_body,
|
||||
headers={
|
||||
**cookie,
|
||||
"Origin": "http://test",
|
||||
"X-GEWU-CSRF": session.csrf_token,
|
||||
},
|
||||
)
|
||||
assert browser_created.status_code == 201, browser_created.text
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,439 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import yaml
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import TextContent
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.search import CanonicalSearchService
|
||||
from gewu.bootstrap.app import create_app
|
||||
from gewu.domain.contracts import EdgeEndpoint, Scope, SearchRequest, TypedEdge
|
||||
from gewu.domain.validation import evidence_digest
|
||||
from tests.domain.proposal_fixtures import CLAIM, EVIDENCE, NODE, SOURCE_REVISION
|
||||
|
||||
|
||||
def _canonical_path(prefix: str, stable_id: str) -> str:
|
||||
key = hashlib.sha256(stable_id.encode()).hexdigest()
|
||||
return f"{prefix}/{key}.yaml"
|
||||
|
||||
|
||||
def test_project_include_resolution_across_http_mcp_and_direct_reads(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
dome_node = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:scope-default-node",
|
||||
"summary": "scopeprobe dome project record",
|
||||
}
|
||||
)
|
||||
video_source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"source_id": "video:scope-source",
|
||||
"revision_id": "video:scope-source:r1",
|
||||
"title": "Video-only scope source",
|
||||
"ingested_for": ("interactive-video",),
|
||||
"artifact_sha256": None,
|
||||
"media_type": "application/octet-stream",
|
||||
"byte_length": 0,
|
||||
"original_url": "https://video.example/scope-source",
|
||||
"archive_status": "reference-only",
|
||||
}
|
||||
)
|
||||
video_evidence = EVIDENCE.model_copy(
|
||||
update={"source_revision_id": video_source.revision_id}
|
||||
)
|
||||
video_assessment = CLAIM.assessment.model_copy(
|
||||
update={"evidence_digests": (evidence_digest(video_evidence),)}
|
||||
)
|
||||
video_claim = CLAIM.model_copy(
|
||||
update={"evidence": (video_evidence,), "assessment": video_assessment}
|
||||
)
|
||||
video_node = NODE.model_copy(
|
||||
update={
|
||||
"project_id": "interactive-video",
|
||||
"node_id": "video:scope-default-node",
|
||||
"title": "Video scope fixture",
|
||||
"summary": "scopeprobe interactive video project record",
|
||||
"claims": (video_claim,),
|
||||
}
|
||||
)
|
||||
edge = TypedEdge(
|
||||
edge_id="scope:cross-project-edge",
|
||||
edge_type="compared_with",
|
||||
source=EdgeEndpoint(kind="node", id=dome_node.node_id),
|
||||
target=EdgeEndpoint(kind="node", id=video_node.node_id),
|
||||
)
|
||||
files = {
|
||||
f"projects/{hashlib.sha256(dome_node.project_id.encode()).hexdigest()}/nodes/"
|
||||
f"{hashlib.sha256(dome_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||||
dome_node.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"projects/{hashlib.sha256(video_node.project_id.encode()).hexdigest()}/nodes/"
|
||||
f"{hashlib.sha256(video_node.node_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||||
video_node.model_dump(mode="json")
|
||||
).encode(),
|
||||
(
|
||||
f"sources/{hashlib.sha256(video_source.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(video_source.revision_id.encode()).hexdigest()}.yaml"
|
||||
): yaml.safe_dump(video_source.model_dump(mode="json")).encode(),
|
||||
_canonical_path("edges", edge.edge_id): yaml.safe_dump(
|
||||
edge.model_dump(mode="json")
|
||||
).encode(),
|
||||
}
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files=files,
|
||||
message="Add project-scope fixtures",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
all_scope_token = identities.provision(
|
||||
credential_id="all-scope-reader",
|
||||
principal_id="all-scope-reader",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance", "interactive-video", "shared"),
|
||||
)
|
||||
dome_only_token = identities.provision(
|
||||
credential_id="dome-only-reader",
|
||||
principal_id="dome-only-reader",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance", "shared"),
|
||||
)
|
||||
search_index = SQLiteSearchIndex(tmp_path / "search.sqlite3", repository)
|
||||
indexed_nodes = search_index.canonical_nodes()[1]
|
||||
assert {node.node_id for node in indexed_nodes} == {dome_node.node_id, video_node.node_id}
|
||||
lexical_scope_results = search_index.search(
|
||||
query="scopeprobe",
|
||||
project_ids=("dome-performance", "interactive-video"),
|
||||
include_shared=False,
|
||||
limit=10,
|
||||
)
|
||||
assert len(lexical_scope_results.nodes) == 2, [
|
||||
item.node.summary for item in lexical_scope_results.nodes
|
||||
]
|
||||
search = CanonicalSearchService(
|
||||
index=search_index,
|
||||
project_ids_provider=lambda: ("dome-performance", "interactive-video"),
|
||||
canonical_head=repository.head,
|
||||
default_includes_provider=lambda project_id: (
|
||||
("interactive-video", "shared") if project_id == "dome-performance" else ()
|
||||
),
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
search_service=search,
|
||||
)
|
||||
direct_service_result = search.search(
|
||||
SearchRequest(
|
||||
query="scopeprobe",
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
limit=10,
|
||||
budget_tokens=50000,
|
||||
),
|
||||
principal=identities.authenticate(all_scope_token),
|
||||
)
|
||||
assert len(direct_service_result.data) == 2
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app), base_url="http://localhost"
|
||||
) as http:
|
||||
default = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert default.status_code == 200, default.text
|
||||
assert {item["node"]["project_id"] for item in default.json()["data"]} == {
|
||||
"dome-performance",
|
||||
"interactive-video",
|
||||
}, default.json()
|
||||
assert video_source.revision_id in {
|
||||
item["source_revision_id"] for item in default.json()["citations"]
|
||||
}
|
||||
|
||||
explicit_empty = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert explicit_empty.status_code == 200, explicit_empty.text
|
||||
assert {item["node"]["project_id"] for item in explicit_empty.json()["data"]} == {
|
||||
"dome-performance"
|
||||
}
|
||||
assert video_source.revision_id not in {
|
||||
item["source_revision_id"] for item in explicit_empty.json()["citations"]
|
||||
}
|
||||
|
||||
explicit_include = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": ["interactive-video"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert explicit_include.status_code == 200, explicit_include.text
|
||||
assert len(explicit_include.json()["data"]) == 2
|
||||
|
||||
denied_default = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert denied_default.status_code == 403
|
||||
denied_explicit_include = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": ["interactive-video"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert denied_explicit_include.status_code == 403
|
||||
allowed_empty = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "scopeprobe",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert allowed_empty.status_code == 200
|
||||
|
||||
default_neighbors = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": dome_node.node_id,
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert default_neighbors.status_code == 200, default_neighbors.text
|
||||
assert default_neighbors.json()["data"][0]["neighbor"]["id"] == video_node.node_id
|
||||
restricted_neighbors = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": dome_node.node_id,
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert restricted_neighbors.status_code == 200
|
||||
assert restricted_neighbors.json()["data"] == []
|
||||
|
||||
default_sources = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert default_sources.status_code == 200, default_sources.text
|
||||
assert video_source.revision_id in {
|
||||
item["source"]["revision_id"] for item in default_sources.json()["data"]
|
||||
}
|
||||
explicit_empty_sources = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert explicit_empty_sources.status_code == 200
|
||||
assert video_source.revision_id not in {
|
||||
item["source"]["revision_id"] for item in explicit_empty_sources.json()["data"]
|
||||
}
|
||||
|
||||
direct_node_denied = await http.get(
|
||||
f"/v1/projects/interactive-video/nodes/{video_node.node_id}",
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert direct_node_denied.status_code == 403
|
||||
direct_source_denied = await http.get(
|
||||
f"/v1/projects/interactive-video/sources/{video_source.revision_id}",
|
||||
headers={"Authorization": f"Bearer {dome_only_token}"},
|
||||
)
|
||||
assert direct_source_denied.status_code == 403
|
||||
direct_node_allowed = await http.get(
|
||||
f"/v1/projects/interactive-video/nodes/{video_node.node_id}",
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert direct_node_allowed.status_code == 200
|
||||
direct_source_allowed = await http.get(
|
||||
f"/v1/projects/interactive-video/sources/{video_source.revision_id}",
|
||||
headers={"Authorization": f"Bearer {all_scope_token}"},
|
||||
)
|
||||
assert direct_source_allowed.status_code == 200
|
||||
|
||||
http.headers["Authorization"] = f"Bearer {all_scope_token}"
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
mcp_default = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "scopeprobe",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_default.is_error
|
||||
assert isinstance(mcp_default.content[0], TextContent)
|
||||
default_payload = json.loads(mcp_default.content[0].text)
|
||||
assert len(default_payload["data"]) == 2
|
||||
|
||||
mcp_default_sources = await mcp.call_tool(
|
||||
"list_sources",
|
||||
{
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_default_sources.is_error
|
||||
assert isinstance(mcp_default_sources.content[0], TextContent)
|
||||
assert video_source.revision_id in {
|
||||
row["source"]["revision_id"]
|
||||
for row in json.loads(mcp_default_sources.content[0].text)["data"]
|
||||
}
|
||||
|
||||
mcp_default_neighbors = await mcp.call_tool(
|
||||
"neighbors",
|
||||
{
|
||||
"node_id": dome_node.node_id,
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"edge_types": [],
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_default_neighbors.is_error
|
||||
assert isinstance(mcp_default_neighbors.content[0], TextContent)
|
||||
assert (
|
||||
json.loads(mcp_default_neighbors.content[0].text)["data"][0]["neighbor"][
|
||||
"id"
|
||||
]
|
||||
== video_node.node_id
|
||||
)
|
||||
|
||||
mcp_explicit_empty = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "scopeprobe",
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_explicit_empty.is_error
|
||||
assert isinstance(mcp_explicit_empty.content[0], TextContent)
|
||||
assert len(json.loads(mcp_explicit_empty.content[0].text)["data"]) == 1
|
||||
|
||||
mcp_empty_sources = await mcp.call_tool(
|
||||
"list_sources",
|
||||
{
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_empty_sources.is_error
|
||||
assert isinstance(mcp_empty_sources.content[0], TextContent)
|
||||
assert video_source.revision_id not in {
|
||||
row["source"]["revision_id"]
|
||||
for row in json.loads(mcp_empty_sources.content[0].text)["data"]
|
||||
}
|
||||
|
||||
mcp_empty_neighbors = await mcp.call_tool(
|
||||
"neighbors",
|
||||
{
|
||||
"node_id": dome_node.node_id,
|
||||
"projects": ["dome-performance"],
|
||||
"include_projects": [],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"edge_types": [],
|
||||
"limit": 10,
|
||||
"budget_tokens": 50000,
|
||||
},
|
||||
)
|
||||
assert not mcp_empty_neighbors.is_error
|
||||
assert isinstance(mcp_empty_neighbors.content[0], TextContent)
|
||||
assert json.loads(mcp_empty_neighbors.content[0].text)["data"] == []
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,616 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx2
|
||||
import yaml
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.types import TextContent
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.adapters.sqlite_identity import SQLiteIdentityStore
|
||||
from gewu.adapters.sqlite_search import SQLiteSearchIndex
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.application.search import CanonicalSearchService
|
||||
from gewu.bootstrap.app import create_app
|
||||
from gewu.domain.contracts import EdgeEndpoint, NodeType, TypedEdge
|
||||
from tests.domain.proposal_fixtures import NODE, PROJECTS, SOURCE_REVISION
|
||||
|
||||
|
||||
def test_authenticated_search_scope_through_http_and_mcp(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
repository = GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
project_key = hashlib.sha256(NODE.project_id.encode()).hexdigest()
|
||||
node_key = hashlib.sha256(NODE.node_id.encode()).hexdigest()
|
||||
source_key = hashlib.sha256(SOURCE_REVISION.revision_id.encode()).hexdigest()
|
||||
stale_node = NODE.model_copy(
|
||||
update={
|
||||
"as_of": datetime(2024, 1, 1, tzinfo=UTC),
|
||||
"stale_after": datetime(2025, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
)
|
||||
problem_node = NODE.model_copy(
|
||||
update={
|
||||
"node_id": "dome:problem-browse-fixture",
|
||||
"node_type": NodeType.PROBLEM,
|
||||
"title": "Problem browse fixture",
|
||||
"value": None,
|
||||
"unit": None,
|
||||
}
|
||||
)
|
||||
problem_key = hashlib.sha256(problem_node.node_id.encode()).hexdigest()
|
||||
older_source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"source_id": "dome:older-pilot-report",
|
||||
"revision_id": "dome:older-pilot-report:r1",
|
||||
"title": "Older pilot installation report",
|
||||
"captured_at": datetime(2025, 1, 1, tzinfo=UTC),
|
||||
}
|
||||
)
|
||||
related = NODE.model_copy(update={"node_id": "z:dome-related"})
|
||||
related_key = hashlib.sha256(related.node_id.encode()).hexdigest()
|
||||
related_two = NODE.model_copy(update={"node_id": "zz:dome-related-two"})
|
||||
related_two_key = hashlib.sha256(related_two.node_id.encode()).hexdigest()
|
||||
edge = TypedEdge(
|
||||
edge_id="dome:related-edge",
|
||||
edge_type="compared_with",
|
||||
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||||
target=EdgeEndpoint(kind="node", id=related.node_id),
|
||||
)
|
||||
edge_two = TypedEdge(
|
||||
edge_id="dome:related-edge-two",
|
||||
edge_type="compared_with",
|
||||
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||||
target=EdgeEndpoint(kind="node", id=related_two.node_id),
|
||||
)
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
f"projects/{project_key}/nodes/{node_key}.yaml": yaml.safe_dump(
|
||||
stale_node.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"projects/{project_key}/nodes/{problem_key}.yaml": yaml.safe_dump(
|
||||
problem_node.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"projects/{project_key}/nodes/{related_key}.yaml": yaml.safe_dump(
|
||||
related.model_dump(mode="json")
|
||||
).encode(),
|
||||
f"projects/{project_key}/nodes/{related_two_key}.yaml": yaml.safe_dump(
|
||||
related_two.model_dump(mode="json")
|
||||
).encode(),
|
||||
(
|
||||
f"sources/{hashlib.sha256(SOURCE_REVISION.source_id.encode()).hexdigest()}/"
|
||||
f"{source_key}.yaml"
|
||||
): yaml.safe_dump(SOURCE_REVISION.model_dump(mode="json")).encode(),
|
||||
(
|
||||
f"sources/{hashlib.sha256(older_source.source_id.encode()).hexdigest()}/"
|
||||
f"{hashlib.sha256(older_source.revision_id.encode()).hexdigest()}.yaml"
|
||||
): yaml.safe_dump(older_source.model_dump(mode="json")).encode(),
|
||||
f"edges/{hashlib.sha256(edge.edge_id.encode()).hexdigest()}.yaml": yaml.safe_dump(
|
||||
edge.model_dump(mode="json")
|
||||
).encode(),
|
||||
(
|
||||
f"edges/{hashlib.sha256(edge_two.edge_id.encode()).hexdigest()}.yaml"
|
||||
): yaml.safe_dump(edge_two.model_dump(mode="json")).encode(),
|
||||
},
|
||||
message="Add searchable node",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
identities = SQLiteIdentityStore(tmp_path / "identity.sqlite3")
|
||||
token = identities.provision(
|
||||
credential_id="search-key",
|
||||
principal_id="search-reader",
|
||||
roles=("viewer",),
|
||||
project_ids=("dome-performance",),
|
||||
)
|
||||
search = CanonicalSearchService(
|
||||
index=SQLiteSearchIndex(tmp_path / "search.sqlite3", repository),
|
||||
project_ids_provider=lambda: PROJECTS.keys(),
|
||||
canonical_head=repository.head,
|
||||
)
|
||||
app = create_app(
|
||||
identity_provider=identities,
|
||||
node_reader=CanonicalNodeReader(repository),
|
||||
search_service=search,
|
||||
)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=app),
|
||||
base_url="http://localhost",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
) as http:
|
||||
diagnostic_unbuilt = await http.get("/v1/diagnostics?projects=dome-performance")
|
||||
assert diagnostic_unbuilt.status_code == 200
|
||||
unbuilt_state = diagnostic_unbuilt.json()
|
||||
assert unbuilt_state["index_state"] == "not_built"
|
||||
assert unbuilt_state["index_revision"] is None
|
||||
assert unbuilt_state["index_schema_version"] == 4
|
||||
result = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert result.status_code == 200, result.text
|
||||
envelope = result.json()
|
||||
assert envelope["retrieval_mode"] == "keyword"
|
||||
assert envelope["index_revision"] == repository.head()
|
||||
assert envelope["data"][0]["node"]["node_id"] == NODE.node_id
|
||||
assert envelope["citations"][0]["quote"] == NODE.claims[0].evidence[0].quote
|
||||
listed_sources = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert listed_sources.status_code == 200, listed_sources.text
|
||||
assert listed_sources.json()["data"][0]["source"]["revision_id"] == (
|
||||
SOURCE_REVISION.revision_id
|
||||
)
|
||||
source_page_one = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
source_cursor = source_page_one.json()["next_cursor"]
|
||||
assert source_cursor
|
||||
source_page_two = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": source_cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert source_page_two.status_code == 200
|
||||
assert source_page_two.json()["data"][0]["source"]["revision_id"] == (
|
||||
older_source.revision_id
|
||||
)
|
||||
denied_sources = await http.post(
|
||||
"/v1/sources",
|
||||
json={
|
||||
"scope": {"projects": ["interactive-video"], "include_shared": False},
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert denied_sources.status_code == 403
|
||||
problem_filter = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "unmatched phrase",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"node_types": ["problem"],
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert problem_filter.status_code == 200, problem_filter.text
|
||||
assert problem_filter.json()["data"] == []
|
||||
problem_browse = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"node_types": ["problem"],
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert problem_browse.status_code == 200, problem_browse.text
|
||||
assert [row["node"]["node_id"] for row in problem_browse.json()["data"]] == [
|
||||
problem_node.node_id
|
||||
]
|
||||
unbounded_browse = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert unbounded_browse.status_code == 422
|
||||
stale_filter = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"stale_only": True,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert stale_filter.status_code == 200, stale_filter.text
|
||||
assert stale_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id
|
||||
facet_filter = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"tags": ["attendance"],
|
||||
"audience": ["product"],
|
||||
"use_for": ["comparison"],
|
||||
"domain": ["production"],
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert facet_filter.status_code == 200, facet_filter.text
|
||||
assert facet_filter.json()["data"][0]["node"]["node_id"] == NODE.node_id
|
||||
unmatched_facet = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"domain": ["unrelated-domain"],
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert unmatched_facet.status_code == 200, unmatched_facet.text
|
||||
assert unmatched_facet.json()["data"] == []
|
||||
diagnostic_current = await http.get("/v1/diagnostics?projects=dome-performance")
|
||||
assert diagnostic_current.status_code == 200
|
||||
current_state = diagnostic_current.json()
|
||||
assert current_state["index_state"] == "current"
|
||||
assert current_state["index_revision"] == repository.head()
|
||||
assert "proposal_submit" not in current_state["authorized_operations"]
|
||||
projects = await http.get("/v1/projects")
|
||||
assert projects.status_code == 200
|
||||
assert projects.json() == ["dome-performance"]
|
||||
page_one = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert page_one.status_code == 200
|
||||
cursor = page_one.json()["next_cursor"]
|
||||
assert cursor
|
||||
page_two = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert page_two.status_code == 200
|
||||
assert (
|
||||
page_two.json()["data"][0]["node"]["node_id"]
|
||||
!= page_one.json()["data"][0]["node"]["node_id"]
|
||||
)
|
||||
reused_cursor = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "400 visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert reused_cursor.status_code == 422
|
||||
changed_facet_cursor = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"node_types": ["problem"],
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert changed_facet_cursor.status_code == 422
|
||||
changed_tag_cursor = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"tags": ["attendance"],
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert changed_tag_cursor.status_code == 422
|
||||
adjacent = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": NODE.node_id,
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert adjacent.status_code == 200, adjacent.text
|
||||
assert adjacent.json()["data"][0]["neighbor"]["id"] == related.node_id
|
||||
neighbor_cursor = adjacent.json()["next_cursor"]
|
||||
assert neighbor_cursor
|
||||
adjacent_two = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": NODE.node_id,
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": neighbor_cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert adjacent_two.status_code == 200
|
||||
assert adjacent_two.json()["data"][0]["neighbor"]["id"] == related_two.node_id
|
||||
assert adjacent_two.json()["next_cursor"] is None
|
||||
assert {citation["quote"] for citation in adjacent.json()["citations"]} == {
|
||||
NODE.claims[0].evidence[0].quote
|
||||
}
|
||||
newer = NODE.model_copy(update={"node_id": "new:unrelated-revision-node"})
|
||||
newer_key = hashlib.sha256(newer.node_id.encode()).hexdigest()
|
||||
repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={
|
||||
f"projects/{project_key}/nodes/{newer_key}.yaml": yaml.safe_dump(
|
||||
newer.model_dump(mode="json")
|
||||
).encode()
|
||||
},
|
||||
message="Advance canonical index for cursor-expiry check",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
diagnostic_lag = await http.get("/v1/diagnostics?projects=dome-performance")
|
||||
assert diagnostic_lag.status_code == 200
|
||||
lag_state = diagnostic_lag.json()
|
||||
assert lag_state["index_state"] == "lagging"
|
||||
assert lag_state["index_revision"] != repository.head()
|
||||
assert lag_state["canonical_revision"] == repository.head()
|
||||
expired_search = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert expired_search.status_code == 409
|
||||
expired_neighbors = await http.post(
|
||||
"/v1/neighbors",
|
||||
json={
|
||||
"node_id": NODE.node_id,
|
||||
"scope": {"projects": ["dome-performance"], "include_shared": False},
|
||||
"limit": 1,
|
||||
"cursor": neighbor_cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert expired_neighbors.status_code == 409
|
||||
too_small = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"budget_tokens": 256,
|
||||
},
|
||||
)
|
||||
assert too_small.status_code == 413
|
||||
dated_confidence_search = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"confidences": ["medium"],
|
||||
"as_of_from": "2024-01-01",
|
||||
"as_of_to": "2024-12-31",
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert dated_confidence_search.status_code == 200, dated_confidence_search.text
|
||||
assert [
|
||||
row["node"]["node_id"] for row in dated_confidence_search.json()["data"]
|
||||
] == [NODE.node_id]
|
||||
filtered_page = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"confidences": ["medium"],
|
||||
"limit": 1,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert filtered_page.status_code == 200
|
||||
filter_cursor = filtered_page.json()["next_cursor"]
|
||||
assert filter_cursor
|
||||
changed_filter_page = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"confidences": ["low"],
|
||||
"limit": 1,
|
||||
"cursor": filter_cursor,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert changed_filter_page.status_code == 422
|
||||
forbidden = await http.post(
|
||||
"/v1/search",
|
||||
json={
|
||||
"query": "visitors",
|
||||
"scope": {
|
||||
"projects": ["interactive-video"],
|
||||
"include_shared": False,
|
||||
},
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert forbidden.status_code == 403
|
||||
async with Client(
|
||||
streamable_http_client("http://localhost/mcp", http_client=http)
|
||||
) as mcp:
|
||||
tools = await mcp.list_tools()
|
||||
assert {tool.name for tool in tools.tools} == {
|
||||
"diagnostics",
|
||||
"get_node",
|
||||
"node_history",
|
||||
"get_source",
|
||||
"list_projects",
|
||||
"search",
|
||||
"neighbors",
|
||||
"list_sources",
|
||||
}
|
||||
listed = await mcp.call_tool("list_projects", {})
|
||||
assert not listed.is_error
|
||||
assert isinstance(listed.content[0], TextContent)
|
||||
assert json.loads(listed.content[0].text) == ["dome-performance"]
|
||||
searched = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "400 visitors",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert not searched.is_error
|
||||
assert isinstance(searched.content[0], TextContent)
|
||||
assert (
|
||||
json.loads(searched.content[0].text)["data"][0]["node"]["node_id"]
|
||||
== NODE.node_id
|
||||
)
|
||||
dated_confidence_tool = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "visitors",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
"confidences": ["medium"],
|
||||
"as_of_from": "2024-01-01",
|
||||
"as_of_to": "2024-12-31",
|
||||
},
|
||||
)
|
||||
assert not dated_confidence_tool.is_error
|
||||
assert [
|
||||
row["node"]["node_id"]
|
||||
for row in json.loads(dated_confidence_tool.content[0].text)["data"]
|
||||
] == [NODE.node_id]
|
||||
filtered_search = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "unmatched phrase",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
"node_types": ["problem"],
|
||||
},
|
||||
)
|
||||
assert not filtered_search.is_error
|
||||
assert json.loads(filtered_search.content[0].text)["data"] == []
|
||||
browsed_problems = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
"node_types": ["problem"],
|
||||
},
|
||||
)
|
||||
assert not browsed_problems.is_error
|
||||
assert [
|
||||
row["node"]["node_id"]
|
||||
for row in json.loads(browsed_problems.content[0].text)["data"]
|
||||
] == [problem_node.node_id]
|
||||
facet_search = await mcp.call_tool(
|
||||
"search",
|
||||
{
|
||||
"query": "400 visitors",
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
"tags": ["attendance"],
|
||||
"audience": ["product"],
|
||||
"use_for": ["comparison"],
|
||||
"domain": ["production"],
|
||||
},
|
||||
)
|
||||
assert not facet_search.is_error
|
||||
assert (
|
||||
json.loads(facet_search.content[0].text)["data"][0]["node"]["node_id"]
|
||||
== NODE.node_id
|
||||
)
|
||||
listed_sources_tool = await mcp.call_tool(
|
||||
"list_sources",
|
||||
{
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert not listed_sources_tool.is_error
|
||||
assert (
|
||||
json.loads(listed_sources_tool.content[0].text)["data"][0]["source"][
|
||||
"revision_id"
|
||||
]
|
||||
== SOURCE_REVISION.revision_id
|
||||
)
|
||||
adjacent_tool = await mcp.call_tool(
|
||||
"neighbors",
|
||||
{
|
||||
"node_id": NODE.node_id,
|
||||
"projects": ["dome-performance"],
|
||||
"include_shared": False,
|
||||
"all_projects": False,
|
||||
"edge_types": [],
|
||||
"limit": 10,
|
||||
"budget_tokens": 5000,
|
||||
},
|
||||
)
|
||||
assert not adjacent_tool.is_error
|
||||
assert isinstance(adjacent_tool.content[0], TextContent)
|
||||
assert (
|
||||
json.loads(adjacent_tool.content[0].text)["data"][0]["neighbor"]["id"]
|
||||
== related.node_id
|
||||
)
|
||||
|
||||
asyncio.run(exercise())
|
||||
@@ -0,0 +1,374 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from mcp import Client
|
||||
from mcp.server import MCPServer
|
||||
from mcp.types import CallToolResult, TextContent
|
||||
|
||||
from gewu.domain.contracts import SourceRevisionRecord
|
||||
from gewu.transports.stdio_connector import (
|
||||
SelectedFile,
|
||||
UploadStateStore,
|
||||
_local_source_id,
|
||||
_local_source_revision_id,
|
||||
_read_selected_file,
|
||||
_remote_factory,
|
||||
_selected_media_type,
|
||||
create_connector_server,
|
||||
upload_selected_file,
|
||||
)
|
||||
|
||||
|
||||
def test_connector_uploads_allowlisted_bytes_without_exposing_them_to_local_tools(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
async def exercise() -> None:
|
||||
source = tmp_path / "selected.md"
|
||||
payload = b"# Private source\n" + b"Exact source bytes stay in the connector.\n" * 20_000
|
||||
source.write_bytes(payload)
|
||||
backend = MCPServer("test GEWU backend")
|
||||
chunks: list[bytes] = []
|
||||
interrupted = False
|
||||
extraction_interrupted = False
|
||||
expected_digest = hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@backend.tool(name="source_upload_begin", structured_output=False)
|
||||
def begin(
|
||||
project_id: str,
|
||||
filename: str,
|
||||
media_type: str,
|
||||
byte_length: int,
|
||||
expected_sha256: str,
|
||||
) -> str:
|
||||
assert (project_id, filename, media_type, byte_length, expected_sha256) == (
|
||||
"dome-performance",
|
||||
"selected.md",
|
||||
"text/markdown",
|
||||
len(payload),
|
||||
expected_digest,
|
||||
)
|
||||
return json.dumps({"upload_id": "opaque-upload-id"})
|
||||
|
||||
@backend.tool(name="source_upload_chunk", structured_output=False)
|
||||
def chunk(upload_id: str, offset: int, content_base64: str, chunk_sha256: str) -> str:
|
||||
nonlocal interrupted
|
||||
decoded = base64.b64decode(content_base64, validate=True)
|
||||
assert upload_id == "opaque-upload-id"
|
||||
assert hashlib.sha256(decoded).hexdigest() == chunk_sha256
|
||||
assert offset == sum(map(len, chunks))
|
||||
if offset > 0 and not interrupted:
|
||||
interrupted = True
|
||||
raise ValueError("simulated interrupted transfer")
|
||||
chunks.append(decoded)
|
||||
return json.dumps({"upload_id": upload_id})
|
||||
|
||||
@backend.tool(name="source_upload_status", structured_output=False)
|
||||
def status(upload_id: str) -> str:
|
||||
assert upload_id == "opaque-upload-id"
|
||||
return json.dumps({"verified_offsets": [0] if chunks else []})
|
||||
|
||||
@backend.tool(name="source_upload_finalize", structured_output=False)
|
||||
def finalize(upload_id: str) -> str:
|
||||
assert upload_id == "opaque-upload-id"
|
||||
return json.dumps(
|
||||
{"sha256": hashlib.sha256(b"".join(chunks)).hexdigest(), "status": "staged"}
|
||||
)
|
||||
|
||||
@backend.tool(name="source_extract", structured_output=False)
|
||||
def extract(project_id: str, artifact_sha256: str, source_revision_id: str) -> str:
|
||||
nonlocal extraction_interrupted
|
||||
assert project_id == "dome-performance"
|
||||
assert artifact_sha256 == expected_digest
|
||||
assert source_revision_id == "source-r1"
|
||||
if not extraction_interrupted:
|
||||
extraction_interrupted = True
|
||||
raise ValueError("simulated extraction interruption")
|
||||
return json.dumps({"extraction_id": "e" * 64, "blocks": []})
|
||||
|
||||
@asynccontextmanager
|
||||
async def remote_factory() -> AsyncIterator[Client]:
|
||||
async with Client(backend) as remote:
|
||||
yield remote
|
||||
|
||||
state_file = tmp_path / "connector-state.json"
|
||||
selected_files = (SelectedFile("research-note", source),)
|
||||
|
||||
async def connector_call() -> CallToolResult:
|
||||
connector_after_restart = create_connector_server(
|
||||
selected_files, remote_factory, UploadStateStore(state_file)
|
||||
)
|
||||
async with Client(connector_after_restart) as local_after_restart:
|
||||
return await local_after_restart.call_tool(
|
||||
"upload_selected_file",
|
||||
{
|
||||
"file_id": "research-note",
|
||||
"project_id": "dome-performance",
|
||||
"source_revision_id": "source-r1",
|
||||
},
|
||||
)
|
||||
|
||||
connector = create_connector_server(
|
||||
selected_files, remote_factory, UploadStateStore(state_file)
|
||||
)
|
||||
async with Client(connector) as local:
|
||||
tools = await local.list_tools()
|
||||
assert {tool.name for tool in tools.tools} == {
|
||||
"list_selected_files",
|
||||
"upload_selected_file",
|
||||
}
|
||||
listed = await local.call_tool("list_selected_files", {})
|
||||
assert not listed.is_error
|
||||
assert isinstance(listed.content[0], TextContent)
|
||||
assert "research-note" in listed.content[0].text
|
||||
assert str(source) not in listed.content[0].text
|
||||
denied = await local.call_tool(
|
||||
"upload_selected_file",
|
||||
{
|
||||
"file_id": "arbitrary-path",
|
||||
"project_id": "dome-performance",
|
||||
"source_revision_id": "source-r1",
|
||||
},
|
||||
)
|
||||
assert denied.is_error
|
||||
interrupted_result = await connector_call()
|
||||
assert interrupted_result.is_error
|
||||
assert state_file.exists()
|
||||
finalize_interrupted_result = await connector_call()
|
||||
assert finalize_interrupted_result.is_error
|
||||
assert json.loads(state_file.read_text())
|
||||
restarted_result = await connector_call()
|
||||
assert not restarted_result.is_error
|
||||
assert isinstance(restarted_result.content[0], TextContent)
|
||||
receipt = json.loads(restarted_result.content[0].text)
|
||||
assert receipt["sha256"] == expected_digest
|
||||
assert receipt["byte_length"] == len(payload)
|
||||
assert payload[:80].decode() not in restarted_result.content[0].text
|
||||
source_candidate = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(receipt["source_candidate"]), strict=True
|
||||
)
|
||||
assert source_candidate.archive_status == "archived"
|
||||
assert source_candidate.capture_method == "local-file"
|
||||
assert source_candidate.source_id.startswith("local:")
|
||||
assert source_candidate.revision_id == "source-r1"
|
||||
assert source_candidate.artifact_sha256 == expected_digest
|
||||
assert str(source) not in restarted_result.content[0].text
|
||||
assert b"".join(chunks) == payload
|
||||
state = json.loads(state_file.read_text())
|
||||
assert len(state) == 1
|
||||
assert "upload_id" not in next(iter(state.values()))
|
||||
duplicate_result = await connector_call()
|
||||
assert not duplicate_result.is_error
|
||||
assert json.loads(duplicate_result.content[0].text) == receipt
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_connector_rejects_symlinked_selected_paths(tmp_path: Path) -> None:
|
||||
outside = tmp_path / "outside.md"
|
||||
outside.write_text("private")
|
||||
link = tmp_path / "selected.md"
|
||||
link.symlink_to(outside)
|
||||
with pytest.raises(ValueError, match="symbolic links"):
|
||||
_read_selected_file(SelectedFile("selected", link))
|
||||
|
||||
|
||||
def test_local_file_identity_is_path_free_and_revisions_follow_content() -> None:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
captured_at = datetime(2026, 10, 13, tzinfo=UTC)
|
||||
source_id = _local_source_id("research-note")
|
||||
first = _local_source_revision_id(source_id, hashlib.sha256(b"first").hexdigest(), captured_at)
|
||||
second = _local_source_revision_id(
|
||||
source_id, hashlib.sha256(b"second").hexdigest(), captured_at
|
||||
)
|
||||
assert source_id.startswith("local:")
|
||||
assert "/" not in source_id
|
||||
assert first != second
|
||||
assert first.startswith(f"{source_id}:r:")
|
||||
assert len(first) <= 128
|
||||
|
||||
|
||||
def test_selected_file_media_types_are_explicit() -> None:
|
||||
assert _selected_media_type(Path("source.pdf")) == "application/pdf"
|
||||
assert _selected_media_type(Path("source.md")) == "text/markdown"
|
||||
assert _selected_media_type(Path("source.html")) == "text/html"
|
||||
assert _selected_media_type(Path("source.txt")) == "text/plain"
|
||||
assert _selected_media_type(Path("source.vtt")) == "text/vtt"
|
||||
assert _selected_media_type(Path("source.srt")) == "application/x-subrip"
|
||||
with pytest.raises(ValueError, match="not supported"):
|
||||
_selected_media_type(Path("source.exe"))
|
||||
|
||||
|
||||
def test_vtt_connector_candidate_is_marked_as_transcript(tmp_path: Path) -> None:
|
||||
async def exercise() -> None:
|
||||
source = tmp_path / "interview.vtt"
|
||||
content = b"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nExact words.\n"
|
||||
source.write_bytes(content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
|
||||
class Remote:
|
||||
async def call_tool(self, name: str, arguments: dict[str, object]) -> object:
|
||||
response: dict[str, object]
|
||||
if name == "source_upload_begin":
|
||||
response = {"upload_id": "transcript-upload"}
|
||||
elif name == "source_upload_chunk":
|
||||
response = {"upload_id": "transcript-upload"}
|
||||
elif name == "source_upload_finalize":
|
||||
response = {"sha256": digest}
|
||||
elif name == "source_extract":
|
||||
response = {"status": "complete", "extraction_id": "e" * 64, "blocks": []}
|
||||
else:
|
||||
raise AssertionError(f"Unexpected remote tool: {name}")
|
||||
return type(
|
||||
"ToolResult",
|
||||
(),
|
||||
{
|
||||
"is_error": False,
|
||||
"content": [TextContent(type="text", text=json.dumps(response))],
|
||||
},
|
||||
)()
|
||||
|
||||
receipt = await upload_selected_file(
|
||||
Remote(), SelectedFile("interview", source), project_id="dome-performance"
|
||||
)
|
||||
candidate = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(receipt["source_candidate"]), strict=True
|
||||
)
|
||||
assert candidate.capture_method == "transcript"
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_local_connector_returns_archived_candidate_when_pdf_extraction_is_unavailable(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
async def exercise() -> None:
|
||||
source = tmp_path / "scanned.pdf"
|
||||
content = b"%PDF-1.4 fixture"
|
||||
source.write_bytes(content)
|
||||
digest = hashlib.sha256(content).hexdigest()
|
||||
calls: list[str] = []
|
||||
|
||||
class Remote:
|
||||
async def call_tool(self, name: str, arguments: dict[str, object]) -> object:
|
||||
calls.append(name)
|
||||
response: dict[str, object]
|
||||
if name == "source_upload_begin":
|
||||
response = {"upload_id": "active-upload"}
|
||||
elif name == "source_upload_chunk":
|
||||
assert arguments["chunk_sha256"] == digest
|
||||
response = {"upload_id": "active-upload"}
|
||||
elif name == "source_upload_finalize":
|
||||
response = {"sha256": digest}
|
||||
elif name == "source_extract":
|
||||
response = {
|
||||
"status": "unavailable",
|
||||
"error_code": "PDF_NO_TEXT",
|
||||
"blocks": [],
|
||||
}
|
||||
else:
|
||||
raise AssertionError(f"Unexpected remote tool: {name}")
|
||||
return type(
|
||||
"ToolResult",
|
||||
(),
|
||||
{
|
||||
"is_error": False,
|
||||
"content": [TextContent(type="text", text=json.dumps(response))],
|
||||
},
|
||||
)()
|
||||
|
||||
state = UploadStateStore(tmp_path / "state.json")
|
||||
first = await upload_selected_file(
|
||||
Remote(),
|
||||
SelectedFile("scanned-paper", source),
|
||||
project_id="dome-performance",
|
||||
state_store=state,
|
||||
)
|
||||
second = await upload_selected_file(
|
||||
Remote(),
|
||||
SelectedFile("scanned-paper", source),
|
||||
project_id="dome-performance",
|
||||
state_store=state,
|
||||
)
|
||||
|
||||
candidate = SourceRevisionRecord.model_validate_json(
|
||||
json.dumps(first["source_candidate"]), strict=True
|
||||
)
|
||||
assert candidate.archive_status == "archived"
|
||||
assert candidate.artifact_sha256 == digest
|
||||
assert len(candidate.revision_id) <= 128
|
||||
assert first["extraction_status"] == "unavailable"
|
||||
assert first["extraction_error_code"] == "PDF_NO_TEXT"
|
||||
assert second["source_candidate"] == first["source_candidate"]
|
||||
assert calls.count("source_upload_begin") == 1
|
||||
assert calls.count("source_extract") == 2
|
||||
state_record = next(iter(json.loads((tmp_path / "state.json").read_text()).values()))
|
||||
assert "completed_receipt" not in state_record
|
||||
assert str(source) not in json.dumps(first)
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_connector_rejects_cleartext_remote_service_urls() -> None:
|
||||
with pytest.raises(ValueError, match="HTTPS"):
|
||||
_remote_factory("http://gewu.example/mcp", "gwu_test")
|
||||
_remote_factory("https://gewu.example/mcp", "gwu_test")
|
||||
_remote_factory("http://localhost:8000/mcp", "gwu_test")
|
||||
|
||||
|
||||
def test_remote_factory_verifies_service_certificate_before_authentication(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import gewu.transports.stdio_connector as connector_module
|
||||
|
||||
observed: dict[str, object] = {}
|
||||
|
||||
class HttpClient:
|
||||
def __init__(self, **kwargs: object) -> None:
|
||||
observed.update(kwargs)
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
return None
|
||||
|
||||
class RemoteClient:
|
||||
def __init__(self, transport: object) -> None:
|
||||
self.transport = transport
|
||||
|
||||
async def __aenter__(self) -> object:
|
||||
await self.transport.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
await self.transport.__aexit__(*args)
|
||||
return None
|
||||
|
||||
@asynccontextmanager
|
||||
async def transport_factory(url: str, *, http_client: object):
|
||||
observed["url"] = url
|
||||
observed["http_client"] = http_client
|
||||
yield object()
|
||||
|
||||
monkeypatch.setattr(connector_module.httpx2, "AsyncClient", HttpClient)
|
||||
monkeypatch.setattr(connector_module, "Client", RemoteClient)
|
||||
monkeypatch.setattr(connector_module, "streamable_http_client", transport_factory)
|
||||
|
||||
async def exercise() -> None:
|
||||
async with _remote_factory("https://gewu.example/mcp", "secret-token")() as remote:
|
||||
assert isinstance(remote, RemoteClient)
|
||||
|
||||
asyncio.run(exercise())
|
||||
assert observed["verify"] is True
|
||||
assert observed["headers"] == {"Authorization": "Bearer secret-token"}
|
||||
assert observed["url"] == "https://gewu.example/mcp"
|
||||
assert "secret-token" not in str(observed["url"])
|
||||
@@ -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"
|
||||
@@ -0,0 +1 @@
|
||||
"""Reusable contract checks for replaceable implementation adapters."""
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Shared canonical repository contract and a substitutable consumer test."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from gewu.adapters.git_repository import GitCanonicalRepository
|
||||
from gewu.application.identity import Principal
|
||||
from gewu.application.ports import (
|
||||
CanonicalConflictError,
|
||||
CanonicalRepository,
|
||||
CanonicalRepositoryError,
|
||||
validate_canonical_path,
|
||||
)
|
||||
from gewu.application.reading import CanonicalNodeReader
|
||||
from gewu.domain.contracts import KnowledgeNode
|
||||
from tests.domain.proposal_fixtures import NODE
|
||||
|
||||
|
||||
class InMemoryCanonicalRepository:
|
||||
"""Independent port implementation used to prove consumer substitution."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
initial = hashlib.sha256(b"memory-repository-initial").hexdigest()[:40]
|
||||
self._head = initial
|
||||
self._snapshots = {initial: {}}
|
||||
self._parents: dict[str, str | None] = {initial: None}
|
||||
self._changed: dict[str, frozenset[str]] = {initial: frozenset()}
|
||||
|
||||
def head(self) -> str:
|
||||
return self._head
|
||||
|
||||
def read_file(self, revision: str, path: str) -> bytes | None:
|
||||
validate_canonical_path(path)
|
||||
try:
|
||||
return self._snapshots[revision].get(path)
|
||||
except KeyError as error:
|
||||
raise CanonicalRepositoryError("Unknown canonical revision") from error
|
||||
|
||||
def list_files(self, revision: str, prefix: str) -> tuple[str, ...]:
|
||||
prefix = prefix.rstrip("/")
|
||||
validate_canonical_path(prefix)
|
||||
try:
|
||||
files = self._snapshots[revision]
|
||||
except KeyError as error:
|
||||
raise CanonicalRepositoryError("Unknown canonical revision") from error
|
||||
return tuple(sorted(path for path in files if path.startswith(prefix)))
|
||||
|
||||
def publish_files(
|
||||
self,
|
||||
*,
|
||||
expected_head: str,
|
||||
files: Mapping[str, bytes],
|
||||
message: str,
|
||||
index_directory: Path,
|
||||
) -> str:
|
||||
del message, index_directory
|
||||
if expected_head != self._head:
|
||||
raise CanonicalConflictError("Canonical branch changed before publication")
|
||||
if not files:
|
||||
raise CanonicalRepositoryError("Canonical publication requires at least one file")
|
||||
for path in files:
|
||||
validate_canonical_path(path)
|
||||
parent_files = self._snapshots[self._head]
|
||||
changed = frozenset(path for path, data in files.items() if parent_files.get(path) != data)
|
||||
digest = hashlib.sha256()
|
||||
digest.update(self._head.encode())
|
||||
for path, data in sorted(files.items()):
|
||||
digest.update(path.encode())
|
||||
digest.update(data)
|
||||
revision = digest.hexdigest()[:40]
|
||||
snapshot = {**parent_files, **files}
|
||||
self._snapshots[revision] = snapshot
|
||||
self._parents[revision] = self._head
|
||||
self._changed[revision] = changed
|
||||
self._head = revision
|
||||
return revision
|
||||
|
||||
def introducing_commit(self, revision: str, path: str) -> str | None:
|
||||
revisions = self.history(revision, path, 50)
|
||||
return revisions[-1] if revisions else None
|
||||
|
||||
def history(self, revision: str, path: str, limit: int) -> tuple[str, ...]:
|
||||
validate_canonical_path(path)
|
||||
if not 1 <= limit <= 50:
|
||||
raise CanonicalRepositoryError("Canonical history limit is invalid")
|
||||
if revision not in self._snapshots:
|
||||
raise CanonicalRepositoryError("Unknown canonical revision")
|
||||
revisions: list[str] = []
|
||||
cursor: str | None = revision
|
||||
while cursor is not None and len(revisions) < limit:
|
||||
if path in self._changed[cursor]:
|
||||
revisions.append(cursor)
|
||||
cursor = self._parents[cursor]
|
||||
return tuple(revisions)
|
||||
|
||||
|
||||
@pytest.fixture(params=("git", "memory"), ids=("git-adapter", "in-memory-substitute"))
|
||||
def canonical_repository(request: pytest.FixtureRequest, tmp_path: Path) -> CanonicalRepository:
|
||||
if request.param == "git":
|
||||
return GitCanonicalRepository(tmp_path / "knowledge.git")
|
||||
return InMemoryCanonicalRepository()
|
||||
|
||||
|
||||
def test_canonical_repository_contract(
|
||||
canonical_repository: CanonicalRepository,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
base = canonical_repository.head()
|
||||
record = "projects/project-a/nodes/node-a.yaml"
|
||||
source = "sources/source-a/revision-a.yaml"
|
||||
first = canonical_repository.publish_files(
|
||||
expected_head=base,
|
||||
files={record: b"title: first\n", source: b"title: source\n"},
|
||||
message="Initial records",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
assert canonical_repository.head() == first
|
||||
assert canonical_repository.read_file(first, record) == b"title: first\n"
|
||||
assert canonical_repository.list_files(first, "projects/") == (record,)
|
||||
assert canonical_repository.read_file(first, "unrelated/user-file.txt") is None
|
||||
assert canonical_repository.introducing_commit(first, record) == first
|
||||
assert canonical_repository.history(first, record, 20) == (first,)
|
||||
assert canonical_repository.introducing_commit(first, "projects/absent.yaml") is None
|
||||
|
||||
second = canonical_repository.publish_files(
|
||||
expected_head=first,
|
||||
files={record: b"title: second\n"},
|
||||
message="Update record",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
assert canonical_repository.read_file(first, record) == b"title: first\n"
|
||||
assert canonical_repository.read_file(second, record) == b"title: second\n"
|
||||
assert canonical_repository.history(second, record, 20) == (second, first)
|
||||
assert canonical_repository.introducing_commit(second, record) == first
|
||||
with pytest.raises(CanonicalConflictError):
|
||||
canonical_repository.publish_files(
|
||||
expected_head=base,
|
||||
files={record: b"stale\n"},
|
||||
message="Stale update",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
assert canonical_repository.head() == second
|
||||
with pytest.raises(CanonicalRepositoryError):
|
||||
canonical_repository.read_file(second, "../outside.yaml")
|
||||
with pytest.raises(CanonicalRepositoryError):
|
||||
canonical_repository.publish_files(
|
||||
expected_head=second,
|
||||
files={"../outside.yaml": b"unsafe\n"},
|
||||
message="Unsafe update",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
|
||||
|
||||
def test_node_reader_works_unchanged_with_substitute_repository(tmp_path: Path) -> None:
|
||||
repository = InMemoryCanonicalRepository()
|
||||
project_hash = hashlib.sha256(NODE.project_id.encode()).hexdigest()
|
||||
node_hash = hashlib.sha256(NODE.node_id.encode()).hexdigest()
|
||||
path = f"projects/{project_hash}/nodes/{node_hash}.yaml"
|
||||
raw = yaml.safe_dump(NODE.model_dump(mode="json"), allow_unicode=True).encode()
|
||||
revision = repository.publish_files(
|
||||
expected_head=repository.head(),
|
||||
files={path: raw},
|
||||
message="Publish reader fixture",
|
||||
index_directory=tmp_path / "publishing",
|
||||
)
|
||||
|
||||
reader = CanonicalNodeReader(repository)
|
||||
response = reader.get_node(
|
||||
NODE.project_id,
|
||||
NODE.node_id,
|
||||
principal=Principal(
|
||||
"reader",
|
||||
"reader-key",
|
||||
frozenset({"viewer"}),
|
||||
frozenset({NODE.project_id}),
|
||||
),
|
||||
)
|
||||
loaded = KnowledgeNode.model_validate_json(json.dumps(response.data[0]), strict=True)
|
||||
assert loaded == NODE
|
||||
assert response.canonical_revision == revision
|
||||
assert response.citations == NODE.claims[0].evidence
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain contract tests and fixtures."""
|
||||
@@ -0,0 +1,212 @@
|
||||
"""Golden pilot proposal fixtures for deterministic evidence and policy checks."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from gewu.domain.contracts import (
|
||||
AutoAcceptPolicy,
|
||||
Claim,
|
||||
Confidence,
|
||||
EvidenceReference,
|
||||
ExtractionBlock,
|
||||
ExtractionRecord,
|
||||
KnowledgeNode,
|
||||
LensFit,
|
||||
Lifecycle,
|
||||
Locator,
|
||||
NodeType,
|
||||
ProjectRecord,
|
||||
ProjectStatus,
|
||||
ProposalOperation,
|
||||
ProposalRevision,
|
||||
ReliabilityGrade,
|
||||
ReviewState,
|
||||
Scope,
|
||||
SourceRevisionRecord,
|
||||
SupportAssessment,
|
||||
SupportStatus,
|
||||
)
|
||||
from gewu.domain.validation import (
|
||||
assertion_digest,
|
||||
evidence_digest,
|
||||
extraction_digest,
|
||||
proposal_payload_digest,
|
||||
)
|
||||
|
||||
BASE_COMMIT = "1" * 40
|
||||
NOW = datetime(2026, 10, 13, tzinfo=UTC)
|
||||
SOURCE_REVISION = SourceRevisionRecord(
|
||||
source_id="shared:pilot-report",
|
||||
revision_id="shared:pilot-report:r1",
|
||||
artifact_sha256="2" * 64,
|
||||
media_type="text/markdown",
|
||||
byte_length=100,
|
||||
captured_at=NOW,
|
||||
original_url=None,
|
||||
title="Pilot dome installation report",
|
||||
authors=("Pilot team",),
|
||||
publisher="GEWU fixtures",
|
||||
license="internal",
|
||||
source_type="internal",
|
||||
reliability_grade=ReliabilityGrade.MEDIUM,
|
||||
ingested_for=("dome-performance",),
|
||||
archive_status="archived",
|
||||
)
|
||||
BLOCK_TEXT = "The installation hosted 400 visitors during the 2026 season."
|
||||
_EXTRACTION_UNHASHED = ExtractionRecord(
|
||||
extraction_id="0" * 64,
|
||||
source_revision_id=SOURCE_REVISION.revision_id,
|
||||
parser_name="gewu-text",
|
||||
parser_version="0.1.0",
|
||||
parser_config_sha256="4" * 64,
|
||||
normalization_profile="unicode-nfc-lf-v1",
|
||||
status="complete",
|
||||
blocks=(
|
||||
ExtractionBlock(
|
||||
block_id="block-1",
|
||||
ordinal=0,
|
||||
original_text=BLOCK_TEXT,
|
||||
normalized_text=BLOCK_TEXT,
|
||||
normalization_map=tuple((index, index + 1) for index in range(len(BLOCK_TEXT))),
|
||||
page=None,
|
||||
),
|
||||
),
|
||||
)
|
||||
EXTRACTION = _EXTRACTION_UNHASHED.model_copy(
|
||||
update={"extraction_id": extraction_digest(_EXTRACTION_UNHASHED)}
|
||||
)
|
||||
CLAIM_TEXT = "The installation hosted 400 visitors during the 2026 season."
|
||||
CLAIM_DIGEST = assertion_digest(CLAIM_TEXT)
|
||||
START = 0
|
||||
END = len(BLOCK_TEXT)
|
||||
EVIDENCE = EvidenceReference(
|
||||
source_revision_id=SOURCE_REVISION.revision_id,
|
||||
extraction_id=EXTRACTION.extraction_id,
|
||||
assertion_path="claims[0].text",
|
||||
assertion_digest=CLAIM_DIGEST,
|
||||
quote=BLOCK_TEXT,
|
||||
locator=Locator(kind="text_span", block_id="block-1", start=START, end=END),
|
||||
normalization_profile="unicode-nfc-lf-v1",
|
||||
)
|
||||
CLAIM = Claim(
|
||||
claim_id="dome:claim-visitors",
|
||||
text=CLAIM_TEXT,
|
||||
evidence_type="reported",
|
||||
evidence=(EVIDENCE,),
|
||||
assessment=SupportAssessment(
|
||||
assessment_id="assessment-1",
|
||||
assertion_digest=CLAIM_DIGEST,
|
||||
evidence_digests=(evidence_digest(EVIDENCE),),
|
||||
assessor_id="agent-pilot",
|
||||
run_id="run-pilot-1",
|
||||
method="paired source claim check",
|
||||
status=SupportStatus.SUPPORTED,
|
||||
rationale="The quoted sentence states the count and period.",
|
||||
),
|
||||
)
|
||||
NODE = KnowledgeNode(
|
||||
node_id="dome:season-visitors",
|
||||
project_id="dome-performance",
|
||||
node_type=NodeType.DATAPOINT,
|
||||
title="Dome season visitors",
|
||||
summary="The installation hosted 400 visitors in the 2026 season.",
|
||||
created=NOW,
|
||||
updated=NOW,
|
||||
review_state=ReviewState.DRAFT,
|
||||
lifecycle=Lifecycle.ACTIVE,
|
||||
confidence=Confidence.MEDIUM,
|
||||
claim_support="supported",
|
||||
tags=("attendance",),
|
||||
audience=("product",),
|
||||
domain=("production",),
|
||||
use_for=("comparison",),
|
||||
lens_fit={
|
||||
"dome-performance": LensFit(
|
||||
score=0.9,
|
||||
horizon="H1",
|
||||
key_question="How do audiences engage?",
|
||||
reason="Reports a representative audience measure.",
|
||||
),
|
||||
},
|
||||
run_id="run-pilot-1",
|
||||
agent_id="agent-pilot",
|
||||
model_id="fixture-model",
|
||||
claims=(CLAIM,),
|
||||
as_of=NOW,
|
||||
stale_after=datetime(2027, 1, 1, tzinfo=UTC),
|
||||
value=400,
|
||||
unit="visitors",
|
||||
sample_size=None,
|
||||
reporting_year=2026,
|
||||
)
|
||||
PROJECTS = {
|
||||
"dome-performance": ProjectRecord(
|
||||
project_id="dome-performance",
|
||||
title="Dome interactive content",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
north_star="Create compelling interactive experiences for dome audiences.",
|
||||
key_questions=("How do audiences engage?",),
|
||||
autonomy_policy_id="dome-policy",
|
||||
),
|
||||
"interactive-video": ProjectRecord(
|
||||
project_id="interactive-video",
|
||||
title="Interactive video",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
north_star="Understand useful interactive video experiences.",
|
||||
key_questions=("How do teams control branching?",),
|
||||
autonomy_policy_id="video-policy",
|
||||
),
|
||||
}
|
||||
POLICIES = {
|
||||
"dome-policy": AutoAcceptPolicy(
|
||||
policy_id="dome-policy",
|
||||
policy_version=1,
|
||||
project_id="dome-performance",
|
||||
automatic_node_types=("datapoint", "problem", "technique", "case"),
|
||||
automatic_source_registration=True,
|
||||
require_supported_assessment=True,
|
||||
),
|
||||
"video-policy": AutoAcceptPolicy(
|
||||
policy_id="video-policy",
|
||||
policy_version=1,
|
||||
project_id="interactive-video",
|
||||
automatic_node_types=("datapoint", "problem", "technique", "case"),
|
||||
automatic_source_registration=True,
|
||||
require_supported_assessment=True,
|
||||
),
|
||||
"shared": AutoAcceptPolicy(
|
||||
policy_id="shared",
|
||||
policy_version=1,
|
||||
project_id="shared",
|
||||
automatic_source_registration=True,
|
||||
require_supported_assessment=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def proposal(
|
||||
*,
|
||||
node: KnowledgeNode | None = NODE,
|
||||
source: SourceRevisionRecord | None = SOURCE_REVISION,
|
||||
scope: Scope | None = None,
|
||||
base_commit: str = BASE_COMMIT,
|
||||
) -> ProposalRevision:
|
||||
operations = tuple(
|
||||
operation
|
||||
for operation in (
|
||||
ProposalOperation(operation="register_source", source=source) if source else None,
|
||||
ProposalOperation(operation="upsert_node", node=node) if node else None,
|
||||
)
|
||||
if operation is not None
|
||||
)
|
||||
candidate = ProposalRevision(
|
||||
proposal_id="proposal-pilot-1",
|
||||
revision=1,
|
||||
initiating_project_id="dome-performance",
|
||||
scope=scope or Scope(projects=("dome-performance",), include_shared=True),
|
||||
base_commit=base_commit,
|
||||
payload_digest="0" * 64,
|
||||
proposer_id="agent-pilot",
|
||||
run_id="run-pilot-1",
|
||||
operations=operations,
|
||||
)
|
||||
return candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
|
||||
@@ -0,0 +1,14 @@
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from scripts.export_agent_contract import PACK
|
||||
|
||||
|
||||
def test_pack_manifest_and_nested_contract_hashes_verify() -> None:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(PACK / "scripts" / "manifest_tool.py"), "verify"],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
@@ -0,0 +1,40 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from gewu.domain.operations import OPERATIONS, Transport, catalog_bytes, operations_for
|
||||
|
||||
|
||||
def test_read_http_catalog_contains_no_mutation_operations() -> None:
|
||||
http_operations = operations_for(Transport.HTTP)
|
||||
assert all(not operation.mutates_knowledge for operation in http_operations)
|
||||
assert not {"proposal_submit", "owner_decide"} & {item.name for item in http_operations}
|
||||
|
||||
|
||||
def test_owner_decision_is_separate_from_agent_mcp() -> None:
|
||||
agent_operations = {item.name for item in operations_for(Transport.MCP)}
|
||||
owner_operations = {item.name for item in operations_for(Transport.OWNER_MCP)}
|
||||
assert "owner_decide" not in agent_operations
|
||||
assert owner_operations == {"owner_challenge_get", "owner_diff_get", "owner_decide"}
|
||||
|
||||
|
||||
def test_stdio_connector_has_only_selected_file_operations() -> None:
|
||||
stdio_operations = {item.name for item in operations_for(Transport.STDIO)}
|
||||
assert stdio_operations == {"list_selected_files", "upload_selected_file"}
|
||||
|
||||
|
||||
def test_exported_catalog_matches_the_application_allowlist() -> None:
|
||||
path = Path(__file__).resolve().parents[2] / "schema" / "v1" / "catalog.json"
|
||||
catalog = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert path.read_bytes() == catalog_bytes()
|
||||
names_by_transport = {
|
||||
transport.value: {operation.name for operation in operations_for(transport)}
|
||||
for transport in Transport
|
||||
}
|
||||
entries = {operation["name"]: operation for operation in catalog["operations"]}
|
||||
assert set(entries) == {operation.name for operation in OPERATIONS}
|
||||
for name, entry in entries.items():
|
||||
assert set(entry["transports"]) == {
|
||||
transport for transport, names in names_by_transport.items() if name in names
|
||||
}
|
||||
expected_mutates = next(item.mutates_knowledge for item in OPERATIONS if item.name == name)
|
||||
assert entry["mutates_knowledge"] is expected_mutates
|
||||
@@ -0,0 +1,231 @@
|
||||
import json
|
||||
from datetime import UTC, date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from gewu.domain.contracts import (
|
||||
AutoAcceptPolicy,
|
||||
Confidence,
|
||||
Locator,
|
||||
NodeType,
|
||||
ProjectRecord,
|
||||
ProjectStatus,
|
||||
ProposalReceipt,
|
||||
ProposalState,
|
||||
ReliabilityGrade,
|
||||
Scope,
|
||||
SearchRequest,
|
||||
SourceRevisionRecord,
|
||||
)
|
||||
|
||||
|
||||
def test_project_contract_rejects_unknown_fields() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ProjectRecord.model_validate(
|
||||
{
|
||||
"project_id": "dome",
|
||||
"title": "Dome",
|
||||
"status": ProjectStatus.ACTIVE,
|
||||
"north_star": "Understand interactive environments",
|
||||
"key_questions": ("What works?",),
|
||||
"autonomy_policy_id": "pilot",
|
||||
"untrusted_extra": "ignored?",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_project_status_supports_pause_without_losing_registry_record() -> None:
|
||||
project = ProjectRecord(
|
||||
project_id="dome",
|
||||
title="Dome",
|
||||
status=ProjectStatus.PAUSED,
|
||||
north_star="Understand interactive environments",
|
||||
key_questions=("What works?",),
|
||||
autonomy_policy_id="pilot",
|
||||
)
|
||||
assert (
|
||||
ProjectRecord.model_validate_json(project.model_dump_json()).status == ProjectStatus.PAUSED
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("project_id", ["Dome", "two_words", "project.slug"])
|
||||
def test_project_ids_must_be_lowercase_slugs(project_id: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ProjectRecord(
|
||||
project_id=project_id,
|
||||
title="Invalid project ID",
|
||||
status=ProjectStatus.ACTIVE,
|
||||
north_star="A stable project lens.",
|
||||
key_questions=("What matters?",),
|
||||
autonomy_policy_id="policy",
|
||||
)
|
||||
|
||||
|
||||
def test_scope_requires_explicit_all_projects_flag() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Scope(projects=("all",))
|
||||
assert Scope(projects=("all",), all_projects=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unsafe_id", ["../private", "bad/id", " leading-space"])
|
||||
def test_scope_rejects_unsafe_project_ids(unsafe_id: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Scope(projects=(unsafe_id,))
|
||||
|
||||
|
||||
def test_search_facets_are_typed_and_reject_duplicate_node_types() -> None:
|
||||
request = SearchRequest(
|
||||
query="audience",
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
node_types=(NodeType.PROBLEM, NodeType.TECHNIQUE),
|
||||
tags=("attendance", "real-time"),
|
||||
audience=("product",),
|
||||
use_for=("comparison",),
|
||||
domain=("production",),
|
||||
confidences=(Confidence.MEDIUM,),
|
||||
as_of_from=date(2024, 1, 1),
|
||||
as_of_to=date(2024, 12, 31),
|
||||
stale_only=True,
|
||||
budget_tokens=1000,
|
||||
)
|
||||
assert request.stale_only
|
||||
assert request.tags == ("attendance", "real-time")
|
||||
assert request.domain == ("production",)
|
||||
assert request.confidences == (Confidence.MEDIUM,)
|
||||
assert request.as_of_from == date(2024, 1, 1)
|
||||
with pytest.raises(ValidationError, match="must not be later"):
|
||||
SearchRequest(
|
||||
query="audience",
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
as_of_from=date(2025, 1, 1),
|
||||
as_of_to=date(2024, 12, 31),
|
||||
budget_tokens=1000,
|
||||
)
|
||||
with pytest.raises(ValidationError, match="confidences must be unique"):
|
||||
SearchRequest(
|
||||
query="audience",
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
confidences=(Confidence.MEDIUM, Confidence.MEDIUM),
|
||||
budget_tokens=1000,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
SearchRequest(
|
||||
query="audience",
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
node_types=(NodeType.PROBLEM, NodeType.PROBLEM),
|
||||
budget_tokens=1000,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
SearchRequest(
|
||||
query="audience",
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
tags=("attendance", "attendance"),
|
||||
budget_tokens=1000,
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
SearchRequest(
|
||||
query="audience",
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
domain=(" production",),
|
||||
budget_tokens=1000,
|
||||
)
|
||||
|
||||
|
||||
def test_search_allows_empty_query_only_for_a_typed_browse() -> None:
|
||||
request = SearchRequest(
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
node_types=(NodeType.PROBLEM,),
|
||||
budget_tokens=1000,
|
||||
)
|
||||
assert request.query == ""
|
||||
with pytest.raises(ValidationError, match="search requires text"):
|
||||
SearchRequest(
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
budget_tokens=1000,
|
||||
)
|
||||
with pytest.raises(ValidationError, match="search requires text"):
|
||||
SearchRequest(
|
||||
query=" ",
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
budget_tokens=1000,
|
||||
)
|
||||
|
||||
|
||||
def test_pdf_locator_uses_one_based_page_and_nonempty_span() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Locator(kind="pdf_page_span", block_id="p1-b1", start=8, end=8, page=0)
|
||||
|
||||
|
||||
def test_unarchived_source_requires_a_url_and_cannot_claim_artifact_bytes() -> None:
|
||||
base = {
|
||||
"source_id": "project:bookmark",
|
||||
"revision_id": "project:bookmark:r1",
|
||||
"media_type": "application/octet-stream",
|
||||
"byte_length": 0,
|
||||
"captured_at": datetime.now(UTC),
|
||||
"original_url": "https://example.org/paper",
|
||||
"title": "Referenced paper",
|
||||
"license": "unspecified",
|
||||
"source_type": "paper",
|
||||
"reliability_grade": ReliabilityGrade.MEDIUM,
|
||||
"ingested_for": ("dome-performance",),
|
||||
"archive_status": "reference-only",
|
||||
}
|
||||
record = SourceRevisionRecord.model_validate(base)
|
||||
assert record.artifact_sha256 is None
|
||||
with pytest.raises(ValidationError):
|
||||
SourceRevisionRecord.model_validate({**base, "byte_length": 10})
|
||||
with pytest.raises(ValidationError):
|
||||
SourceRevisionRecord.model_validate({**base, "original_url": None})
|
||||
|
||||
|
||||
def test_receipt_is_immutable_and_versioned() -> None:
|
||||
receipt = ProposalReceipt(
|
||||
proposal_id="proposal-1",
|
||||
revision=1,
|
||||
payload_digest="a" * 64,
|
||||
state=ProposalState.ACCEPTED,
|
||||
outcome="accepted",
|
||||
commit_id="b" * 64,
|
||||
decided_at=datetime.now(UTC),
|
||||
)
|
||||
assert receipt.schema_version == 1
|
||||
with pytest.raises(ValidationError):
|
||||
receipt.revision = 2 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_pilot_registry_fixture_matches_project_and_policy_contracts() -> None:
|
||||
fixture_path = (
|
||||
Path(__file__).resolve().parents[2] / "schema" / "fixtures" / "pilot-projects.json"
|
||||
)
|
||||
|
||||
def reject_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
|
||||
result: dict[str, object] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
raise ValueError(f"duplicate JSON key: {key}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
fixture = json.loads(
|
||||
fixture_path.read_text(encoding="utf-8"), object_pairs_hook=reject_duplicate_keys
|
||||
)
|
||||
projects = tuple(
|
||||
ProjectRecord.model_validate_json(json.dumps(record), strict=True)
|
||||
for record in fixture["projects"]
|
||||
)
|
||||
policies = tuple(
|
||||
AutoAcceptPolicy.model_validate_json(json.dumps(record), strict=True)
|
||||
for record in fixture["test_policies"]
|
||||
)
|
||||
assert {project.project_id for project in projects} == {
|
||||
"dome-performance",
|
||||
"interactive-video",
|
||||
}
|
||||
assert {policy.project_id for policy in policies} == {
|
||||
"dome-performance",
|
||||
"interactive-video",
|
||||
"shared",
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import agents.gewu.scripts.run_checkpoint as checkpoint_module
|
||||
import pytest
|
||||
from agents.gewu.scripts.run_checkpoint import (
|
||||
create_checkpoint,
|
||||
load_checkpoint,
|
||||
update_checkpoint,
|
||||
)
|
||||
|
||||
|
||||
def test_run_checkpoint_records_scope_coverage_receipt_and_pins(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "research-workspace"
|
||||
checkpoint = create_checkpoint(
|
||||
workspace,
|
||||
run_id="run-2026-01",
|
||||
objective="Compare audience response",
|
||||
projects=["dome-performance"],
|
||||
include_shared=True,
|
||||
lens="Audience behavior",
|
||||
budgets={"sources": 8},
|
||||
)
|
||||
path = workspace / "Runs" / "run-2026-01.json"
|
||||
assert stat.S_IMODE(path.stat().st_mode) == 0o600
|
||||
assert checkpoint["pack"]["snapshot_digest"]
|
||||
|
||||
updated = update_checkpoint(
|
||||
workspace,
|
||||
"run-2026-01",
|
||||
{
|
||||
"stage": "report",
|
||||
"sources": [
|
||||
{
|
||||
"source_revision_id": "source:r1",
|
||||
"extraction_id": "a" * 64,
|
||||
"status": "extracted",
|
||||
}
|
||||
],
|
||||
"coverage": {
|
||||
"selected": ["section-1", "section-2"],
|
||||
"included": ["section-1"],
|
||||
"excluded": [{"item": "section-2", "reason": "outside project lens"}],
|
||||
"skipped": [],
|
||||
},
|
||||
"pending_operations": [
|
||||
{
|
||||
"operation_id": "proposal-op-1",
|
||||
"tool_name": "proposal_submit",
|
||||
"input_sha256": "b" * 64,
|
||||
"status": "complete",
|
||||
}
|
||||
],
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_id": "proposal-1",
|
||||
"revision": 1,
|
||||
"status": "accepted",
|
||||
"receipt": {
|
||||
"proposal_id": "proposal-1",
|
||||
"revision": 1,
|
||||
"state": "accepted",
|
||||
"commit_id": "c" * 40,
|
||||
},
|
||||
}
|
||||
],
|
||||
"outputs": [{"sha256": "d" * 64}],
|
||||
},
|
||||
)
|
||||
resumed = load_checkpoint(workspace, "run-2026-01")
|
||||
assert resumed == updated
|
||||
assert resumed["coverage"]["excluded"][0]["reason"] == "outside project lens"
|
||||
assert resumed["proposals"][0]["receipt"]["commit_id"] == "c" * 40
|
||||
with pytest.raises(ValueError, match="does not match"):
|
||||
update_checkpoint(
|
||||
workspace,
|
||||
"run-2026-01",
|
||||
{
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_id": "proposal-1",
|
||||
"revision": 1,
|
||||
"status": "accepted",
|
||||
"receipt": {
|
||||
"proposal_id": "proposal-other",
|
||||
"revision": 1,
|
||||
"state": "accepted",
|
||||
"commit_id": "c" * 40,
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_run_checkpoint_rejects_secrets_bad_progress_and_snapshot_drift(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "research-workspace"
|
||||
create_checkpoint(
|
||||
workspace,
|
||||
run_id="run-drift-01",
|
||||
objective="Test resume pinning",
|
||||
projects=["dome-performance"],
|
||||
include_shared=False,
|
||||
lens="A bounded lens",
|
||||
)
|
||||
with pytest.raises(ValueError, match="credentials or source bodies"):
|
||||
update_checkpoint(workspace, "run-drift-01", {"last_failure": {"api_key": "secret"}})
|
||||
with pytest.raises(ValueError, match="SHA-256"):
|
||||
update_checkpoint(
|
||||
workspace,
|
||||
"run-drift-01",
|
||||
{
|
||||
"outputs": [{"sha256": "not-a-digest"}],
|
||||
"stage": "report",
|
||||
},
|
||||
)
|
||||
|
||||
path = workspace / "Runs" / "run-drift-01.json"
|
||||
checkpoint = json.loads(path.read_text(encoding="utf-8"))
|
||||
checkpoint["pack"]["snapshot_digest"] = "0" * 64
|
||||
path.write_text(json.dumps(checkpoint), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="pack changed"):
|
||||
load_checkpoint(workspace, "run-drift-01")
|
||||
|
||||
|
||||
def test_existing_run_cannot_resume_after_verified_pack_upgrade(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
workspace = tmp_path / "research-workspace"
|
||||
create_checkpoint(
|
||||
workspace,
|
||||
run_id="run-pack-update-01",
|
||||
objective="Resume with pinned modules",
|
||||
projects=["dome-performance"],
|
||||
include_shared=True,
|
||||
lens="Keep the original extraction and evidence rules",
|
||||
)
|
||||
checkpoint_path = workspace / "Runs" / "run-pack-update-01.json"
|
||||
original_checkpoint = checkpoint_path.read_bytes()
|
||||
|
||||
updated_manifest = copy.deepcopy(checkpoint_module.load_manifest())
|
||||
updated_manifest["pack_version"] = "0.7.0"
|
||||
updated_manifest["modules"][0]["implementation_version"] += ".updated"
|
||||
modules_json = json.dumps(
|
||||
updated_manifest["modules"], ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
updated_manifest["snapshot"]["digest"] = hashlib.sha256(modules_json.encode()).hexdigest()
|
||||
|
||||
# This fixture stands in for a newer pack whose own manifest verification
|
||||
# succeeded; the behavior under test is the active run's compatibility pin.
|
||||
monkeypatch.setattr(checkpoint_module, "load_manifest", lambda: updated_manifest)
|
||||
monkeypatch.setattr(checkpoint_module, "verify", lambda _manifest: None)
|
||||
|
||||
with pytest.raises(ValueError, match="pack changed since this run"):
|
||||
load_checkpoint(workspace, "run-pack-update-01")
|
||||
assert checkpoint_path.read_bytes() == original_checkpoint
|
||||
@@ -0,0 +1,698 @@
|
||||
from gewu.adapters.text_extractor import TextMarkdownExtractor
|
||||
from gewu.domain.contracts import (
|
||||
EdgeEndpoint,
|
||||
KnowledgeNode,
|
||||
Locator,
|
||||
NodeType,
|
||||
ProjectStatus,
|
||||
ProposalOperation,
|
||||
ProposalRevision,
|
||||
Scope,
|
||||
SupportStatus,
|
||||
TypedEdge,
|
||||
)
|
||||
from gewu.domain.validation import (
|
||||
ValidationReport,
|
||||
assertion_digest,
|
||||
edge_assertion_digest,
|
||||
evidence_digest,
|
||||
proposal_payload_digest,
|
||||
validate_proposal,
|
||||
)
|
||||
from tests.domain.proposal_fixtures import (
|
||||
BASE_COMMIT,
|
||||
CLAIM,
|
||||
EVIDENCE,
|
||||
EXTRACTION,
|
||||
NODE,
|
||||
POLICIES,
|
||||
PROJECTS,
|
||||
SOURCE_REVISION,
|
||||
proposal,
|
||||
)
|
||||
|
||||
|
||||
def _validate(candidate: ProposalRevision) -> ValidationReport:
|
||||
return validate_proposal(
|
||||
candidate,
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
|
||||
|
||||
def test_supported_exact_citation_and_both_bundle_policies_are_eligible() -> None:
|
||||
report = _validate(proposal())
|
||||
assert report.status == "eligible", report.findings
|
||||
assert report.eligible_for_auto_accept
|
||||
assert set(report.affected_policy_ids) == {"dome-policy", "shared"}
|
||||
|
||||
|
||||
def test_transcript_timestamp_locator_must_match_the_cited_cue() -> None:
|
||||
transcript_bytes = (
|
||||
b"WEBVTT\n\n"
|
||||
b"cue-1\n00:14:32.000 --> 00:14:36.500\n"
|
||||
b"The installation hosted 400 visitors during the 2026 season.\n"
|
||||
)
|
||||
source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"media_type": "text/vtt",
|
||||
"byte_length": len(transcript_bytes),
|
||||
"artifact_sha256": __import__("hashlib").sha256(transcript_bytes).hexdigest(),
|
||||
}
|
||||
)
|
||||
extraction = TextMarkdownExtractor().extract(source.revision_id, transcript_bytes, "text/vtt")
|
||||
cue = next(block for block in extraction.blocks if block.time_start_ms is not None)
|
||||
quote = CLAIM.text
|
||||
start = cue.normalized_text.index(quote)
|
||||
evidence = EVIDENCE.model_copy(
|
||||
update={
|
||||
"extraction_id": extraction.extraction_id,
|
||||
"quote": quote,
|
||||
"locator": Locator(
|
||||
kind="transcript_timestamp_span",
|
||||
block_id=cue.block_id,
|
||||
start=start,
|
||||
end=start + len(quote),
|
||||
time_start_ms=cue.time_start_ms,
|
||||
time_end_ms=cue.time_end_ms,
|
||||
),
|
||||
}
|
||||
)
|
||||
assessment = CLAIM.assessment.model_copy(
|
||||
update={"evidence_digests": (evidence_digest(evidence),)}
|
||||
)
|
||||
claim = CLAIM.model_copy(update={"evidence": (evidence,), "assessment": assessment})
|
||||
node = NODE.model_copy(update={"claims": (claim,)})
|
||||
eligible = validate_proposal(
|
||||
proposal(node=node, source=source),
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={},
|
||||
extractions={extraction.extraction_id: extraction},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert eligible.status == "eligible", eligible.findings
|
||||
|
||||
wrong_time = evidence.model_copy(
|
||||
update={
|
||||
"locator": evidence.locator.model_copy(
|
||||
update={"time_start_ms": evidence.locator.time_start_ms + 1}
|
||||
)
|
||||
}
|
||||
)
|
||||
wrong_assessment = assessment.model_copy(
|
||||
update={"evidence_digests": (evidence_digest(wrong_time),)}
|
||||
)
|
||||
wrong_claim = claim.model_copy(
|
||||
update={"evidence": (wrong_time,), "assessment": wrong_assessment}
|
||||
)
|
||||
rejected = validate_proposal(
|
||||
proposal(node=NODE.model_copy(update={"claims": (wrong_claim,)}), source=None),
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={source.revision_id: source},
|
||||
extractions={extraction.extraction_id: extraction},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert rejected.status == "invalid"
|
||||
assert "EVIDENCE_LOCATION_MISMATCH" in {finding.code for finding in rejected.findings}
|
||||
|
||||
|
||||
def test_paused_or_archived_projects_reject_new_acceptance() -> None:
|
||||
for status in (ProjectStatus.PAUSED, ProjectStatus.ARCHIVED):
|
||||
project = PROJECTS["dome-performance"].model_copy(update={"status": status})
|
||||
report = validate_proposal(
|
||||
proposal(),
|
||||
projects={**PROJECTS, "dome-performance": project},
|
||||
policies=POLICIES,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert not report.eligible_for_auto_accept
|
||||
assert any(item.code == "PROJECT_NOT_ACTIVE" for item in report.findings)
|
||||
|
||||
|
||||
def test_cross_project_source_bundle_requires_every_affected_policy() -> None:
|
||||
shared_source = SOURCE_REVISION.model_copy(
|
||||
update={"ingested_for": ("dome-performance", "interactive-video")}
|
||||
)
|
||||
candidate = proposal(
|
||||
source=shared_source,
|
||||
scope=Scope(projects=("dome-performance", "interactive-video"), include_shared=True),
|
||||
)
|
||||
eligible = validate_proposal(
|
||||
candidate,
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert eligible.status == "eligible", eligible.findings
|
||||
assert set(eligible.affected_policy_ids) == {
|
||||
"dome-policy",
|
||||
"video-policy",
|
||||
"shared",
|
||||
}
|
||||
|
||||
restrictive_video_policy = POLICIES["video-policy"].model_copy(
|
||||
update={"automatic_source_registration": False}
|
||||
)
|
||||
blocked = validate_proposal(
|
||||
candidate,
|
||||
projects=PROJECTS,
|
||||
policies={**POLICIES, "video-policy": restrictive_video_policy},
|
||||
sources={},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert blocked.status == "needs-review"
|
||||
assert not blocked.eligible_for_auto_accept
|
||||
assert "AUTO_ACCEPT_UNSPECIFIED" in {finding.code for finding in blocked.findings}
|
||||
|
||||
|
||||
def test_matching_quote_at_wrong_location_is_rejected() -> None:
|
||||
evidence = EVIDENCE.model_copy(
|
||||
update={
|
||||
"quote": "400 visitors",
|
||||
"locator": EVIDENCE.locator.model_copy(update={"start": 0, "end": 13}),
|
||||
}
|
||||
)
|
||||
changed_claim = CLAIM.model_copy(update={"evidence": (evidence,)})
|
||||
changed_node = NODE.model_copy(update={"claims": (changed_claim,)})
|
||||
report = _validate(proposal(node=changed_node, source=None))
|
||||
assert report.status == "invalid"
|
||||
assert "EVIDENCE_MISMATCH" in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_reference_only_source_cannot_support_accepted_evidence() -> None:
|
||||
reference = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"artifact_sha256": None,
|
||||
"media_type": "application/octet-stream",
|
||||
"byte_length": 0,
|
||||
"original_url": "https://research.example/missing",
|
||||
"archive_status": "reference-only",
|
||||
}
|
||||
)
|
||||
report = validate_proposal(
|
||||
proposal(source=None),
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={reference.revision_id: reference},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert report.status == "invalid"
|
||||
assert "SOURCE_NOT_ARCHIVED" in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_correct_quote_with_unsupported_assessment_is_rejected() -> None:
|
||||
assessment = CLAIM.assessment.model_copy(update={"status": SupportStatus.UNSUPPORTED})
|
||||
changed_claim = CLAIM.model_copy(update={"assessment": assessment})
|
||||
changed_node = NODE.model_copy(update={"claims": (changed_claim,)})
|
||||
report = _validate(proposal(node=changed_node, source=None))
|
||||
assert report.status == "invalid"
|
||||
assert "SUPPORT_UNAVAILABLE" in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_node_and_assessor_attribution_cannot_impersonate_another_agent() -> None:
|
||||
other_assessment = CLAIM.assessment.model_copy(update={"assessor_id": "agent-other"})
|
||||
assessment_claim = CLAIM.model_copy(update={"assessment": other_assessment})
|
||||
assessment_node = NODE.model_copy(update={"claims": (assessment_claim,)})
|
||||
assessment_report = _validate(proposal(node=assessment_node, source=None))
|
||||
assert "ASSESSOR_ATTRIBUTION_MISMATCH" in {
|
||||
finding.code for finding in assessment_report.findings
|
||||
}
|
||||
|
||||
other_node = NODE.model_copy(update={"agent_id": "agent-other"})
|
||||
node_report = _validate(proposal(node=other_node, source=None))
|
||||
assert "AGENT_ATTRIBUTION_MISMATCH" in {finding.code for finding in node_report.findings}
|
||||
|
||||
|
||||
def test_entity_name_collision_requires_review_without_creating_a_link() -> None:
|
||||
entity = NODE.model_copy(
|
||||
update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)}
|
||||
)
|
||||
existing = NODE.model_copy(
|
||||
update={
|
||||
"node_type": NodeType.ENTITY,
|
||||
"node_id": "dome:existing-entity",
|
||||
"title": " planetarium ",
|
||||
}
|
||||
)
|
||||
report = validate_proposal(
|
||||
proposal(
|
||||
node=entity,
|
||||
source=None,
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
),
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={existing.node_id: existing},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert report.status == "needs-review"
|
||||
assert not report.eligible_for_auto_accept
|
||||
assert "POSSIBLE_DUPLICATE_ENTITY" in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_entity_duplicate_detection_respects_project_scope() -> None:
|
||||
entity = NODE.model_copy(
|
||||
update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)}
|
||||
)
|
||||
unrelated = NODE.model_copy(
|
||||
update={
|
||||
"node_type": NodeType.ENTITY,
|
||||
"node_id": "video:existing-entity",
|
||||
"project_id": "interactive-video",
|
||||
"title": "Planetarium",
|
||||
}
|
||||
)
|
||||
report = validate_proposal(
|
||||
proposal(node=entity, source=None),
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={unrelated.node_id: unrelated},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert "POSSIBLE_DUPLICATE_ENTITY" not in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_entity_duplicate_review_uses_only_readable_projects_in_explicit_scope() -> None:
|
||||
entity = NODE.model_copy(
|
||||
update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)}
|
||||
)
|
||||
other_entity = NODE.model_copy(
|
||||
update={
|
||||
"node_type": NodeType.ENTITY,
|
||||
"node_id": "video:existing-entity",
|
||||
"project_id": "interactive-video",
|
||||
"title": "Planetarium",
|
||||
}
|
||||
)
|
||||
existing = {other_entity.node_id: other_entity}
|
||||
candidate = proposal(
|
||||
node=entity,
|
||||
source=None,
|
||||
scope=Scope(projects=("dome-performance", "interactive-video")),
|
||||
)
|
||||
readable = validate_proposal(
|
||||
candidate,
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes=existing,
|
||||
current_base_commit=BASE_COMMIT,
|
||||
readable_project_ids=frozenset({"interactive-video"}),
|
||||
)
|
||||
unreadable = validate_proposal(
|
||||
candidate,
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes=existing,
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert "POSSIBLE_DUPLICATE_ENTITY" in {finding.code for finding in readable.findings}
|
||||
assert "POSSIBLE_DUPLICATE_ENTITY" not in {finding.code for finding in unreadable.findings}
|
||||
|
||||
|
||||
def test_entity_duplicate_review_does_not_expand_beyond_proposal_scope() -> None:
|
||||
entity = NODE.model_copy(
|
||||
update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)}
|
||||
)
|
||||
shared_entity = NODE.model_copy(
|
||||
update={
|
||||
"node_type": NodeType.ENTITY,
|
||||
"node_id": "shared:planetarium-entity",
|
||||
"project_id": "shared",
|
||||
"title": "Planetarium",
|
||||
}
|
||||
)
|
||||
report = validate_proposal(
|
||||
proposal(
|
||||
node=entity,
|
||||
source=None,
|
||||
scope=Scope(projects=("dome-performance",), include_shared=False),
|
||||
),
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={shared_entity.node_id: shared_entity},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
readable_project_ids=frozenset({"shared"}),
|
||||
)
|
||||
assert "POSSIBLE_DUPLICATE_ENTITY" not in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_requested_shared_scope_does_not_reveal_unreadable_shared_entity_names() -> None:
|
||||
entity = NODE.model_copy(
|
||||
update={"node_type": NodeType.ENTITY, "title": "The Dome", "aliases": ("Planetarium",)}
|
||||
)
|
||||
shared_entity = NODE.model_copy(
|
||||
update={
|
||||
"node_type": NodeType.ENTITY,
|
||||
"node_id": "shared:planetarium-entity",
|
||||
"project_id": "shared",
|
||||
"title": "Planetarium",
|
||||
}
|
||||
)
|
||||
report = validate_proposal(
|
||||
proposal(node=entity, source=None),
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={shared_entity.node_id: shared_entity},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert "POSSIBLE_DUPLICATE_ENTITY" not in {finding.code for finding in report.findings}
|
||||
|
||||
explicitly_readable = validate_proposal(
|
||||
proposal(node=entity, source=None, scope=Scope(projects=("dome-performance", "shared"))),
|
||||
projects=PROJECTS,
|
||||
policies=POLICIES,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={shared_entity.node_id: shared_entity},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
readable_project_ids=frozenset({"shared"}),
|
||||
)
|
||||
assert "POSSIBLE_DUPLICATE_ENTITY" in {finding.code for finding in explicitly_readable.findings}
|
||||
|
||||
|
||||
def test_changed_assertion_and_old_assessment_are_rejected() -> None:
|
||||
changed_text = "The installation hosted 401 visitors during the 2026 season."
|
||||
stale_assessment = CLAIM.assessment
|
||||
evidence = CLAIM.evidence[0].model_copy(
|
||||
update={"assertion_digest": assertion_digest(changed_text)}
|
||||
)
|
||||
changed_claim = CLAIM.model_copy(
|
||||
update={"text": changed_text, "evidence": (evidence,), "assessment": stale_assessment}
|
||||
)
|
||||
changed_node = NODE.model_copy(update={"claims": (changed_claim,)})
|
||||
report = _validate(proposal(node=changed_node, source=None))
|
||||
assert report.status == "invalid"
|
||||
assert "ASSESSMENT_STALE" in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_assumption_and_limitation_claims_use_the_same_evidence_contract() -> None:
|
||||
assumption_text = CLAIM.text
|
||||
assumption_digest = assertion_digest(assumption_text)
|
||||
assumption_evidence = EVIDENCE.model_copy(
|
||||
update={
|
||||
"assertion_path": "assumptions[0].text",
|
||||
"assertion_digest": assumption_digest,
|
||||
}
|
||||
)
|
||||
assumption = CLAIM.model_copy(
|
||||
update={
|
||||
"claim_id": "dome:assumption-seasonality",
|
||||
"text": assumption_text,
|
||||
"evidence": (assumption_evidence,),
|
||||
"assessment": CLAIM.assessment.model_copy(
|
||||
update={
|
||||
"assertion_digest": assumption_digest,
|
||||
"evidence_digests": (evidence_digest(assumption_evidence),),
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
limitation_evidence = EVIDENCE.model_copy(
|
||||
update={
|
||||
"assertion_path": "limitations[0].text",
|
||||
"assertion_digest": assumption_digest,
|
||||
}
|
||||
)
|
||||
limitation = assumption.model_copy(
|
||||
update={
|
||||
"claim_id": "dome:limitation-seasonality",
|
||||
"evidence": (limitation_evidence,),
|
||||
"assessment": assumption.assessment.model_copy(
|
||||
update={"evidence_digests": (evidence_digest(limitation_evidence),)}
|
||||
),
|
||||
}
|
||||
)
|
||||
node = NODE.model_copy(update={"assumptions": (assumption,), "limitations": (limitation,)})
|
||||
report = _validate(proposal(node=node, source=None))
|
||||
assert report.eligible_for_auto_accept, report.findings
|
||||
|
||||
wrong_location = assumption_evidence.model_copy(
|
||||
update={
|
||||
"quote": "400 visitors",
|
||||
"locator": assumption_evidence.locator.model_copy(update={"start": 0, "end": 13}),
|
||||
}
|
||||
)
|
||||
invalid_assumption = assumption.model_copy(
|
||||
update={
|
||||
"evidence": (wrong_location,),
|
||||
"assessment": assumption.assessment.model_copy(
|
||||
update={"evidence_digests": (evidence_digest(wrong_location),)}
|
||||
),
|
||||
}
|
||||
)
|
||||
invalid = _validate(
|
||||
proposal(node=NODE.model_copy(update={"assumptions": (invalid_assumption,)}), source=None)
|
||||
)
|
||||
assert "EVIDENCE_MISMATCH" in {finding.code for finding in invalid.findings}
|
||||
assert any(
|
||||
finding.path == "operations[0].node.assumptions[0].evidence[0]"
|
||||
for finding in invalid.findings
|
||||
)
|
||||
|
||||
|
||||
def test_missing_policy_and_explicit_scope_escape_do_not_accept() -> None:
|
||||
no_shared_policy = {key: value for key, value in POLICIES.items() if key != "shared"}
|
||||
candidate = proposal()
|
||||
report = validate_proposal(
|
||||
candidate,
|
||||
projects=PROJECTS,
|
||||
policies=no_shared_policy,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert report.status == "needs-review"
|
||||
assert "POLICY_MISSING" in {finding.code for finding in report.findings}
|
||||
|
||||
outside_scope = proposal(
|
||||
node=NODE.model_copy(update={"project_id": "interactive-video"}),
|
||||
source=None,
|
||||
scope=Scope(projects=("dome-performance",)),
|
||||
)
|
||||
report = _validate(outside_scope)
|
||||
assert report.status == "invalid"
|
||||
assert "OUT_OF_SCOPE" in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_policy_cannot_waive_required_support_review() -> None:
|
||||
weakened_policy = POLICIES["dome-policy"].model_copy(
|
||||
update={"require_supported_assessment": False}
|
||||
)
|
||||
report = validate_proposal(
|
||||
proposal(),
|
||||
projects=PROJECTS,
|
||||
policies={**POLICIES, "dome-policy": weakened_policy},
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert report.status == "invalid"
|
||||
assert not report.eligible_for_auto_accept
|
||||
assert "POLICY_WEAKENING" in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_changed_base_is_a_conflict_and_edge_policy_is_explicit() -> None:
|
||||
report = _validate(proposal(base_commit="f" * 64))
|
||||
assert report.status == "conflict"
|
||||
assert "BASE_CONFLICT" in {finding.code for finding in report.findings}
|
||||
|
||||
|
||||
def test_relationship_evidence_is_bound_to_the_edge_and_exact_source_span() -> None:
|
||||
policies = {
|
||||
**POLICIES,
|
||||
"dome-policy": POLICIES["dome-policy"].model_copy(
|
||||
update={"automatic_edge_types": ("causes",)}
|
||||
),
|
||||
}
|
||||
edge = TypedEdge(
|
||||
edge_id="edge-pilot",
|
||||
edge_type="causes",
|
||||
source=EdgeEndpoint(kind="node", id=NODE.node_id),
|
||||
target=EdgeEndpoint(kind="node", id="dome:other-node"),
|
||||
evidence=(EVIDENCE,),
|
||||
)
|
||||
bound_evidence = EVIDENCE.model_copy(update={"assertion_digest": edge_assertion_digest(edge)})
|
||||
edge = edge.model_copy(update={"evidence": (bound_evidence,)})
|
||||
base = proposal(source=None)
|
||||
candidate = base.model_copy(
|
||||
update={
|
||||
"operations": (*base.operations, ProposalOperation(operation="add_edge", edge=edge)),
|
||||
"payload_digest": "0" * 64,
|
||||
}
|
||||
)
|
||||
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
|
||||
report = validate_proposal(
|
||||
candidate,
|
||||
projects=PROJECTS,
|
||||
policies=policies,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={
|
||||
"dome:other-node": NODE.model_copy(update={"node_id": "dome:other-node"}),
|
||||
},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert report.status == "eligible", report.findings
|
||||
|
||||
tampered = bound_evidence.model_copy(
|
||||
update={"assertion_digest": assertion_digest("unrelated claim")}
|
||||
)
|
||||
invalid_edge = edge.model_copy(update={"evidence": (tampered,)})
|
||||
invalid = candidate.model_copy(
|
||||
update={
|
||||
"operations": (
|
||||
*base.operations,
|
||||
ProposalOperation(operation="add_edge", edge=invalid_edge),
|
||||
),
|
||||
"payload_digest": "0" * 64,
|
||||
}
|
||||
)
|
||||
invalid = invalid.model_copy(update={"payload_digest": proposal_payload_digest(invalid)})
|
||||
invalid_report = validate_proposal(
|
||||
invalid,
|
||||
projects=PROJECTS,
|
||||
policies=policies,
|
||||
sources={SOURCE_REVISION.revision_id: SOURCE_REVISION},
|
||||
extractions={EXTRACTION.extraction_id: EXTRACTION},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
assert invalid_report.status == "invalid"
|
||||
assert "EVIDENCE_ASSERTION_MISMATCH" in {finding.code for finding in invalid_report.findings}
|
||||
|
||||
|
||||
def test_contradiction_bundle_preserves_both_supported_claims() -> None:
|
||||
import hashlib
|
||||
|
||||
from gewu.domain.contracts import EdgeEndpoint
|
||||
|
||||
first_quote = "The seasonal audience count was 400."
|
||||
second_quote = "The seasonal audience count was 450."
|
||||
content = f"Field report: {first_quote}\n\nVendor report: {second_quote}\n".encode()
|
||||
source = SOURCE_REVISION.model_copy(
|
||||
update={
|
||||
"artifact_sha256": hashlib.sha256(content).hexdigest(),
|
||||
"byte_length": len(content),
|
||||
}
|
||||
)
|
||||
extraction = TextMarkdownExtractor().extract(source.revision_id, content, "text/markdown")
|
||||
|
||||
def node_with_claim(node_id: str, claim_id: str, quote: str, title: str) -> KnowledgeNode:
|
||||
block = next(item for item in extraction.blocks if quote in item.normalized_text)
|
||||
start = block.normalized_text.index(quote)
|
||||
evidence = EVIDENCE.model_copy(
|
||||
update={
|
||||
"source_revision_id": source.revision_id,
|
||||
"extraction_id": extraction.extraction_id,
|
||||
"quote": quote,
|
||||
"locator": Locator(
|
||||
kind="text_span", block_id=block.block_id, start=start, end=start + len(quote)
|
||||
),
|
||||
"assertion_digest": assertion_digest(quote),
|
||||
}
|
||||
)
|
||||
assessment = CLAIM.assessment.model_copy(
|
||||
update={
|
||||
"assertion_digest": assertion_digest(quote),
|
||||
"evidence_digests": (evidence_digest(evidence),),
|
||||
}
|
||||
)
|
||||
claim = CLAIM.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": 400 if "400" in quote else 450,
|
||||
}
|
||||
)
|
||||
|
||||
first = node_with_claim(
|
||||
"dome:count-field-report", "dome:claim-count-400", first_quote, "Field count"
|
||||
)
|
||||
second = node_with_claim(
|
||||
"dome:count-vendor-report", "dome:claim-count-450", second_quote, "Vendor count"
|
||||
)
|
||||
contradiction = 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.",
|
||||
)
|
||||
base = proposal(node=None, source=source)
|
||||
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=contradiction),
|
||||
),
|
||||
"payload_digest": "0" * 64,
|
||||
}
|
||||
)
|
||||
candidate = candidate.model_copy(update={"payload_digest": proposal_payload_digest(candidate)})
|
||||
report = validate_proposal(
|
||||
candidate,
|
||||
projects=PROJECTS,
|
||||
policies={
|
||||
**POLICIES,
|
||||
"dome-policy": POLICIES["dome-policy"].model_copy(
|
||||
update={"automatic_edge_types": ("contradicts",)}
|
||||
),
|
||||
},
|
||||
sources={},
|
||||
extractions={extraction.extraction_id: extraction},
|
||||
existing_nodes={},
|
||||
current_base_commit=BASE_COMMIT,
|
||||
)
|
||||
|
||||
assert report.status == "eligible", report.findings
|
||||
assert tuple(
|
||||
operation.node.node_id for operation in candidate.operations if operation.node is not None
|
||||
) == (first.node_id, second.node_id)
|
||||
assert first.claims[0].text == first_quote
|
||||
assert second.claims[0].text == second_quote
|
||||
assert any(operation.edge == contradiction for operation in candidate.operations)
|
||||
@@ -0,0 +1,200 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
INSTALLER_PATH = Path(__file__).resolve().parents[1] / "agents/gewu/scripts/install.py"
|
||||
SPEC = importlib.util.spec_from_file_location("gewu_pack_installer", INSTALLER_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
installer = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(installer)
|
||||
|
||||
|
||||
def make_pack(tmp_path: Path) -> Path:
|
||||
pack = tmp_path / "pack"
|
||||
(pack / "scripts").mkdir(parents=True)
|
||||
(pack / "manifest.json").write_text('{"pack":"fixture"}\n', encoding="utf-8")
|
||||
(pack / "AGENTS.md").write_text("fixture profile\n", encoding="utf-8")
|
||||
return pack
|
||||
|
||||
|
||||
def test_install_and_uninstall_preserve_unrelated_workspace_and_mcp_config(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "AGENTS.md").write_bytes(b"Existing project guidance\nKeep it.\n")
|
||||
(workspace / "CLAUDE.md").write_bytes(b"Existing Claude instructions without final newline")
|
||||
(workspace / ".gemini").mkdir()
|
||||
mcp_config = workspace / ".gemini/settings.json"
|
||||
original_mcp = b'{"mcpServers":{"other":{"httpUrl":"https://example.test/mcp"}}}\n'
|
||||
mcp_config.write_bytes(original_mcp)
|
||||
(workspace / ".agents").mkdir()
|
||||
(workspace / ".agents/skills").mkdir()
|
||||
(workspace / ".agents/skills/custom.md").write_text("user skill", encoding="utf-8")
|
||||
original_agents = (workspace / "AGENTS.md").read_bytes()
|
||||
original_claude = (workspace / "CLAUDE.md").read_bytes()
|
||||
|
||||
installer.install(workspace, make_pack(tmp_path), "integrated")
|
||||
assert (workspace / ".agents/gewu/AGENTS.md").is_file()
|
||||
assert original_agents in (workspace / "AGENTS.md").read_bytes()
|
||||
assert original_claude in (workspace / "CLAUDE.md").read_bytes()
|
||||
assert (workspace / "GEMINI.md").is_file()
|
||||
assert mcp_config.read_bytes() == original_mcp
|
||||
assert (workspace / ".agents/skills/custom.md").read_text() == "user skill"
|
||||
|
||||
installer.uninstall(workspace)
|
||||
assert (workspace / "AGENTS.md").read_bytes() == original_agents
|
||||
assert (workspace / "CLAUDE.md").read_bytes() == original_claude
|
||||
assert not (workspace / "GEMINI.md").exists()
|
||||
assert mcp_config.read_bytes() == original_mcp
|
||||
assert (workspace / ".agents/skills/custom.md").read_text() == "user skill"
|
||||
assert not (workspace / ".agents/gewu").exists()
|
||||
assert not (workspace / ".agents/gewu-install.json").exists()
|
||||
|
||||
|
||||
def test_uninstall_refuses_modified_owned_entry_and_preserves_everything(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
(workspace / "AGENTS.md").write_text("Project rules\n", encoding="utf-8")
|
||||
installer.install(workspace, make_pack(tmp_path))
|
||||
(workspace / "AGENTS.md").write_text("User edit after installation\n", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="edited routing block"):
|
||||
installer.uninstall(workspace)
|
||||
assert "User edit after installation" in (workspace / "AGENTS.md").read_text()
|
||||
assert (workspace / ".agents/gewu/manifest.json").is_file()
|
||||
assert (workspace / ".agents/gewu-install.json").is_file()
|
||||
|
||||
|
||||
def test_uninstall_preserves_edits_outside_routing_block_and_user_pack_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
root_instructions = workspace / "AGENTS.md"
|
||||
root_instructions.write_text("Project rules\n", encoding="utf-8")
|
||||
installer.install(workspace, make_pack(tmp_path))
|
||||
root_instructions.write_text(
|
||||
"Updated project rules\n" + root_instructions.read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
custom = workspace / ".agents/gewu/user-notes.txt"
|
||||
custom.write_text("user-owned", encoding="utf-8")
|
||||
|
||||
installer.uninstall(workspace)
|
||||
assert "Updated project rules" in root_instructions.read_text(encoding="utf-8")
|
||||
assert custom.read_text(encoding="utf-8") == "user-owned"
|
||||
assert (workspace / ".agents/gewu").is_dir()
|
||||
assert not (workspace / ".agents/gewu/AGENTS.md").exists()
|
||||
|
||||
|
||||
def test_install_refuses_occupied_gewu_destination_without_overwriting(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
(workspace / ".agents/gewu").mkdir(parents=True)
|
||||
marker = workspace / ".agents/gewu/keep.txt"
|
||||
marker.write_text("user data", encoding="utf-8")
|
||||
with pytest.raises(FileExistsError):
|
||||
installer.install(workspace, make_pack(tmp_path))
|
||||
assert marker.read_text() == "user data"
|
||||
|
||||
|
||||
def test_install_rollback_restores_original_routes_if_pack_copy_fails(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
original = b"Existing instructions\n"
|
||||
(workspace / "AGENTS.md").write_bytes(original)
|
||||
pack = make_pack(tmp_path)
|
||||
real_atomic_write = installer._atomic_write
|
||||
|
||||
def failing_write(path: Path, content: bytes) -> None:
|
||||
if path.name == "CLAUDE.md":
|
||||
raise OSError("fixture failure")
|
||||
real_atomic_write(path, content)
|
||||
|
||||
monkeypatch.setattr(installer, "_atomic_write", failing_write)
|
||||
with pytest.raises(OSError, match="fixture failure"):
|
||||
installer.install(workspace, pack)
|
||||
assert (workspace / "AGENTS.md").read_bytes() == original
|
||||
assert not (workspace / "CLAUDE.md").exists()
|
||||
assert not (workspace / "GEMINI.md").exists()
|
||||
assert not (workspace / ".agents/gewu").exists()
|
||||
assert not (workspace / ".agents/gewu-install.json").exists()
|
||||
|
||||
|
||||
def test_racing_installer_does_not_delete_the_other_installation(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
pack = make_pack(tmp_path)
|
||||
original_mkdir = Path.mkdir
|
||||
|
||||
def competing_create(
|
||||
path: Path,
|
||||
mode: int = 0o777,
|
||||
parents: bool = False,
|
||||
exist_ok: bool = False,
|
||||
) -> None:
|
||||
if path == workspace / ".agents/gewu":
|
||||
original_mkdir(path, mode=mode, parents=parents, exist_ok=True)
|
||||
(path / "other-installation.txt").write_text("keep", encoding="utf-8")
|
||||
raise FileExistsError("another installer won the race")
|
||||
original_mkdir(path, mode=mode, parents=parents, exist_ok=exist_ok)
|
||||
|
||||
monkeypatch.setattr(Path, "mkdir", competing_create)
|
||||
with pytest.raises(FileExistsError, match="another installer"):
|
||||
installer.install(workspace, pack)
|
||||
assert (workspace / ".agents/gewu/other-installation.txt").read_text() == "keep"
|
||||
assert not (workspace / "AGENTS.md").exists()
|
||||
|
||||
|
||||
def test_uninstall_rejects_traversal_in_edited_state_before_deleting_files(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
outside = workspace.parent / "keep.txt"
|
||||
outside.write_text("user data", encoding="utf-8")
|
||||
installer.install(workspace, make_pack(tmp_path))
|
||||
|
||||
state_path = workspace / ".agents/gewu-install.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["files"]["../../keep.txt"] = installer.digest(b"user data")
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="file list is invalid"):
|
||||
installer.uninstall(workspace)
|
||||
assert outside.read_text(encoding="utf-8") == "user data"
|
||||
assert (workspace / ".agents/gewu/manifest.json").is_file()
|
||||
|
||||
|
||||
def test_uninstall_rejects_unrecognized_route_names_in_edited_state(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
installer.install(workspace, make_pack(tmp_path))
|
||||
|
||||
state_path = workspace / ".agents/gewu-install.json"
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
state["routes"]["../../keep.txt"] = state["routes"].pop("AGENTS.md")
|
||||
state_path.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="routes are invalid"):
|
||||
installer.uninstall(workspace)
|
||||
assert (workspace / "AGENTS.md").is_file()
|
||||
|
||||
|
||||
def test_uninstall_rejects_agents_symlink_before_reading_state(tmp_path: Path) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
external = tmp_path / "external"
|
||||
workspace.mkdir()
|
||||
external.mkdir()
|
||||
(external / "gewu-install.json").write_text("not a state file", encoding="utf-8")
|
||||
(workspace / ".agents").symlink_to(external, target_is_directory=True)
|
||||
|
||||
with pytest.raises(ValueError, match=".agents must not be a symlink"):
|
||||
installer.uninstall(workspace)
|
||||
assert (external / "gewu-install.json").read_text(encoding="utf-8") == "not a state file"
|
||||
Reference in new issue
Block a user