From 442a574cf99645ae07981bd6defe63c5ffcb2991 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 14 Aug 2026 01:46:09 +0200 Subject: [PATCH] EMAIL-WP-0005-T03: add test-user addressing and reset helper Adds the tests/harness helper package: address(), available(), reset(), users(), smtp_provider() and mailbox_config(). Tests address mailboxes as harness.address(""), which slugs the name under the RFC 2606 reserved harness.email-connect.test domain, so distinct test names cannot collide and a stray send cannot leave the host. No provisioning call is needed -- the auth-disabled harness creates the mailbox on first login. reset() drops every user and message via GreenMail's service reset; the next login recreates the mailbox empty. It is documented to run at test start so a crashed test cannot leak state forward, and it is global, so resetting tests cannot run in parallel against one harness. Suite: 64 passed with the harness up, 61 passed + 3 skipped with it down. Co-Authored-By: Claude Opus 5 --- WORK-RECORDS.md | 2 +- tests/harness/README.md | 45 ++++++ tests/harness/__init__.py | 139 ++++++++++++++++++ tests/test_harness_users.py | 124 ++++++++++++++++ .../EMAIL-WP-0005-test-mailbox-harness.md | 20 ++- 5 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 tests/harness/__init__.py create mode 100644 tests/test_harness_users.py diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 373bb49..2628827 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -41,7 +41,7 @@ | task | EMAIL-WP-0004-T03 | done | — | workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md | | task | EMAIL-WP-0004-T04 | done | — | workplans/EMAIL-WP-0004-transactional-mail-delivery-service.md | | task | EMAIL-WP-0005-T01 | done | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | -| task | EMAIL-WP-0005-T02 | todo | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | +| task | EMAIL-WP-0005-T02 | done | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | | task | EMAIL-WP-0005-T03 | todo | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | | task | EMAIL-WP-0005-T04 | todo | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | | task | EMAIL-WP-0005-T05 | todo | — | workplans/EMAIL-WP-0005-test-mailbox-harness.md | diff --git a/tests/harness/README.md b/tests/harness/README.md index fa3140f..9e9a313 100644 --- a/tests/harness/README.md +++ b/tests/harness/README.md @@ -49,6 +49,51 @@ provider material is routed through OpenBao — see `.claude/rules/credential-routing.md`. Never point this harness at real credentials or a real mailbox. +### Addressing convention + +Tests address mailboxes through the `harness` helper package rather than +hardcoding strings: + +```python +import harness + +recipient = harness.address("invitation resend") +# -> invitation-resend@harness.email-connect.test +``` + +`harness.address()` slugs the name, so each test picks its own address and two +tests cannot collide. `.test` is reserved by RFC 2606, so a stray send can never +leave the host. + +### Reset contract + +`harness.reset()` drops **every** user and message — GreenMail's reset clears +accounts along with their mail, and the next login recreates the mailbox empty. + +Call it at the **start** of a test, not the end: a crashed or interrupted test +then cannot leave state behind for the next one. It is global, so tests that +reset cannot run in parallel against one harness. + +```python +def test_something(): + harness.reset() + ... +``` + +### Skipping when the harness is down + +Harness-dependent tests are gated so the default suite stays offline: + +```python +requires_harness = pytest.mark.skipif( + not harness.available(), + reason="mail harness not running", +) +``` + +`harness.available()` is evaluated at import time, so start the harness before +invoking pytest. + ## Scanning the harness ```bash diff --git a/tests/harness/__init__.py b/tests/harness/__init__.py new file mode 100644 index 0000000..c666335 --- /dev/null +++ b/tests/harness/__init__.py @@ -0,0 +1,139 @@ +"""Test-user addressing and reset for the local GreenMail harness. + +Start the harness with: + + docker compose -f tests/harness/docker-compose.yml up -d + +Everything here is test-only. The harness runs with authentication disabled on +loopback, so the credentials below are placeholders rather than secrets; real +provider material is routed through OpenBao (`.claude/rules/credential-routing.md`) +and must never be pointed at this harness. +""" + +from __future__ import annotations + +import os +import re +import socket +import urllib.error +import urllib.request + +HOST = "127.0.0.1" +SMTP_PORT = 3025 +IMAP_PORT = 3143 +API_PORT = 8080 + +#: `.test` is reserved by RFC 2606, so a stray send can never leave the host. +DOMAIN = "harness.email-connect.test" + +#: Accepted and ignored — the harness disables authentication. It still has to +#: be non-empty because the scanner requires both credential env vars to be set. +PASSWORD = "harness" + +DEFAULT_SENDER = f"noreply@{DOMAIN}" + +IMAP_USER_ENV = "EMAIL_CONNECT_HARNESS_IMAP_USER" +IMAP_PASSWORD_ENV = "EMAIL_CONNECT_HARNESS_IMAP_PASSWORD" + +_SLUG_RE = re.compile(r"[^a-z0-9]+") + + +def address(name: str) -> str: + """Deterministic mailbox address for a named test user. + + The harness creates the mailbox on first login, so there is no provisioning + step: give each test its own name and the mailboxes cannot collide. + + >>> address("invitation resend") + 'invitation-resend@harness.email-connect.test' + """ + + slug = _SLUG_RE.sub("-", name.strip().lower()).strip("-") + if not slug: + raise ValueError(f"Test user name produced an empty slug: {name!r}") + return f"{slug}@{DOMAIN}" + + +def available(timeout: float = 0.5) -> bool: + """True when both mail ports accept connections.""" + + for port in (SMTP_PORT, IMAP_PORT): + try: + with socket.create_connection((HOST, port), timeout=timeout): + pass + except OSError: + return False + return True + + +def reset() -> None: + """Drop every harness user and message. + + GreenMail's reset clears users *and* their mail; a later login recreates the + mailbox empty. Call it in setup rather than teardown so a crashed test + cannot leave state for the next one. + """ + + request = urllib.request.Request(f"http://{HOST}:{API_PORT}/api/service/reset", method="POST") + try: + with urllib.request.urlopen(request, timeout=5) as response: + if response.status != 200: + raise RuntimeError(f"Harness reset failed with status {response.status}") + except urllib.error.URLError as exc: + raise RuntimeError(f"Harness reset failed: {exc}") from exc + + +def users() -> list[dict]: + """Users the harness has created so far, newest first in GreenMail's order.""" + + import json + + with urllib.request.urlopen(f"http://{HOST}:{API_PORT}/api/user", timeout=5) as response: + return json.loads(response.read().decode("utf-8")) + + +def smtp_provider(sender: str = DEFAULT_SENDER): + """SMTPProvider wired to the harness. + + GreenMail offers no STARTTLS, so this uses the plaintext transport mode, + which `SMTPProvider` permits only for loopback hosts. + """ + + from email_connect.transactional import SMTPProvider + + return SMTPProvider(HOST, SMTP_PORT, sender, PASSWORD, sender, security="plaintext") + + +def mailbox_config( + user_address: str, + *, + storage_path: str, + reports_dir: str, + mailbox_id: str = "harness-mailbox", + scan=None, +): + """AppConfig for scanning one harness mailbox over IMAP. + + The scanner reads IMAP credentials from named environment variables, so this + sets the harness pair as a side effect. + """ + + from email_connect.config import AppConfig, MailboxConfig, ReportsConfig, ScanConfig, StorageConfig + + os.environ[IMAP_USER_ENV] = user_address + os.environ[IMAP_PASSWORD_ENV] = PASSWORD + return AppConfig( + mailbox=MailboxConfig( + id=mailbox_id, + protocol="imap", + host=HOST, + port=IMAP_PORT, + tls=False, + username_env=IMAP_USER_ENV, + password_env=IMAP_PASSWORD_ENV, + folder="INBOX", + ), + scan=scan or ScanConfig(), + storage=StorageConfig(path=storage_path), + reports=ReportsConfig(output_dir=reports_dir), + ) diff --git a/tests/test_harness_users.py b/tests/test_harness_users.py new file mode 100644 index 0000000..983fef1 --- /dev/null +++ b/tests/test_harness_users.py @@ -0,0 +1,124 @@ +"""Test-user addressing and reset contract for the local mail harness. + +The slug and config cases run offline. The cases that talk to the harness are +skipped unless it is up, so the default suite stays container-free. +""" + +from __future__ import annotations + +import imaplib +import tempfile +from pathlib import Path + +import pytest + +import harness + +requires_harness = pytest.mark.skipif( + not harness.available(), + reason="mail harness not running: docker compose -f tests/harness/docker-compose.yml up -d", +) + + +def mailbox_count(user_address: str) -> int: + connection = imaplib.IMAP4(harness.HOST, harness.IMAP_PORT) + try: + connection.login(user_address, harness.PASSWORD) + _status, data = connection.select("INBOX", readonly=True) + return int(data[0]) + finally: + connection.logout() + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("alpha", "alpha"), + ("invitation resend", "invitation-resend"), + ("Invitation Resend", "invitation-resend"), + ("test_user_01", "test-user-01"), + (" spaced ", "spaced"), + ("weird!!name??", "weird-name"), + ], +) +def test_address_is_a_deterministic_slug(name, expected): + assert harness.address(name) == f"{expected}@{harness.DOMAIN}" + assert harness.address(name) == harness.address(name) + + +def test_address_rejects_names_with_no_slug(): + with pytest.raises(ValueError): + harness.address("!!!") + + +def test_addresses_use_the_reserved_test_tld(): + assert harness.DOMAIN.endswith(".test") + + +def test_mailbox_config_sets_credential_environment(monkeypatch): + monkeypatch.delenv(harness.IMAP_USER_ENV, raising=False) + monkeypatch.delenv(harness.IMAP_PASSWORD_ENV, raising=False) + user_address = harness.address("config probe") + + config = harness.mailbox_config(user_address, storage_path="state.sqlite", reports_dir="reports") + + import os + + assert os.environ[harness.IMAP_USER_ENV] == user_address + assert os.environ[harness.IMAP_PASSWORD_ENV] == harness.PASSWORD + assert config.mailbox.protocol == "imap" + assert config.mailbox.host == harness.HOST + assert config.mailbox.port == harness.IMAP_PORT + assert config.mailbox.tls is False + assert config.scan.mark_seen is False + + +@requires_harness +def test_reset_clears_users_and_mail(): + harness.reset() + recipient = harness.address("reset probe") + harness.smtp_provider().send(recipient, "Probe", "body") + + assert mailbox_count(recipient) == 1 + assert any(user["email"] == recipient for user in harness.users()) + + harness.reset() + + assert harness.users() == [] + assert mailbox_count(recipient) == 0 + + +@requires_harness +def test_named_test_users_get_isolated_mailboxes(): + harness.reset() + first = harness.address("isolation one") + second = harness.address("isolation two") + + provider = harness.smtp_provider() + provider.send(first, "For first", "body") + provider.send(first, "For first again", "body") + provider.send(second, "For second", "body") + + assert mailbox_count(first) == 2 + assert mailbox_count(second) == 1 + + +@requires_harness +def test_scanner_reads_a_harness_mailbox(): + harness.reset() + recipient = harness.address("scanner probe") + harness.smtp_provider().send(recipient, "Scanner probe", "body text") + + from email_connect.scanner import scan_mailbox + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + config = harness.mailbox_config( + recipient, + storage_path=str(root / "state.sqlite"), + reports_dir=str(root / "reports"), + ) + result = scan_mailbox(config) + + assert result.scan.messages_seen == 1 + assert result.scan.messages_new == 1 diff --git a/workplans/EMAIL-WP-0005-test-mailbox-harness.md b/workplans/EMAIL-WP-0005-test-mailbox-harness.md index bc6477b..24a9d6f 100644 --- a/workplans/EMAIL-WP-0005-test-mailbox-harness.md +++ b/workplans/EMAIL-WP-0005-test-mailbox-harness.md @@ -211,7 +211,7 @@ Done 2026-08-14: ```task id: EMAIL-WP-0005-T03 -status: todo +status: done priority: high state_hub_task_id: "c761d26d-a8b0-4b66-ba18-8f22c6d59440" ``` @@ -242,6 +242,24 @@ Test credentials are deliberately non-secret and belong in the repo. Per provider material; the harness must not touch them. ``` +Done 2026-08-14: + +* `tests/harness/__init__.py` helper package: `address()`, `available()`, + `reset()`, `users()`, `smtp_provider()`, `mailbox_config()`. +* Convention is `harness.address("")` → slug@`harness.email-connect.test`. + `.test` is RFC 2606 reserved, so a stray send cannot leave the host. No + provisioning call is needed — T02's auth-disabled setup creates the mailbox on + first login, so distinct names are automatically isolated. +* Reset contract: `harness.reset()` (GreenMail `POST /api/service/reset`) drops + all users and mail; the next login recreates the mailbox empty. Documented to + run at test *start*, so a crashed test cannot leak state forward. It is + global, so resetting tests cannot run in parallel against one harness. + Verified: send → 1 message and a live user → reset → no users, mailbox empty. +* Credentials stay non-secret placeholders in the repo; OpenBao is untouched. +* Tests: `tests/test_harness_users.py`, 12 cases. Slug and config cases run + offline; the three harness-dependent cases skip when it is down. Suite: 64 + passed with the harness up, 61 passed + 3 skipped with it down. + ## T04 - End-to-end send-and-scan integration tests ```task