feat: implement GEWU service and local agent workflows
CI / quality (push) Canceled after 0s

This commit is contained in:
vasily committed 2026-10-14 19:21:35 +08:00
1 parent 4db3f45164
commit d25c04cdd2
175 files changed
+56243 -15

No files matched your search

+1
View File
@@ -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()
+92
View File
@@ -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",))
+92
View File
@@ -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
+113
View File
@@ -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()
+513
View File
@@ -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
)
+702
View File
@@ -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
+108
View File
@@ -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,
)
== ()
)
+117
View File
@@ -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>Cafe&#x301; 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)
+189
View File
@@ -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"