feat: browser client for smol mail
This commit is contained in:
commit
9798d38040
19 changed files with 3331 additions and 0 deletions
131
test/e2e.py
Normal file
131
test/e2e.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
#!/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)
|
||||
|
||||
# --- reference client sends to the JS client
|
||||
run(alice + ["send", f"bob@{host}", "--subject", "greetings", "--body", "hello bob"])
|
||||
print(run(bob + ["fetch"]).strip())
|
||||
bob_list = run(bob + ["list"])
|
||||
check("bob lists alice's message", "greetings" in bob_list, bob_list)
|
||||
bob_read = run(bob + ["read", re.match(r"([0-9a-f]{8})", bob_list).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)
|
||||
|
||||
# --- JS client replies, through the bridge's WebSocket relay
|
||||
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_list = run(alice + ["list"])
|
||||
check("alice lists bob's reply", "re: greetings" in alice_list, alice_list)
|
||||
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)
|
||||
|
||||
# --- rotation: the JS client rotates, the reference client walks the chain
|
||||
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"])
|
||||
print(run(bob + ["fetch"]).strip())
|
||||
check("bob reads mail addressed to the new key", "post-rotation" in run(bob + ["list"]))
|
||||
|
||||
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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue