EMAIL-WP-0005-T03: add test-user addressing and reset helper
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Adds the tests/harness helper package: address(), available(), reset(),
users(), smtp_provider() and mailbox_config().

Tests address mailboxes as harness.address("<test name>"), 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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-14 01:46:09 +02:00
parent 89fd13ac2d
commit 442a574cf9
5 changed files with 328 additions and 2 deletions

View file

@ -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 |

View file

@ -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

139
tests/harness/__init__.py Normal file
View file

@ -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),
)

124
tests/test_harness_users.py Normal file
View file

@ -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

View file

@ -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("<test name>")` → 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