feat: provision SMTP profiles from deployment receipts
This commit is contained in:
@@ -43,6 +43,28 @@ session primitives, cryptographic secret helpers, audit infrastructure, and the
|
|||||||
module registry. Optional consumers provide narrow context through capabilities;
|
module registry. Optional consumers provide narrow context through capabilities;
|
||||||
Mail does not import their ORM or service implementations.
|
Mail does not import their ORM or service implementations.
|
||||||
|
|
||||||
|
## Deployment configuration packages
|
||||||
|
|
||||||
|
Mail registers the `mail.configuration` capability for `smtp_profile`
|
||||||
|
fragments. The provider reads the validated `mail.smtp` entry from the
|
||||||
|
installer-generated infrastructure capability receipt. Receipt host and port
|
||||||
|
are authoritative; the generic package workflow asks for missing non-secret
|
||||||
|
transport fields such as security mode. An external relay may operate without
|
||||||
|
authentication, or the operator may select an existing credential-envelope id.
|
||||||
|
Inline usernames, passwords, tokens, and secret values are rejected.
|
||||||
|
|
||||||
|
Tenant scope is the default. A system-scoped profile requires system settings
|
||||||
|
or governance write authority. The fragment's stable slug is its idempotency
|
||||||
|
identity: an absent profile is created, an exact profile is skipped, and a
|
||||||
|
conflicting profile is preserved unless the reviewed fragment explicitly sets
|
||||||
|
`on_conflict` to `update`. Credential bindings are added idempotently and are
|
||||||
|
never removed merely because a package omits a credential reference.
|
||||||
|
|
||||||
|
Preflight does not prove SMTP reachability. After apply, use the normal Mail
|
||||||
|
profile test and Ops health surfaces. If the receipt says SMTP is unavailable,
|
||||||
|
is invalid, or is not mounted, import is blocked with an operator-facing
|
||||||
|
resolution instead of creating a partial profile.
|
||||||
|
|
||||||
## Interface patterns and unavailable actions
|
## Interface patterns and unavailable actions
|
||||||
|
|
||||||
Mail uses the platform's shared explorer, connection tree, adaptive form,
|
Mail uses the platform's shared explorer, connection tree, adaptive form,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,7 @@ from govoplan_core.core.provider_governance import (
|
|||||||
from govoplan_core.core.search import SearchSourceProviderRegistration
|
from govoplan_core.core.search import SearchSourceProviderRegistration
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
|
from govoplan_mail.backend.configuration_provider import MAIL_CONFIGURATION_CAPABILITY
|
||||||
from govoplan_mail.backend.documentation import (
|
from govoplan_mail.backend.documentation import (
|
||||||
documentation_configuration_states,
|
documentation_configuration_states,
|
||||||
documentation_topics,
|
documentation_topics,
|
||||||
@@ -95,6 +96,13 @@ def _mail_retirement_provider(session: object | None, module_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_provider(context: ModuleContext) -> object:
|
||||||
|
del context
|
||||||
|
from govoplan_mail.backend.configuration_provider import SqlMailConfigurationProvider
|
||||||
|
|
||||||
|
return SqlMailConfigurationProvider()
|
||||||
|
|
||||||
|
|
||||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||||
module_id, resource, action = scope.split(":", 2)
|
module_id, resource, action = scope.split(":", 2)
|
||||||
return PermissionDefinition(
|
return PermissionDefinition(
|
||||||
@@ -432,6 +440,7 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
capability_factories={
|
capability_factories={
|
||||||
|
MAIL_CONFIGURATION_CAPABILITY: _configuration_provider,
|
||||||
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
||||||
CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__(
|
CAPABILITY_MAIL_DELIVERY_OUTBOX: lambda context: __import__(
|
||||||
"govoplan_mail.backend.capabilities",
|
"govoplan_mail.backend.capabilities",
|
||||||
@@ -447,6 +456,57 @@ manifest = ModuleManifest(
|
|||||||
).SqlMailBounceProcessingProvider(),
|
).SqlMailBounceProcessingProvider(),
|
||||||
},
|
},
|
||||||
documentation=(
|
documentation=(
|
||||||
|
DocumentationTopic(
|
||||||
|
id="mail.configuration-package.smtp-profile",
|
||||||
|
title="Provision SMTP profiles from deployment receipts",
|
||||||
|
summary="Review and apply an idempotent Mail profile without moving plaintext credentials through a package.",
|
||||||
|
body=(
|
||||||
|
"The Mail configuration provider reads the validated mail.smtp deployment capability and derives authoritative endpoint metadata. "
|
||||||
|
"Missing non-secret transport fields are collected by preflight; authentication is represented only by an existing credential-envelope id. "
|
||||||
|
"Tenant scope is the default, system scope requires system authority, conflicting profiles are preserved unless update is explicitly reviewed, "
|
||||||
|
"and an unchanged second apply is a no-op. SMTP reachability remains a separate Mail profile test."
|
||||||
|
),
|
||||||
|
layer="configured",
|
||||||
|
documentation_types=("admin",),
|
||||||
|
audience=("mail_admin", "administrator", "operator"),
|
||||||
|
related_modules=("core", "ops", "access"),
|
||||||
|
conditions=(
|
||||||
|
DocumentationCondition(
|
||||||
|
required_modules=("mail", "access"),
|
||||||
|
any_scopes=("admin:settings:read", "system:settings:read"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
links=(
|
||||||
|
DocumentationLink(
|
||||||
|
label="Configuration packages",
|
||||||
|
href="/admin?section=configuration-packages",
|
||||||
|
kind="runtime",
|
||||||
|
),
|
||||||
|
DocumentationLink(
|
||||||
|
label="Mail handbook",
|
||||||
|
href="govoplan-mail/docs/MAIL_HANDBOOK.md",
|
||||||
|
kind="repository",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "SMTP-Profile aus Bereitstellungsnachweisen einrichten",
|
||||||
|
"summary": "Ein idempotentes Mail-Profil prüfen und anwenden, ohne Klartext-Zugangsdaten durch ein Konfigurationspaket zu übertragen.",
|
||||||
|
"body": (
|
||||||
|
"Der Mail-Konfigurationsprovider liest die validierte Bereitstellungsfähigkeit mail.smtp und übernimmt deren maßgebliche Endpunktdaten. "
|
||||||
|
"Fehlende nicht geheime Transportangaben werden im Preflight abgefragt; Authentifizierung wird ausschließlich durch die ID eines vorhandenen Zugangsdaten-Umschlags referenziert. "
|
||||||
|
"Mandantenbezug ist der Standard, Systembezug erfordert Systemberechtigung, abweichende vorhandene Profile bleiben ohne ausdrücklich geprüfte Aktualisierung unverändert, "
|
||||||
|
"und eine unveränderte zweite Anwendung bleibt wirkungslos. Die SMTP-Erreichbarkeit wird weiterhin separat im Mail-Profil getestet."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
metadata={
|
||||||
|
"kind": "workflow",
|
||||||
|
"route": "/admin?section=configuration-packages",
|
||||||
|
"help_contexts": ["admin.configuration-packages", "mail.admin.profiles"],
|
||||||
|
},
|
||||||
|
order=4,
|
||||||
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
id="mail.quick-access-and-product-area",
|
id="mail.quick-access-and-product-area",
|
||||||
title="Mail in Communication and Messages",
|
title="Mail in Communication and Messages",
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
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,
|
||||||
|
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)
|
||||||
|
description = self.provider.describe()
|
||||||
|
self.assertEqual(("smtp_profile",), description.fragment_types)
|
||||||
|
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user