Compare commits
2 Commits
3075ef7f5b
...
01ef541917
| Author | SHA1 | Date | |
|---|---|---|---|
| 01ef541917 | |||
| d567257311 |
@@ -44,6 +44,57 @@ If the smoke is started immediately after `docker compose up -d`, GreenMail may
|
|||||||
bind the SMTP/IMAP ports before the services are fully ready. The smoke retries
|
bind the SMTP/IMAP ports before the services are fully ready. The smoke retries
|
||||||
login and folder setup for `GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS`.
|
login and folder setup for `GOVOPLAN_MAIL_TEST_READY_TIMEOUT_SECONDS`.
|
||||||
|
|
||||||
|
## Campaign Acceptance
|
||||||
|
|
||||||
|
The transport smoke proves the Mail adapters. The Campaign acceptance runner
|
||||||
|
proves the public composition: it creates an isolated temporary Core database,
|
||||||
|
creates a Mail-owned encrypted profile through the API, materializes the
|
||||||
|
credential-free [`greenmail-delivery`](../../examples/greenmail-delivery/campaign.json)
|
||||||
|
fixture with only that profile reference, validates/builds it, sends the exact
|
||||||
|
generated EML through Campaign once, appends it once, and cross-checks Campaign
|
||||||
|
report/audit state with one unique-subject message in the GreenMail INBOX and
|
||||||
|
Sent folders. Provider mailbox verification is not a byte-for-byte comparison
|
||||||
|
after provider-side header or storage transformations.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-campaign/dev/mail-testbed
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
/mnt/DATA/git/govoplan/.venv/bin/python run_campaign_acceptance.py \
|
||||||
|
--evidence /tmp/govoplan-campaign-greenmail-evidence.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The default run also uses controlled loopback protocol endpoints to prove that
|
||||||
|
an SMTP connection loss before transmission is temporary, an explicit SMTP
|
||||||
|
authentication rejection is permanent, and an IMAP authentication rejection
|
||||||
|
after SMTP acceptance leaves the send accepted while the append fails. A
|
||||||
|
second ordinary send must be rejected before another provider effect.
|
||||||
|
|
||||||
|
The bounded JSON contains no endpoint, account, address, credential, profile,
|
||||||
|
campaign, version, or job identifiers. It records module versions, the fixture
|
||||||
|
hash, normalized classifications/counts, the Mail-profile boundary, required
|
||||||
|
audit actions, provider mailbox increments, and coverage flags. Runtime version
|
||||||
|
declarations identify the exercised composition; they do not claim that the
|
||||||
|
sources are clean, tagged, signed, or release-provenanced. The evidence names
|
||||||
|
them `declared_module_versions` and keeps `source_artifact_provenance` false;
|
||||||
|
exact commit/artifact provenance belongs to the package and release gate.
|
||||||
|
|
||||||
|
This runner is restricted to literal loopback IP addresses and the synchronous
|
||||||
|
Campaign delivery mode. Hostnames such as `localhost` and every non-loopback
|
||||||
|
address fail before profile creation, avoiding a DNS change between validation
|
||||||
|
and connection. It is local target-like evidence, not approval of an
|
||||||
|
institution's SMTP/IMAP service. A post-DATA connection loss
|
||||||
|
with ambiguous SMTP outcome, an explicit temporary SMTP response, partial
|
||||||
|
recipient refusal, and a real worker restart remain separate drills and are
|
||||||
|
explicitly `false` in the coverage projection. Use `--success-only` only when
|
||||||
|
testing the success journey without the local failure endpoints.
|
||||||
|
|
||||||
|
Starting the maintained test bed requires a working Docker CLI, Compose plugin,
|
||||||
|
daemon/socket access, and permission to pull `greenmail/standalone:2.1.9`. The
|
||||||
|
Campaign runner needs only the already-running loopback endpoints; it neither
|
||||||
|
starts Docker nor claims that it did.
|
||||||
|
|
||||||
## Use With A Campaign
|
## Use With A Campaign
|
||||||
|
|
||||||
Use the same settings in a campaign mail profile:
|
Use the same settings in a campaign mail profile:
|
||||||
|
|||||||
1312
dev/mail-testbed/run_campaign_acceptance.py
Normal file
1312
dev/mail-testbed/run_campaign_acceptance.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,19 @@ bed where possible:
|
|||||||
- IMAP append failure after SMTP acceptance.
|
- IMAP append failure after SMTP acceptance.
|
||||||
- Worker restart with queued, claimed, and sending jobs.
|
- Worker restart with queued, claimed, and sending jobs.
|
||||||
|
|
||||||
|
For the maintained loopback baseline, run
|
||||||
|
`dev/mail-testbed/run_campaign_acceptance.py`. It proves the public Campaign
|
||||||
|
path for SMTP acceptance, IMAP append, repeat-send blocking, an SMTP connection
|
||||||
|
failure before transmission, an explicit SMTP authentication rejection, and an
|
||||||
|
IMAP authentication rejection after SMTP acceptance. Its evidence is an
|
||||||
|
allowlisted classification/count projection; raw provider diagnostics and
|
||||||
|
transport/account identifiers are deliberately excluded.
|
||||||
|
|
||||||
|
The runner does not simulate a connection loss after SMTP DATA and does not
|
||||||
|
restart a real worker. Keep those two checklist items open until a controlled
|
||||||
|
fault proxy/target provider and supervised worker environment can produce the
|
||||||
|
corresponding `outcome_unknown`, restart, retry, and reconciliation evidence.
|
||||||
|
|
||||||
## Reporting Checks
|
## Reporting Checks
|
||||||
|
|
||||||
- Partial delivery must show accepted, failed, and unknown counts separately.
|
- Partial delivery must show accepted, failed, and unknown counts separately.
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ schema and are safe to run in non-production environments.
|
|||||||
- campaign with blocked recipients or attachment errors that must not be sent
|
- campaign with blocked recipients or attachment errors that must not be sent
|
||||||
- mock delivery campaign that captures SMTP and IMAP append messages in the mail
|
- mock delivery campaign that captures SMTP and IMAP append messages in the mail
|
||||||
development mailbox
|
development mailbox
|
||||||
- real non-production delivery campaign against the GreenMail test bed
|
- [`greenmail-delivery`](../examples/greenmail-delivery/campaign.json), a
|
||||||
|
credential-free real-delivery Campaign materialized with a temporary
|
||||||
|
Mail-owned profile by the loopback acceptance runner
|
||||||
|
|
||||||
## Fixture Rules
|
## Fixture Rules
|
||||||
|
|
||||||
@@ -53,6 +55,13 @@ Before tagging a campaign release:
|
|||||||
- Run the mock delivery example when the mail development mailbox capability is
|
- Run the mock delivery example when the mail development mailbox capability is
|
||||||
enabled.
|
enabled.
|
||||||
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path.
|
- Run the GreenMail SMTP/IMAP smoke for a non-production real delivery path.
|
||||||
|
- Run `dev/mail-testbed/run_campaign_acceptance.py` and retain its bounded JSON
|
||||||
|
projection. It must show one SMTP acceptance, one IMAP append, no duplicate
|
||||||
|
effect from a repeated ordinary send, matching Campaign report/audit state,
|
||||||
|
and no resolved transport material in Campaign JSON or its execution
|
||||||
|
snapshot.
|
||||||
|
- Do not interpret the local failure drills as post-DATA ambiguity or worker
|
||||||
|
restart proof; the evidence coverage flags must keep those gaps explicit.
|
||||||
- Confirm reusable mail profile selection is revalidated after campaign owner
|
- Confirm reusable mail profile selection is revalidated after campaign owner
|
||||||
transfer.
|
transfer.
|
||||||
- Confirm every inline SMTP/IMAP field is rejected on import/write, omitted
|
- Confirm every inline SMTP/IMAP field is rejected on import/write, omitted
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ campaign schema and do not require production data.
|
|||||||
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. |
|
| `warnings-review` | core, access, campaigns | Require explicit review before queueing jobs with warnings. |
|
||||||
| `blocked-send` | core, access, campaigns | Confirm blocked recipients or missing attachments cannot be queued. |
|
| `blocked-send` | core, access, campaigns | Confirm blocked recipients or missing attachments cannot be queued. |
|
||||||
| `mock-delivery` | core, access, campaigns, mail with dev capability | Capture messages in the development mailbox. |
|
| `mock-delivery` | core, access, campaigns, mail with dev capability | Capture messages in the development mailbox. |
|
||||||
| `greenmail-delivery` | core, access, campaigns, mail | Send no-attachment, normal attachment, and ZIP attachment variants through `dev/mail-testbed`. |
|
| [`greenmail-delivery`](greenmail-delivery/campaign.json) | core, access, audit, campaigns, mail | Run a credential-free Campaign through a Mail-owned profile, GreenMail SMTP/IMAP, report/audit checks, repeat-send protection, and bounded failure drills. |
|
||||||
|
|
||||||
## Fixture Rules
|
## Fixture Rules
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ Before a release tag:
|
|||||||
3. Validate every committed example fixture against the current campaign schema.
|
3. Validate every committed example fixture against the current campaign schema.
|
||||||
4. Build exact messages for each fixture.
|
4. Build exact messages for each fixture.
|
||||||
5. Run the mock-delivery example when the dev mailbox capability is enabled.
|
5. Run the mock-delivery example when the dev mailbox capability is enabled.
|
||||||
6. Run `dev/mail-testbed/run_transport_smoke.py`.
|
6. Run `dev/mail-testbed/run_transport_smoke.py` for low-level transport and attachment variants.
|
||||||
7. Execute the delivery checklist in
|
7. Run `dev/mail-testbed/run_campaign_acceptance.py` for the Campaign journey and bounded evidence.
|
||||||
|
8. Execute the delivery checklist in
|
||||||
`docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md`.
|
`docs/EXAMPLE_CAMPAIGNS_AND_RELEASE_CHECKLIST.md`.
|
||||||
|
|||||||
88
examples/greenmail-delivery/campaign.json
Normal file
88
examples/greenmail-delivery/campaign.json
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0",
|
||||||
|
"campaign": {
|
||||||
|
"id": "greenmail-delivery",
|
||||||
|
"name": "GreenMail delivery acceptance",
|
||||||
|
"description": "Credential-free Campaign fixture for the local SMTP/IMAP acceptance test bed.",
|
||||||
|
"mode": "test"
|
||||||
|
},
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "display_name",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Display name",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "acceptance_run",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Acceptance run",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"server": {
|
||||||
|
"mail_profile_id": "00000000-0000-4000-8000-000000000001"
|
||||||
|
},
|
||||||
|
"recipients": {
|
||||||
|
"from": [
|
||||||
|
{
|
||||||
|
"email": "campaign-test@govoplan.test",
|
||||||
|
"name": "GovOPlaN acceptance",
|
||||||
|
"type": "to"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"allow_individual_to": true
|
||||||
|
},
|
||||||
|
"template": {
|
||||||
|
"subject": "[GovOPlaN acceptance ${acceptance_run}] Campaign delivery",
|
||||||
|
"text": "Hello ${display_name},\n\nThis is an isolated GovOPlaN Campaign SMTP/IMAP acceptance message.\n",
|
||||||
|
"body_mode": "text"
|
||||||
|
},
|
||||||
|
"attachments": {
|
||||||
|
"base_path": ".",
|
||||||
|
"send_without_attachments_behavior": "continue",
|
||||||
|
"global": []
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"inline": [
|
||||||
|
{
|
||||||
|
"id": "greenmail-recipient",
|
||||||
|
"to": [
|
||||||
|
{
|
||||||
|
"email": "campaign-test@govoplan.test",
|
||||||
|
"name": "GreenMail recipient",
|
||||||
|
"type": "to"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fields": {
|
||||||
|
"display_name": "GreenMail recipient",
|
||||||
|
"acceptance_run": "fixture"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"validation_policy": {
|
||||||
|
"missing_email": "block",
|
||||||
|
"template_error": "block"
|
||||||
|
},
|
||||||
|
"delivery": {
|
||||||
|
"rate_limit": {
|
||||||
|
"messages_per_minute": 60
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"max_attempts": 3,
|
||||||
|
"backoff_seconds": [
|
||||||
|
1,
|
||||||
|
5,
|
||||||
|
30
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"imap_append_sent": {
|
||||||
|
"enabled": true,
|
||||||
|
"folder": "Sent"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"status_tracking": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
22
examples/greenmail-delivery/fixture.json
Normal file
22
examples/greenmail-delivery/fixture.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"scenario": "greenmail-delivery",
|
||||||
|
"campaign_file": "campaign.json",
|
||||||
|
"required_modules": [
|
||||||
|
"core",
|
||||||
|
"access",
|
||||||
|
"audit",
|
||||||
|
"campaigns",
|
||||||
|
"mail"
|
||||||
|
],
|
||||||
|
"required_capabilities": [
|
||||||
|
"mail.campaign_delivery"
|
||||||
|
],
|
||||||
|
"transport": "local GreenMail SMTP/IMAP test bed",
|
||||||
|
"credentials": "local environment only; never copied into Campaign JSON or evidence",
|
||||||
|
"expected": {
|
||||||
|
"entries_count": 1,
|
||||||
|
"built_count": 1,
|
||||||
|
"smtp_accepted_count": 1,
|
||||||
|
"imap_appended_count": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
388
tests/test_mail_testbed_acceptance.py
Normal file
388
tests/test_mail_testbed_acceptance.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.campaign import load_campaign_config
|
||||||
|
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
RUNNER_PATH = REPOSITORY_ROOT / "dev" / "mail-testbed" / "run_campaign_acceptance.py"
|
||||||
|
FIXTURE_PATH = REPOSITORY_ROOT / "examples" / "greenmail-delivery" / "campaign.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_runner():
|
||||||
|
spec = importlib.util.spec_from_file_location("govoplan_campaign_greenmail_acceptance", RUNNER_PATH)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
runner = _load_runner()
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
def __init__(self, status_code: int, payload: dict[str, Any]) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def json(self) -> dict[str, Any]:
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class _AcceptanceClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.campaign_json: dict[str, Any] | None = None
|
||||||
|
self.send_calls = 0
|
||||||
|
|
||||||
|
def post(self, path: str, **kwargs: Any) -> _Response:
|
||||||
|
if path == "/api/v1/campaigns":
|
||||||
|
self.campaign_json = kwargs["json"]["config"]
|
||||||
|
return _Response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"campaign": {"id": "campaign-internal"},
|
||||||
|
"version": {"id": "version-internal"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if path.endswith("/validate"):
|
||||||
|
return _Response(200, {"ok": True, "error_count": 0, "warning_count": 0})
|
||||||
|
if path.endswith("/build"):
|
||||||
|
return _Response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"built_count": 1,
|
||||||
|
"build_failed_count": 0,
|
||||||
|
"queueable_count": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if path.endswith("/send-now"):
|
||||||
|
self.send_calls += 1
|
||||||
|
if self.send_calls == 2:
|
||||||
|
return _Response(422, {"detail": "Already accepted"})
|
||||||
|
return _Response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"attempted_count": 1,
|
||||||
|
"sent_count": 1,
|
||||||
|
"failed_count": 0,
|
||||||
|
"outcome_unknown_count": 0,
|
||||||
|
"skipped_count": 0,
|
||||||
|
"delivery_mode": "synchronous",
|
||||||
|
"results": [{"job_id": "not-retained", "status": "smtp_accepted"}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if path.endswith("/append-sent"):
|
||||||
|
return _Response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"result": {
|
||||||
|
"pending_count": 1,
|
||||||
|
"processed_count": 1,
|
||||||
|
"appended_count": 1,
|
||||||
|
"failed_count": 0,
|
||||||
|
"skipped_count": 0,
|
||||||
|
"results": [{"job_id": "not-retained", "status": "appended"}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise AssertionError(f"unexpected POST {path}")
|
||||||
|
|
||||||
|
def get(self, path: str, **kwargs: Any) -> _Response:
|
||||||
|
if path.endswith("/report"):
|
||||||
|
return _Response(
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
"cards": {
|
||||||
|
"jobs_total": 1,
|
||||||
|
"sent": 1,
|
||||||
|
"smtp_accepted": 1,
|
||||||
|
"failed": 0,
|
||||||
|
"outcome_unknown": 0,
|
||||||
|
"retryable": 0,
|
||||||
|
"needs_attention": 0,
|
||||||
|
"imap_appended": 1,
|
||||||
|
"imap_failed": 0,
|
||||||
|
},
|
||||||
|
"status_counts": {
|
||||||
|
"send": {"smtp_accepted": 1},
|
||||||
|
"imap": {"appended": 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise AssertionError(f"unexpected GET {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _settings():
|
||||||
|
return runner.TestbedSettings(
|
||||||
|
smtp_host="127.0.0.1",
|
||||||
|
smtp_port=3025,
|
||||||
|
imap_host="127.0.0.1",
|
||||||
|
imap_port=3143,
|
||||||
|
username="campaign-test@govoplan.test",
|
||||||
|
password="local-test-password",
|
||||||
|
sender="campaign-test@govoplan.test",
|
||||||
|
recipient="campaign-test@govoplan.test",
|
||||||
|
sent_folder="Sent",
|
||||||
|
provider_timeout_seconds=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", ["localhost", "mail.test", "192.168.1.20", "8.8.8.8"])
|
||||||
|
def test_testbed_rejects_hostnames_and_non_loopback_addresses(host: str) -> None:
|
||||||
|
settings = _settings()
|
||||||
|
rejected = runner.TestbedSettings(
|
||||||
|
smtp_host=host,
|
||||||
|
smtp_port=settings.smtp_port,
|
||||||
|
imap_host=settings.imap_host,
|
||||||
|
imap_port=settings.imap_port,
|
||||||
|
username=settings.username,
|
||||||
|
password=settings.password,
|
||||||
|
sender=settings.sender,
|
||||||
|
recipient=settings.recipient,
|
||||||
|
sent_folder=settings.sent_folder,
|
||||||
|
provider_timeout_seconds=settings.provider_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(runner.AcceptanceError, match="literal loopback|restricted to the loopback"):
|
||||||
|
rejected.assert_local_testbed()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("host", ["127.0.0.1", "127.8.9.10", "::1"])
|
||||||
|
def test_testbed_accepts_literal_loopback_and_preserves_it_in_profile(host: str) -> None:
|
||||||
|
settings = _settings()
|
||||||
|
accepted = runner.TestbedSettings(
|
||||||
|
smtp_host=host,
|
||||||
|
smtp_port=settings.smtp_port,
|
||||||
|
imap_host=host,
|
||||||
|
imap_port=settings.imap_port,
|
||||||
|
username=settings.username,
|
||||||
|
password=settings.password,
|
||||||
|
sender=settings.sender,
|
||||||
|
recipient=settings.recipient,
|
||||||
|
sent_folder=settings.sent_folder,
|
||||||
|
provider_timeout_seconds=settings.provider_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
accepted.assert_local_testbed()
|
||||||
|
profile = runner._profile_payload(accepted, name="Literal loopback")
|
||||||
|
assert profile["smtp"]["host"] == host
|
||||||
|
assert profile["imap"]["host"] == host
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_acceptance_orchestration_retains_only_profile_reference_and_safe_evidence() -> None:
|
||||||
|
client = _AcceptanceClient()
|
||||||
|
|
||||||
|
def snapshot_probe(_version_id: str):
|
||||||
|
assert client.campaign_json is not None
|
||||||
|
return client.campaign_json, {
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"smtp_transport_revision": "opaque-smtp-revision",
|
||||||
|
"imap_transport_revision": "opaque-imap-revision",
|
||||||
|
"delivery": {"imap_append_sent": {"enabled": True, "folder": "Sent"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
audit = {
|
||||||
|
"campaign.created": 1,
|
||||||
|
"campaign.validated": 1,
|
||||||
|
"campaign.messages_built": 1,
|
||||||
|
"campaign.sent_now": 1,
|
||||||
|
"campaign.send_now_rejected": 1,
|
||||||
|
"campaign.append_sent_enqueued": 1,
|
||||||
|
}
|
||||||
|
evidence, subject = runner.execute_campaign_scenario(
|
||||||
|
client,
|
||||||
|
{"Authorization": "not-retained"},
|
||||||
|
fixture_path=FIXTURE_PATH,
|
||||||
|
profile_id="profile-1",
|
||||||
|
settings=_settings(),
|
||||||
|
scenario="success",
|
||||||
|
snapshot_probe=snapshot_probe,
|
||||||
|
audit_probe=lambda _campaign_id, _version_id: audit,
|
||||||
|
append_sent=True,
|
||||||
|
repeat_send=True,
|
||||||
|
)
|
||||||
|
evidence["provider_verification"] = {
|
||||||
|
"inbox_increment": 1,
|
||||||
|
"sent_increment": 1,
|
||||||
|
"unique_subject_matches_in_inbox": 1,
|
||||||
|
"unique_subject_matches_in_sent": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
runner._assert_success_evidence(evidence)
|
||||||
|
runner._assert_evidence_safe(
|
||||||
|
{
|
||||||
|
"schema_version": runner.EVIDENCE_SCHEMA,
|
||||||
|
"coverage": {
|
||||||
|
"smtp_acceptance": True,
|
||||||
|
"partial_envelope_refusal": False,
|
||||||
|
"post_data_connection_loss_outcome_unknown": False,
|
||||||
|
"source_artifact_provenance": False,
|
||||||
|
"worker_restart_interruption": False,
|
||||||
|
},
|
||||||
|
"success": evidence,
|
||||||
|
},
|
||||||
|
settings=_settings(),
|
||||||
|
)
|
||||||
|
assert subject.startswith("[GovOPlaN acceptance ")
|
||||||
|
assert client.send_calls == 2
|
||||||
|
assert client.campaign_json is not None
|
||||||
|
assert client.campaign_json["server"] == {"mail_profile_id": "profile-1"}
|
||||||
|
assert "credentials" not in json.dumps(client.campaign_json).casefold()
|
||||||
|
serialized = json.dumps(evidence, sort_keys=True)
|
||||||
|
assert "not-retained" not in serialized
|
||||||
|
assert "local-test-password" not in serialized
|
||||||
|
|
||||||
|
|
||||||
|
def test_campaign_boundary_rejects_resolved_transport_material() -> None:
|
||||||
|
with pytest.raises(runner.AcceptanceError, match="forbidden transport material"):
|
||||||
|
runner.assert_campaign_boundary(
|
||||||
|
{"server": {"mail_profile_id": "profile-1"}},
|
||||||
|
{
|
||||||
|
"mail_profile_id": "profile-1",
|
||||||
|
"smtp_transport_revision": "smtp-revision",
|
||||||
|
"imap_transport_revision": "imap-revision",
|
||||||
|
"smtp": {"host": "should-not-be-here"},
|
||||||
|
},
|
||||||
|
profile_id="profile-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_projection_fails_closed_on_inconsistent_campaign_report() -> None:
|
||||||
|
evidence = {
|
||||||
|
"send": {
|
||||||
|
"attempted_count": 1,
|
||||||
|
"sent_count": 1,
|
||||||
|
"failed_count": 0,
|
||||||
|
"outcome_unknown_count": 0,
|
||||||
|
"skipped_count": 0,
|
||||||
|
"delivery_mode": "synchronous",
|
||||||
|
"statuses": {"smtp_accepted": 1},
|
||||||
|
},
|
||||||
|
"append_sent": {
|
||||||
|
"pending_count": 1,
|
||||||
|
"processed_count": 1,
|
||||||
|
"appended_count": 1,
|
||||||
|
"failed_count": 0,
|
||||||
|
"skipped_count": 0,
|
||||||
|
"statuses": {"appended": 1},
|
||||||
|
},
|
||||||
|
"report": {
|
||||||
|
"cards": {
|
||||||
|
"jobs_total": 1,
|
||||||
|
"sent": 0,
|
||||||
|
"smtp_accepted": 0,
|
||||||
|
"failed": 0,
|
||||||
|
"outcome_unknown": 0,
|
||||||
|
"needs_attention": 0,
|
||||||
|
"imap_appended": 0,
|
||||||
|
"imap_failed": 0,
|
||||||
|
},
|
||||||
|
"send_status_counts": {},
|
||||||
|
"imap_status_counts": {},
|
||||||
|
},
|
||||||
|
"provider_verification": {
|
||||||
|
"inbox_increment": 1,
|
||||||
|
"sent_increment": 1,
|
||||||
|
"unique_subject_matches_in_inbox": 1,
|
||||||
|
"unique_subject_matches_in_sent": 1,
|
||||||
|
},
|
||||||
|
"campaign_mail_boundary": {
|
||||||
|
"profile_reference_only": True,
|
||||||
|
"smtp_revision_frozen": True,
|
||||||
|
"imap_revision_frozen": True,
|
||||||
|
"resolved_transport_material_present": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(runner.AcceptanceError, match="report does not agree"):
|
||||||
|
runner._assert_success_evidence(evidence)
|
||||||
|
|
||||||
|
|
||||||
|
def test_evidence_projection_rejects_unknown_status_keys() -> None:
|
||||||
|
with pytest.raises(runner.AcceptanceError, match="unsupported status"):
|
||||||
|
runner._send_evidence(
|
||||||
|
{
|
||||||
|
"delivery_mode": "synchronous",
|
||||||
|
"results": [{"status": "provider diagnostic: recipient@example.test"}],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(runner.AcceptanceError, match="synchronous delivery mode"):
|
||||||
|
runner._send_evidence(
|
||||||
|
{
|
||||||
|
"delivery_mode": "provider diagnostic: recipient@example.test",
|
||||||
|
"results": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(runner.AcceptanceError, match="unsupported"):
|
||||||
|
runner._report_evidence(
|
||||||
|
{
|
||||||
|
"cards": {},
|
||||||
|
"status_counts": {
|
||||||
|
"send": {"smtp_accepted": 1, "provider-secret": 1},
|
||||||
|
"imap": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixture_contains_no_transport_credentials() -> None:
|
||||||
|
raw = json.loads(FIXTURE_PATH.read_text(encoding="utf-8"))
|
||||||
|
runner._assert_no_forbidden_campaign_keys(raw)
|
||||||
|
assert raw["server"] == {"mail_profile_id": "00000000-0000-4000-8000-000000000001"}
|
||||||
|
config = load_campaign_config(FIXTURE_PATH)
|
||||||
|
assert config.server.mail_profile_id == "00000000-0000-4000-8000-000000000001"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixture_composition_versions_are_complete_and_exact() -> None:
|
||||||
|
versions = {
|
||||||
|
"core": "0.1.13",
|
||||||
|
"access": "0.1.11",
|
||||||
|
"audit": "0.1.8",
|
||||||
|
"campaigns": "0.1.10",
|
||||||
|
"mail": "0.1.10",
|
||||||
|
"files": "0.1.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert runner.required_composition_versions(FIXTURE_PATH, versions) == {
|
||||||
|
"core": "0.1.13",
|
||||||
|
"access": "0.1.11",
|
||||||
|
"audit": "0.1.8",
|
||||||
|
"campaigns": "0.1.10",
|
||||||
|
"mail": "0.1.10",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_fixture_composition_fails_closed_when_a_required_version_is_missing() -> None:
|
||||||
|
with pytest.raises(runner.AcceptanceError, match="versions are unavailable"):
|
||||||
|
runner.required_composition_versions(
|
||||||
|
FIXTURE_PATH,
|
||||||
|
{
|
||||||
|
"core": "0.1.13",
|
||||||
|
"access": "0.1.11",
|
||||||
|
"campaigns": "0.1.10",
|
||||||
|
"mail": "0.1.10",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_testbed_documentation_distinguishes_proven_and_open_failure_drills() -> None:
|
||||||
|
testbed = (REPOSITORY_ROOT / "dev" / "mail-testbed" / "README.md").read_text(encoding="utf-8")
|
||||||
|
runbook = (REPOSITORY_ROOT / "docs" / "CAMPAIGN_DELIVERY_RUNBOOK.md").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "second ordinary send must be rejected before another provider effect" in testbed
|
||||||
|
assert "post-DATA connection loss" in testbed
|
||||||
|
assert "real worker restart" in testbed
|
||||||
|
assert "does not simulate a connection loss after SMTP DATA" in runbook
|
||||||
|
assert "raw provider diagnostics" in runbook
|
||||||
Reference in New Issue
Block a user