162 lines
7.5 KiB
Python
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())
|