feat: enforce Mail-owned campaign transport boundary
This commit is contained in:
322
tests/test_campaign_capability.py
Normal file
322
tests/test_campaign_capability.py
Normal file
@@ -0,0 +1,322 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from dataclasses import asdict
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_mail.backend.capabilities import (
|
||||
MailCampaignCapability,
|
||||
append_campaign_message_to_sent,
|
||||
campaign_profile_delivery_summary,
|
||||
send_campaign_email_bytes,
|
||||
)
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||
from govoplan_mail.backend.mail_profiles import MailProfileError
|
||||
from govoplan_mail.backend.sending.imap import ImapAppendError
|
||||
from govoplan_mail.backend.sending.smtp import SmtpSendError
|
||||
|
||||
|
||||
def _profile() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
smtp_config={
|
||||
"host": "smtp.internal.example",
|
||||
"port": 587,
|
||||
"security": "starttls",
|
||||
},
|
||||
smtp_username="service-account",
|
||||
smtp_password_encrypted="encrypted-secret",
|
||||
smtp_transport_revision="smtp-revision",
|
||||
imap_config=None,
|
||||
imap_username=None,
|
||||
imap_password_encrypted=None,
|
||||
imap_transport_revision="imap-revision",
|
||||
)
|
||||
|
||||
|
||||
class CampaignMailCapabilityTests(unittest.TestCase):
|
||||
def test_summary_does_not_materialize_or_decrypt_credentials(self) -> None:
|
||||
profile = _profile()
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.ensure_mail_profile_allowed_for_campaign",
|
||||
return_value=profile,
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.effective_mail_profile_policy", return_value=object()),
|
||||
patch("govoplan_mail.backend.capabilities._assert_campaign_inherits_profile_credentials"),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.smtp_config_from_profile",
|
||||
side_effect=AssertionError("summary must not decrypt SMTP credentials"),
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.imap_config_from_profile",
|
||||
side_effect=AssertionError("summary must not decrypt IMAP credentials"),
|
||||
),
|
||||
):
|
||||
summary = campaign_profile_delivery_summary(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
profile_id="profile-1",
|
||||
)
|
||||
|
||||
self.assertTrue(summary["smtp_available"])
|
||||
self.assertFalse(summary["imap_available"])
|
||||
self.assertNotIn("service-account", repr(summary))
|
||||
self.assertNotIn("internal.example", repr(summary))
|
||||
self.assertNotIn("secret", repr(summary))
|
||||
|
||||
def test_send_compares_revision_before_provider_effect(self) -> None:
|
||||
profile = _profile()
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
|
||||
return_value=profile,
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.smtp_config_from_profile",
|
||||
side_effect=AssertionError("stale revisions must be rejected before SMTP credential decryption"),
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
|
||||
return_value={"smtp": "current", "imap": None},
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.send_email_bytes") as provider_send,
|
||||
):
|
||||
with self.assertRaisesRegex(MailProfileError, "changed after this campaign was built"):
|
||||
send_campaign_email_bytes(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
profile_id="profile-1",
|
||||
message_bytes=b"message",
|
||||
envelope_from="sender@example.test",
|
||||
envelope_recipients=["recipient@example.test"],
|
||||
from_header="sender@example.test",
|
||||
expected_smtp_transport_revision="stale",
|
||||
)
|
||||
|
||||
provider_send.assert_not_called()
|
||||
|
||||
def test_stale_imap_revision_is_rejected_before_credential_decryption(self) -> None:
|
||||
profile = _profile()
|
||||
profile.imap_config = {
|
||||
"host": "imap.internal.example",
|
||||
"port": 993,
|
||||
"security": "tls",
|
||||
}
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
|
||||
return_value=profile,
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
|
||||
return_value={"smtp": "smtp-current", "imap": "imap-current"},
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.imap_config_from_profile",
|
||||
side_effect=AssertionError("stale revisions must be rejected before IMAP credential decryption"),
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.append_message_to_sent") as provider_append,
|
||||
):
|
||||
with self.assertRaisesRegex(MailProfileError, "changed after this campaign was built"):
|
||||
append_campaign_message_to_sent(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
profile_id="profile-1",
|
||||
message_bytes=b"message",
|
||||
folder="Sent",
|
||||
expected_smtp_transport_revision="smtp-current",
|
||||
expected_imap_transport_revision="imap-stale",
|
||||
)
|
||||
|
||||
provider_append.assert_not_called()
|
||||
|
||||
def test_provider_response_is_sanitized_before_campaign_evidence(self) -> None:
|
||||
profile = _profile()
|
||||
smtp = SmtpConfig(host="smtp.internal.example", port=587)
|
||||
provider_secret = b"smtp.internal.example says credential=provider-secret"
|
||||
provider_result = SimpleNamespace(
|
||||
envelope_recipients=["ok@example.test", "blocked@example.test"],
|
||||
refused_recipients={"blocked@example.test": (550, provider_secret)},
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
|
||||
return_value=profile,
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.smtp_config_from_profile", return_value=smtp),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.imap_config_from_profile",
|
||||
side_effect=AssertionError("SMTP delivery must not decrypt IMAP credentials"),
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
|
||||
return_value={"smtp": "current", "imap": None},
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
|
||||
patch("govoplan_mail.backend.capabilities.send_email_bytes", return_value=provider_result),
|
||||
):
|
||||
result = send_campaign_email_bytes(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
profile_id="profile-1",
|
||||
message_bytes=b"message",
|
||||
envelope_from="sender@example.test",
|
||||
envelope_recipients=provider_result.envelope_recipients,
|
||||
from_header="sender@example.test",
|
||||
expected_smtp_transport_revision="current",
|
||||
)
|
||||
|
||||
evidence = json.dumps(asdict(result), sort_keys=True)
|
||||
self.assertEqual(result.accepted_count, 1)
|
||||
self.assertIn('"status_code": 550', evidence)
|
||||
self.assertIn('"classification": "permanent"', evidence)
|
||||
self.assertNotIn("smtp.internal.example", evidence)
|
||||
self.assertNotIn("provider-secret", evidence)
|
||||
|
||||
def test_append_materializes_only_imap_credentials_and_preserves_unknown_outcome(self) -> None:
|
||||
profile = _profile()
|
||||
profile.imap_config = {
|
||||
"host": "imap.internal.example",
|
||||
"port": 993,
|
||||
"security": "tls",
|
||||
}
|
||||
imap = ImapConfig(host="imap.internal.example", port=993)
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
|
||||
return_value=profile,
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.imap_config_from_profile", return_value=imap),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.smtp_config_from_profile",
|
||||
side_effect=AssertionError("IMAP append must not decrypt SMTP credentials"),
|
||||
),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
|
||||
return_value={"smtp": "smtp-current", "imap": "imap-current"},
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.append_message_to_sent",
|
||||
side_effect=ImapAppendError(
|
||||
"imap.internal.example provider-secret",
|
||||
outcome_unknown=True,
|
||||
),
|
||||
),
|
||||
):
|
||||
with self.assertRaises(ImapAppendError) as captured:
|
||||
append_campaign_message_to_sent(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
profile_id="profile-1",
|
||||
message_bytes=b"message",
|
||||
folder="Sent",
|
||||
expected_smtp_transport_revision="smtp-current",
|
||||
expected_imap_transport_revision="imap-current",
|
||||
)
|
||||
|
||||
self.assertTrue(captured.exception.outcome_unknown)
|
||||
self.assertFalse(captured.exception.temporary)
|
||||
self.assertIn("inspect the mailbox", str(captured.exception))
|
||||
self.assertNotIn("internal.example", str(captured.exception))
|
||||
self.assertNotIn("provider-secret", str(captured.exception))
|
||||
|
||||
def test_provider_exception_is_sanitized_and_bounded(self) -> None:
|
||||
profile = _profile()
|
||||
smtp = SmtpConfig(host="smtp.internal.example", port=587)
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
|
||||
return_value=profile,
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.smtp_config_from_profile", return_value=smtp),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
|
||||
return_value={"smtp": "current", "imap": None},
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.send_email_bytes",
|
||||
side_effect=SmtpSendError(
|
||||
"smtp.internal.example provider-secret " + ("x" * 1_000),
|
||||
temporary=True,
|
||||
),
|
||||
),
|
||||
):
|
||||
with self.assertRaises(SmtpSendError) as captured:
|
||||
send_campaign_email_bytes(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
profile_id="profile-1",
|
||||
message_bytes=b"message",
|
||||
envelope_from="sender@example.test",
|
||||
envelope_recipients=["recipient@example.test"],
|
||||
from_header="sender@example.test",
|
||||
expected_smtp_transport_revision="current",
|
||||
)
|
||||
|
||||
message = str(captured.exception)
|
||||
self.assertEqual(message, "Mail delivery failed temporarily.")
|
||||
self.assertNotIn("internal.example", message)
|
||||
self.assertLessEqual(len(message), 80)
|
||||
self.assertIsNone(captured.exception.__cause__)
|
||||
|
||||
def test_unclassified_provider_exception_is_outcome_unknown(self) -> None:
|
||||
profile = _profile()
|
||||
smtp = SmtpConfig(host="smtp.internal.example", port=587)
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities._authorized_campaign_profile",
|
||||
return_value=profile,
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.smtp_config_from_profile", return_value=smtp),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.campaign_profile_transport_revisions",
|
||||
return_value={"smtp": "current", "imap": None},
|
||||
),
|
||||
patch("govoplan_mail.backend.capabilities.assert_mail_policy_allows_send"),
|
||||
patch(
|
||||
"govoplan_mail.backend.capabilities.send_email_bytes",
|
||||
side_effect=RuntimeError("smtp.internal.example provider-secret"),
|
||||
),
|
||||
):
|
||||
with self.assertRaises(SmtpSendError) as captured:
|
||||
send_campaign_email_bytes(
|
||||
object(), # type: ignore[arg-type]
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
profile_id="profile-1",
|
||||
message_bytes=b"message",
|
||||
envelope_from="sender@example.test",
|
||||
envelope_recipients=["recipient@example.test"],
|
||||
from_header="sender@example.test",
|
||||
expected_smtp_transport_revision="current",
|
||||
)
|
||||
|
||||
self.assertTrue(captured.exception.outcome_unknown)
|
||||
self.assertNotIn("internal.example", str(captured.exception))
|
||||
self.assertIsNone(captured.exception.__cause__)
|
||||
|
||||
def test_capability_does_not_export_raw_profile_or_transport_helpers(self) -> None:
|
||||
capability = MailCampaignCapability()
|
||||
|
||||
for name in (
|
||||
"smtp_config_from_profile",
|
||||
"imap_config_from_profile",
|
||||
"send_email_bytes",
|
||||
"send_email_message",
|
||||
"materialize_campaign_mail_profile_config",
|
||||
):
|
||||
self.assertFalse(hasattr(capability, name), name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -247,6 +247,22 @@ class ImapMailboxCommandTests(unittest.TestCase):
|
||||
self.assertEqual(result.folder, "Gesendete Elemente")
|
||||
self.assertEqual(result.bytes_appended, len(b"Subject: test\r\n\r\nBody"))
|
||||
|
||||
def test_connection_loss_after_append_starts_has_unknown_outcome(self):
|
||||
class Client:
|
||||
def append(self, _mailbox, _flags, _date_time, _message):
|
||||
raise OSError("provider detail")
|
||||
|
||||
def logout(self):
|
||||
return "BYE", [b"logged out"]
|
||||
|
||||
config = ImapConfig(host="imap.example.org", username="user", password="secret", sent_folder="Sent")
|
||||
with patch("govoplan_mail.backend.sending.imap._open_imap", return_value=Client()):
|
||||
with self.assertRaises(ImapAppendError) as captured:
|
||||
append_message_to_sent(b"Subject: test\r\n\r\nBody", imap_config=config, folder="Sent")
|
||||
|
||||
self.assertTrue(captured.exception.outcome_unknown)
|
||||
self.assertFalse(captured.exception.temporary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -2,20 +2,144 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||
from govoplan_mail.backend.mail_profiles import (
|
||||
EffectiveCredentialPolicy,
|
||||
EffectiveMailProfilePolicy,
|
||||
MailProfileError,
|
||||
_assert_campaign_inherits_profile_credentials,
|
||||
_campaign_mail_profile_reference_id,
|
||||
_apply_profile_transport_update,
|
||||
campaign_profile_transport_revisions,
|
||||
_merge_policy,
|
||||
_next_profile_transport_state,
|
||||
_policy_parent_lock_message,
|
||||
_policy_parent_lock_violations,
|
||||
delete_mail_profile_credentials,
|
||||
)
|
||||
|
||||
|
||||
class MailProfileTransportHelperTests(unittest.TestCase):
|
||||
def test_campaign_transport_revisions_are_random_opaque_values(self):
|
||||
profile = SimpleNamespace(
|
||||
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
|
||||
smtp_username="first-user",
|
||||
smtp_password_encrypted="first-secret-ciphertext",
|
||||
smtp_transport_revision="032837ce-fd97-401a-8a9f-8df67f3ab41d",
|
||||
imap_config=None,
|
||||
imap_username=None,
|
||||
imap_password_encrypted=None,
|
||||
imap_transport_revision="9ca09521-c543-42ed-a8f2-f06783f1ebd3",
|
||||
)
|
||||
|
||||
original = campaign_profile_transport_revisions(profile)
|
||||
profile.smtp_password_encrypted = "second-secret-ciphertext"
|
||||
|
||||
self.assertEqual(campaign_profile_transport_revisions(profile), original)
|
||||
self.assertNotIn("smtp.example.org", repr(original))
|
||||
self.assertEqual(original["smtp"], profile.smtp_transport_revision)
|
||||
|
||||
def test_profile_deletion_scrubs_both_passwords_and_writes_non_secret_audit(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_password_encrypted="smtp-ciphertext",
|
||||
imap_password_encrypted="imap-ciphertext",
|
||||
)
|
||||
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
|
||||
|
||||
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
|
||||
deleted = delete_mail_profile_credentials(
|
||||
session, # type: ignore[arg-type]
|
||||
profile=profile, # type: ignore[arg-type]
|
||||
deletion_reason="profile_deactivated",
|
||||
user_id="user-1",
|
||||
)
|
||||
|
||||
self.assertEqual(deleted, ("smtp", "imap"))
|
||||
self.assertIsNone(profile.smtp_password_encrypted)
|
||||
self.assertIsNone(profile.imap_password_encrypted)
|
||||
self.assertEqual(audit.call_count, 1)
|
||||
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_deleted")
|
||||
self.assertEqual(audit.call_args.kwargs["scope"], "tenant")
|
||||
self.assertEqual(audit.call_args.kwargs["details"]["deletion_reason"], "profile_deactivated")
|
||||
self.assertNotIn("ciphertext", repr(audit.call_args.kwargs))
|
||||
|
||||
def test_system_profile_secret_deletion_uses_system_audit_scope(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-system",
|
||||
tenant_id=None,
|
||||
scope_type="system",
|
||||
scope_id=None,
|
||||
smtp_password_encrypted="smtp-ciphertext",
|
||||
imap_password_encrypted=None,
|
||||
)
|
||||
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
|
||||
|
||||
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
|
||||
delete_mail_profile_credentials(
|
||||
session, # type: ignore[arg-type]
|
||||
profile=profile, # type: ignore[arg-type]
|
||||
deletion_reason="module_data_retired",
|
||||
)
|
||||
|
||||
self.assertEqual(audit.call_args.kwargs["scope"], "system")
|
||||
self.assertIsNone(audit.call_args.kwargs["tenant_id"])
|
||||
self.assertNotIn("ciphertext", repr(audit.call_args.kwargs))
|
||||
|
||||
def test_repeated_profile_secret_deletion_is_an_idempotent_no_op(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_password_encrypted=None,
|
||||
imap_password_encrypted=None,
|
||||
)
|
||||
session = SimpleNamespace(add=lambda _value: self.fail("no-op must not add"), flush=lambda: self.fail("no-op must not flush"))
|
||||
|
||||
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
|
||||
deleted = delete_mail_profile_credentials(
|
||||
session, # type: ignore[arg-type]
|
||||
profile=profile, # type: ignore[arg-type]
|
||||
deletion_reason="profile_deactivated",
|
||||
)
|
||||
|
||||
self.assertEqual(deleted, ())
|
||||
audit.assert_not_called()
|
||||
|
||||
def test_profile_secret_deletion_does_not_mutate_ciphertext_when_audit_fails(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_password_encrypted="smtp-ciphertext",
|
||||
imap_password_encrypted="imap-ciphertext",
|
||||
)
|
||||
session = SimpleNamespace(add=lambda _value: None, flush=lambda: None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.mail_profiles.audit_event",
|
||||
side_effect=RuntimeError("audit unavailable"),
|
||||
),
|
||||
self.assertRaisesRegex(RuntimeError, "audit unavailable"),
|
||||
):
|
||||
delete_mail_profile_credentials(
|
||||
session, # type: ignore[arg-type]
|
||||
profile=profile, # type: ignore[arg-type]
|
||||
deletion_reason="profile_deactivated",
|
||||
)
|
||||
|
||||
self.assertEqual(profile.smtp_password_encrypted, "smtp-ciphertext")
|
||||
self.assertEqual(profile.imap_password_encrypted, "imap-ciphertext")
|
||||
|
||||
def test_next_transport_state_uses_saved_profile_credentials(self):
|
||||
profile = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
@@ -41,16 +165,26 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
||||
|
||||
def test_apply_transport_update_preserves_unsupplied_passwords(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
|
||||
smtp_username="saved-smtp",
|
||||
smtp_password_encrypted=encrypt_secret("smtp-secret"),
|
||||
smtp_transport_revision="smtp-before",
|
||||
imap_config={"host": "imap.example.org", "port": 993, "security": "tls", "sent_folder": "Sent", "timeout_seconds": 30},
|
||||
imap_username="saved-imap",
|
||||
imap_password_encrypted=encrypt_secret("imap-secret"),
|
||||
imap_transport_revision="imap-before",
|
||||
)
|
||||
session = SimpleNamespace(flush=lambda: None)
|
||||
|
||||
_apply_profile_transport_update(
|
||||
session, # type: ignore[arg-type]
|
||||
profile,
|
||||
user_id="user-1",
|
||||
api_key_id=None,
|
||||
smtp=SmtpConfig(host="smtp2.example.org", username="new-smtp"),
|
||||
imap=ImapConfig(host="imap2.example.org", username="new-imap"),
|
||||
clear_imap=False,
|
||||
@@ -62,14 +196,158 @@ class MailProfileTransportHelperTests(unittest.TestCase):
|
||||
self.assertEqual(profile.imap_config["host"], "imap2.example.org")
|
||||
self.assertEqual(profile.imap_username, "new-imap")
|
||||
self.assertEqual(decrypt_secret(profile.imap_password_encrypted), "imap-secret")
|
||||
self.assertNotEqual(profile.smtp_transport_revision, "smtp-before")
|
||||
self.assertNotEqual(profile.imap_transport_revision, "imap-before")
|
||||
|
||||
_apply_profile_transport_update(profile, smtp=None, imap=None, clear_imap=True)
|
||||
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
|
||||
_apply_profile_transport_update(
|
||||
session, # type: ignore[arg-type]
|
||||
profile,
|
||||
user_id="user-1",
|
||||
api_key_id=None,
|
||||
smtp=None,
|
||||
imap=None,
|
||||
clear_imap=True,
|
||||
)
|
||||
self.assertIsNone(profile.imap_config)
|
||||
self.assertIsNone(profile.imap_username)
|
||||
self.assertIsNone(profile.imap_password_encrypted)
|
||||
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_deleted")
|
||||
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "imap")
|
||||
self.assertNotIn("imap-secret", repr(audit.call_args.kwargs))
|
||||
|
||||
def test_password_replacement_preserves_revision_and_is_audited_without_secret(self):
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
|
||||
smtp_username="saved-smtp",
|
||||
smtp_password_encrypted=encrypt_secret("old-secret"),
|
||||
smtp_transport_revision="smtp-before",
|
||||
imap_config=None,
|
||||
imap_username=None,
|
||||
imap_password_encrypted=None,
|
||||
imap_transport_revision="imap-before",
|
||||
)
|
||||
session = SimpleNamespace(flush=lambda: None)
|
||||
|
||||
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
|
||||
_apply_profile_transport_update(
|
||||
session, # type: ignore[arg-type]
|
||||
profile,
|
||||
user_id="user-1",
|
||||
api_key_id="key-1",
|
||||
smtp=SmtpConfig(
|
||||
host="smtp.example.org",
|
||||
port=587,
|
||||
security="starttls",
|
||||
timeout_seconds=30,
|
||||
password="new-secret",
|
||||
),
|
||||
imap=None,
|
||||
clear_imap=False,
|
||||
)
|
||||
|
||||
self.assertEqual(decrypt_secret(profile.smtp_password_encrypted), "new-secret")
|
||||
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
|
||||
self.assertEqual(audit.call_args.kwargs["action"], "mail.profile_credentials_replaced")
|
||||
self.assertEqual(audit.call_args.kwargs["details"]["protocol"], "smtp")
|
||||
self.assertNotIn("old-secret", repr(audit.call_args.kwargs))
|
||||
self.assertNotIn("new-secret", repr(audit.call_args.kwargs))
|
||||
|
||||
def test_audit_failure_leaves_existing_ciphertext_and_revision_unchanged(self):
|
||||
encrypted = encrypt_secret("old-secret")
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
|
||||
smtp_username="saved-smtp",
|
||||
smtp_password_encrypted=encrypted,
|
||||
smtp_transport_revision="smtp-before",
|
||||
imap_config=None,
|
||||
imap_username=None,
|
||||
imap_password_encrypted=None,
|
||||
imap_transport_revision="imap-before",
|
||||
)
|
||||
session = SimpleNamespace(flush=lambda: None)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"govoplan_mail.backend.mail_profiles.audit_event",
|
||||
side_effect=RuntimeError("audit unavailable"),
|
||||
),
|
||||
self.assertRaisesRegex(RuntimeError, "audit unavailable"),
|
||||
):
|
||||
_apply_profile_transport_update(
|
||||
session, # type: ignore[arg-type]
|
||||
profile,
|
||||
user_id="user-1",
|
||||
api_key_id=None,
|
||||
smtp=SmtpConfig(host="smtp.example.org", password="new-secret"),
|
||||
imap=None,
|
||||
clear_imap=False,
|
||||
)
|
||||
|
||||
self.assertEqual(profile.smtp_password_encrypted, encrypted)
|
||||
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
|
||||
|
||||
def test_exact_password_update_is_an_idempotent_no_op(self):
|
||||
encrypted = encrypt_secret("same-secret")
|
||||
profile = SimpleNamespace(
|
||||
id="profile-1",
|
||||
tenant_id="tenant-1",
|
||||
scope_type="tenant",
|
||||
scope_id="tenant-1",
|
||||
smtp_config={"host": "smtp.example.org", "port": 587, "security": "starttls", "timeout_seconds": 30},
|
||||
smtp_username="saved-smtp",
|
||||
smtp_password_encrypted=encrypted,
|
||||
smtp_transport_revision="smtp-before",
|
||||
imap_config=None,
|
||||
imap_username=None,
|
||||
imap_password_encrypted=None,
|
||||
imap_transport_revision="imap-before",
|
||||
)
|
||||
session = SimpleNamespace(flush=lambda: None)
|
||||
|
||||
with patch("govoplan_mail.backend.mail_profiles.audit_event") as audit:
|
||||
_apply_profile_transport_update(
|
||||
session, # type: ignore[arg-type]
|
||||
profile,
|
||||
user_id="user-1",
|
||||
api_key_id=None,
|
||||
smtp=SmtpConfig(host="smtp.example.org", password="same-secret"),
|
||||
imap=None,
|
||||
clear_imap=False,
|
||||
)
|
||||
|
||||
self.assertEqual(profile.smtp_password_encrypted, encrypted)
|
||||
self.assertEqual(profile.smtp_transport_revision, "smtp-before")
|
||||
audit.assert_not_called()
|
||||
|
||||
|
||||
class MailProfilePolicyHelperTests(unittest.TestCase):
|
||||
def test_campaign_contract_accepts_only_mail_profile_reference(self):
|
||||
self.assertEqual(
|
||||
_campaign_mail_profile_reference_id({"mail_profile_id": " profile-1 "}),
|
||||
"profile-1",
|
||||
)
|
||||
for legacy in ("smtp", "imap", "credentials", "inherit_smtp_credentials"):
|
||||
with self.subTest(legacy=legacy), self.assertRaisesRegex(MailProfileError, "select a Mail profile"):
|
||||
_campaign_mail_profile_reference_id({"mail_profile_id": "profile-1", legacy: {}})
|
||||
|
||||
def test_campaign_delivery_fails_when_policy_requires_local_credentials(self):
|
||||
profile = SimpleNamespace(imap_config=None)
|
||||
policy = EffectiveMailProfilePolicy(
|
||||
smtp_credentials=EffectiveCredentialPolicy(inherit=False),
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(MailProfileError, "Store the credentials on a Mail profile"):
|
||||
_assert_campaign_inherits_profile_credentials(profile, policy)
|
||||
|
||||
def test_merge_policy_respects_locked_lower_level_limits(self):
|
||||
policy = EffectiveMailProfilePolicy()
|
||||
_merge_policy(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_mail.backend.manifest import get_manifest
|
||||
from govoplan_core.core.modules import MigrationRetirementPlan, ModuleManifest
|
||||
from govoplan_mail.backend.manifest import _mail_retirement_provider, get_manifest
|
||||
|
||||
|
||||
class MailManifestTests(unittest.TestCase):
|
||||
@@ -14,6 +16,62 @@ class MailManifestTests(unittest.TestCase):
|
||||
self.assertEqual(manifest.id, "mail")
|
||||
self.assertIn("addresses", manifest.optional_dependencies)
|
||||
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn(
|
||||
{"name": "mail.campaign_delivery", "version": "0.2.0"},
|
||||
[
|
||||
{"name": interface.name, "version": interface.version}
|
||||
for interface in manifest.provides_interfaces
|
||||
],
|
||||
)
|
||||
topics = {topic.id: topic for topic in manifest.documentation}
|
||||
ownership = topics["mail.profile-ownership-and-consumers"]
|
||||
self.assertEqual(ownership.metadata["kind"], "reference")
|
||||
self.assertEqual(ownership.metadata["route"], "/mail")
|
||||
self.assertIn("campaigns.mail-profile-user-journey", ownership.metadata["related_topic_ids"])
|
||||
|
||||
def test_retirement_scrubs_credentials_before_dropping_mail_tables(self) -> None:
|
||||
events: list[str] = []
|
||||
|
||||
def table_provider(_session, _module_id):
|
||||
return MigrationRetirementPlan(
|
||||
supported=True,
|
||||
summary="Mail tables",
|
||||
destroy_data_supported=True,
|
||||
destroy_data_executor=lambda _execute_session, _execute_module_id: events.append("drop"),
|
||||
)
|
||||
|
||||
session = SimpleNamespace(get_bind=lambda: object(), query=lambda *_args: None)
|
||||
inspector = SimpleNamespace(has_table=lambda _name: True)
|
||||
with (
|
||||
patch("govoplan_mail.backend.manifest._mail_table_retirement_provider", table_provider),
|
||||
patch("govoplan_mail.backend.manifest.inspect", return_value=inspector),
|
||||
patch(
|
||||
"govoplan_mail.backend.mail_profiles.delete_mail_profile_credentials_for_retirement",
|
||||
side_effect=lambda _session: events.append("scrub"),
|
||||
),
|
||||
):
|
||||
plan = _mail_retirement_provider(session, "mail")
|
||||
assert plan.destroy_data_executor is not None
|
||||
plan.destroy_data_executor(session, "mail")
|
||||
|
||||
self.assertEqual(events, ["scrub", "drop"])
|
||||
self.assertTrue(any("audit" in warning for warning in plan.destroy_data_warnings))
|
||||
|
||||
events.clear()
|
||||
with (
|
||||
patch("govoplan_mail.backend.manifest._mail_table_retirement_provider", table_provider),
|
||||
patch("govoplan_mail.backend.manifest.inspect", return_value=inspector),
|
||||
patch(
|
||||
"govoplan_mail.backend.mail_profiles.delete_mail_profile_credentials_for_retirement",
|
||||
side_effect=RuntimeError("audit unavailable"),
|
||||
),
|
||||
):
|
||||
blocked_plan = _mail_retirement_provider(session, "mail")
|
||||
assert blocked_plan.destroy_data_executor is not None
|
||||
with self.assertRaisesRegex(RuntimeError, "audit unavailable"):
|
||||
blocked_plan.destroy_data_executor(session, "mail")
|
||||
|
||||
self.assertEqual(events, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user