184 lines
6.6 KiB
Python
184 lines
6.6 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
|
|
from govoplan_campaign.backend.archive_encryption import (
|
|
CampaignArchiveEncryptionError,
|
|
LEGACY_ZIPCRYPTO_SCOPE,
|
|
assert_archive_encryption_allowed,
|
|
effective_archive_encryption_policy,
|
|
stamp_legacy_zipcrypto_acknowledgements,
|
|
)
|
|
from govoplan_campaign.backend.db.models import Campaign
|
|
from govoplan_core.auth import ApiPrincipal
|
|
from govoplan_core.core.access import PrincipalRef
|
|
from govoplan_core.core.policy import (
|
|
CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION,
|
|
CampaignArchiveEncryptionDecision,
|
|
PolicySourceStep,
|
|
)
|
|
|
|
|
|
class _PolicyProvider:
|
|
def __init__(self, methods: set[str]) -> None:
|
|
self.methods = methods
|
|
|
|
def resolve_campaign_archive_encryption(self, session=None, *, request):
|
|
del session, request
|
|
return CampaignArchiveEncryptionDecision(
|
|
allowed_password_encryption_methods=frozenset(self.methods),
|
|
allowed_password_delivery_channels=frozenset(
|
|
{"separate_mail", "sms", "letter", "phone", "in_person"}
|
|
),
|
|
policy_hash="f" * 64,
|
|
source_path=(
|
|
PolicySourceStep(
|
|
scope_type="system",
|
|
label="System archive-encryption policy",
|
|
applied_fields=("allowed_password_encryption_methods",),
|
|
policy={"allowed_password_encryption_methods": sorted(self.methods)},
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
class _Registry:
|
|
def __init__(self, provider) -> None:
|
|
self.provider = provider
|
|
|
|
def has_capability(self, name: str) -> bool:
|
|
return name == CAPABILITY_POLICY_CAMPAIGN_ARCHIVE_ENCRYPTION
|
|
|
|
def capability(self, name: str):
|
|
return self.provider if self.has_capability(name) else None
|
|
|
|
|
|
class CampaignArchiveEncryptionGovernanceTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.engine = create_engine("sqlite:///:memory:")
|
|
self.session = Session(self.engine)
|
|
self.campaign = Campaign(
|
|
id="campaign-1",
|
|
tenant_id="tenant-1",
|
|
external_id="example",
|
|
name="Example",
|
|
owner_user_id="user-1",
|
|
)
|
|
|
|
def tearDown(self) -> None:
|
|
self.session.close()
|
|
self.engine.dispose()
|
|
|
|
def test_unavailable_policy_keeps_aes_and_fails_closed_for_legacy(self) -> None:
|
|
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=None):
|
|
policy = effective_archive_encryption_policy(self.session, self.campaign)
|
|
self.assertFalse(policy.available)
|
|
self.assertEqual(frozenset({"aes"}), policy.allowed_password_encryption_methods)
|
|
assert_archive_encryption_allowed(
|
|
self.session,
|
|
self.campaign,
|
|
_raw_archive("aes"),
|
|
)
|
|
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "blocked"):
|
|
assert_archive_encryption_allowed(
|
|
self.session,
|
|
self.campaign,
|
|
_raw_archive("zip_standard", stamped=True),
|
|
)
|
|
|
|
def test_existing_password_archive_inherits_separate_mail_channel(self) -> None:
|
|
raw = _raw_archive("aes")
|
|
raw["attachments"]["zip"]["archives"][0].pop("password_delivery_channel")
|
|
with patch(
|
|
"govoplan_campaign.backend.archive_encryption.get_registry",
|
|
return_value=None,
|
|
):
|
|
decision = assert_archive_encryption_allowed(
|
|
self.session,
|
|
self.campaign,
|
|
raw,
|
|
)
|
|
self.assertIn(
|
|
"separate_mail",
|
|
decision.allowed_password_delivery_channels,
|
|
)
|
|
|
|
def test_legacy_selection_requires_permission_and_gets_server_stamp(self) -> None:
|
|
registry = _Registry(_PolicyProvider({"aes", "zip_standard"}))
|
|
candidate = _raw_archive("zip_standard")
|
|
candidate["attachments"]["zip"]["archives"][0].update(
|
|
{
|
|
"legacy_zipcrypto_acknowledged": True,
|
|
"legacy_zipcrypto_reason": "Recipient requires built-in Windows extraction",
|
|
}
|
|
)
|
|
with patch("govoplan_campaign.backend.archive_encryption.get_registry", return_value=registry):
|
|
with self.assertRaisesRegex(CampaignArchiveEncryptionError, "Missing scope"):
|
|
stamp_legacy_zipcrypto_acknowledgements(
|
|
self.session,
|
|
self.campaign,
|
|
{},
|
|
candidate,
|
|
principal=_principal(set()),
|
|
)
|
|
stamped, evidence = stamp_legacy_zipcrypto_acknowledgements(
|
|
self.session,
|
|
self.campaign,
|
|
{},
|
|
candidate,
|
|
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
|
)
|
|
archive = stamped["attachments"]["zip"]["archives"][0]
|
|
self.assertEqual("user-1", archive["legacy_zipcrypto_acknowledged_by"])
|
|
self.assertTrue(archive["legacy_zipcrypto_acknowledged_at"])
|
|
self.assertEqual("f" * 64, evidence[0]["policy_hash"])
|
|
assert_archive_encryption_allowed(
|
|
self.session,
|
|
self.campaign,
|
|
stamped,
|
|
principal=_principal({LEGACY_ZIPCRYPTO_SCOPE}),
|
|
)
|
|
|
|
|
|
def _raw_archive(method: str, *, stamped: bool = False) -> dict:
|
|
archive = {
|
|
"id": "archive-1",
|
|
"method": method,
|
|
"password_enabled": True,
|
|
"password_delivery_channel": "separate_mail",
|
|
"legacy_zipcrypto_acknowledged": method == "zip_standard",
|
|
"legacy_zipcrypto_reason": "Windows recipient compatibility required"
|
|
if method == "zip_standard"
|
|
else None,
|
|
}
|
|
if stamped:
|
|
archive.update(
|
|
{
|
|
"legacy_zipcrypto_acknowledged_by": "user-1",
|
|
"legacy_zipcrypto_acknowledged_at": "2026-08-20T10:00:00+00:00",
|
|
}
|
|
)
|
|
return {"attachments": {"zip": {"enabled": True, "archives": [archive]}}}
|
|
|
|
|
|
def _principal(scopes: set[str]) -> ApiPrincipal:
|
|
return ApiPrincipal(
|
|
principal=PrincipalRef(
|
|
account_id="account-1",
|
|
membership_id="user-1",
|
|
tenant_id="tenant-1",
|
|
scopes=frozenset(scopes),
|
|
),
|
|
account=SimpleNamespace(id="account-1"),
|
|
user=SimpleNamespace(id="user-1"),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|