mail-address-semantics/mas_modes/harness.py
tegwick 71c17fb5f9 MAS-WP-0014 T01: independent JS email side for mode contract v0.1; MCP-1..6; flow a-008
impl/js/src/email.mjs (written from CONTRACT.md + FLOWS.md): both roles,
case store, handoffs, timeouts, one-cap outbox with holding, authorization.
Passes all 22 flows from the bundle; with the JS master all 12 slave
flows (no Python contract party). Writing it found six contract gaps,
folded into the draft as MCP-1..6 (acknowledgement, details convention,
resume names the outcome, opaque case key, error table, transfer
reporting); flows now check outcome tokens, not reference prose. A
mutation check found a missing flow: a-008 (per-type master check).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 5787@bnt-lap001
Assistant-Session: 5db26b82-138b-40cc-9856-4d5eb0f752fd
2026-10-19 13:56:53 +02:00

165 lines
7.6 KiB
Python

"""Wire the email side, the stubs and the gateway together on loopback HTTP.
Used by the tests and by the contract flow runner. Mail enters through the
real gateway (`build_event` from raw RFC 822, with trusted
Authentication-Results), so the whole path is exercised. Time is a fake
clock that only moves on `advance()`. Every peer gets its own pairwise key
with email: `callback-1` (the stub) and `cms-1` (the stub master), plus
`gateway` when events are signed.
"""
from __future__ import annotations
import json
import secrets
import sys
import tempfile
from pathlib import Path
from typing import Optional
from mas_gateway.core import Resolver, gateway, message_meta_from_rfc822
from .contract import serve
from .email_mode import EmailMode, RoleConfig
from .mailer import Mailer
from .stubs.callback import CallbackStub
from .stubs.master import StubMaster
ROOT = Path(__file__).resolve().parents[1]
CUSTOMER = "customer@example.net"
def reference_ticket_service():
"""The reference ticket service (examples/), the master-role store."""
path = str(ROOT / "examples/ticket_service")
if path not in sys.path:
sys.path.append(path)
from ticket_service import TicketService
return TicketService
class FakeClock:
def __init__(self, t: float = 1_000_000.0):
self.t = t
def __call__(self) -> float:
return self.t
class Harness:
"""roles: resource type -> {"role": "master"|"slave", ...RoleConfig fields}.
The strings "{callback}" and "{master}" in them are replaced by the stub URLs.
Without explicit settings, master roles hand callbacks to the stub and
slave roles take commands from the stub master."""
def __init__(self, statute_file, roles: dict, scenario: str = "completed",
auto_assign: bool = True, max_sent: Optional[int] = None, ticket_ids=None,
sign_events: bool = False, keys: Optional[dict] = None, master_factory=None,
email_factory=None):
"""master_factory(key) -> an outside master with StubMaster's interface and
an `events_url` (e.g. `ExternalMaster.js`); default: the Python stub.
email_factory(config, scratch_dir) -> an email side with `base_url`,
`outbox()`, `advance()` and `close()` (e.g. `ExternalEmail.js`); default:
`EmailMode` over the reference ticket service."""
self.resolver = Resolver.with_statute_files([statute_file])
[self.domain] = self.resolver.pinned
self.clock = FakeClock()
self.keys = keys or {peer: secrets.token_bytes(32) for peer in ("callback-1", "cms-1", "gateway")}
self.callback = CallbackStub({"email": self.keys["callback-1"]}, default=scenario)
self._servers = []
cb_url = self.callback_url = self._serve(self.callback.server()) + "/handoffs"
if master_factory is None:
self.master = StubMaster({"email": self.keys["cms-1"]}, auto_assign=auto_assign)
master_url = self._serve(self.master.server()) + "/events"
else:
self.master = master_factory(self.keys["cms-1"])
master_url = self.master.events_url
def fill(v):
if isinstance(v, str):
return v.replace("{callback}", cb_url).replace("{master}", master_url)
if isinstance(v, dict):
return {k: fill(x) for k, x in v.items()}
return v
defaults = {"master": {"modes": {"callback": {"url": "{callback}", "peer": "callback-1"}}},
"slave": {"master_url": "{master}", "master_peer": "cms-1"}}
filled = {t: fill({**defaults[c["role"]], **c}) for t, c in roles.items()}
self.external = email_factory is not None
if self.external:
self._scratch = tempfile.TemporaryDirectory()
config = {"peer_id": "email", "keys": {p: k.hex() for p, k in self.keys.items()},
"event_peer": "gateway" if sign_events else None, "max_sent": max_sent,
"statute": json.loads(Path(statute_file).read_text()),
"roles": {t: {**c, "fields": list(c.get("fields", ("phone", "time")))}
for t, c in filled.items()}}
self.email = email_factory(config, Path(self._scratch.name))
self.tickets = None
self.signer = ("gateway", self.keys["gateway"]) if sign_events else None
self.master.email_url = self.email.base_url + "/master"
self.app_url = self.email.base_url + "/mas-events"
return
configs = {t: RoleConfig(**c) for t, c in filled.items()}
# One outbound path for both roles. SMTP is a sink when a cap is under test,
# since only mail actually sent counts against it.
self.sent: list = []
self.mailer = Mailer(smtp=("sink", 0) if max_sent is not None else None, max_sent=max_sent,
clock=self.clock)
self.mailer._send = lambda *mail: self.sent.append(mail)
self.tickets = None
if any(c.role == "master" for c in configs.values()):
kw = {"id_factory": (lambda it=iter(ticket_ids): next(it))} if ticket_ids else {}
self.tickets = reference_ticket_service()(mailer=self.mailer, **kw)
self.signer = ("gateway", self.keys["gateway"]) if sign_events else None
self.email = EmailMode(configs, dict(self.keys), self.tickets,
event_peer="gateway" if sign_events else None,
statutes=dict(self.resolver.pinned), clock=self.clock,
mailer=self.mailer)
self.email.base_url = self._serve(self.email.server())
self.master.email_url = self.email.base_url + "/master"
self.app_url = self.email.base_url + "/mas-events"
def _serve(self, server):
self._servers.append(server)
return serve(server)
def close(self):
for s in self._servers:
s.shutdown()
if self.external:
self.email.close()
self._scratch.cleanup()
if hasattr(self.master, "close"):
self.master.close()
# -- driving -------------------------------------------------------------------
def mail(self, local_part: str, text: str = "", subject: str = "Help needed",
sender: str = CUSTOMER, dmarc: Optional[str] = "pass", headers: str = ""):
to = f"{local_part}@{self.domain}"
ar = (f"Authentication-Results: {self.domain}; dmarc={dmarc} header.from={sender.split('@')[1]}\r\n"
if dmarc else "")
raw = (f"{ar}From: <{sender}>\r\nTo: {to}\r\nSubject: {subject}\r\n"
f"Message-ID: <{abs(hash((to, subject, text, self.clock.t)))}@example.net>\r\n"
f"{headers}\r\n{text}\r\n").encode()
meta = message_meta_from_rfc822(raw, trusted_authserv_id=self.domain)
[d] = gateway([to], meta, self.resolver, self.app_url, self.signer)
return d
def advance(self, seconds: float):
if self.external:
return self.email.advance(seconds)
self.clock.t += seconds
return self.email.tick()
def outbox(self) -> list[dict]:
"""Every mail email decided to send, in either role (held mail excluded)."""
mails = self.email.outbox() if self.external else self.mailer.outbox()
return [m for m in mails if m["held"] is None]
def thread(self, ticket_id: str) -> list[str]:
"""Mode entries recorded in a master-role ticket thread."""
t = self.tickets.ticket(ticket_id)
return [m["body"] for m in t["messages"] if m["disposition"].startswith("mode-")]
def statuses(self, handoff_id: str) -> list[str]:
return [m["status"] for m in self.callback.handoffs[handoff_id].sent]