mail-address-semantics/mas_modes/harness.py
tegwick ecb1ba47f6 MAS-WP-0013 finished: mode contract flows runnable from the bundle alone
conformance/FLOWS.md defines setup, steps, captures and expectations, all
observable at the contract boundary. flows.json carries setups (peer ids),
fixed test keys, statute, scenario and explicit assign steps;
scenarios.json holds the callback scenarios (the stub loads them).
CONTRACT.md defines the case.assign response. Runner reads only the
bundle and rejects undefined keys; new flow s-006 (held over cap).

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 12:49:15 +02:00

130 lines
5.8 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 secrets
import sys
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):
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"
self.master = StubMaster({"email": self.keys["cms-1"]}, auto_assign=auto_assign)
master_url = self._serve(self.master.server()) + "/events"
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"}}
configs = {t: RoleConfig(**fill({**defaults[c["role"]], **c})) for t, c in roles.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()
# -- 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):
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)."""
return [m for m in self.mailer.outbox() 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]