feat(postbox): enforce unified inbox separation
This commit is contained in:
@@ -42,6 +42,10 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
"govoplan_postbox.backend.migrations.versions."
|
||||
"a7c1e4f8b2d6_v016_protection_transitions"
|
||||
)
|
||||
grouping_policy_migration = importlib.import_module(
|
||||
"govoplan_postbox.backend.migrations.versions."
|
||||
"d8b4f1a6c9e2_v017_grouping_policy"
|
||||
)
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
try:
|
||||
with engine.begin() as connection:
|
||||
@@ -54,6 +58,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
scope_original = scope_migration.op
|
||||
portal_original = portal_migration.op
|
||||
transition_original = transition_migration.op
|
||||
grouping_policy_original = grouping_policy_migration.op
|
||||
migration.op = operations
|
||||
route_migration.op = operations
|
||||
occ_migration.op = operations
|
||||
@@ -62,6 +67,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
scope_migration.op = operations
|
||||
portal_migration.op = operations
|
||||
transition_migration.op = operations
|
||||
grouping_policy_migration.op = operations
|
||||
try:
|
||||
migration.upgrade()
|
||||
route_migration.upgrade()
|
||||
@@ -71,6 +77,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
scope_migration.upgrade()
|
||||
portal_migration.upgrade()
|
||||
transition_migration.upgrade()
|
||||
grouping_policy_migration.upgrade()
|
||||
tables = set(inspect(connection).get_table_names())
|
||||
self.assertIn("postboxes", tables)
|
||||
self.assertIn("postbox_messages", tables)
|
||||
@@ -110,6 +117,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
"scope_structure_id",
|
||||
"scope_relation_type_ids",
|
||||
"portal_visible",
|
||||
"grouping_policy",
|
||||
}.issubset(template_revision_columns)
|
||||
)
|
||||
self.assertIn("authoring_key", message_columns)
|
||||
@@ -134,6 +142,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
self.assertTrue(
|
||||
{"execute_after", "processed_at"}.issubset(route_columns)
|
||||
)
|
||||
grouping_policy_migration.downgrade()
|
||||
transition_migration.downgrade()
|
||||
portal_migration.downgrade()
|
||||
scope_migration.downgrade()
|
||||
@@ -158,6 +167,7 @@ class PostboxMigrationTests(unittest.TestCase):
|
||||
scope_migration.op = scope_original
|
||||
portal_migration.op = portal_original
|
||||
transition_migration.op = transition_original
|
||||
grouping_policy_migration.op = grouping_policy_original
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@@ -546,6 +546,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
encryption_profile: str = "plaintext_v1",
|
||||
encryption_vault_id: str | None = None,
|
||||
protection_policy: dict[str, object] | None = None,
|
||||
grouping_policy: dict[str, object] | None = None,
|
||||
) -> Postbox:
|
||||
return self.service.create_exact_postbox(
|
||||
session,
|
||||
@@ -560,6 +561,7 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
encryption_profile=encryption_profile,
|
||||
encryption_vault_id=encryption_vault_id,
|
||||
protection_policy=protection_policy,
|
||||
grouping_policy=grouping_policy,
|
||||
)
|
||||
|
||||
def _routing_policy(
|
||||
@@ -2091,6 +2093,211 @@ class PostboxServiceTests(unittest.TestCase):
|
||||
}
|
||||
self.assertEqual({parent.id, child.id}, visible_ids)
|
||||
|
||||
def test_grouping_policy_enforces_function_and_classification_separation(
|
||||
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="delegated",
|
||||
delegated_from_assignment_id="assignment-1",
|
||||
)
|
||||
self.idm.assignments.extend((self.assignment, child_assignment))
|
||||
privileged_actor = replace(
|
||||
self.actor,
|
||||
authorized_classifications=frozenset(
|
||||
{"public", "internal", "confidential"}
|
||||
),
|
||||
)
|
||||
with Session(self.engine) as session:
|
||||
parent = self._create_exact(
|
||||
session,
|
||||
grouping_policy={
|
||||
"mode": "same_classification",
|
||||
"reason": "Confidential responsibilities remain partitioned.",
|
||||
},
|
||||
)
|
||||
child = self.service.create_exact_postbox(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
name="Service Desk / Delegated complaints",
|
||||
organization_unit_id="unit-child",
|
||||
function_id="function-child",
|
||||
address_key=None,
|
||||
description=None,
|
||||
classification="confidential",
|
||||
actor_id="admin-1",
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
PostboxError,
|
||||
"classification_separation_required",
|
||||
):
|
||||
self.service.save_grouping(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
actor=privileged_actor,
|
||||
grouping_id=None,
|
||||
name="Mixed classification",
|
||||
is_default=False,
|
||||
postbox_ids=(parent.id, child.id),
|
||||
)
|
||||
|
||||
grouping = self.service.save_grouping(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
actor=privileged_actor,
|
||||
grouping_id=None,
|
||||
name="Same responsibility",
|
||||
is_default=True,
|
||||
postbox_ids=(parent.id,),
|
||||
)
|
||||
session.commit()
|
||||
self.service.update_grouping_policy(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_id=parent.id,
|
||||
grouping_policy={
|
||||
"mode": "separate",
|
||||
"reason": "This function must remain a dedicated inbox.",
|
||||
},
|
||||
actor_id="admin-1",
|
||||
expected_revision=parent.resource_revision,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
with self.assertRaisesRegex(PostboxError, "source_requires_separation"):
|
||||
self.service.list_messages(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_ids=(parent.id, child.id),
|
||||
actor=privileged_actor,
|
||||
)
|
||||
self.assertEqual(
|
||||
(parent.id,),
|
||||
tuple(source.postbox_id for source in grouping.sources),
|
||||
)
|
||||
|
||||
def test_grouping_pagination_is_stable_and_delegation_expiry_hides_source(
|
||||
self,
|
||||
) -> None:
|
||||
delegated = 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="delegated",
|
||||
delegated_from_assignment_id="assignment-1",
|
||||
)
|
||||
self.idm.assignments.extend((self.assignment, delegated))
|
||||
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 / Delegated 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="Delegated work",
|
||||
is_default=True,
|
||||
postbox_ids=(parent.id, child.id),
|
||||
)
|
||||
focused_grouping = self.service.save_grouping(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
actor=self.actor,
|
||||
grouping_id=None,
|
||||
name="Delegated intake only",
|
||||
is_default=False,
|
||||
postbox_ids=(child.id,),
|
||||
)
|
||||
self.assertEqual(
|
||||
(child.id,),
|
||||
tuple(source.postbox_id for source in focused_grouping.sources),
|
||||
)
|
||||
for index, postbox in enumerate((parent, child, parent, child), start=1):
|
||||
self.service.deliver(
|
||||
session,
|
||||
PostboxDeliveryRequest(
|
||||
tenant_id="tenant-1",
|
||||
target=PostboxTargetRef(postbox_id=postbox.id),
|
||||
producer_module="tests",
|
||||
producer_resource_type="stable_page",
|
||||
producer_resource_id=str(index),
|
||||
idempotency_key=f"stable-page-{index}",
|
||||
subject=f"Message {index}",
|
||||
classification="internal",
|
||||
),
|
||||
)
|
||||
boundary = utc_now()
|
||||
session.query(PostboxMessage).update(
|
||||
{PostboxMessage.delivered_at: boundary}
|
||||
)
|
||||
session.commit()
|
||||
|
||||
first = self.service.list_messages(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_ids=(parent.id, child.id),
|
||||
actor=self.actor,
|
||||
limit=2,
|
||||
offset=0,
|
||||
)
|
||||
second = self.service.list_messages(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_ids=(parent.id, child.id),
|
||||
actor=self.actor,
|
||||
limit=2,
|
||||
offset=2,
|
||||
)
|
||||
repeated = self.service.list_messages(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
postbox_ids=(parent.id, child.id),
|
||||
actor=self.actor,
|
||||
limit=2,
|
||||
offset=0,
|
||||
)
|
||||
self.assertEqual(
|
||||
[item.id for item in first], [item.id for item in repeated]
|
||||
)
|
||||
self.assertFalse({item.id for item in first} & {item.id for item in second})
|
||||
|
||||
self.idm.assignments = [
|
||||
self.assignment,
|
||||
replace(delegated, status="expired"),
|
||||
]
|
||||
visible_ids = {
|
||||
item.id
|
||||
for item in self.service.list_visible_postboxes(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
actor=self.actor,
|
||||
)
|
||||
}
|
||||
self.assertEqual({parent.id}, visible_ids)
|
||||
self.assertEqual(
|
||||
(parent.id, child.id),
|
||||
tuple(source.postbox_id for source in grouping.sources),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user