feat: implement permission-aware search baseline

This commit is contained in:
2026-07-29 15:50:11 +02:00
parent 89a4fd0874
commit c60ca2776e
27 changed files with 2282 additions and 0 deletions

20
tests/test_manifest.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import annotations
import unittest
from govoplan_core.core.search import CAPABILITY_SEARCH_INDEX_WRITER
from govoplan_search.backend.manifest import get_manifest
class SearchManifestTests(unittest.TestCase):
def test_manifest_exposes_optional_search_runtime(self) -> None:
manifest = get_manifest()
self.assertEqual("search", manifest.id)
self.assertIn(CAPABILITY_SEARCH_INDEX_WRITER, manifest.capability_factories)
self.assertEqual("search.index", manifest.search_providers[0].id)
self.assertEqual("/search", manifest.frontend.routes[0].path)
if __name__ == "__main__":
unittest.main()

48
tests/test_migrations.py Normal file
View File

@@ -0,0 +1,48 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from alembic.runtime.migration import MigrationContext
from sqlalchemy import create_engine, inspect
from govoplan_core.db.migrations import migrate_database
from govoplan_search.backend.manifest import get_manifest
class SearchMigrationTests(unittest.TestCase):
def test_migration_creates_search_tables_and_head(self) -> None:
with tempfile.TemporaryDirectory(
prefix="govoplan-search-migration-"
) as directory:
url = f"sqlite:///{Path(directory) / 'search.db'}"
migrate_database(
database_url=url,
enabled_modules=("search",),
manifest_factories=(get_manifest,),
)
engine = create_engine(url)
try:
with engine.connect() as connection:
self.assertIn(
"a1b2c3d4e5f6",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(
{
"search_index_acl_tokens",
"search_index_documents",
},
{
name
for name in inspect(connection).get_table_names()
if name.startswith("search_index_")
},
)
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.search import SearchDocument, SearchQuery
from govoplan_core.db.base import Base
from govoplan_search.backend.db.models import (
SearchIndexAclToken,
SearchIndexDocument,
)
from govoplan_search.backend.service import SearchIndexService
class _Registry:
def manifests(self):
return (
SimpleNamespace(id="search"),
SimpleNamespace(id="cases"),
)
class SearchServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite://")
Base.metadata.create_all(
self.engine,
tables=(
SearchIndexDocument.__table__,
SearchIndexAclToken.__table__,
),
)
self.session = Session(self.engine)
self.service = SearchIndexService(_Registry())
self.principal = SimpleNamespace(
tenant_id="tenant-1",
account_id="account-1",
membership_id="membership-1",
identity_id=None,
group_ids=frozenset({"group-1"}),
role_ids=frozenset(),
function_assignment_ids=frozenset(),
scopes=frozenset({"cases:case:read"}),
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_index_search_and_acl_filtering(self) -> None:
self.service.upsert_document(
self.session,
self.principal,
document=SearchDocument(
tenant_id="tenant-1",
module_id="cases",
resource_type="case",
resource_id="case-1",
title="Resident parking permit",
summary="Monthly permit review",
url="/cases/case-1",
acl_tokens=("group:group-1",),
),
)
self.service.upsert_document(
self.session,
self.principal,
document=SearchDocument(
tenant_id="tenant-1",
module_id="cases",
resource_type="case",
resource_id="case-2",
title="Restricted permit",
url="/cases/case-2",
acl_tokens=("account:someone-else",),
),
)
self.session.flush()
results = self.service.search(
self.session,
self.principal,
query=SearchQuery(text="permit", tenant_id="tenant-1"),
)
self.assertEqual(["case-1"], [result.resource_id for result in results])
def test_upsert_replaces_acl_tokens_and_delete_is_idempotent(self) -> None:
document = SearchDocument(
tenant_id="tenant-1",
module_id="cases",
resource_type="case",
resource_id="case-1",
title="Permit",
url="/cases/case-1",
acl_tokens=("group:group-1",),
)
self.service.upsert_document(
self.session, self.principal, document=document
)
self.session.flush()
self.service.upsert_document(
self.session,
self.principal,
document=SearchDocument(
**{
**{
field: getattr(document, field)
for field in (
"tenant_id",
"module_id",
"resource_type",
"resource_id",
"title",
"url",
)
},
"acl_tokens": ("account:account-1",),
}
),
)
self.session.flush()
self.assertEqual(
["account:account-1"],
[
token.token
for token in self.session.query(SearchIndexAclToken).all()
],
)
arguments = {
"tenant_id": "tenant-1",
"module_id": "cases",
"resource_type": "case",
"resource_id": "case-1",
}
self.assertTrue(
self.service.delete_document(
self.session, self.principal, **arguments
)
)
self.assertFalse(
self.service.delete_document(
self.session, self.principal, **arguments
)
)
def test_index_rejects_cross_tenant_write(self) -> None:
with self.assertRaises(PermissionError):
self.service.upsert_document(
self.session,
self.principal,
document=SearchDocument(
tenant_id="tenant-2",
module_id="cases",
resource_type="case",
resource_id="case-1",
title="Permit",
url="/cases/case-1",
visibility="tenant",
),
)
if __name__ == "__main__":
unittest.main()