665 lines
22 KiB
Python
665 lines
22 KiB
Python
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()
|