Set up document management for Stirling Engine (#6476)

# Description of Changes
Change Stirling Engine to support deleting documents automatically. This
happens both on user logout and after an amount of time specified by the
Java when ingesting a document (allowing for personal documents to have
short lifetimes but org documents to be left in the db with no expiry
date). Also sets up an [ACL
policy](https://en.wikipedia.org/wiki/Access-control_list) for the
documents so the database knows which users have access to which
documents. This is not fully implemented in the Java, so currently all
docs are treated as having a single owner, the uploader, but
theoretically when we need to support org storage, we shouldn't need to
change the db schema.
This commit is contained in:
James Brunton
2026-06-03 11:52:11 +00:00
committed by GitHub
parent 71633861d0
commit 1264f4cfed
48 changed files with 2039 additions and 411 deletions
+22 -2
View File
@@ -15,9 +15,12 @@ import pytest
from stirling.agents.shared import ChunkedReasoner, ChunkNotes, WholeDocReaderCapability
from stirling.contracts import AiFile, PageText
from stirling.documents import Document, DocumentService, SqliteVecStore
from stirling.models import FileId
from stirling.models import FileId, OwnerId, PrincipalId
from stirling.services.runtime import AppRuntime
OWNER = OwnerId("test-user")
PRINCIPALS = [PrincipalId("test-user")]
class StubEmbedder:
"""Deterministic embeddings so tests don't need a real provider."""
@@ -75,7 +78,9 @@ async def test_read_full_document_returns_formatted_notes_for_single_file(
PageText(page_number=1, text="Chapter one prose."),
PageText(page_number=2, text="Chapter two prose."),
]
await runtime_with_stub_docs.documents.ingest(FileId("doc-id"), pages, source="doc.pdf")
await runtime_with_stub_docs.documents.ingest(
FileId("doc-id"), pages, source="doc.pdf", owner_id=OWNER, read_principals=PRINCIPALS, expires_at=None
)
reasoner = ChunkedReasoner(runtime_with_stub_docs)
canned_notes = [ChunkNotes(pages=[1, 2], summary="overview", facts=["fact-A"])]
@@ -83,6 +88,7 @@ async def test_read_full_document_returns_formatted_notes_for_single_file(
capability = WholeDocReaderCapability(
runtime=runtime_with_stub_docs,
files=[_ai_file("doc-id", "doc.pdf")],
principals=PRINCIPALS,
reasoner=reasoner,
)
result = await capability._read_full_document("what is in the document?")
@@ -106,6 +112,9 @@ async def test_read_full_document_iterates_multiple_files(runtime_with_stub_docs
FileId(cid),
[PageText(page_number=1, text=f"contents of {cid}")],
source=source,
owner_id=OWNER,
read_principals=PRINCIPALS,
expires_at=None,
)
reasoner = ChunkedReasoner(runtime_with_stub_docs)
@@ -121,6 +130,7 @@ async def test_read_full_document_iterates_multiple_files(runtime_with_stub_docs
capability = WholeDocReaderCapability(
runtime=runtime_with_stub_docs,
files=[_ai_file("doc-a", "a.pdf"), _ai_file("doc-b", "b.pdf")],
principals=PRINCIPALS,
reasoner=reasoner,
)
result = await capability._read_full_document("compare them")
@@ -140,6 +150,9 @@ async def test_read_full_document_skips_files_without_pages(runtime_with_stub_do
FileId("present"),
[PageText(page_number=1, text="real content")],
source="present.pdf",
owner_id=OWNER,
read_principals=PRINCIPALS,
expires_at=None,
)
# 'missing' is never ingested -> read_pages returns [].
@@ -149,6 +162,7 @@ async def test_read_full_document_skips_files_without_pages(runtime_with_stub_do
capability = WholeDocReaderCapability(
runtime=runtime_with_stub_docs,
files=[_ai_file("missing", "missing.pdf"), _ai_file("present", "present.pdf")],
principals=PRINCIPALS,
reasoner=reasoner,
)
result = await capability._read_full_document("anything")
@@ -167,6 +181,7 @@ async def test_read_full_document_returns_empty_message_when_no_pages_anywhere(
capability = WholeDocReaderCapability(
runtime=runtime_with_stub_docs,
files=[_ai_file("nope", "nope.pdf")],
principals=PRINCIPALS,
reasoner=reasoner,
)
result = await capability._read_full_document("anything")
@@ -186,12 +201,16 @@ async def test_read_full_document_budget_hides_tool_when_exhausted(
FileId("doc-id"),
[PageText(page_number=1, text="content")],
source="doc.pdf",
owner_id=OWNER,
read_principals=PRINCIPALS,
expires_at=None,
)
reasoner = ChunkedReasoner(runtime_with_stub_docs)
with patch.object(reasoner, "gather_notes", AsyncMock(return_value=[ChunkNotes(pages=[1], summary="s")])):
capability = WholeDocReaderCapability(
runtime=runtime_with_stub_docs,
files=[_ai_file("doc-id", "doc.pdf")],
principals=PRINCIPALS,
reasoner=reasoner,
max_reads=1,
)
@@ -212,6 +231,7 @@ async def test_instructions_mention_attached_files(runtime_with_stub_docs: AppRu
capability = WholeDocReaderCapability(
runtime=runtime_with_stub_docs,
files=[_ai_file("doc-a", "alpha.pdf"), _ai_file("doc-b", "beta.pdf")],
principals=PRINCIPALS,
)
text = capability.instructions
@@ -17,9 +17,11 @@ from stirling.contracts.contradiction import (
ContradictionReport,
ContradictionSeverity,
)
from stirling.models import FileId
from stirling.models import FileId, PrincipalId
from stirling.services.runtime import AppRuntime
PRINCIPALS = [PrincipalId("test-user")]
def _file(file_id: str, name: str) -> AiFile:
return AiFile(id=FileId(file_id), name=name)
@@ -58,7 +60,7 @@ async def test_find_contradictions_returns_formatted_text(runtime: AppRuntime) -
canned = _canned_report()
detector.detect = AsyncMock(return_value=canned)
capability = ContradictionCapability(detector=detector, files=[_file("doc-a", "a.pdf")])
capability = ContradictionCapability(detector=detector, files=[_file("doc-a", "a.pdf")], principals=PRINCIPALS)
result = await capability._find_contradictions("are there inconsistent deadlines?")
detector.detect.assert_awaited_once()
@@ -84,6 +86,7 @@ async def test_budget_gate_hides_tool_after_first_audit(runtime: AppRuntime) ->
capability = ContradictionCapability(
detector=detector,
files=[_file("doc-a", "a.pdf")],
principals=PRINCIPALS,
max_audits=1,
)
# A real, minimal ToolDefinition — the prepare callback returns this
@@ -107,7 +110,7 @@ async def test_budget_gate_hides_tool_after_first_audit(runtime: AppRuntime) ->
async def test_find_contradictions_with_no_files_returns_message(runtime: AppRuntime) -> None:
detector = ContradictionDetector(runtime)
detector.detect = AsyncMock(return_value=_canned_report())
capability = ContradictionCapability(detector=detector, files=[])
capability = ContradictionCapability(detector=detector, files=[], principals=PRINCIPALS)
result = await capability._find_contradictions("anything")
@@ -120,6 +123,7 @@ def test_instructions_mention_attached_files(runtime: AppRuntime) -> None:
capability = ContradictionCapability(
detector=detector,
files=[_file("doc-a", "alpha.pdf"), _file("doc-b", "beta.pdf")],
principals=PRINCIPALS,
)
text = capability.instructions
@@ -149,6 +153,7 @@ def test_instructions_escape_filename_injection_attempt(runtime: AppRuntime) ->
capability = ContradictionCapability(
detector=detector,
files=[_file("doc-evil", evil_name)],
principals=PRINCIPALS,
)
text = capability.instructions
+24 -17
View File
@@ -27,7 +27,7 @@ from stirling.agents.shared.chunked_mapper import ChunkOutput
from stirling.contracts import AiFile
from stirling.contracts.contradiction import ContradictionSeverity
from stirling.contracts.documents import Page, PageRange
from stirling.models import FileId
from stirling.models import FileId, PrincipalId
from stirling.services.runtime import AppRuntime
@@ -58,10 +58,17 @@ def pages_a() -> list[Page]:
]
PRINCIPALS = [PrincipalId("test-user")]
def _install_documents_stub(runtime: AppRuntime, pages_by_id: dict[FileId, list[Page]]) -> None:
"""Patch ``runtime.documents.read_pages`` to return canned pages per file."""
async def _read(collection: FileId, page_range: PageRange | None = None) -> list[Page]:
async def _read(
collection: FileId,
principals: list[PrincipalId],
page_range: PageRange | None = None,
) -> list[Page]:
return pages_by_id.get(collection, [])
# AppRuntime is frozen; monkey-patch the documents service.
@@ -76,7 +83,7 @@ async def test_no_pages_returns_clean_empty_report(runtime: AppRuntime, file_a:
_install_documents_stub(runtime, {file_a.id: []})
detector = ContradictionDetector(runtime)
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
assert report.contradictions == []
assert report.pages_examined == []
@@ -126,7 +133,7 @@ async def test_happy_path_finds_contradiction_across_two_pages(
)
detector._summary_agent.run = AsyncMock(return_value=_stub_result("Examined 2 pages; found 1 contradiction."))
report = await detector.detect([file_a], query="check the deadline")
report = await detector.detect([file_a], principals=PRINCIPALS, query="check the deadline")
assert len(report.contradictions) == 1
c = report.contradictions[0]
@@ -150,12 +157,12 @@ async def test_zero_claims_returns_clean_report(runtime: AppRuntime, file_a: AiF
return_value=[ChunkOutput(pages=[1, 2], output=_ExtractedClaims(claims=[]), label="pages=1-2")]
)
# Stubbing the summary agent is unavoidable (the production code calls
# it on every detect()); we just don't assert on what it returns
# asserting on the canned value here would only re-prove that AsyncMock
# it on every detect()); we just don't assert on what it returns.
# Asserting on the canned value here would only re-prove that AsyncMock
# works.
detector._summary_agent.run = AsyncMock(return_value=_stub_result("any text"))
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
assert report.contradictions == []
assert report.clean is True
@@ -204,7 +211,7 @@ async def test_canonicaliser_accepts_empty_alias_list(runtime: AppRuntime, file_
)
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
assert len(report.contradictions) == 1
@@ -332,7 +339,7 @@ async def test_canonicaliser_failure_falls_back_to_lexical_keys(
)
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
# Lexical key collapses both subjects so the bucket still forms.
assert len(report.contradictions) == 1
@@ -389,7 +396,7 @@ async def test_same_page_contradiction_is_surfaced(runtime: AppRuntime, file_a:
)
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
assert len(report.contradictions) == 1
assert report.contradictions[0].severity == ContradictionSeverity.ERROR
@@ -426,7 +433,7 @@ async def test_identical_quote_pair_is_still_dropped(runtime: AppRuntime, file_a
)
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
assert report.contradictions == []
@@ -455,7 +462,7 @@ async def test_summary_falls_back_to_deterministic_when_llm_unavailable(
)
detector._summary_agent.run = AsyncMock(side_effect=failure)
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
assert "No contradictions" in report.summary
assert report.clean is True
@@ -499,7 +506,7 @@ async def test_detector_chunk_timeout_falls_through(runtime: AppRuntime, file_a:
detector._pair_detector.run = AsyncMock(side_effect=TimeoutError("simulated"))
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
# Detector timed out so no pairs come back. Crucially: the pipeline
# reached the summary stage rather than crashing earlier, so
@@ -533,7 +540,7 @@ async def test_empty_chunk_with_substantial_content_logs_warning(
detector._summary_agent.run = AsyncMock(return_value=_stub_result("ok"))
with caplog.at_level(logging.WARNING, logger="stirling.agents.contradiction.detector"):
await detector.detect([file_a])
await detector.detect([file_a], principals=PRINCIPALS)
assert any(
"produced 0 claims" in record.getMessage() and "pages=1" in record.getMessage() for record in caplog.records
@@ -580,7 +587,7 @@ async def test_pages_examined_includes_every_attempted_page(runtime: AppRuntime,
detector._pair_detector.run = AsyncMock(return_value=_stub_result(_BucketContradictions(pairs=[])))
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
# Every page the extractor ran against is reported, even page 2
# (which produced no claim).
@@ -655,7 +662,7 @@ async def test_oversized_bucket_windows_translate_indices_globally(runtime: AppR
detector._pair_detector.run = _stub_detector # type: ignore[method-assign]
detector._summary_agent.run = AsyncMock(return_value=_stub_result("done"))
report = await detector.detect([file_a])
report = await detector.detect([file_a], principals=PRINCIPALS)
# Both windows produced one valid pair each; dedup by global (i, j)
# leaves exactly two contradictions.
@@ -791,7 +798,7 @@ async def test_multi_file_pages_dont_collide_in_validation(runtime: AppRuntime)
)
detector._summary_agent.run = AsyncMock(return_value=_stub_result("ok"))
report = await detector.detect([file_a, file_b])
report = await detector.detect([file_a, file_b], principals=PRINCIPALS)
# Both claims validated as verbatim — each against the right file's
# page text. A collision bug would have produced "paraphrased" for at
@@ -9,6 +9,7 @@ detector when invoked.
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import replace
import pytest
@@ -22,10 +23,24 @@ from stirling.contracts import (
)
from stirling.contracts.contradiction import Claim
from stirling.documents import DocumentService, SqliteVecStore
from stirling.models import FileId
from stirling.models import FileId, OwnerId, PrincipalId, UserId
from stirling.services import current_user_id
from stirling.services.runtime import AppRuntime
from tests.test_pdf_question_agent import StubEmbedder
USER = UserId("test-user")
OWNER = OwnerId("test-user")
OWNER_PRINCIPALS = [PrincipalId("test-user")]
@pytest.fixture(autouse=True)
def _set_user_context() -> Iterator[None]:
token = current_user_id.set(USER)
try:
yield
finally:
current_user_id.reset(token)
def _file(file_id: str, name: str) -> AiFile:
return AiFile(id=FileId(file_id), name=name)
@@ -69,6 +84,9 @@ async def test_run_answer_agent_builds_agent_with_three_toolsets(
file.id,
[PageText(page_number=1, text="content")],
source=file.name,
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
agent = PdfQuestionAgent(runtime_with_stub_docs)
@@ -8,6 +8,7 @@ contradiction and the right cross-references and anchor handling.
from __future__ import annotations
import json
from collections.abc import Iterator
from dataclasses import replace
from typing import Literal
from unittest.mock import AsyncMock
@@ -27,11 +28,25 @@ from stirling.contracts import (
)
from stirling.contracts.contradiction import Claim
from stirling.documents import DocumentService, SqliteVecStore
from stirling.models import FileId, ToolEndpoint
from stirling.models import FileId, OwnerId, PrincipalId, ToolEndpoint, UserId
from stirling.models.tool_models import AddCommentsParams
from stirling.services import current_user_id
from stirling.services.runtime import AppRuntime
from tests.test_pdf_question_agent import StubEmbedder
USER = UserId("test-user")
OWNER = OwnerId("test-user")
OWNER_PRINCIPALS = [PrincipalId("test-user")]
@pytest.fixture(autouse=True)
def _set_user_context() -> Iterator[None]:
token = current_user_id.set(USER)
try:
yield
finally:
current_user_id.reset(token)
def _file(file_id: str, name: str) -> AiFile:
return AiFile(id=FileId(file_id), name=name)
@@ -88,6 +103,9 @@ async def test_localiser_prompt_escapes_verdict_tag_injection(
file.id,
[PageText(page_number=1, text="x")],
source=file.name,
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
agent = PdfReviewAgent(runtime_with_stub_docs)
@@ -159,6 +177,9 @@ async def test_contradiction_intent_emits_add_comments_plan(
file.id,
[PageText(page_number=1, text="ignored"), PageText(page_number=5, text="ignored")],
source=file.name,
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
agent = PdfReviewAgent(runtime_with_stub_docs)
@@ -264,6 +285,9 @@ async def test_contradiction_takes_precedence_over_math(
file.id,
[PageText(page_number=1, text="x")],
source=file.name,
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
agent = PdfReviewAgent(runtime_with_stub_docs)
+343 -53
View File
@@ -8,7 +8,17 @@ from stirling.documents.rag_capability import RagCapability
from stirling.documents.service import DocumentService
from stirling.documents.sqlite_vec_store import SqliteVecStore
from stirling.documents.store import Document, SearchResult
from stirling.models import FileId
from stirling.models import FileId, OwnerId, PrincipalId
# Personal-doc tests reuse the same opaque string in all three roles — keeps the
# defaults of ingest() exercised and tests honest about what the same id means
# (caller, tenant, single ACL grantee). Org-doc tests construct ad-hoc owners
# and principal sets inline.
OWNER = OwnerId("test-user")
OWNER_PRINCIPALS = [PrincipalId("test-user")]
OTHER_OWNER = OwnerId("other-user")
OTHER_OWNER_PRINCIPALS = [PrincipalId("other-user")]
# chunk_text
@@ -56,7 +66,8 @@ class TestSqliteVecStore:
@pytest.mark.anyio
async def test_add_and_search(self) -> None:
store = SqliteVecStore.ephemeral()
await store.ensure_collection("test-col", "test.pdf")
await store.ensure_collection("test-col", "test.pdf", OWNER, None)
await store.grant_read("test-col", OWNER, OWNER_PRINCIPALS)
docs = [
Document(id="1", text="Python is a programming language", metadata={"source": "test"}),
Document(id="2", text="Java is another programming language", metadata={"source": "test"}),
@@ -67,9 +78,9 @@ class TestSqliteVecStore:
[0.9, 0.1, 0.0],
[0.0, 0.0, 1.0],
]
await store.add_documents("test-col", docs, embeddings)
await store.add_documents("test-col", docs, embeddings, OWNER)
results = await store.search("test-col", [1.0, 0.05, 0.0], top_k=2)
results = await store.search("test-col", [1.0, 0.05, 0.0], top_k=2, principals=OWNER_PRINCIPALS)
assert len(results) == 2
assert isinstance(results[0], SearchResult)
assert results[0].document.id == "1"
@@ -78,33 +89,36 @@ class TestSqliteVecStore:
@pytest.mark.anyio
async def test_list_and_has_collection(self) -> None:
store = SqliteVecStore.ephemeral()
await store.ensure_collection("my-collection", "test.pdf")
await store.ensure_collection("my-collection", "test.pdf", OWNER, None)
await store.grant_read("my-collection", OWNER, OWNER_PRINCIPALS)
docs = [Document(id="1", text="test", metadata={})]
await store.add_documents("my-collection", docs, [[1.0, 0.0]])
await store.add_documents("my-collection", docs, [[1.0, 0.0]], OWNER)
collections = await store.list_collections()
collections = await store.list_collections(OWNER_PRINCIPALS)
assert "my-collection" in collections
assert await store.has_collection("my-collection") is True
assert await store.has_collection("nonexistent") is False
assert await store.has_collection("my-collection", OWNER_PRINCIPALS) is True
assert await store.has_collection("nonexistent", OWNER_PRINCIPALS) is False
@pytest.mark.anyio
async def test_delete_collection(self) -> None:
store = SqliteVecStore.ephemeral()
await store.ensure_collection("to-delete", "test.pdf")
await store.ensure_collection("to-delete", "test.pdf", OWNER, None)
await store.grant_read("to-delete", OWNER, OWNER_PRINCIPALS)
docs = [Document(id="1", text="test", metadata={})]
await store.add_documents("to-delete", docs, [[1.0]])
await store.add_documents("to-delete", docs, [[1.0]], OWNER)
assert await store.has_collection("to-delete") is True
await store.delete_collection("to-delete")
assert await store.has_collection("to-delete") is False
assert await store.has_collection("to-delete", OWNER_PRINCIPALS) is True
await store.delete_collection("to-delete", OWNER)
assert await store.has_collection("to-delete", OWNER_PRINCIPALS) is False
@pytest.mark.anyio
async def test_search_empty_collection(self) -> None:
store = SqliteVecStore.ephemeral()
await store.ensure_collection("empty-test", "test.pdf")
await store.ensure_collection("empty-test", "test.pdf", OWNER, None)
await store.grant_read("empty-test", OWNER, OWNER_PRINCIPALS)
docs = [Document(id="1", text="test", metadata={})]
await store.add_documents("empty-test", docs, [[1.0, 0.0]])
results = await store.search("empty-test", [1.0, 0.0], top_k=5)
await store.add_documents("empty-test", docs, [[1.0, 0.0]], OWNER)
results = await store.search("empty-test", [1.0, 0.0], top_k=5, principals=OWNER_PRINCIPALS)
assert len(results) == 1
@pytest.mark.anyio
@@ -112,7 +126,118 @@ class TestSqliteVecStore:
store = SqliteVecStore.ephemeral()
docs = [Document(id="1", text="test", metadata={})]
with pytest.raises(ValueError, match="documents.*embeddings"):
await store.add_documents("bad", docs, [[1.0], [2.0]])
await store.add_documents("bad", docs, [[1.0], [2.0]], OWNER)
@pytest.mark.anyio
async def test_collections_isolated_by_owner(self) -> None:
"""Two owners can store the same collection id; reads stay scoped to ACL."""
store = SqliteVecStore.ephemeral()
await store.ensure_collection("shared-id", "alice.pdf", OWNER, None)
await store.grant_read("shared-id", OWNER, OWNER_PRINCIPALS)
await store.ensure_collection("shared-id", "bob.pdf", OTHER_OWNER, None)
await store.grant_read("shared-id", OTHER_OWNER, OTHER_OWNER_PRINCIPALS)
await store.add_documents(
"shared-id",
[Document(id="1", text="alice content", metadata={})],
[[1.0, 0.0]],
OWNER,
)
await store.add_documents(
"shared-id",
[Document(id="1", text="bob content", metadata={})],
[[1.0, 0.0]],
OTHER_OWNER,
)
alice_results = await store.search("shared-id", [1.0, 0.0], top_k=5, principals=OWNER_PRINCIPALS)
bob_results = await store.search("shared-id", [1.0, 0.0], top_k=5, principals=OTHER_OWNER_PRINCIPALS)
assert [r.document.text for r in alice_results] == ["alice content"]
assert [r.document.text for r in bob_results] == ["bob content"]
assert await store.list_collections(OWNER_PRINCIPALS) == ["shared-id"]
assert await store.list_collections(OTHER_OWNER_PRINCIPALS) == ["shared-id"]
await store.delete_collection("shared-id", OWNER)
assert await store.has_collection("shared-id", OWNER_PRINCIPALS) is False
assert await store.has_collection("shared-id", OTHER_OWNER_PRINCIPALS) is True
@pytest.mark.anyio
async def test_purge_owner_removes_only_that_owners_collections(self) -> None:
"""Logout path: one owner's purge must not touch another owner's docs."""
store = SqliteVecStore.ephemeral()
for owner, principals, name in (
(OWNER, OWNER_PRINCIPALS, "a.pdf"),
(OWNER, OWNER_PRINCIPALS, "b.pdf"),
(OTHER_OWNER, OTHER_OWNER_PRINCIPALS, "c.pdf"),
):
await store.ensure_collection(name, name, owner, None)
await store.grant_read(name, owner, principals)
await store.add_documents(name, [Document(id="1", text="x", metadata={})], [[1.0, 0.0]], owner)
deleted = await store.purge_owner(OWNER)
assert deleted == 2
assert await store.list_collections(OWNER_PRINCIPALS) == []
assert await store.list_collections(OTHER_OWNER_PRINCIPALS) == ["c.pdf"]
@pytest.mark.anyio
async def test_reap_expired_drops_collections_past_expires_at(self) -> None:
"""TTL backstop: rows with ``expires_at`` in the past go away on reap."""
from datetime import UTC, datetime, timedelta
store = SqliteVecStore.ephemeral()
now = datetime.now(UTC)
await store.ensure_collection("fresh", "fresh.pdf", OWNER, now + timedelta(hours=1))
await store.grant_read("fresh", OWNER, OWNER_PRINCIPALS)
await store.ensure_collection("stale", "stale.pdf", OWNER, now - timedelta(seconds=1))
await store.grant_read("stale", OWNER, OWNER_PRINCIPALS)
deleted = await store.reap_expired()
assert deleted == 1
assert await store.list_collections(OWNER_PRINCIPALS) == ["fresh"]
@pytest.mark.anyio
async def test_reap_expired_keeps_persistent_and_unexpired(self) -> None:
"""Rows with ``expires_at`` in the future stay. Rows with null
``expires_at`` (org docs) are persistent and never touched, regardless
of age."""
from datetime import UTC, datetime, timedelta
store = SqliteVecStore.ephemeral()
await store.ensure_collection("session", "s.pdf", OWNER, datetime.now(UTC) + timedelta(hours=1))
await store.grant_read("session", OWNER, OWNER_PRINCIPALS)
await store.ensure_collection("persistent", "p.pdf", OTHER_OWNER, None)
await store.grant_read("persistent", OTHER_OWNER, OTHER_OWNER_PRINCIPALS)
deleted = await store.reap_expired()
assert deleted == 0
assert await store.list_collections(OWNER_PRINCIPALS) == ["session"]
assert await store.list_collections(OTHER_OWNER_PRINCIPALS) == ["persistent"]
@pytest.mark.anyio
async def test_acl_grants_read_to_extra_principal(self) -> None:
"""A principal without an ACL row can't read; once granted, they can."""
store = SqliteVecStore.ephemeral()
team_principal = PrincipalId("group:engineering")
await store.ensure_collection("doc", "engineering-runbook.pdf", OWNER, None)
await store.grant_read("doc", OWNER, OWNER_PRINCIPALS)
await store.add_documents(
"doc",
[Document(id="1", text="how to deploy", metadata={})],
[[1.0, 0.0]],
OWNER,
)
# Engineering can't see it yet.
assert await store.has_collection("doc", [team_principal]) is False
# Owner grants engineering read access.
await store.grant_read("doc", OWNER, [team_principal])
assert await store.has_collection("doc", [team_principal]) is True
# Revoke kills it.
await store.revoke("doc", OWNER, team_principal)
assert await store.has_collection("doc", [team_principal]) is False
# Owner still can.
assert await store.has_collection("doc", OWNER_PRINCIPALS) is True
# DocumentService (with stub embedder)
@@ -163,39 +288,131 @@ class TestDocumentService:
@pytest.mark.anyio
async def test_ingest_and_search(self, documents: DocumentService) -> None:
text = "Python is great for data science. It has many libraries like pandas and numpy."
count = await documents.ingest(FileId("docs"), _pages(text), source="guide.pdf")
count = await documents.ingest(
FileId("docs"),
_pages(text),
source="guide.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
assert count > 0
results = await documents.search("Python libraries", collection=FileId("docs"))
results = await documents.search("Python libraries", principals=OWNER_PRINCIPALS, collection=FileId("docs"))
assert len(results) > 0
assert results[0].document.text
@pytest.mark.anyio
async def test_ingest_empty_text_returns_zero_chunks(self, documents: DocumentService) -> None:
count = await documents.ingest(FileId("docs"), _pages(""), source="empty.pdf")
count = await documents.ingest(
FileId("docs"),
_pages(""),
source="empty.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
assert count == 0
@pytest.mark.anyio
async def test_search_nonexistent_collection_returns_empty(self, documents: DocumentService) -> None:
results = await documents.search("anything", collection=FileId("nonexistent"))
results = await documents.search("anything", principals=OWNER_PRINCIPALS, collection=FileId("nonexistent"))
assert results == []
@pytest.mark.anyio
async def test_search_all_collections(self, documents: DocumentService) -> None:
await documents.ingest(FileId("col-a"), _pages("Machine learning overview."), source="ml.pdf")
await documents.ingest(FileId("col-b"), _pages("Deep learning with neural networks."), source="dl.pdf")
async def test_search_all_collections_for_principal(self, documents: DocumentService) -> None:
await documents.ingest(
FileId("col-a"),
_pages("Machine learning overview."),
source="ml.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
await documents.ingest(
FileId("col-b"),
_pages("Deep learning with neural networks."),
source="dl.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
results = await documents.search("neural networks")
results = await documents.search("neural networks", principals=OWNER_PRINCIPALS)
assert len(results) > 0
@pytest.mark.anyio
async def test_search_does_not_cross_owner_boundary(self, documents: DocumentService) -> None:
"""A search by one principal set never returns docs owned by an unrelated owner."""
await documents.ingest(
FileId("col-a"),
_pages("Alice's private notes."),
source="alice.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
await documents.ingest(
FileId("col-b"),
_pages("Bob's private notes."),
source="bob.pdf",
owner_id=OTHER_OWNER,
read_principals=OTHER_OWNER_PRINCIPALS,
expires_at=None,
)
alice_results = await documents.search("notes", principals=OWNER_PRINCIPALS)
bob_results = await documents.search("notes", principals=OTHER_OWNER_PRINCIPALS)
alice_texts = [r.document.text for r in alice_results]
bob_texts = [r.document.text for r in bob_results]
assert any("Alice" in t for t in alice_texts)
assert not any("Bob" in t for t in alice_texts)
assert any("Bob" in t for t in bob_texts)
assert not any("Alice" in t for t in bob_texts)
@pytest.mark.anyio
async def test_org_doc_visible_only_to_granted_group(self, documents: DocumentService) -> None:
"""Org-owned doc with explicit ACL: only granted principals see it."""
org_owner = OwnerId("org:acme")
eng_group = PrincipalId("group:engineering")
hr_group = PrincipalId("group:hr")
await documents.ingest(
FileId("runbook"),
_pages("Production deploy steps."),
source="runbook.pdf",
owner_id=org_owner,
read_principals=[eng_group],
expires_at=None,
)
# Engineering can read.
eng_results = await documents.search("deploy", principals=[eng_group])
assert len(eng_results) > 0
# HR cannot.
hr_results = await documents.search("deploy", principals=[hr_group])
assert hr_results == []
# Caller with both memberships (or org-wide principal) still sees it via eng.
multi_results = await documents.search("deploy", principals=[hr_group, eng_group])
assert len(multi_results) > 0
@pytest.mark.anyio
async def test_delete_collection(self, documents: DocumentService) -> None:
await documents.ingest(FileId("temp"), _pages("Temporary data."), source="tmp.pdf")
collections = await documents.list_collections()
await documents.ingest(
FileId("temp"),
_pages("Temporary data."),
source="tmp.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
collections = await documents.list_collections(OWNER_PRINCIPALS)
assert "temp" in collections
await documents.delete_collection(FileId("temp"))
collections = await documents.list_collections()
await documents.delete_collection(FileId("temp"), owner_id=OWNER)
collections = await documents.list_collections(OWNER_PRINCIPALS)
assert "temp" not in collections
@pytest.mark.anyio
@@ -205,9 +422,16 @@ class TestDocumentService:
PageText(page_number=2, text="Second page text."),
PageText(page_number=3, text="Third page text."),
]
await documents.ingest(FileId("ordered"), pages, source="ordered.pdf")
await documents.ingest(
FileId("ordered"),
pages,
source="ordered.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
stored = await documents.read_pages(FileId("ordered"))
stored = await documents.read_pages(FileId("ordered"), principals=OWNER_PRINCIPALS)
assert [p.page_number for p in stored] == [1, 2, 3]
assert stored[0].text == "First page text."
assert stored[0].char_count == len("First page text.")
@@ -217,9 +441,13 @@ class TestDocumentService:
from stirling.contracts import PageRange
pages = [PageText(page_number=i, text=f"page {i}") for i in range(1, 6)]
await documents.ingest(FileId("ranged"), pages, source="r.pdf")
await documents.ingest(
FileId("ranged"), pages, source="r.pdf", owner_id=OWNER, read_principals=OWNER_PRINCIPALS, expires_at=None
)
subset = await documents.read_pages(FileId("ranged"), PageRange(start=2, end=4))
subset = await documents.read_pages(
FileId("ranged"), principals=OWNER_PRINCIPALS, page_range=PageRange(start=2, end=4)
)
assert [p.page_number for p in subset] == [2, 3, 4]
@pytest.mark.anyio
@@ -228,14 +456,20 @@ class TestDocumentService:
FileId("doc"),
[PageText(page_number=1, text="old"), PageText(page_number=2, text="old2")],
source="v1.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
await documents.ingest(
FileId("doc"),
[PageText(page_number=1, text="new")],
source="v2.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
stored = await documents.read_pages(FileId("doc"))
stored = await documents.read_pages(FileId("doc"), principals=OWNER_PRINCIPALS)
assert [p.page_number for p in stored] == [1]
assert stored[0].text == "new"
@@ -248,9 +482,16 @@ class TestDocumentService:
PageText(page_number=2, text=" "),
PageText(page_number=3, text="Real text on page 3."),
]
await documents.ingest(FileId("with-blanks"), pages, source="blanks.pdf")
await documents.ingest(
FileId("with-blanks"),
pages,
source="blanks.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
stored = await documents.read_pages(FileId("with-blanks"))
stored = await documents.read_pages(FileId("with-blanks"), principals=OWNER_PRINCIPALS)
assert [p.page_number for p in stored] == [1, 2, 3]
assert stored[1].text.strip() == ""
@@ -270,22 +511,36 @@ async def _invoke_search_knowledge(capability: RagCapability, query: str, max_re
class TestRagCapability:
def test_instructions_static_when_collections_pinned(self, documents: DocumentService) -> None:
cap = RagCapability(documents, collections=[FileId("docs"), FileId("manuals")])
cap = RagCapability(documents, principals=OWNER_PRINCIPALS, collections=[FileId("docs"), FileId("manuals")])
instructions = cap.instructions
assert isinstance(instructions, str)
assert "docs, manuals" in instructions
assert "search_knowledge" in instructions
def test_instructions_dynamic_when_no_collections(self, documents: DocumentService) -> None:
cap = RagCapability(documents)
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
instructions = cap.instructions
assert callable(instructions)
@pytest.mark.anyio
async def test_dynamic_instructions_list_available_collections(self, documents: DocumentService) -> None:
await documents.ingest(FileId("col-a"), _pages("Alpha content."), source="a.pdf")
await documents.ingest(FileId("col-b"), _pages("Beta content."), source="b.pdf")
cap = RagCapability(documents)
await documents.ingest(
FileId("col-a"),
_pages("Alpha content."),
source="a.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
await documents.ingest(
FileId("col-b"),
_pages("Beta content."),
source="b.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
instructions_fn = cap.instructions
assert callable(instructions_fn)
text = await instructions_fn()
@@ -294,7 +549,7 @@ class TestRagCapability:
@pytest.mark.anyio
async def test_dynamic_instructions_when_store_empty(self, documents: DocumentService) -> None:
cap = RagCapability(documents)
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
instructions_fn = cap.instructions
assert callable(instructions_fn)
text = await instructions_fn()
@@ -302,14 +557,21 @@ class TestRagCapability:
@pytest.mark.anyio
async def test_search_knowledge_returns_no_results_message_when_empty(self, documents: DocumentService) -> None:
cap = RagCapability(documents)
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
output = await _invoke_search_knowledge(cap, "anything")
assert output == "No relevant results found in the knowledge base."
@pytest.mark.anyio
async def test_search_knowledge_formats_results_with_source_and_score(self, documents: DocumentService) -> None:
await documents.ingest(FileId("docs"), _pages("Python is a programming language."), source="guide.pdf")
cap = RagCapability(documents)
await documents.ingest(
FileId("docs"),
_pages("Python is a programming language."),
source="guide.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
output = await _invoke_search_knowledge(cap, "Python")
assert "[Result 1" in output
assert "source: guide.pdf" in output
@@ -318,10 +580,24 @@ class TestRagCapability:
@pytest.mark.anyio
async def test_search_knowledge_restricts_to_pinned_collections(self, documents: DocumentService) -> None:
await documents.ingest(FileId("pinned"), _pages("Pinned collection content."), source="pinned.pdf")
await documents.ingest(FileId("other"), _pages("Content in another collection."), source="other.pdf")
await documents.ingest(
FileId("pinned"),
_pages("Pinned collection content."),
source="pinned.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
await documents.ingest(
FileId("other"),
_pages("Content in another collection."),
source="other.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
cap = RagCapability(documents, collections=[FileId("pinned")])
cap = RagCapability(documents, principals=OWNER_PRINCIPALS, collections=[FileId("pinned")])
output = await _invoke_search_knowledge(cap, "content")
assert "pinned.pdf" in output
assert "other.pdf" not in output
@@ -329,9 +605,16 @@ class TestRagCapability:
@pytest.mark.anyio
async def test_search_knowledge_respects_max_results(self, documents: DocumentService) -> None:
paragraphs = "\n\n".join(f"Paragraph {i} about topic." for i in range(10))
await documents.ingest(FileId("bulk"), _pages(paragraphs), source="bulk.pdf")
await documents.ingest(
FileId("bulk"),
_pages(paragraphs),
source="bulk.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
cap = RagCapability(documents)
cap = RagCapability(documents, principals=OWNER_PRINCIPALS)
output = await _invoke_search_knowledge(cap, "topic", max_results=2)
assert "[Result 1" in output
assert "[Result 2" in output
@@ -341,8 +624,15 @@ class TestRagCapability:
async def test_search_knowledge_tool_is_hidden_after_budget_exhausted(self, documents: DocumentService) -> None:
"""The prepare callback must return None once max_searches has been reached
so the agent can no longer call the tool on subsequent turns."""
await documents.ingest(FileId("docs"), _pages("Some content."), source="x.pdf")
cap = RagCapability(documents, max_searches=2)
await documents.ingest(
FileId("docs"),
_pages("Some content."),
source="x.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
cap = RagCapability(documents, principals=OWNER_PRINCIPALS, max_searches=2)
tool_def = _dummy_tool_def()
assert await cap._prepare_search_knowledge(None, tool_def) is tool_def # type: ignore[arg-type]
+174 -9
View File
@@ -8,7 +8,11 @@ from fastapi.testclient import TestClient
from stirling.api import app
from stirling.api.dependencies import get_document_service
from stirling.documents import Document, DocumentService, SqliteVecStore
from stirling.models import FileId
from stirling.models import FileId, PrincipalId, UserId
USER = UserId("test-user")
USER_PRINCIPALS = [PrincipalId("test-user")]
HEADERS = {"X-User-Id": USER}
class StubEmbedder:
@@ -78,7 +82,11 @@ def test_ingest_document_indexes_page_text(client: TestClient, service: Document
{"pageNumber": 1, "text": "The introduction covers the main topic."},
{"pageNumber": 2, "text": "The conclusion summarises the findings."},
],
"ownerId": USER,
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
assert response.status_code == 200
body = response.json()
@@ -94,7 +102,11 @@ async def test_ingest_document_replaces_existing_content(client: TestClient, ser
"documentId": "replace-me",
"source": "replace-me.pdf",
"pageText": [{"pageNumber": 1, "text": "Original content that existed before."}],
"ownerId": USER,
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
# Second ingest with different content should replace the first entirely
response = client.post(
@@ -103,11 +115,15 @@ async def test_ingest_document_replaces_existing_content(client: TestClient, ser
"documentId": "replace-me",
"source": "replace-me.pdf",
"pageText": [{"pageNumber": 1, "text": "New content that replaced the old."}],
"ownerId": USER,
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
assert response.status_code == 200
results = await service.search("New content", collection=FileId("replace-me"), top_k=5)
results = await service.search("New content", principals=USER_PRINCIPALS, collection=FileId("replace-me"), top_k=5)
texts = [r.document.text for r in results]
assert any("New content" in t for t in texts)
assert not any("Original content" in t for t in texts)
@@ -123,14 +139,28 @@ def test_ingest_document_skips_empty_pages(client: TestClient) -> None:
{"pageNumber": 1, "text": " "},
{"pageNumber": 2, "text": "Real content on page 2."},
],
"ownerId": USER,
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
assert response.status_code == 200
assert response.json()["chunksIndexed"] >= 1
def test_ingest_document_with_no_content_returns_zero(client: TestClient) -> None:
response = client.post("/api/v1/documents", json={"documentId": "empty", "source": "empty.pdf"})
response = client.post(
"/api/v1/documents",
json={
"documentId": "empty",
"source": "empty.pdf",
"ownerId": USER,
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
assert response.status_code == 200
assert response.json()["chunksIndexed"] == 0
@@ -139,6 +169,7 @@ def test_ingest_document_rejects_empty_id(client: TestClient) -> None:
response = client.post(
"/api/v1/documents",
json={"documentId": "", "source": "x.pdf", "pageText": [{"pageNumber": 1, "text": "something"}]},
headers=HEADERS,
)
assert response.status_code == 422
@@ -147,6 +178,7 @@ def test_ingest_document_rejects_missing_source(client: TestClient) -> None:
response = client.post(
"/api/v1/documents",
json={"documentId": "doc-1", "pageText": [{"pageNumber": 1, "text": "something"}]},
headers=HEADERS,
)
assert response.status_code == 422
@@ -155,6 +187,7 @@ def test_ingest_document_rejects_empty_source(client: TestClient) -> None:
response = client.post(
"/api/v1/documents",
json={"documentId": "doc-1", "source": "", "pageText": [{"pageNumber": 1, "text": "something"}]},
headers=HEADERS,
)
assert response.status_code == 422
@@ -166,11 +199,61 @@ def test_ingest_document_rejects_non_positive_page_number(client: TestClient) ->
"documentId": "bad-page",
"source": "bad-page.pdf",
"pageText": [{"pageNumber": 0, "text": "something"}],
"ownerId": USER,
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
assert response.status_code == 422
def test_ingest_document_rejects_missing_owner_id(client: TestClient) -> None:
"""ownerId is required — never derived from the caller. Forgetting it must 422,
not silently fall back to personal-doc semantics."""
response = client.post(
"/api/v1/documents",
json={
"documentId": "no-owner",
"source": "no-owner.pdf",
"pageText": [{"pageNumber": 1, "text": "something"}],
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
assert response.status_code == 422
def test_ingest_document_rejects_empty_read_principals(client: TestClient) -> None:
"""readPrincipals is required and must not be empty — every doc needs at least one reader."""
response = client.post(
"/api/v1/documents",
json={
"documentId": "no-readers",
"source": "no-readers.pdf",
"pageText": [{"pageNumber": 1, "text": "something"}],
"ownerId": USER,
"readPrincipals": [],
},
headers=HEADERS,
)
assert response.status_code == 422
def test_ingest_document_rejects_missing_user_header(client: TestClient) -> None:
"""The route must refuse to write per-user data when the caller didn't identify themselves."""
response = client.post(
"/api/v1/documents",
json={
"documentId": "doc-1",
"source": "x.pdf",
"pageText": [{"pageNumber": 1, "text": "something"}],
},
)
assert response.status_code == 401
# ── DELETE /documents/{id} ──────────────────────────────────────────────
@@ -181,15 +264,19 @@ def test_delete_document_reports_deleted_true_when_existed(client: TestClient) -
"documentId": "to-delete",
"source": "to-delete.pdf",
"pageText": [{"pageNumber": 1, "text": "Text."}],
"ownerId": USER,
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
response = client.delete("/api/v1/documents/to-delete")
response = client.delete("/api/v1/documents/by-id/to-delete", headers=HEADERS)
assert response.status_code == 200
assert response.json() == {"documentId": "to-delete", "deleted": True}
def test_delete_document_is_idempotent(client: TestClient) -> None:
response = client.delete("/api/v1/documents/never-existed")
response = client.delete("/api/v1/documents/by-id/never-existed", headers=HEADERS)
assert response.status_code == 200
assert response.json() == {"documentId": "never-existed", "deleted": False}
@@ -198,8 +285,86 @@ def test_delete_document_is_idempotent(client: TestClient) -> None:
async def test_delete_document_removes_collection(client: TestClient, service: DocumentService) -> None:
client.post(
"/api/v1/documents",
json={"documentId": "gone", "source": "gone.pdf", "pageText": [{"pageNumber": 1, "text": "Text."}]},
json={
"documentId": "gone",
"source": "gone.pdf",
"pageText": [{"pageNumber": 1, "text": "Text."}],
"ownerId": USER,
"readPrincipals": [USER],
"expiresAt": None,
},
headers=HEADERS,
)
assert await service.has_collection(FileId("gone"))
client.delete("/api/v1/documents/gone")
assert not await service.has_collection(FileId("gone"))
assert await service.has_collection(FileId("gone"), principals=USER_PRINCIPALS)
client.delete("/api/v1/documents/by-id/gone", headers=HEADERS)
assert not await service.has_collection(FileId("gone"), principals=USER_PRINCIPALS)
def test_delete_document_rejects_missing_user_header(client: TestClient) -> None:
response = client.delete("/api/v1/documents/by-id/anything")
assert response.status_code == 401
@pytest.mark.anyio
async def test_purge_by_owner_removes_only_callers_collections(client: TestClient, service: DocumentService) -> None:
"""Logout path: DELETE /api/v1/documents/by-owner purges only the caller's docs."""
alice = {
"documentId": "alice-doc",
"source": "alice.pdf",
"pageText": [{"pageNumber": 1, "text": "alice content"}],
"ownerId": "alice",
"readPrincipals": ["alice"],
"expiresAt": None,
}
bob = {
"documentId": "bob-doc",
"source": "bob.pdf",
"pageText": [{"pageNumber": 1, "text": "bob content"}],
"ownerId": "bob",
"readPrincipals": ["bob"],
"expiresAt": None,
}
client.post("/api/v1/documents", json=alice, headers={"X-User-Id": "alice"})
client.post("/api/v1/documents", json=bob, headers={"X-User-Id": "bob"})
response = client.delete("/api/v1/documents/by-owner", headers={"X-User-Id": "alice"})
assert response.status_code == 200
assert response.json() == {"ownerId": "alice", "deleted": 1}
# Alice gone, Bob still there.
assert await service.has_collection(FileId("alice-doc"), principals=[PrincipalId("alice")]) is False
assert await service.has_collection(FileId("bob-doc"), principals=[PrincipalId("bob")]) is True
def test_purge_by_owner_is_idempotent(client: TestClient) -> None:
"""Calling purge with no docs is fine — deleted=0 and no error."""
response = client.delete("/api/v1/documents/by-owner", headers=HEADERS)
assert response.status_code == 200
assert response.json() == {"ownerId": USER, "deleted": 0}
def test_purge_by_owner_rejects_missing_user_header(client: TestClient) -> None:
response = client.delete("/api/v1/documents/by-owner")
assert response.status_code == 401
def test_delete_document_only_affects_calling_user(client: TestClient) -> None:
"""Two users with the same document id: one user's delete must not remove the other's."""
alice_body = {
"documentId": "shared",
"source": "shared.pdf",
"pageText": [{"pageNumber": 1, "text": "x"}],
"ownerId": "alice",
"readPrincipals": ["alice"],
"expiresAt": None,
}
bob_body = {**alice_body, "ownerId": "bob", "readPrincipals": ["bob"], "expiresAt": None}
client.post("/api/v1/documents", json=alice_body, headers={"X-User-Id": "alice"})
client.post("/api/v1/documents", json=bob_body, headers={"X-User-Id": "bob"})
alice_delete = client.delete("/api/v1/documents/by-id/shared", headers={"X-User-Id": "alice"})
assert alice_delete.json() == {"documentId": "shared", "deleted": True}
# Bob's copy is still there
bob_delete = client.delete("/api/v1/documents/by-id/shared", headers={"X-User-Id": "bob"})
assert bob_delete.json() == {"documentId": "shared", "deleted": True}
+27 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import replace
import pytest
@@ -19,9 +20,25 @@ from stirling.contracts import (
SupportedCapability,
)
from stirling.documents import Document, DocumentService, SqliteVecStore
from stirling.models import FileId
from stirling.models import FileId, OwnerId, PrincipalId, UserId
from stirling.services import current_user_id
from stirling.services.runtime import AppRuntime
USER = UserId("test-user")
OWNER = OwnerId("test-user")
OWNER_PRINCIPALS = [PrincipalId("test-user")]
@pytest.fixture(autouse=True)
def _set_user_context() -> Iterator[None]:
"""Set current_user_id to a fixed test user for every test in this module so
agent code calling require_current_user_id() doesn't fail closed."""
token = current_user_id.set(USER)
try:
yield
finally:
current_user_id.reset(token)
class StubEmbedder:
"""Deterministic embeddings so RAG lookups work in tests without network."""
@@ -94,6 +111,9 @@ async def test_reports_only_missing_files(runtime_with_stub_rag: AppRuntime) ->
FileId("present-id"),
[PageText(page_number=1, text="Invoice total: 120.00.")],
source="present.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
agent = PdfQuestionAgent(runtime_with_stub_rag)
@@ -111,6 +131,9 @@ async def test_returns_grounded_answer_when_all_files_ingested(runtime_with_stub
FileId("invoice-id"),
[PageText(page_number=1, text="Invoice total: 120.00.")],
source="invoice.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
agent = StubPdfQuestionAgent(
runtime_with_stub_rag,
@@ -142,6 +165,9 @@ async def test_returns_not_found_when_answer_not_in_doc(runtime_with_stub_rag: A
FileId("shipping-id"),
[PageText(page_number=1, text="This page contains only a shipping address.")],
source="shipping.pdf",
owner_id=OWNER,
read_principals=OWNER_PRINCIPALS,
expires_at=None,
)
agent = StubPdfQuestionAgent(
runtime_with_stub_rag,