This commit is contained in:
1 parent
4db3f45164
commit
d25c04cdd2
175 files changed
+56243
-15
No files matched your search
@@ -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"
|
||||
Reference in new issue
Block a user