feat: implement permission-aware search baseline
This commit is contained in:
20
tests/test_manifest.py
Normal file
20
tests/test_manifest.py
Normal 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
48
tests/test_migrations.py
Normal 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()
|
||||
169
tests/test_search_service.py
Normal file
169
tests/test_search_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user