mail-address-semantics/mas_modes/harness.py
tegwick a841ceec73 MAS-WP-0009 T01: independent JS implementation (impl/js), differential test, SCP-9
impl/js (Node, zero deps, written from the specs): parser, v0.2
statute-aware rules, signing, outside master. Passes addresses.json,
statute-aware.json and auth.json unchanged; its master passes the 11
slave-setup mode flows against the Python email side (ExternalMaster).
Differential testing vs mas_core found two unstated stand-in rules, now
SCP-9 in the v0.2 draft with fixtures si-011..013, dt-109; ABNF comments
no longer say v0.1. Runner: external masters, step errors fail the flow.

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:22:22 +02:00

138 lines
6.2 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, master_factory=None):
"""master_factory(key) -> an outside master with StubMaster's interface and
an `events_url` (e.g. `ExternalMaster.js`); default: the Python stub."""
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"}}
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()
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):
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]