gsmol/test/e2e.py
2026-10-09 13:47:50 +03:00

162 lines
7.5 KiB
Python

#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""End-to-end test: the browser JS stack (web/js, driven headless by
test/client.mjs) against the reference server and client in ../smolmail.
Two transports are covered: the driver connects directly over TCP, and once
more through bridge.py's WebSocket relay — the exact byte path the browser
takes. Alice is always the reference smolmail.py client, so every exchange
crosses implementations.
"""
from __future__ import annotations
import os
import re
import signal
import socket
import subprocess
import sys
import tempfile
import time
from pathlib import Path
SMOL = Path(__file__).resolve().parent.parent.parent / "smolmail"
NODE = "node"
failures: list[str] = []
def check(name: str, condition: bool, detail: str = "") -> None:
print(("ok " if condition else "FAIL ") + name + (f" — {detail}" if detail and not condition else ""))
if not condition:
failures.append(name)
def wait_port(port: int, timeout: float = 20.0) -> None:
deadline = time.time() + timeout
while time.time() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=1):
return
except OSError:
time.sleep(0.2)
raise TimeoutError(f"port {port} never opened")
def run(command: list[str], expect: int = 0, env: dict | None = None) -> str:
result = subprocess.run(command, capture_output=True, text=True, env=env)
if result.returncode != expect:
raise RuntimeError(f"command failed ({result.returncode}): {command}\n{result.stdout}{result.stderr}")
return result.stdout + result.stderr
def main() -> int:
work = Path(tempfile.mkdtemp(prefix="gsmol-e2e-"))
server_port, bridge_port = 11961, 18096
smolmaild = ["uv", "run", str(SMOL / "smolmaild.py")]
smolmail = ["uv", "run", str(SMOL / "smolmail.py")]
alice = smolmail + ["--key", str(work / "alice.key"), "--db", str(work / "alice.db")]
bob = [NODE, "test/client.mjs", "--state", str(work / "bob.json")]
server_key = re.search(r"public key: (\S+)", run(
smolmaild + ["keygen", "--key", str(work / "server.key")])).group(1)
server = subprocess.Popen(
smolmaild + ["serve", "--key", str(work / "server.key"), "--db", str(work / "mail.db"),
"--host", "127.0.0.1", "--port", str(server_port)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
bridge = subprocess.Popen(
["uv", "run", "bridge.py", "--port", str(bridge_port), "--allow-port", str(server_port)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
wait_port(server_port)
wait_port(bridge_port)
host = f"127.0.0.1:{server_port}"
# --- both sides register on the reference server
run(alice + ["keygen"])
run(alice + ["trust", "127.0.0.1", server_key])
run(alice + ["register", f"alice@{host}"])
run(bob + ["keygen"])
run(bob + ["trust", "127.0.0.1", server_key])
run(bob + ["register", f"bob@{host}"])
check("bob registers on the reference server", True)
# bob needs alice as a known contact before he can accept her (§5.8).
run(bob + ["resolve", f"alice@{host}"])
# --- reference client sends to the JS client, unsolicited: bob has not
# accepted alice yet, so it lands in his requests tier, not the main one
run(alice + ["send", f"bob@{host}", "--subject", "greetings", "--body", "hello bob"])
print(run(bob + ["fetch"]).strip())
bob_requests = run(bob + ["list", "--requests"])
check("bob's requests tier holds alice's unsolicited message", "greetings" in bob_requests,
bob_requests)
check("bob's main tier is still empty", "greetings" not in run(bob + ["list"]))
bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_requests).group(1)])
alice_pub = re.search(r"public key: (\S+)", run(alice + ["whoami"])).group(1)
check("bob reads the message", "hello bob" in bob_read, bob_read)
check("bob sees alice's key", alice_pub in bob_read)
check("signature verifies", "signature verified" in bob_read)
# the reference client now emits its signed reply address by default
check("reference client emits the signed reply address",
"reply-to: smol://alice@" in bob_read, bob_read)
# --- bob accepts alice (§5.8): pushed to the server right away, so her
# future mail reaches his main tier
accepted = run(bob + ["accept", f"alice@{host}"])
check("bob's server now holds an accept token", "server now holds 1 accept token" in accepted,
accepted)
# --- JS client replies, through the bridge's WebSocket relay. Bob has
# accepted alice, so this carries the token issued to her — but it is
# still unsolicited from alice's side, landing in her requests tier.
ws_env = dict(os.environ, GSMOL_TRANSPORT="ws", GSMOL_BRIDGE=f"ws://127.0.0.1:{bridge_port}")
reply_id = re.search(r"sent ([0-9a-f]+)", run(bob + ["send", f"alice@{host}",
"--subject", "re: greetings", "--body", "hello alice"], env=ws_env)).group(1)
check("bob sends through the bridge", bool(reply_id))
run(alice + ["fetch"])
alice_requests = run(alice + ["list", "--requests"])
check("alice's requests tier holds bob's reply", "re: greetings" in alice_requests,
alice_requests)
alice_read = run(alice + ["read", reply_id[:8]])
check("alice reads bob's reply", "hello alice" in alice_read, alice_read)
check("reference client verifies the JS signature", "signature verified" in alice_read)
# bob's reply carries his signed smol:// address; a receiver that does
# not know the field must still preserve and display it (SPEC.md §5.5)
check("reference client preserves the signed reply address",
bool(re.search(r"reply-to:\s+smol://bob@", alice_read)), alice_read)
# --- having now learned bob's accept token from that reply's Accept
# field, alice's next message to him carries a matching MAC and
# reaches his main tier even after he rotates his key
bob_rotated = run(bob + ["rotate"])
new_key = re.search(r"new key: (\S+)", bob_rotated).group(1)
resolved = run(alice + ["resolve", f"bob@{host}"])
check("chain confirms the rotation", "a signed chain confirms it" in resolved, resolved)
run(alice + ["send", f"bob@{host}", "--subject", "post-rotation", "--body", "still here",
"--anonymous"])
print(run(bob + ["fetch"]).strip())
bob_list = run(bob + ["list"])
check("bob's main tier reaches mail addressed to the new key", "post-rotation" in bob_list,
bob_list)
# --anonymous omits the reply address; the field list must show none
post_read = run(bob + ["read", re.search(r"([0-9a-f]{8}) .*post-rotation",
bob_list).group(1)])
check("--anonymous omits the reply address", "reply-to:" not in post_read, post_read)
print(f"\n{len(failures)} failure(s)" + (f": {failures}" if failures else ""))
return 1 if failures else 0
finally:
for process in (bridge, server):
process.send_signal(signal.SIGTERM)
process.wait(timeout=10)
print(f"workdir left at {work}" if failures else f"cleaning {work}")
if __name__ == "__main__":
os.chdir(Path(__file__).resolve().parent.parent)
sys.exit(main())