411 lines
15 KiB
Python
411 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from dataclasses import replace
|
|
import tempfile
|
|
import unittest
|
|
|
|
from sqlalchemy import create_engine, select
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
from govoplan_access.backend.db import models as access_models # noqa: F401
|
|
from govoplan_core.core.configuration_packages import (
|
|
ConfigurationPackageFragment,
|
|
ConfigurationPreflightContext,
|
|
)
|
|
from govoplan_core.admin.models import SystemSettings
|
|
from govoplan_core.core.infrastructure_capabilities import (
|
|
infrastructure_capability_receipt_from_mapping,
|
|
)
|
|
from govoplan_core.db.base import Base
|
|
from govoplan_core.db.session import configure_database, reset_database
|
|
from govoplan_core.security.credential_envelopes import CredentialEnvelope
|
|
from govoplan_mail.backend.configuration_provider import (
|
|
MAIL_CONFIGURATION_CAPABILITY,
|
|
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
|
|
SqlMailConfigurationProvider,
|
|
)
|
|
from govoplan_mail.backend.db.models import (
|
|
MailProfilePolicy,
|
|
MailServerCredentialBinding,
|
|
MailServerEndpoint,
|
|
MailServerProfile,
|
|
)
|
|
from govoplan_mail.backend.manifest import manifest
|
|
|
|
|
|
def _receipt():
|
|
return infrastructure_capability_receipt_from_mapping(
|
|
{
|
|
"schema_version": 1,
|
|
"installation_id": "mail-provider-test",
|
|
"profile": "evaluation",
|
|
"capabilities": [
|
|
{
|
|
"id": "mail.smtp",
|
|
"label": "SMTP delivery",
|
|
"state": "available_unconfigured",
|
|
"source": "installer-managed-test",
|
|
"detail": "GreenMail is available for profile binding.",
|
|
"endpoint": {
|
|
"scheme": "smtp",
|
|
"host": "test-mail",
|
|
"port": 3025,
|
|
},
|
|
"secret_refs": [],
|
|
"dependent_modules": ["mail"],
|
|
}
|
|
],
|
|
"post_install_tasks": [
|
|
{
|
|
"id": "mail.smtp-profile",
|
|
"resume_key": "mail-provider-test:mail.smtp-profile:v1",
|
|
"capability_id": "mail.smtp",
|
|
"state": "pending",
|
|
"owner_module": "mail",
|
|
"summary": "Create a Mail SMTP profile.",
|
|
"required_inputs": ["credential envelope reference when required"],
|
|
"secret_boundary": "credential-envelope-reference-only",
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
|
|
def _external_receipt(*, credential_required: bool = False):
|
|
return infrastructure_capability_receipt_from_mapping(
|
|
{
|
|
"schema_version": 1,
|
|
"installation_id": "mail-provider-external",
|
|
"profile": "production",
|
|
"capabilities": [
|
|
{
|
|
"id": "mail.smtp",
|
|
"label": "SMTP delivery",
|
|
"state": "available_unconfigured",
|
|
"source": "operator-supplied",
|
|
"detail": "An external relay needs reviewed Mail configuration.",
|
|
"endpoint": {},
|
|
"secret_refs": (
|
|
["env:SMTP_CREDENTIAL_ENVELOPE_REF"]
|
|
if credential_required
|
|
else []
|
|
),
|
|
"dependent_modules": ["mail"],
|
|
}
|
|
],
|
|
"post_install_tasks": [],
|
|
}
|
|
)
|
|
|
|
|
|
class MailConfigurationProviderTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self.tempdir = tempfile.TemporaryDirectory(prefix="govoplan-mail-config-")
|
|
self.addCleanup(self.tempdir.cleanup)
|
|
database_path = Path(self.tempdir.name) / "mail.sqlite3"
|
|
self.engine = create_engine(f"sqlite:///{database_path}")
|
|
Base.metadata.create_all(
|
|
self.engine,
|
|
tables=(
|
|
access_models.User.__table__,
|
|
SystemSettings.__table__,
|
|
CredentialEnvelope.__table__,
|
|
MailServerProfile.__table__,
|
|
MailServerEndpoint.__table__,
|
|
MailServerCredentialBinding.__table__,
|
|
MailProfilePolicy.__table__,
|
|
),
|
|
)
|
|
configure_database(
|
|
f"sqlite:///{database_path}",
|
|
engine=self.engine,
|
|
dispose_previous=True,
|
|
)
|
|
self.SessionLocal = sessionmaker(
|
|
bind=self.engine,
|
|
class_=Session,
|
|
expire_on_commit=False,
|
|
)
|
|
with self.SessionLocal() as session:
|
|
session.add(
|
|
MailProfilePolicy(
|
|
id="tenant-policy",
|
|
tenant_id="tenant-1",
|
|
scope_type="tenant",
|
|
scope_id="tenant-1",
|
|
policy={},
|
|
)
|
|
)
|
|
session.add(
|
|
CredentialEnvelope(
|
|
id="credential-1",
|
|
tenant_id="tenant-1",
|
|
scope_type="tenant",
|
|
scope_id="tenant-1",
|
|
name="SMTP credential",
|
|
credential_kind="username_password",
|
|
public_data={"username": "mailer"},
|
|
secret_data_encrypted="encrypted-outside-package",
|
|
secret_keys=["password"],
|
|
allowed_modules=["mail"],
|
|
allowed_server_refs=[],
|
|
inherit_to_lower_scopes=True,
|
|
is_active=True,
|
|
)
|
|
)
|
|
session.commit()
|
|
self.addCleanup(self._cleanup_database)
|
|
self.provider = SqlMailConfigurationProvider()
|
|
self.context = ConfigurationPreflightContext(
|
|
tenant_id="tenant-1",
|
|
operator_user_id=None,
|
|
infrastructure_receipt=_receipt(),
|
|
)
|
|
|
|
def _cleanup_database(self) -> None:
|
|
reset_database()
|
|
self.engine.dispose()
|
|
|
|
def test_provider_is_registered_and_describes_receipt_bound_fragment(self) -> None:
|
|
self.assertIn(MAIL_CONFIGURATION_CAPABILITY, manifest.capability_factories)
|
|
self.assertIn(
|
|
MAIL_INFRASTRUCTURE_DEPENDENCY_CAPABILITY,
|
|
manifest.capability_factories,
|
|
)
|
|
description = self.provider.describe()
|
|
self.assertEqual(("smtp_profile",), description.fragment_types)
|
|
|
|
def test_inventory_reports_actual_smtp_endpoint_and_credential_binding(self) -> None:
|
|
fragment = ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type="smtp_profile",
|
|
fragment_id="inventory-smtp",
|
|
payload={"credential_envelope_id": "credential-1"},
|
|
)
|
|
self.provider.apply(fragment, {}, self.context)
|
|
|
|
dependencies = self.provider.infrastructure_dependencies()
|
|
|
|
self.assertEqual(1, len(dependencies))
|
|
self.assertEqual("mail.smtp", dependencies[0].capability_id)
|
|
self.assertEqual("smtp_endpoint", dependencies[0].dependency_type)
|
|
self.assertEqual(1, dependencies[0].metrics["credential_binding_count"])
|
|
self.assertNotIn("test-mail", str(dependencies[0].to_dict()))
|
|
|
|
def test_apply_is_idempotent_and_binds_existing_credential_envelope(self) -> None:
|
|
fragment = ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type="smtp_profile",
|
|
fragment_id="test-smtp",
|
|
payload={"credential_envelope_id": "credential-1"},
|
|
)
|
|
|
|
first_plan = self.provider.preflight(fragment, self.context)
|
|
self.assertEqual("create", first_plan.plan[0].action)
|
|
self.assertFalse(
|
|
any(item.severity == "blocker" for item in first_plan.diagnostics)
|
|
)
|
|
first_apply = self.provider.apply(fragment, {}, self.context)
|
|
self.assertIn("test-smtp", first_apply.created_refs)
|
|
|
|
second_plan = self.provider.preflight(fragment, self.context)
|
|
second_apply = self.provider.apply(fragment, {}, self.context)
|
|
|
|
self.assertEqual("skip", second_plan.plan[0].action)
|
|
self.assertEqual({}, second_apply.created_refs)
|
|
self.assertEqual({}, second_apply.updated_refs)
|
|
with self.SessionLocal() as session:
|
|
profiles = session.scalars(select(MailServerProfile)).all()
|
|
servers = session.scalars(select(MailServerEndpoint)).all()
|
|
bindings = session.scalars(select(MailServerCredentialBinding)).all()
|
|
self.assertEqual(1, len(profiles))
|
|
self.assertEqual("test-mail", profiles[0].smtp_config["host"])
|
|
self.assertEqual("plain", profiles[0].smtp_config["security"])
|
|
self.assertEqual(1, len(servers))
|
|
self.assertEqual("test-mail", servers[0].config["host"])
|
|
self.assertEqual(1, len(bindings))
|
|
self.assertEqual("credential-1", bindings[0].credential_id)
|
|
|
|
def test_conflicting_existing_profile_is_preserved_by_default(self) -> None:
|
|
fragment = ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type="smtp_profile",
|
|
fragment_id="test-smtp",
|
|
payload={},
|
|
)
|
|
self.provider.apply(fragment, {}, self.context)
|
|
with self.SessionLocal() as session:
|
|
server = session.scalar(select(MailServerEndpoint))
|
|
assert server is not None
|
|
server.config = {**server.config, "host": "manually-changed.example.test"}
|
|
session.commit()
|
|
|
|
plan = self.provider.preflight(fragment, self.context)
|
|
result = self.provider.apply(fragment, {}, self.context)
|
|
|
|
self.assertEqual("blocked", plan.plan[0].action)
|
|
self.assertIn(
|
|
"mail_configuration_conflict",
|
|
{item.code for item in result.diagnostics},
|
|
)
|
|
with self.SessionLocal() as session:
|
|
server = session.scalar(select(MailServerEndpoint))
|
|
assert server is not None
|
|
self.assertEqual("manually-changed.example.test", server.config["host"])
|
|
|
|
def test_explicit_conflict_update_reconciles_then_becomes_noop(self) -> None:
|
|
fragment = ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type="smtp_profile",
|
|
fragment_id="test-smtp",
|
|
payload={},
|
|
)
|
|
self.provider.apply(fragment, {}, self.context)
|
|
with self.SessionLocal() as session:
|
|
server = session.scalar(select(MailServerEndpoint))
|
|
assert server is not None
|
|
server.config = {**server.config, "host": "manually-changed.example.test"}
|
|
session.commit()
|
|
update_fragment = replace(
|
|
fragment,
|
|
payload={"on_conflict": "update"},
|
|
)
|
|
|
|
plan = self.provider.preflight(update_fragment, self.context)
|
|
result = self.provider.apply(update_fragment, {}, self.context)
|
|
settled = self.provider.preflight(update_fragment, self.context)
|
|
|
|
self.assertEqual("update", plan.plan[0].action)
|
|
self.assertIn("test-smtp", result.updated_refs)
|
|
self.assertEqual("skip", settled.plan[0].action)
|
|
with self.SessionLocal() as session:
|
|
server = session.scalar(select(MailServerEndpoint))
|
|
assert server is not None
|
|
self.assertEqual("test-mail", server.config["host"])
|
|
|
|
def test_inline_credentials_are_rejected(self) -> None:
|
|
fragment = ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type="smtp_profile",
|
|
payload={
|
|
"smtp": {
|
|
"host": "test-mail",
|
|
"port": 3025,
|
|
"security": "plain",
|
|
"password": "must-not-cross-boundary",
|
|
}
|
|
},
|
|
)
|
|
|
|
plan = self.provider.preflight(fragment, self.context)
|
|
|
|
self.assertEqual("blocked", plan.plan[0].action)
|
|
self.assertIn(
|
|
"mail_configuration_secret_forbidden",
|
|
{item.code for item in plan.diagnostics},
|
|
)
|
|
|
|
def test_external_relay_collects_missing_non_secret_inputs(self) -> None:
|
|
fragment = ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type="smtp_profile",
|
|
fragment_id="external-smtp",
|
|
payload={},
|
|
)
|
|
missing = self.provider.preflight(
|
|
fragment,
|
|
replace(self.context, infrastructure_receipt=_external_receipt()),
|
|
)
|
|
ready = self.provider.preflight(
|
|
fragment,
|
|
replace(
|
|
self.context,
|
|
infrastructure_receipt=_external_receipt(),
|
|
supplied_data={
|
|
"mail.smtp.external-smtp.host": "smtp.example.test",
|
|
"mail.smtp.external-smtp.port": 587,
|
|
"mail.smtp.external-smtp.security": "starttls",
|
|
},
|
|
),
|
|
)
|
|
|
|
self.assertEqual("blocked", missing.plan[0].action)
|
|
self.assertEqual(
|
|
{
|
|
"mail.smtp.external-smtp.host",
|
|
"mail.smtp.external-smtp.port",
|
|
"mail.smtp.external-smtp.security",
|
|
},
|
|
{item.key for item in missing.required_data if item.required},
|
|
)
|
|
self.assertEqual("create", ready.plan[0].action)
|
|
|
|
def test_receipt_secret_reference_requires_credential_envelope_reference(
|
|
self,
|
|
) -> None:
|
|
fragment = ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type="smtp_profile",
|
|
fragment_id="authenticated-smtp",
|
|
payload={
|
|
"smtp": {
|
|
"host": "smtp.example.test",
|
|
"port": 587,
|
|
"security": "starttls",
|
|
}
|
|
},
|
|
)
|
|
context = replace(
|
|
self.context,
|
|
infrastructure_receipt=_external_receipt(credential_required=True),
|
|
)
|
|
|
|
missing = self.provider.preflight(fragment, context)
|
|
ready = self.provider.preflight(
|
|
ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type="smtp_profile",
|
|
fragment_id="authenticated-smtp",
|
|
payload={
|
|
**fragment.payload,
|
|
"credential_envelope_id": "credential-1",
|
|
},
|
|
),
|
|
context,
|
|
)
|
|
|
|
self.assertEqual("blocked", missing.plan[0].action)
|
|
self.assertIn(
|
|
"mail.smtp.authenticated-smtp.credential_envelope_id",
|
|
{item.key for item in missing.required_data if item.required},
|
|
)
|
|
self.assertEqual("create", ready.plan[0].action)
|
|
|
|
def test_system_profile_requires_system_configuration_authority(self) -> None:
|
|
fragment = ConfigurationPackageFragment(
|
|
module_id="mail",
|
|
fragment_type="smtp_profile",
|
|
payload={"profile": {"scope_type": "system"}},
|
|
)
|
|
|
|
blocked = self.provider.preflight(fragment, self.context)
|
|
ready = self.provider.preflight(
|
|
fragment,
|
|
replace(
|
|
self.context,
|
|
operator_scopes=frozenset({"system:settings:write"}),
|
|
),
|
|
)
|
|
|
|
self.assertEqual("blocked", blocked.plan[0].action)
|
|
self.assertIn(
|
|
"system_configuration_authority_required",
|
|
{item.code for item in blocked.diagnostics},
|
|
)
|
|
self.assertEqual("create", ready.plan[0].action)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|