feat: initialize governed postbox module
This commit is contained in:
43
tests/test_manifest.py
Normal file
43
tests/test_manifest.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from govoplan_core.core.postbox import (
|
||||
CAPABILITY_POSTBOX_ACCESS,
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
CAPABILITY_POSTBOX_MESSAGES,
|
||||
)
|
||||
from govoplan_postbox.backend.manifest import get_manifest
|
||||
|
||||
|
||||
class PostboxManifestTests(unittest.TestCase):
|
||||
def test_manifest_announces_owned_contracts_and_dependencies(self) -> None:
|
||||
manifest = get_manifest()
|
||||
|
||||
self.assertEqual(manifest.id, "postbox")
|
||||
self.assertEqual(
|
||||
{"identity", "organizations", "idm"},
|
||||
set(manifest.dependencies),
|
||||
)
|
||||
self.assertEqual(
|
||||
{
|
||||
CAPABILITY_POSTBOX_DIRECTORY,
|
||||
CAPABILITY_POSTBOX_ACCESS,
|
||||
CAPABILITY_POSTBOX_MESSAGES,
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
},
|
||||
set(manifest.capability_factories),
|
||||
)
|
||||
self.assertEqual("@govoplan/postbox-webui", manifest.frontend.package_name)
|
||||
self.assertEqual(["/postbox"], [route.path for route in manifest.frontend.routes])
|
||||
self.assertIn(
|
||||
"idm.function_assignments",
|
||||
manifest.required_capabilities,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
61
tests/test_migration.py
Normal file
61
tests/test_migration.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import unittest
|
||||
|
||||
from alembic.migration import MigrationContext
|
||||
from alembic.operations import Operations
|
||||
from sqlalchemy import create_engine, inspect
|
||||
|
||||
|
||||
class PostboxMigrationTests(unittest.TestCase):
|
||||
def test_baseline_creates_and_drops_owned_tables(self) -> None:
|
||||
migration = importlib.import_module(
|
||||
"govoplan_postbox.backend.migrations.versions."
|
||||
"c7d2e5f8a1b4_v010_postbox_baseline"
|
||||
)
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
original = migration.op
|
||||
migration.op = operations
|
||||
try:
|
||||
migration.upgrade()
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
self.assertIn("postboxes", tables)
|
||||
self.assertIn("postbox_messages", tables)
|
||||
self.assertIn("postbox_deliveries", tables)
|
||||
self.assertIn("postbox_access_events", tables)
|
||||
message_columns = {
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns(
|
||||
"postbox_messages"
|
||||
)
|
||||
}
|
||||
self.assertTrue(
|
||||
{
|
||||
"ciphertext_ref",
|
||||
"signed_manifest_ref",
|
||||
"wrapped_keys",
|
||||
"key_epoch",
|
||||
"expires_at",
|
||||
"withdrawn_at",
|
||||
}.issubset(message_columns)
|
||||
)
|
||||
migration.downgrade()
|
||||
self.assertFalse(
|
||||
{
|
||||
table
|
||||
for table in inspect(connection).get_table_names()
|
||||
if table.startswith("postbox")
|
||||
}
|
||||
)
|
||||
finally:
|
||||
migration.op = original
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
37
tests/test_module_boundary.py
Normal file
37
tests/test_module_boundary.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PostboxModuleBoundaryTests(unittest.TestCase):
|
||||
def test_backend_does_not_import_sibling_module_implementations(self) -> None:
|
||||
root = Path(__file__).parents[1] / "src" / "govoplan_postbox"
|
||||
forbidden = (
|
||||
"govoplan_access",
|
||||
"govoplan_campaign",
|
||||
"govoplan_files",
|
||||
"govoplan_identity",
|
||||
"govoplan_idm",
|
||||
"govoplan_mail",
|
||||
"govoplan_organizations",
|
||||
"govoplan_portal",
|
||||
)
|
||||
violations: list[str] = []
|
||||
for path in root.rglob("*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
names: list[str] = []
|
||||
if isinstance(node, ast.Import):
|
||||
names.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
names.append(node.module)
|
||||
for name in names:
|
||||
if name.startswith(forbidden):
|
||||
violations.append(f"{path.relative_to(root)}: {name}")
|
||||
self.assertEqual([], violations)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
302
tests/test_router.py
Normal file
302
tests/test_router.py
Normal file
@@ -0,0 +1,302 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal
|
||||
from govoplan_core.core.access import PrincipalRef
|
||||
from govoplan_core.core.identity import IdentityRef
|
||||
from govoplan_core.core.idm import (
|
||||
OrganizationFunctionAssignmentRef,
|
||||
OrganizationFunctionIncumbencyRef,
|
||||
)
|
||||
from govoplan_core.core.organizations import (
|
||||
OrganizationFunctionRef,
|
||||
OrganizationUnitRef,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_postbox.backend.db.models import (
|
||||
Postbox,
|
||||
PostboxAccessEvent,
|
||||
PostboxAddress,
|
||||
PostboxAttachmentReference,
|
||||
PostboxBinding,
|
||||
PostboxDelivery,
|
||||
PostboxGrouping,
|
||||
PostboxGroupingSource,
|
||||
PostboxMessage,
|
||||
PostboxMessageReceipt,
|
||||
PostboxParticipant,
|
||||
PostboxRoute,
|
||||
PostboxTemplate,
|
||||
PostboxTemplateRevision,
|
||||
)
|
||||
from govoplan_postbox.backend.router import router
|
||||
from govoplan_postbox.backend.service import PostboxService
|
||||
|
||||
|
||||
TABLES = (
|
||||
PostboxTemplate.__table__,
|
||||
PostboxTemplateRevision.__table__,
|
||||
PostboxAddress.__table__,
|
||||
Postbox.__table__,
|
||||
PostboxBinding.__table__,
|
||||
PostboxMessage.__table__,
|
||||
PostboxParticipant.__table__,
|
||||
PostboxAttachmentReference.__table__,
|
||||
PostboxDelivery.__table__,
|
||||
PostboxRoute.__table__,
|
||||
PostboxMessageReceipt.__table__,
|
||||
PostboxGrouping.__table__,
|
||||
PostboxGroupingSource.__table__,
|
||||
PostboxAccessEvent.__table__,
|
||||
)
|
||||
|
||||
|
||||
class FakeIdentityDirectory:
|
||||
def get_identity(self, identity_id: str):
|
||||
return IdentityRef(id=identity_id, primary_account_id="account-1")
|
||||
|
||||
def identity_for_account(self, account_id: str):
|
||||
return IdentityRef(id="identity-1", primary_account_id=account_id)
|
||||
|
||||
def identities_for_accounts(self, account_ids):
|
||||
return tuple(self.identity_for_account(account_id) for account_id in account_ids)
|
||||
|
||||
def accounts_for_identity(self, identity_id: str):
|
||||
return ()
|
||||
|
||||
|
||||
class FakeIdmDirectory:
|
||||
def __init__(self, assignment: OrganizationFunctionAssignmentRef) -> None:
|
||||
self.assignment = assignment
|
||||
|
||||
def get_organization_function_assignment(self, assignment_id: str):
|
||||
return self.assignment if assignment_id == self.assignment.id else None
|
||||
|
||||
def organization_function_assignments_for_identity(
|
||||
self,
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
):
|
||||
return (self.assignment,) if identity_id == self.assignment.identity_id else ()
|
||||
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
):
|
||||
return (self.assignment,) if account_id == self.assignment.account_id else ()
|
||||
|
||||
def organization_function_assignments_for_function(
|
||||
self,
|
||||
function_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
):
|
||||
return (self.assignment,) if function_id == self.assignment.function_id else ()
|
||||
|
||||
def organization_function_incumbencies(
|
||||
self,
|
||||
function_ids,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at=None,
|
||||
):
|
||||
del effective_at
|
||||
return {
|
||||
function_id: OrganizationFunctionIncumbencyRef(
|
||||
tenant_id=tenant_id,
|
||||
function_id=function_id,
|
||||
assignments=self.organization_function_assignments_for_function(
|
||||
function_id,
|
||||
tenant_id=tenant_id,
|
||||
),
|
||||
)
|
||||
for function_id in function_ids
|
||||
}
|
||||
|
||||
|
||||
class FakeOrganizationDirectory:
|
||||
unit = OrganizationUnitRef(
|
||||
id="unit-1",
|
||||
tenant_id="tenant-1",
|
||||
slug="district",
|
||||
name="District",
|
||||
)
|
||||
function = OrganizationFunctionRef(
|
||||
id="function-1",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id="unit-1",
|
||||
slug="clerk",
|
||||
name="Clerk",
|
||||
function_type_id="clerk-type",
|
||||
)
|
||||
|
||||
def get_organization_unit(self, organization_unit_id: str):
|
||||
return self.unit if organization_unit_id == self.unit.id else None
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str):
|
||||
return (self.unit,) if tenant_id == self.unit.tenant_id else ()
|
||||
|
||||
def get_function(self, function_id: str):
|
||||
return self.function if function_id == self.function.id else None
|
||||
|
||||
def functions_for_organization_unit(
|
||||
self,
|
||||
organization_unit_id: str,
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
):
|
||||
return (self.function,) if organization_unit_id == self.unit.id else ()
|
||||
|
||||
|
||||
class PostboxRouterTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
Base.metadata.create_all(self.engine, tables=TABLES)
|
||||
assignment = OrganizationFunctionAssignmentRef(
|
||||
id="assignment-1",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
)
|
||||
idm = FakeIdmDirectory(assignment)
|
||||
self.service = PostboxService(
|
||||
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
|
||||
idm=idm, # type: ignore[arg-type]
|
||||
incumbencies=idm, # type: ignore[arg-type]
|
||||
organizations=FakeOrganizationDirectory(), # type: ignore[arg-type]
|
||||
)
|
||||
with Session(self.engine) as session:
|
||||
self.postbox = self.service.create_exact_postbox(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
name="District / Clerk",
|
||||
organization_unit_id="unit-1",
|
||||
function_id="function-1",
|
||||
address_key=None,
|
||||
description=None,
|
||||
classification="internal",
|
||||
actor_id="account-1",
|
||||
)
|
||||
session.commit()
|
||||
self.postbox_id = self.postbox.id
|
||||
|
||||
principal = ApiPrincipal(
|
||||
principal=PrincipalRef(
|
||||
account_id="account-1",
|
||||
membership_id="membership-1",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-1",
|
||||
scopes=frozenset(
|
||||
{
|
||||
"postbox:postbox:read",
|
||||
"postbox:message:write",
|
||||
"postbox:message:acknowledge",
|
||||
"postbox:delivery:write",
|
||||
"postbox:binding:admin",
|
||||
"postbox:template:admin",
|
||||
}
|
||||
),
|
||||
),
|
||||
account=SimpleNamespace(id="account-1"),
|
||||
user=SimpleNamespace(id="membership-1"),
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1")
|
||||
|
||||
def session_dependency():
|
||||
with Session(self.engine) as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = session_dependency
|
||||
app.dependency_overrides[get_api_principal] = lambda: principal
|
||||
self.patch = patch(
|
||||
"govoplan_postbox.backend.router.get_service",
|
||||
return_value=self.service,
|
||||
)
|
||||
self.patch.start()
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.client.close()
|
||||
self.patch.stop()
|
||||
self.engine.dispose()
|
||||
|
||||
def test_directory_delivery_message_and_receipt_round_trip(self) -> None:
|
||||
directory = self.client.get("/api/v1/postbox/directory")
|
||||
self.assertEqual(200, directory.status_code, directory.text)
|
||||
self.assertEqual(self.postbox_id, directory.json()["postboxes"][0]["id"])
|
||||
|
||||
delivery = self.client.post(
|
||||
"/api/v1/postbox/deliveries",
|
||||
json={
|
||||
"target": {"postbox_id": self.postbox_id},
|
||||
"producer_module": "campaigns",
|
||||
"producer_resource_type": "campaign_recipient",
|
||||
"producer_resource_id": "recipient-1",
|
||||
"idempotency_key": "campaign-1:recipient-1",
|
||||
"subject": "Decision",
|
||||
"body_text": "The decision is ready.",
|
||||
},
|
||||
)
|
||||
self.assertEqual(201, delivery.status_code, delivery.text)
|
||||
message_id = delivery.json()["message_id"]
|
||||
|
||||
messages = self.client.get(
|
||||
"/api/v1/postbox/messages",
|
||||
params={"postbox_id": self.postbox_id},
|
||||
)
|
||||
self.assertEqual(200, messages.status_code, messages.text)
|
||||
self.assertEqual(1, messages.json()["total"])
|
||||
self.assertEqual(message_id, messages.json()["messages"][0]["id"])
|
||||
|
||||
filtered = self.client.get(
|
||||
"/api/v1/postbox/messages",
|
||||
params={
|
||||
"postbox_id": self.postbox_id,
|
||||
"q": "decision",
|
||||
"state": "unread",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, filtered.status_code, filtered.text)
|
||||
self.assertEqual(1, filtered.json()["total"])
|
||||
|
||||
acknowledged = self.client.patch(
|
||||
f"/api/v1/postbox/messages/{message_id}/state",
|
||||
json={"state": "acknowledged"},
|
||||
)
|
||||
self.assertEqual(200, acknowledged.status_code, acknowledged.text)
|
||||
self.assertIsNotNone(acknowledged.json()["read_at"])
|
||||
self.assertIsNotNone(acknowledged.json()["acknowledged_at"])
|
||||
|
||||
unread = self.client.get(
|
||||
"/api/v1/postbox/messages",
|
||||
params={
|
||||
"postbox_id": self.postbox_id,
|
||||
"state": "unread",
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, unread.status_code, unread.text)
|
||||
self.assertEqual(0, unread.json()["total"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
664
tests/test_service.py
Normal file
664
tests/test_service.py
Normal file
@@ -0,0 +1,664 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.events import EventBus, event_bus_context
|
||||
from govoplan_core.core.identity import IdentityRef
|
||||
from govoplan_core.core.idm import (
|
||||
OrganizationFunctionAssignmentRef,
|
||||
OrganizationFunctionIncumbencyRef,
|
||||
)
|
||||
from govoplan_core.core.organizations import (
|
||||
OrganizationFunctionRef,
|
||||
OrganizationUnitRef,
|
||||
)
|
||||
from govoplan_core.core.postbox import (
|
||||
PostboxActorRef,
|
||||
PostboxDeliveryRequest,
|
||||
PostboxTargetRef,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.security.time import utc_now
|
||||
from govoplan_postbox.backend.db.models import (
|
||||
Postbox,
|
||||
PostboxAccessEvent,
|
||||
PostboxAddress,
|
||||
PostboxAttachmentReference,
|
||||
PostboxBinding,
|
||||
PostboxDelivery,
|
||||
PostboxGrouping,
|
||||
PostboxGroupingSource,
|
||||
PostboxMessage,
|
||||
PostboxMessageReceipt,
|
||||
PostboxParticipant,
|
||||
PostboxRoute,
|
||||
PostboxTemplate,
|
||||
PostboxTemplateRevision,
|
||||
)
|
||||
from govoplan_postbox.backend.service import PostboxService
|
||||
|
||||
|
||||
POSTBOX_TABLES = (
|
||||
PostboxTemplate.__table__,
|
||||
PostboxTemplateRevision.__table__,
|
||||
PostboxAddress.__table__,
|
||||
Postbox.__table__,
|
||||
PostboxBinding.__table__,
|
||||
PostboxMessage.__table__,
|
||||
PostboxParticipant.__table__,
|
||||
PostboxAttachmentReference.__table__,
|
||||
PostboxDelivery.__table__,
|
||||
PostboxRoute.__table__,
|
||||
PostboxMessageReceipt.__table__,
|
||||
PostboxGrouping.__table__,
|
||||
PostboxGroupingSource.__table__,
|
||||
PostboxAccessEvent.__table__,
|
||||
)
|
||||
|
||||
|
||||
class FakeIdentityDirectory:
|
||||
def get_identity(self, identity_id: str):
|
||||
return IdentityRef(id=identity_id, primary_account_id="account-1")
|
||||
|
||||
def identity_for_account(self, account_id: str):
|
||||
return IdentityRef(
|
||||
id="identity-1",
|
||||
primary_account_id=account_id,
|
||||
account_ids=(account_id,),
|
||||
)
|
||||
|
||||
def identities_for_accounts(self, account_ids):
|
||||
return tuple(self.identity_for_account(account_id) for account_id in account_ids)
|
||||
|
||||
def accounts_for_identity(self, identity_id: str):
|
||||
return ()
|
||||
|
||||
|
||||
class FakeIdmDirectory:
|
||||
def __init__(self) -> None:
|
||||
self.assignments: list[OrganizationFunctionAssignmentRef] = []
|
||||
|
||||
def get_organization_function_assignment(self, assignment_id: str):
|
||||
return next(
|
||||
(
|
||||
assignment
|
||||
for assignment in self.assignments
|
||||
if assignment.id == assignment_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def organization_function_assignments_for_identity(
|
||||
self,
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
):
|
||||
return tuple(
|
||||
assignment
|
||||
for assignment in self.assignments
|
||||
if assignment.identity_id == identity_id
|
||||
and (tenant_id is None or assignment.tenant_id == tenant_id)
|
||||
and assignment.status == "active"
|
||||
)
|
||||
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
):
|
||||
return tuple(
|
||||
assignment
|
||||
for assignment in self.assignments
|
||||
if assignment.account_id == account_id
|
||||
and (tenant_id is None or assignment.tenant_id == tenant_id)
|
||||
and assignment.status == "active"
|
||||
)
|
||||
|
||||
def organization_function_assignments_for_function(
|
||||
self,
|
||||
function_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
):
|
||||
return tuple(
|
||||
assignment
|
||||
for assignment in self.assignments
|
||||
if assignment.function_id == function_id
|
||||
and (tenant_id is None or assignment.tenant_id == tenant_id)
|
||||
and assignment.status == "active"
|
||||
)
|
||||
|
||||
def organization_function_incumbencies(
|
||||
self,
|
||||
function_ids,
|
||||
*,
|
||||
tenant_id: str,
|
||||
effective_at=None,
|
||||
):
|
||||
del effective_at
|
||||
return {
|
||||
function_id: OrganizationFunctionIncumbencyRef(
|
||||
tenant_id=tenant_id,
|
||||
function_id=function_id,
|
||||
assignments=self.organization_function_assignments_for_function(
|
||||
function_id,
|
||||
tenant_id=tenant_id,
|
||||
),
|
||||
)
|
||||
for function_id in function_ids
|
||||
}
|
||||
|
||||
|
||||
class FakeOrganizationDirectory:
|
||||
def __init__(self) -> None:
|
||||
self.units = {
|
||||
"unit-1": OrganizationUnitRef(
|
||||
id="unit-1",
|
||||
tenant_id="tenant-1",
|
||||
slug="district-north",
|
||||
name="District North",
|
||||
unit_type_id="district",
|
||||
),
|
||||
"unit-child": OrganizationUnitRef(
|
||||
id="unit-child",
|
||||
tenant_id="tenant-1",
|
||||
slug="service-desk",
|
||||
name="Service Desk",
|
||||
unit_type_id="desk",
|
||||
parent_id="unit-1",
|
||||
),
|
||||
}
|
||||
self.functions = {
|
||||
"function-1": OrganizationFunctionRef(
|
||||
id="function-1",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id="unit-1",
|
||||
slug="case-clerk",
|
||||
name="Case Clerk",
|
||||
function_type_id="case-clerk-type",
|
||||
delegable=True,
|
||||
),
|
||||
"function-child": OrganizationFunctionRef(
|
||||
id="function-child",
|
||||
tenant_id="tenant-1",
|
||||
organization_unit_id="unit-child",
|
||||
slug="case-clerk",
|
||||
name="Case Clerk",
|
||||
function_type_id="case-clerk-type",
|
||||
delegable=True,
|
||||
),
|
||||
}
|
||||
|
||||
def get_organization_unit(self, organization_unit_id: str):
|
||||
return self.units.get(organization_unit_id)
|
||||
|
||||
def organization_units_for_tenant(self, tenant_id: str):
|
||||
return tuple(
|
||||
unit for unit in self.units.values() if unit.tenant_id == tenant_id
|
||||
)
|
||||
|
||||
def get_function(self, function_id: str):
|
||||
return self.functions.get(function_id)
|
||||
|
||||
def functions_for_organization_unit(
|
||||
self,
|
||||
organization_unit_id: str,
|
||||
*,
|
||||
include_subunits: bool = False,
|
||||
):
|
||||
return tuple(
|
||||
function
|
||||
for function in self.functions.values()
|
||||
if function.organization_unit_id == organization_unit_id
|
||||
)
|
||||
|
||||
|
||||
class FakeNotificationDispatch:
|
||||
def __init__(self) -> None:
|
||||
self.requests = []
|
||||
|
||||
def enqueue_notification(
|
||||
self,
|
||||
session,
|
||||
request,
|
||||
*,
|
||||
enqueue_delivery=True,
|
||||
):
|
||||
del session
|
||||
self.requests.append((request, enqueue_delivery))
|
||||
return {"id": f"notification-{len(self.requests)}"}
|
||||
|
||||
|
||||
class PostboxServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(self.engine, tables=POSTBOX_TABLES)
|
||||
self.idm = FakeIdmDirectory()
|
||||
self.organizations = FakeOrganizationDirectory()
|
||||
self.service = PostboxService(
|
||||
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
|
||||
idm=self.idm, # type: ignore[arg-type]
|
||||
incumbencies=self.idm, # type: ignore[arg-type]
|
||||
organizations=self.organizations, # type: ignore[arg-type]
|
||||
)
|
||||
self.assignment = OrganizationFunctionAssignmentRef(
|
||||
id="assignment-1",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source="direct",
|
||||
)
|
||||
self.actor = PostboxActorRef(
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
authorized_actions=frozenset(
|
||||
{"discover", "read", "send", "acknowledge"}
|
||||
),
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.engine.dispose()
|
||||
|
||||
def _create_exact(self, session: Session) -> Postbox:
|
||||
return self.service.create_exact_postbox(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
name="District North / Case Clerk Intake",
|
||||
organization_unit_id="unit-1",
|
||||
function_id="function-1",
|
||||
address_key=None,
|
||||
description=None,
|
||||
classification="internal",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
|
||||
def test_access_follows_current_assignment_and_reports_vacancy(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(session)
|
||||
session.commit()
|
||||
|
||||
denied = self.service.explain_access(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
actor=self.actor,
|
||||
action="read",
|
||||
)
|
||||
self.assertFalse(denied.allowed)
|
||||
self.assertTrue(denied.vacant)
|
||||
|
||||
self.idm.assignments.append(self.assignment)
|
||||
allowed = self.service.explain_access(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
actor=self.actor,
|
||||
action="read",
|
||||
)
|
||||
self.assertTrue(allowed.allowed)
|
||||
self.assertFalse(allowed.vacant)
|
||||
self.assertEqual("assignment-1", allowed.selected_assignment_id)
|
||||
|
||||
self.idm.assignments.clear()
|
||||
revoked = self.service.explain_access(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
actor=self.actor,
|
||||
action="read",
|
||||
)
|
||||
self.assertFalse(revoked.allowed)
|
||||
|
||||
def test_acting_assignment_requires_selected_context(self) -> None:
|
||||
acting = OrganizationFunctionAssignmentRef(
|
||||
id="acting-assignment",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-1",
|
||||
organization_unit_id="unit-1",
|
||||
source="acting_for",
|
||||
delegated_from_assignment_id="source-assignment",
|
||||
acting_for_account_id="represented-account",
|
||||
)
|
||||
self.idm.assignments.append(acting)
|
||||
with Session(self.engine) as session:
|
||||
postbox = self._create_exact(session)
|
||||
session.commit()
|
||||
|
||||
denied = self.service.explain_access(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
actor=self.actor,
|
||||
action="read",
|
||||
)
|
||||
allowed = self.service.explain_access(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=postbox.id,
|
||||
actor=PostboxActorRef(
|
||||
account_id="account-1",
|
||||
identity_id="identity-1",
|
||||
selected_assignment_id=acting.id,
|
||||
acting_for_account_id="represented-account",
|
||||
authorized_actions=frozenset({"read"}),
|
||||
),
|
||||
action="read",
|
||||
)
|
||||
|
||||
self.assertFalse(denied.allowed)
|
||||
self.assertTrue(allowed.allowed)
|
||||
self.assertEqual("effective_acting_for_assignment", allowed.reason_code)
|
||||
|
||||
def test_template_revision_is_immutable_and_materialization_idempotent(self) -> None:
|
||||
with Session(self.engine) as session:
|
||||
template = self.service.create_template(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
slug="case-intake",
|
||||
name="Case intake",
|
||||
description=None,
|
||||
function_type_id="case-clerk-type",
|
||||
scope_kind="subtree",
|
||||
scope_id="unit-1",
|
||||
name_pattern="{unit_name} / {function_name} Intake",
|
||||
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
|
||||
classification="internal",
|
||||
allow_vacant_delivery=True,
|
||||
actor_id="admin-1",
|
||||
)
|
||||
self.service.publish_template(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
template_id=template.id,
|
||||
revision_number=1,
|
||||
actor_id="admin-1",
|
||||
)
|
||||
first = self.service.materialize_template(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
template_id=template.id,
|
||||
organization_unit_id="unit-child",
|
||||
function_id="function-child",
|
||||
context_key="case-42",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
second = self.service.materialize_template(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
template_id=template.id,
|
||||
organization_unit_id="unit-child",
|
||||
function_id="function-child",
|
||||
context_key="case-42",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
revised = self.service.revise_template(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
template_id=template.id,
|
||||
function_type_id="case-clerk-type",
|
||||
scope_kind="subtree",
|
||||
scope_id="unit-1",
|
||||
name_pattern="{unit_name} / {function_name} Work",
|
||||
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
|
||||
classification="restricted",
|
||||
allow_vacant_delivery=True,
|
||||
actor_id="admin-1",
|
||||
)
|
||||
|
||||
self.assertEqual(first.id, second.id)
|
||||
self.assertEqual(2, len(revised.revisions))
|
||||
self.assertEqual(
|
||||
"{unit_name} / {function_name} Intake",
|
||||
revised.revisions[0].name_pattern,
|
||||
)
|
||||
self.assertEqual(
|
||||
"{unit_name} / {function_name} Work",
|
||||
revised.revisions[1].name_pattern,
|
||||
)
|
||||
|
||||
def test_delivery_catalog_exposes_exact_and_derived_target_choices(
|
||||
self,
|
||||
) -> None:
|
||||
with Session(self.engine) as session:
|
||||
exact = self._create_exact(session)
|
||||
template = self.service.create_template(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
slug="case-intake",
|
||||
name="Case intake",
|
||||
description="Functional intake",
|
||||
function_type_id="case-clerk-type",
|
||||
scope_kind="subtree",
|
||||
scope_id="unit-1",
|
||||
name_pattern="{unit_name} / {function_name} Intake",
|
||||
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
|
||||
classification="internal",
|
||||
allow_vacant_delivery=True,
|
||||
actor_id="admin-1",
|
||||
)
|
||||
self.service.publish_template(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
template_id=template.id,
|
||||
revision_number=None,
|
||||
actor_id="admin-1",
|
||||
)
|
||||
session.commit()
|
||||
|
||||
catalog = self.service.delivery_catalog(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
)
|
||||
|
||||
self.assertEqual([exact.id], [item.id for item in catalog.postboxes])
|
||||
self.assertEqual(
|
||||
["case-intake"],
|
||||
[item.slug for item in catalog.templates],
|
||||
)
|
||||
north = next(
|
||||
unit
|
||||
for unit in catalog.organization_units
|
||||
if unit.id == "unit-1"
|
||||
)
|
||||
self.assertEqual(
|
||||
["function-1"],
|
||||
[function.id for function in north.functions],
|
||||
)
|
||||
|
||||
def test_delivery_is_idempotent_and_receipts_are_per_account(self) -> None:
|
||||
self.idm.assignments.append(self.assignment)
|
||||
notifications = FakeNotificationDispatch()
|
||||
service = PostboxService(
|
||||
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
|
||||
idm=self.idm, # type: ignore[arg-type]
|
||||
incumbencies=self.idm, # type: ignore[arg-type]
|
||||
organizations=self.organizations, # type: ignore[arg-type]
|
||||
notifications=notifications, # type: ignore[arg-type]
|
||||
)
|
||||
events = []
|
||||
event_bus = EventBus()
|
||||
event_bus.subscribe("*", events.append)
|
||||
with Session(self.engine) as session:
|
||||
postbox = service.create_exact_postbox(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
name="District North / Case Clerk Intake",
|
||||
organization_unit_id="unit-1",
|
||||
function_id="function-1",
|
||||
address_key=None,
|
||||
description=None,
|
||||
classification="internal",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
request = PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
producer_module="campaigns",
|
||||
producer_resource_type="campaign_recipient",
|
||||
producer_resource_id="recipient-1",
|
||||
idempotency_key="campaign-1:recipient-1:postbox",
|
||||
subject="Permit decision",
|
||||
body_text="The decision is available.",
|
||||
expires_at=utc_now() + timedelta(days=30),
|
||||
)
|
||||
with event_bus_context(event_bus):
|
||||
first = service.deliver(session, request)
|
||||
second = service.deliver(session, request)
|
||||
self.assertEqual(
|
||||
1,
|
||||
service.count_messages(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_ids=(postbox.id,),
|
||||
actor=self.actor,
|
||||
query="permit",
|
||||
state="unread",
|
||||
),
|
||||
)
|
||||
marked = service.mark_message(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
message_id=first.message_id,
|
||||
actor=self.actor,
|
||||
state="acknowledged",
|
||||
)
|
||||
service.mark_message(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
message_id=first.message_id,
|
||||
actor=self.actor,
|
||||
state="acknowledged",
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertFalse(first.duplicate)
|
||||
self.assertTrue(second.duplicate)
|
||||
self.assertEqual(first.message_id, second.message_id)
|
||||
self.assertIsNotNone(marked.read_at)
|
||||
self.assertIsNotNone(marked.acknowledged_at)
|
||||
self.assertEqual(
|
||||
1,
|
||||
session.query(PostboxMessage).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
session.query(PostboxDelivery).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
session.query(PostboxMessageReceipt).count(),
|
||||
)
|
||||
self.assertEqual(
|
||||
(),
|
||||
service.list_messages(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_ids=(postbox.id,),
|
||||
actor=self.actor,
|
||||
query="not present",
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
1,
|
||||
service.count_messages(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_ids=(postbox.id,),
|
||||
actor=self.actor,
|
||||
state="acknowledged",
|
||||
),
|
||||
)
|
||||
self.assertEqual(1, len(notifications.requests))
|
||||
notification, enqueue_delivery = notifications.requests[0]
|
||||
self.assertEqual("account-1", notification.recipient_id)
|
||||
self.assertEqual("account", notification.recipient_type)
|
||||
self.assertEqual(
|
||||
f"/postbox?message={first.message_id}",
|
||||
notification.action_url,
|
||||
)
|
||||
self.assertFalse(enqueue_delivery)
|
||||
self.assertEqual(
|
||||
[
|
||||
"postbox.delivery.accepted.v1",
|
||||
"postbox.message.acknowledged.v1",
|
||||
],
|
||||
[event.type for event in events],
|
||||
)
|
||||
|
||||
def test_grouping_update_retains_temporarily_hidden_sources(self) -> None:
|
||||
child_assignment = OrganizationFunctionAssignmentRef(
|
||||
id="assignment-child",
|
||||
tenant_id="tenant-1",
|
||||
identity_id="identity-1",
|
||||
account_id="account-1",
|
||||
function_id="function-child",
|
||||
organization_unit_id="unit-child",
|
||||
source="direct",
|
||||
)
|
||||
self.idm.assignments.extend((self.assignment, child_assignment))
|
||||
with Session(self.engine) as session:
|
||||
parent = self._create_exact(session)
|
||||
child = self.service.create_exact_postbox(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
name="Service Desk / Case Clerk Intake",
|
||||
organization_unit_id="unit-child",
|
||||
function_id="function-child",
|
||||
address_key=None,
|
||||
description=None,
|
||||
classification="internal",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
grouping = self.service.save_grouping(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
actor=self.actor,
|
||||
grouping_id=None,
|
||||
name="Assigned work",
|
||||
is_default=True,
|
||||
postbox_ids=(parent.id, child.id),
|
||||
)
|
||||
session.commit()
|
||||
grouping_id = grouping.id
|
||||
|
||||
self.idm.assignments.remove(child_assignment)
|
||||
updated = self.service.save_grouping(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
actor=self.actor,
|
||||
grouping_id=grouping_id,
|
||||
name="Assigned work",
|
||||
is_default=True,
|
||||
postbox_ids=(parent.id,),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
self.assertEqual(
|
||||
(parent.id, child.id),
|
||||
tuple(source.postbox_id for source in updated.sources),
|
||||
)
|
||||
|
||||
self.idm.assignments.append(child_assignment)
|
||||
visible_ids = {
|
||||
item.id
|
||||
for item in self.service.list_visible_postboxes(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
actor=self.actor,
|
||||
)
|
||||
}
|
||||
self.assertEqual({parent.id, child.id}, visible_ids)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user