"""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]