chore: flatten repository history
This commit is contained in:
commit
3a03fab876
30 changed files with 7223 additions and 0 deletions
162
test/e2e.py
Normal file
162
test/e2e.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
#!/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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue