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
130 lines
5.8 KiB
Python
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]
|