398 lines
15 KiB
Python
398 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from sqlalchemy import create_engine, event
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_core.core.events import EventObjectRef, EventTenantRef, PlatformEvent
|
|
from govoplan_core.core.search import (
|
|
SearchAuthorizationRequest,
|
|
SearchBackfillRequest,
|
|
SearchResourceReference,
|
|
)
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_docs.backend.db.models import (
|
|
SemanticDocumentationEntry,
|
|
SemanticDocumentationRevision,
|
|
)
|
|
from govoplan_docs.backend.search_source import (
|
|
PROVIDER_ID,
|
|
RESOURCE_TYPE,
|
|
SemanticDocumentationSearchSource,
|
|
)
|
|
from govoplan_docs.backend.semantic_service import (
|
|
SemanticDocumentationError,
|
|
current_revision,
|
|
list_semantic_entries,
|
|
prefetch_semantic_revisions,
|
|
semantic_entry_payload,
|
|
)
|
|
from test_semantic_documentation import (
|
|
_content,
|
|
_principal,
|
|
_Registry,
|
|
_SubjectProvider,
|
|
)
|
|
|
|
|
|
@contextmanager
|
|
def select_queries(engine):
|
|
statements: list[str] = []
|
|
|
|
def observe(_connection, _cursor, statement, _parameters, _context, _many):
|
|
if statement.lstrip().upper().startswith("SELECT"):
|
|
statements.append(statement)
|
|
|
|
event.listen(engine, "before_cursor_execute", observe)
|
|
try:
|
|
yield statements
|
|
finally:
|
|
event.remove(engine, "before_cursor_execute", observe)
|
|
|
|
|
|
class SemanticReadEfficiencyTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.engine = create_engine("sqlite:///:memory:")
|
|
Base.metadata.create_all(
|
|
self.engine,
|
|
tables=[
|
|
SemanticDocumentationEntry.__table__,
|
|
SemanticDocumentationRevision.__table__,
|
|
],
|
|
)
|
|
self.provider = _SubjectProvider()
|
|
self.registry = _Registry(self.provider)
|
|
self.principal = _principal("author")
|
|
|
|
def tearDown(self) -> None:
|
|
self.engine.dispose()
|
|
|
|
def seed(self, count: int) -> None:
|
|
with Session(self.engine) as session:
|
|
for index in range(count):
|
|
identifier = f"entry-{index:04}"
|
|
session.add(
|
|
SemanticDocumentationEntry(
|
|
id=identifier,
|
|
tenant_id="tenant-1",
|
|
subject_stable_key="same-subject",
|
|
subject_module_id="forms",
|
|
subject_kind="form",
|
|
subject_id="form-1",
|
|
locale=f"de-{index:03}",
|
|
lifecycle_state="draft",
|
|
current_revision=2,
|
|
current_revision_id=f"draft-{index:04}",
|
|
published_revision_id=f"published-{index:04}",
|
|
created_by="author",
|
|
updated_by="author",
|
|
)
|
|
)
|
|
session.flush()
|
|
for revision, state, title in (
|
|
(1, "published", f"Published {index}"),
|
|
(2, "draft", f"Unpublished secret {index}"),
|
|
):
|
|
session.add(
|
|
SemanticDocumentationRevision(
|
|
id=f"{state}-{index:04}",
|
|
tenant_id="tenant-1",
|
|
entry_id=identifier,
|
|
revision=revision,
|
|
lifecycle_state=state,
|
|
action="publish" if revision == 1 else "save",
|
|
change_reason="Fixture",
|
|
content=_content(title),
|
|
content_hash=f"hash-{state}-{index}",
|
|
authored_by="author",
|
|
search_text=title,
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
def test_reader_and_editor_batching_preserve_payloads_with_two_queries_for_forty_entries(
|
|
self,
|
|
) -> None:
|
|
self.seed(40)
|
|
for editor in (False, True):
|
|
with self.subTest(editor=editor):
|
|
with (
|
|
Session(self.engine) as session,
|
|
select_queries(self.engine) as ordinary_queries,
|
|
):
|
|
entries = list_semantic_entries(session, self.principal)
|
|
ordinary = [
|
|
semantic_entry_payload(
|
|
session,
|
|
self.registry,
|
|
self.principal,
|
|
entry=entry,
|
|
editor=editor,
|
|
)
|
|
for entry in entries
|
|
]
|
|
with (
|
|
Session(self.engine) as session,
|
|
select_queries(self.engine) as batch_queries,
|
|
):
|
|
entries = list_semantic_entries(session, self.principal)
|
|
revisions = prefetch_semantic_revisions(
|
|
session, self.principal, entries=entries, editor=editor
|
|
)
|
|
batched = [
|
|
semantic_entry_payload(
|
|
session,
|
|
self.registry,
|
|
self.principal,
|
|
entry=entry,
|
|
editor=editor,
|
|
revisions=revisions,
|
|
)
|
|
for entry in entries
|
|
]
|
|
if not editor:
|
|
self.assertTrue(
|
|
all(
|
|
item.lifecycle_state == "published"
|
|
for item in revisions.values()
|
|
)
|
|
)
|
|
self.assertTrue(all(item["pending_draft"] for item in batched))
|
|
self.assertTrue(
|
|
all(
|
|
"Unpublished" not in item["content"]["title"]
|
|
for item in batched
|
|
)
|
|
)
|
|
self.assertEqual(ordinary, batched)
|
|
self.assertEqual(81 if editor else 41, len(ordinary_queries))
|
|
self.assertEqual(2, len(batch_queries))
|
|
|
|
def test_revision_prefetch_chunks_large_collections(self) -> None:
|
|
self.seed(401)
|
|
with Session(self.engine) as session, select_queries(self.engine) as queries:
|
|
entries = list_semantic_entries(session, self.principal)
|
|
revisions = prefetch_semantic_revisions(
|
|
session, self.principal, entries=entries, editor=False
|
|
)
|
|
self.assertEqual(401, len(revisions))
|
|
self.assertEqual(
|
|
3, len(queries), "One entry query and two bounded revision batches."
|
|
)
|
|
|
|
def test_search_authorization_batches_rows_without_weakening_owner_audience_or_revision_checks(
|
|
self,
|
|
) -> None:
|
|
self.seed(40)
|
|
requests = tuple(
|
|
SearchAuthorizationRequest(
|
|
reference=SearchResourceReference(
|
|
tenant_id="tenant-1",
|
|
module_id="docs",
|
|
resource_type=RESOURCE_TYPE,
|
|
resource_id=f"entry-{index:04}",
|
|
),
|
|
source_revision=f"published-{index:04}",
|
|
)
|
|
for index in range(40)
|
|
)
|
|
source = SemanticDocumentationSearchSource(self.registry)
|
|
with Session(self.engine) as session, select_queries(self.engine) as queries:
|
|
decisions = source.authorize(session, self.principal, requests=requests)
|
|
self.assertTrue(all(decisions.values()))
|
|
self.assertEqual(2, len(queries))
|
|
self.provider.denied_accounts.add(self.principal.account_id)
|
|
with Session(self.engine) as session:
|
|
self.assertFalse(
|
|
any(
|
|
source.authorize(
|
|
session, self.principal, requests=requests
|
|
).values()
|
|
)
|
|
)
|
|
self.provider.denied_accounts.clear()
|
|
stale = SearchAuthorizationRequest(
|
|
reference=requests[0].reference, source_revision="old-publication"
|
|
)
|
|
with Session(self.engine) as session:
|
|
self.assertFalse(
|
|
source.authorize(session, self.principal, requests=(stale,))[
|
|
stale.reference.key
|
|
]
|
|
)
|
|
|
|
def test_audience_denial_precedes_provider_work_and_foreign_entries_do_not_load_revisions(
|
|
self,
|
|
) -> None:
|
|
self.seed(1)
|
|
with Session(self.engine) as session:
|
|
revision = session.get(SemanticDocumentationRevision, "published-0000")
|
|
revision.content = {
|
|
**revision.content,
|
|
"classification": "restricted",
|
|
"audience": ["account:other"],
|
|
}
|
|
session.commit()
|
|
with (
|
|
Session(self.engine) as session,
|
|
patch.object(
|
|
self.provider, "resolve_subject", wraps=self.provider.resolve_subject
|
|
) as resolve,
|
|
):
|
|
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
|
self.assertIsNone(
|
|
semantic_entry_payload(
|
|
session, self.registry, self.principal, entry=entry, editor=False
|
|
)
|
|
)
|
|
resolve.assert_not_called()
|
|
with select_queries(self.engine) as queries:
|
|
self.assertIsNone(
|
|
semantic_entry_payload(
|
|
session,
|
|
self.registry,
|
|
_principal("other", tenant_id="tenant-other"),
|
|
entry=entry,
|
|
editor=False,
|
|
)
|
|
)
|
|
self.assertEqual([], queries)
|
|
|
|
def test_cross_tenant_or_cross_entry_revision_references_fail_closed_in_reading_and_indexing(
|
|
self,
|
|
) -> None:
|
|
self.seed(2)
|
|
source = SemanticDocumentationSearchSource(self.registry)
|
|
publication_event = PlatformEvent(
|
|
type="docs.semantic.published",
|
|
module_id="docs",
|
|
tenant=EventTenantRef(id="tenant-1"),
|
|
resource=EventObjectRef(type=RESOURCE_TYPE, id="entry-0000"),
|
|
)
|
|
with Session(self.engine) as session:
|
|
changes = source.index_changes_for_event(
|
|
session, event=publication_event, delivery_key="valid-fixture"
|
|
)
|
|
self.assertEqual("upsert", changes[0].kind)
|
|
self.assertEqual("published-0000", changes[0].document.source_revision)
|
|
for corruption in ("tenant", "entry"):
|
|
with self.subTest(corruption=corruption), Session(self.engine) as session:
|
|
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
|
revision = session.get(SemanticDocumentationRevision, "published-0000")
|
|
if corruption == "tenant":
|
|
revision.tenant_id = "tenant-other"
|
|
else:
|
|
revision.tenant_id = "tenant-1"
|
|
revision.entry_id = "entry-0001"
|
|
revision.revision = 3
|
|
session.commit()
|
|
self.assertIsNone(
|
|
semantic_entry_payload(
|
|
session,
|
|
self.registry,
|
|
self.principal,
|
|
entry=entry,
|
|
editor=False,
|
|
)
|
|
)
|
|
page = SemanticDocumentationSearchSource(self.registry).backfill(
|
|
session,
|
|
request=SearchBackfillRequest(
|
|
tenant_id="tenant-1",
|
|
provider_id=PROVIDER_ID,
|
|
resource_type=RESOURCE_TYPE,
|
|
rebuild_id="fixture",
|
|
limit=100,
|
|
),
|
|
)
|
|
self.assertNotIn(
|
|
"entry-0000", {document.resource_id for document in page.documents}
|
|
)
|
|
changes = source.index_changes_for_event(
|
|
session,
|
|
event=publication_event,
|
|
delivery_key=f"invalid-{corruption}",
|
|
)
|
|
self.assertEqual("delete", changes[0].kind)
|
|
self.assertIsNone(changes[0].document)
|
|
|
|
def test_published_reader_does_not_depend_on_unavailable_draft_and_editor_still_validates_it(
|
|
self,
|
|
) -> None:
|
|
self.seed(1)
|
|
with Session(self.engine) as session:
|
|
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
|
draft = session.get(
|
|
SemanticDocumentationRevision, entry.current_revision_id
|
|
)
|
|
draft.tenant_id = "tenant-other"
|
|
session.commit()
|
|
self.assertEqual(
|
|
"Published 0",
|
|
semantic_entry_payload(
|
|
session, self.registry, self.principal, entry=entry, editor=False
|
|
)["content"]["title"],
|
|
)
|
|
with self.assertRaises(SemanticDocumentationError):
|
|
current_revision(session, entry)
|
|
|
|
def test_unpublished_revision_pointer_cannot_promote_a_draft_into_reading_or_search(
|
|
self,
|
|
) -> None:
|
|
self.seed(1)
|
|
with Session(self.engine) as session:
|
|
entry = session.get(SemanticDocumentationEntry, "entry-0000")
|
|
entry.published_revision_id = entry.current_revision_id
|
|
session.commit()
|
|
revisions = prefetch_semantic_revisions(
|
|
session, self.principal, entries=(entry,), editor=False
|
|
)
|
|
self.assertEqual({}, revisions)
|
|
self.assertIsNone(
|
|
semantic_entry_payload(
|
|
session,
|
|
self.registry,
|
|
self.principal,
|
|
entry=entry,
|
|
editor=False,
|
|
revisions=revisions,
|
|
)
|
|
)
|
|
self.assertIsNone(
|
|
semantic_entry_payload(
|
|
session, self.registry, self.principal, entry=entry, editor=False
|
|
)
|
|
)
|
|
page = SemanticDocumentationSearchSource(self.registry).backfill(
|
|
session,
|
|
request=SearchBackfillRequest(
|
|
tenant_id="tenant-1",
|
|
provider_id=PROVIDER_ID,
|
|
resource_type=RESOURCE_TYPE,
|
|
rebuild_id="fixture",
|
|
limit=100,
|
|
),
|
|
)
|
|
self.assertEqual((), page.documents)
|
|
changes = SemanticDocumentationSearchSource(
|
|
self.registry
|
|
).index_changes_for_event(
|
|
session,
|
|
delivery_key="draft-fixture",
|
|
event=PlatformEvent(
|
|
type="docs.semantic.published",
|
|
module_id="docs",
|
|
tenant=EventTenantRef(id="tenant-1"),
|
|
resource=EventObjectRef(type=RESOURCE_TYPE, id=entry.id),
|
|
),
|
|
)
|
|
self.assertEqual("delete", changes[0].kind)
|
|
self.assertIsNone(changes[0].document)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|