420 lines
17 KiB
Python
420 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from govoplan_mail.backend import router
|
|
from govoplan_mail.backend.mail_profiles import (
|
|
EffectiveMailProfilePolicy,
|
|
MailProfileError,
|
|
list_mail_server_profiles,
|
|
mail_profile_visible_to_actor,
|
|
)
|
|
from govoplan_mail.backend.schemas import (
|
|
MailServerProfileCreateRequest,
|
|
MailServerProfileUpdateRequest,
|
|
)
|
|
|
|
|
|
def _profile(
|
|
profile_id: str,
|
|
*,
|
|
scope_type: str,
|
|
scope_id: str | None,
|
|
tenant_id: str | None = "tenant-1",
|
|
):
|
|
return SimpleNamespace(
|
|
id=profile_id,
|
|
tenant_id=tenant_id,
|
|
scope_type=scope_type,
|
|
scope_id=scope_id,
|
|
is_active=True,
|
|
name=profile_id,
|
|
smtp_config={"host": "smtp.example.test"},
|
|
imap_config={"host": "imap.example.test"},
|
|
)
|
|
|
|
|
|
class _Query:
|
|
def __init__(self, profiles):
|
|
self._profiles = profiles
|
|
|
|
def filter(self, *_args):
|
|
return self
|
|
|
|
def all(self):
|
|
return list(self._profiles)
|
|
|
|
|
|
class _Session:
|
|
def __init__(self, profiles=()):
|
|
self.profiles = list(profiles)
|
|
self.commits = 0
|
|
self.rollbacks = 0
|
|
|
|
def query(self, _model):
|
|
return _Query(self.profiles)
|
|
|
|
def get(self, _model, profile_id):
|
|
return next((profile for profile in self.profiles if profile.id == profile_id), None)
|
|
|
|
def commit(self):
|
|
self.commits += 1
|
|
|
|
def rollback(self):
|
|
self.rollbacks += 1
|
|
|
|
|
|
class _Principal:
|
|
tenant_id = "tenant-1"
|
|
user = SimpleNamespace(id="user-1")
|
|
group_ids = frozenset({"group-1"})
|
|
|
|
def __init__(self, scopes):
|
|
self._scopes = frozenset(scopes)
|
|
|
|
def has(self, scope: str) -> bool:
|
|
return scope in self._scopes
|
|
|
|
|
|
class ProfileActorAuthorizationTests(unittest.TestCase):
|
|
def test_general_profile_list_hides_other_owner_contexts(self) -> None:
|
|
profiles = [
|
|
_profile("system", scope_type="system", scope_id=None, tenant_id=None),
|
|
_profile("tenant", scope_type="tenant", scope_id="tenant-1"),
|
|
_profile("own-user", scope_type="user", scope_id="user-1"),
|
|
_profile("other-user", scope_type="user", scope_id="user-2"),
|
|
_profile("own-group", scope_type="group", scope_id="group-1"),
|
|
_profile("other-group", scope_type="group", scope_id="group-2"),
|
|
]
|
|
session = _Session(profiles)
|
|
with patch(
|
|
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
|
|
return_value=EffectiveMailProfilePolicy(),
|
|
):
|
|
visible = list_mail_server_profiles(
|
|
session, # type: ignore[arg-type]
|
|
tenant_id="tenant-1",
|
|
actor_user_id="user-1",
|
|
actor_group_ids=(group_id for group_id in ("group-1",)),
|
|
)
|
|
|
|
self.assertEqual(
|
|
{profile.id for profile in visible},
|
|
{"system", "tenant", "own-user", "own-group"},
|
|
)
|
|
|
|
def test_general_access_denies_shared_profile_excluded_by_effective_policy(self) -> None:
|
|
denied = EffectiveMailProfilePolicy(allowed_profile_id_sets=[{"approved"}])
|
|
cases = (("system", None), ("tenant", "tenant-1"))
|
|
for scope_type, tenant_id in cases:
|
|
with self.subTest(scope_type=scope_type):
|
|
profile = _profile("denied", scope_type=scope_type, scope_id=tenant_id, tenant_id=tenant_id)
|
|
with patch("govoplan_mail.backend.mail_profiles.effective_mail_profile_policy", return_value=denied):
|
|
visible = mail_profile_visible_to_actor(
|
|
_Session(), # type: ignore[arg-type]
|
|
profile=profile,
|
|
tenant_id="tenant-1",
|
|
user_id="user-1",
|
|
group_ids={"group-1"},
|
|
)
|
|
self.assertFalse(visible)
|
|
|
|
def test_administrative_list_can_include_inactive_visible_profiles(self) -> None:
|
|
profile = _profile("inactive", scope_type="tenant", scope_id="tenant-1")
|
|
profile.is_active = False
|
|
session = _Session([profile])
|
|
with patch(
|
|
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
|
|
return_value=EffectiveMailProfilePolicy(),
|
|
):
|
|
visible = list_mail_server_profiles(
|
|
session, # type: ignore[arg-type]
|
|
tenant_id="tenant-1",
|
|
include_inactive=True,
|
|
actor_user_id="user-1",
|
|
actor_can_manage_tenant_profiles=True,
|
|
actor_administrative_visibility=True,
|
|
)
|
|
|
|
self.assertEqual([item.id for item in visible], ["inactive"])
|
|
|
|
def test_profile_admin_can_list_but_not_use_a_policy_denied_profile(self) -> None:
|
|
profile = _profile("denied", scope_type="tenant", scope_id="tenant-1")
|
|
session = _Session([profile])
|
|
denied = EffectiveMailProfilePolicy(allowed_profile_id_sets=[{"approved"}])
|
|
with patch("govoplan_mail.backend.mail_profiles.effective_mail_profile_policy", return_value=denied):
|
|
listed = list_mail_server_profiles(
|
|
session, # type: ignore[arg-type]
|
|
tenant_id="tenant-1",
|
|
actor_user_id="user-1",
|
|
actor_group_ids={"group-1"},
|
|
actor_can_manage_tenant_profiles=True,
|
|
actor_administrative_visibility=True,
|
|
)
|
|
self.assertEqual([item.id for item in listed], ["denied"])
|
|
|
|
principal = _Principal(
|
|
{"mail:profile:write", "mail:profile:test", "mail:profile:use"}
|
|
)
|
|
with (
|
|
patch("govoplan_mail.backend.router.smtp_config_from_profile") as materialize,
|
|
patch("govoplan_mail.backend.router.test_smtp_login") as provider,
|
|
self.assertRaises(HTTPException) as captured,
|
|
):
|
|
router.test_profile_smtp(
|
|
"denied",
|
|
principal=principal, # type: ignore[arg-type]
|
|
session=session, # type: ignore[arg-type]
|
|
)
|
|
|
|
self.assertEqual(captured.exception.status_code, 404)
|
|
materialize.assert_not_called()
|
|
provider.assert_not_called()
|
|
|
|
def test_campaign_scoped_profile_requires_campaign_acl_before_policy_resolution(self) -> None:
|
|
profile = _profile("campaign-profile", scope_type="campaign", scope_id="campaign-1")
|
|
access = SimpleNamespace(can_read_campaign=lambda *_args, **_kwargs: False)
|
|
with (
|
|
patch("govoplan_mail.backend.mail_profiles._campaign_access_provider", return_value=access),
|
|
patch("govoplan_mail.backend.mail_profiles._campaign_policy_context") as policy_context,
|
|
):
|
|
visible = mail_profile_visible_to_actor(
|
|
_Session(), # type: ignore[arg-type]
|
|
profile=profile,
|
|
tenant_id="tenant-1",
|
|
user_id="user-1",
|
|
)
|
|
self.assertFalse(visible)
|
|
policy_context.assert_not_called()
|
|
|
|
def test_mail_profile_admin_does_not_bypass_campaign_acl_but_tenant_admin_does(self) -> None:
|
|
profile = _profile("campaign-profile", scope_type="campaign", scope_id="campaign-1")
|
|
tenant_admin_values: list[bool] = []
|
|
|
|
def can_read(*_args, **kwargs):
|
|
tenant_admin_values.append(bool(kwargs["tenant_admin"]))
|
|
return bool(kwargs["tenant_admin"])
|
|
|
|
access = SimpleNamespace(can_read_campaign=can_read)
|
|
with (
|
|
patch("govoplan_mail.backend.mail_profiles._campaign_access_provider", return_value=access),
|
|
patch("govoplan_mail.backend.mail_profiles._campaign_policy_context") as policy_context,
|
|
):
|
|
mail_admin_visible = mail_profile_visible_to_actor(
|
|
_Session(), # type: ignore[arg-type]
|
|
profile=profile,
|
|
tenant_id="tenant-1",
|
|
user_id="user-1",
|
|
can_manage_tenant_profiles=True,
|
|
administrative_visibility=True,
|
|
)
|
|
tenant_admin_visible = mail_profile_visible_to_actor(
|
|
_Session(), # type: ignore[arg-type]
|
|
profile=profile,
|
|
tenant_id="tenant-1",
|
|
user_id="user-1",
|
|
can_manage_tenant_profiles=True,
|
|
tenant_admin=True,
|
|
administrative_visibility=True,
|
|
)
|
|
|
|
self.assertFalse(mail_admin_visible)
|
|
self.assertTrue(tenant_admin_visible)
|
|
self.assertEqual(tenant_admin_values, [False, True])
|
|
policy_context.assert_not_called()
|
|
|
|
def test_policy_read_rejects_unshared_and_mismatched_campaign_contexts(self) -> None:
|
|
principal = _Principal({"mail:profile:read"})
|
|
with (
|
|
patch(
|
|
"govoplan_mail.backend.router.campaign_mail_context_visible_to_actor",
|
|
return_value=False,
|
|
),
|
|
patch("govoplan_mail.backend.router._policy_response") as policy_response,
|
|
self.assertRaises(HTTPException) as unshared,
|
|
):
|
|
router.read_mail_profile_policy(
|
|
"tenant",
|
|
scope_id="tenant-1",
|
|
campaign_id="campaign-1",
|
|
principal=principal, # type: ignore[arg-type]
|
|
session=_Session(), # type: ignore[arg-type]
|
|
)
|
|
self.assertEqual(unshared.exception.status_code, 404)
|
|
policy_response.assert_not_called()
|
|
|
|
with self.assertRaises(HTTPException) as mismatched:
|
|
router._require_policy_campaign_context( # noqa: SLF001 - authorization seam regression
|
|
_Session(), # type: ignore[arg-type]
|
|
principal=principal, # type: ignore[arg-type]
|
|
scope_type="campaign",
|
|
scope_id="campaign-1",
|
|
campaign_id="campaign-2",
|
|
)
|
|
self.assertEqual(mismatched.exception.status_code, 422)
|
|
|
|
def test_campaign_profile_crud_requires_campaign_acl(self) -> None:
|
|
principal = _Principal({"mail:profile:write", "mail:secret:manage"})
|
|
profile = _profile("campaign-profile", scope_type="campaign", scope_id="campaign-1")
|
|
create_payload = MailServerProfileCreateRequest.model_validate(
|
|
{
|
|
"name": "Campaign profile",
|
|
"scope_type": "campaign",
|
|
"scope_id": "campaign-1",
|
|
"smtp": {"host": "smtp.example.test"},
|
|
}
|
|
)
|
|
with (
|
|
patch(
|
|
"govoplan_mail.backend.router.campaign_mail_context_visible_to_actor",
|
|
return_value=False,
|
|
),
|
|
patch("govoplan_mail.backend.router.create_mail_server_profile") as create,
|
|
self.assertRaises(HTTPException) as create_denied,
|
|
):
|
|
router.create_profile(
|
|
create_payload,
|
|
principal=principal, # type: ignore[arg-type]
|
|
session=_Session(), # type: ignore[arg-type]
|
|
)
|
|
self.assertEqual(create_denied.exception.status_code, 404)
|
|
create.assert_not_called()
|
|
|
|
for operation in ("update", "delete"):
|
|
with (
|
|
self.subTest(operation=operation),
|
|
patch("govoplan_mail.backend.router.get_mail_server_profile", return_value=profile),
|
|
patch(
|
|
"govoplan_mail.backend.router.campaign_mail_context_visible_to_actor",
|
|
return_value=False,
|
|
),
|
|
patch("govoplan_mail.backend.router.update_mail_server_profile") as update,
|
|
patch("govoplan_mail.backend.router.delete_mail_profile_credentials") as delete,
|
|
self.assertRaises(HTTPException) as denied,
|
|
):
|
|
if operation == "update":
|
|
router.update_profile(
|
|
profile.id,
|
|
MailServerProfileUpdateRequest(name="Renamed"),
|
|
principal=principal, # type: ignore[arg-type]
|
|
session=_Session(), # type: ignore[arg-type]
|
|
)
|
|
else:
|
|
router.deactivate_profile(
|
|
profile.id,
|
|
principal=principal, # type: ignore[arg-type]
|
|
session=_Session(), # type: ignore[arg-type]
|
|
)
|
|
self.assertEqual(denied.exception.status_code, 404)
|
|
update.assert_not_called()
|
|
delete.assert_not_called()
|
|
|
|
def test_inactive_visible_profile_change_remains_a_tombstone_candidate(self) -> None:
|
|
profile = _profile("deactivated", scope_type="tenant", scope_id="tenant-1")
|
|
profile.is_active = False
|
|
entry = SimpleNamespace(
|
|
resource_id=profile.id,
|
|
tenant_id="tenant-1",
|
|
payload={"scope_type": "tenant", "scope_id": "tenant-1"},
|
|
)
|
|
principal = _Principal({"mail:profile:read"})
|
|
with patch(
|
|
"govoplan_mail.backend.mail_profiles.effective_mail_profile_policy",
|
|
return_value=EffectiveMailProfilePolicy(),
|
|
):
|
|
visible = router._profile_change_visible_to_principal( # noqa: SLF001
|
|
_Session([profile]), # type: ignore[arg-type]
|
|
entry=entry, # type: ignore[arg-type]
|
|
principal=principal, # type: ignore[arg-type]
|
|
)
|
|
self.assertTrue(visible)
|
|
|
|
def test_stale_mailbox_cache_refreshes_synchronously_under_request_authorization(self) -> None:
|
|
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
|
|
session = _Session()
|
|
imap = SimpleNamespace(
|
|
host="imap.example.test",
|
|
port=993,
|
|
security=SimpleNamespace(value="tls"),
|
|
)
|
|
stale = SimpleNamespace(stale=True)
|
|
provider_result = SimpleNamespace(
|
|
host="imap.example.test",
|
|
port=993,
|
|
security="tls",
|
|
folders=[
|
|
SimpleNamespace(
|
|
name="INBOX",
|
|
flags=[],
|
|
message_count=0,
|
|
unseen_count=0,
|
|
)
|
|
],
|
|
detected_sent_folder=None,
|
|
)
|
|
with (
|
|
patch("govoplan_mail.backend.router._imap_config_for_principal", return_value=imap),
|
|
patch("govoplan_mail.backend.router.cached_mailbox_folders", return_value=stale),
|
|
patch("govoplan_mail.backend.router.list_imap_folders", return_value=provider_result) as provider,
|
|
patch("govoplan_mail.backend.router.cache_mailbox_folders") as cache,
|
|
):
|
|
response = router.list_profile_mailbox_folders(
|
|
"profile-1",
|
|
include_status=False,
|
|
refresh=False,
|
|
principal=principal, # type: ignore[arg-type]
|
|
session=session, # type: ignore[arg-type]
|
|
)
|
|
|
|
provider.assert_called_once_with(imap_config=imap, include_status=False)
|
|
cache.assert_called_once()
|
|
self.assertFalse(response.from_cache)
|
|
self.assertFalse(response.refreshing)
|
|
self.assertEqual(session.commits, 1)
|
|
|
|
def test_unauthorized_profile_test_fails_before_credentials_or_provider_effect(self) -> None:
|
|
principal = _Principal({"mail:profile:test", "mail:profile:use"})
|
|
with (
|
|
patch(
|
|
"govoplan_mail.backend.router.get_mail_server_profile_for_actor",
|
|
side_effect=MailProfileError("Mail-server profile not found"),
|
|
),
|
|
patch("govoplan_mail.backend.router.smtp_config_from_profile") as materialize,
|
|
patch("govoplan_mail.backend.router.test_smtp_login") as provider,
|
|
):
|
|
with self.assertRaises(HTTPException) as captured:
|
|
router.test_profile_smtp(
|
|
"other-user-profile",
|
|
principal=principal, # type: ignore[arg-type]
|
|
session=_Session(), # type: ignore[arg-type]
|
|
)
|
|
|
|
self.assertEqual(captured.exception.status_code, 404)
|
|
materialize.assert_not_called()
|
|
provider.assert_not_called()
|
|
|
|
def test_unauthorized_mailbox_read_fails_before_credential_decryption(self) -> None:
|
|
principal = _Principal({"mail:mailbox:read", "mail:profile:use"})
|
|
with (
|
|
patch(
|
|
"govoplan_mail.backend.router.get_mail_server_profile_for_actor",
|
|
side_effect=MailProfileError("Mail-server profile not found"),
|
|
),
|
|
patch("govoplan_mail.backend.router.imap_config_from_profile") as materialize,
|
|
):
|
|
with self.assertRaisesRegex(MailProfileError, "not found"):
|
|
router._imap_config_for_principal( # noqa: SLF001 - authorization seam regression
|
|
_Session(), # type: ignore[arg-type]
|
|
principal=principal, # type: ignore[arg-type]
|
|
profile_id="other-group-profile",
|
|
)
|
|
materialize.assert_not_called()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|