From b8a52e9658b6427928fbad4e7db2367e622eba56 Mon Sep 17 00:00:00 2001 From: Aletheia Date: Tue, 7 Jul 2026 20:28:03 +0200 Subject: [PATCH] test: offline verification suite for server and relay clients Two self-contained scripts, no hardware or network needed: - tests/verify_server.py boots the FastAPI app in-process against a throwaway database and walks the full surface: health, MCP protocol (202 notifications, version negotiation, tool schemas), the complete OAuth flow (register -> authorize -> code -> token -> authenticated MCP session), and the per-user safety config round-trip. - tests/verify_relays.py drives both relay clients' pattern engines against a recorded fake of the Intiface layer: indefinite durations, the escalate hold contract, error-mid-ramp stops, pattern supersession, tracked auto-stops, feature_index routing, and the stop-unknown-device fallback. Run before shipping changes: python tests/verify_server.py && python tests/verify_relays.py Co-Authored-By: Claude Fable 5 --- tests/verify_relays.py | 313 +++++++++++++++++++++++++++++++++++++++++ tests/verify_server.py | 167 ++++++++++++++++++++++ 2 files changed, 480 insertions(+) create mode 100644 tests/verify_relays.py create mode 100644 tests/verify_server.py diff --git a/tests/verify_relays.py b/tests/verify_relays.py new file mode 100644 index 0000000..4af0ff6 --- /dev/null +++ b/tests/verify_relays.py @@ -0,0 +1,313 @@ +"""Behavioral verification of the pattern-lifecycle + feature_index changes +in both relay clients, using fakes for the hardware layer. + +Run from anywhere: python tests/verify_relays.py +Needs the phone deps (buttplug, websockets). No hardware, no network — +the Intiface/Bluetooth layer is faked and every call is recorded. +""" +import asyncio +import importlib.util +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +PASS, FAIL = [], [] + + +def check(name, cond, detail=""): + (PASS if cond else FAIL).append(name) + print((" ok " if cond else " FAIL") + f" {name}" + (f" — {detail}" if detail and not cond else "")) + + +def load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +# ════════════════════════════════════════════════════════════════════ +# Part 1: termux_relay_v3.PatternRunner with a fake ButtplugRaw +# ════════════════════════════════════════════════════════════════════ + +v3 = load("termux_relay_v3", str(REPO_ROOT / "termux_relay_v3.py")) + + +class FakeBP: + def __init__(self): + self.calls = [] # (kind, idx, value, otype, feature_index) + self.profiles = {"lush": {"name": "Lush", "intensity_floor": 0.0}, + "dolce": {"name": "Dolce", "intensity_floor": 0.0}} + self.name_map = {"lush": 0, "dolce": 1} + self.bp_devices = { + 0: {"DeviceIndex": 0, "DeviceName": "Lovense Lush", + "DeviceMessages": {"ScalarCmd": [{"ActuatorType": "Vibrate"}]}}, + 1: {"DeviceIndex": 1, "DeviceName": "Lovense Dolce", + "DeviceMessages": {"ScalarCmd": [{"ActuatorType": "Vibrate"}, + {"ActuatorType": "Vibrate"}]}}, + } + + async def scalar_cmd(self, idx, intensity, actuator_type="Vibrate", feature_index=None): + self.calls.append(("scalar", idx, intensity, actuator_type, feature_index)) + + async def stop_device(self, idx): + self.calls.append(("stop", idx, None, None, None)) + + async def stop_all(self): + self.calls.append(("stop_all", None, None, None, None)) + + +async def test_v3(): + # duration=0 pulse runs indefinitely, stop cancels + stops device + bp = FakeBP() + r = v3.PatternRunner(bp) + ack = await r.run_command({"type": "pattern", "pattern": "pulse", "device": "lush", + "intensity": 0.5, "duration": 0}) + await asyncio.sleep(1.0) + writes = [c for c in bp.calls if c[0] == "scalar"] + task = r.active_tasks.get("lush") + check("v3 pulse duration=0 keeps running", ack["success"] and len(writes) >= 2 and task and not task.done(), + f"writes={len(writes)} task_done={task.done() if task else 'missing'}") + await r.run_command({"type": "stop", "device": "lush"}) + await asyncio.sleep(0.05) + check("v3 stop cancels indefinite pulse", not r.active_tasks and any(c[0] == "stop" for c in bp.calls)) + + # escalate hold=0 suspends at peak (task alive), cancel -> device stopped + bp = FakeBP() + r = v3.PatternRunner(bp) + await r.run_command({"type": "pattern", "pattern": "escalate", "device": "lush", + "intensity": 0.8, "duration": 0.2, "hold_seconds": 0}) + await asyncio.sleep(0.6) + task = r.active_tasks.get("lush") + peak_writes = [c for c in bp.calls if c[0] == "scalar" and abs(c[2] - 0.8) < 1e-6] + stops_before = [c for c in bp.calls if c[0] == "stop"] + check("v3 escalate hold=0 suspends at peak", task and not task.done() and peak_writes and not stops_before, + f"task={bool(task)} done={task.done() if task else '-'} peak={len(peak_writes)} stops={len(stops_before)}") + await r.run_command({"type": "stop", "device": "lush"}) + await asyncio.sleep(0.05) + check("v3 escalate stop after hold works", any(c[0] == "stop" for c in bp.calls)) + + # escalate hold>0 auto-stops via finally + bp = FakeBP() + r = v3.PatternRunner(bp) + await r.run_command({"type": "pattern", "pattern": "escalate", "device": "lush", + "intensity": 0.8, "duration": 0.1, "hold_seconds": 0.2}) + await asyncio.sleep(0.8) + check("v3 escalate hold>0 auto-stops", any(c[0] == "stop" for c in bp.calls), + f"calls={bp.calls[-3:]}") + + # direct command supersedes a running pattern + bp = FakeBP() + r = v3.PatternRunner(bp) + await r.run_command({"type": "pattern", "pattern": "wave", "device": "lush", + "intensity": 0.6, "duration": 0}) + await asyncio.sleep(0.2) + await r.run_command({"type": "command", "action": "vibrate", "device": "lush", + "intensity": 0.3, "duration": 0}) + await asyncio.sleep(0.3) + # after the direct command, no further wave writes should occur: + # the last scalar calls should all be the direct 0.3 write + idx_direct = max(i for i, c in enumerate(bp.calls) if c[0] == "scalar" and c[2] == 0.3) + later_waves = [c for c in bp.calls[idx_direct + 1:] if c[0] == "scalar" and c[2] != 0.3] + check("v3 direct command supersedes pattern", not r.active_tasks and not later_waves, + f"tasks={list(r.active_tasks)} later={later_waves[:3]}") + + # auto-stop tracked: superseded before firing + bp = FakeBP() + r = v3.PatternRunner(bp) + await r.run_command({"type": "command", "action": "vibrate", "device": "lush", + "intensity": 0.5, "duration": 0.2}) + await asyncio.sleep(0.05) + await r.run_command({"type": "command", "action": "vibrate", "device": "lush", + "intensity": 0.7, "duration": 0}) + await asyncio.sleep(0.4) + zero_writes = [c for c in bp.calls if c[0] == "scalar" and c[2] == 0.0] + check("v3 stale auto-stop cancelled by newer command", not zero_writes and not r.timed_stops, + f"zeros={zero_writes} pending={list(r.timed_stops)}") + + # auto-stop still fires when NOT superseded + bp = FakeBP() + r = v3.PatternRunner(bp) + await r.run_command({"type": "command", "action": "vibrate", "device": "lush", + "intensity": 0.5, "duration": 0.15}) + await asyncio.sleep(0.5) + zero_writes = [c for c in bp.calls if c[0] == "scalar" and c[2] == 0.0] + check("v3 auto-stop fires when due", len(zero_writes) == 1 and not r.timed_stops, + f"zeros={zero_writes}") + + # feature_index: dolce has two vibrate features; index filters ScalarCmd + bp = FakeBP() + r = v3.PatternRunner(bp) + await r.run_command({"type": "command", "action": "vibrate", "device": "dolce", + "intensity": 0.4, "duration": 0, "feature_index": 1}) + fi_calls = [c for c in bp.calls if c[0] == "scalar"] + check("v3 feature_index threaded to scalar_cmd", fi_calls and fi_calls[0][4] == 1, f"{fi_calls}") + + # stop unknown device -> stop-all fallback intact + bp = FakeBP() + r = v3.PatternRunner(bp) + ack = await r.run_command({"type": "stop", "device": "nonexistent"}) + check("v3 unknown-device stop falls back to all", ack["success"] and "ALL" in ack["message"] + and any(c[0] == "stop" for c in bp.calls), ack["message"]) + + +asyncio.run(test_v3()) + +# ════════════════════════════════════════════════════════════════════ +# Part 2: relay_client.DeviceController with a fake buttplug device +# ════════════════════════════════════════════════════════════════════ + +rc = load("relay_client", str(REPO_ROOT / "phone" / "relay_client.py")) + + +class FakeFeature: + def __init__(self, rec, fi, otypes): + self.rec, self.fi, self.otypes = rec, fi, otypes + + def has_output(self, otype): + return otype in self.otypes + + async def run_output(self, cmd): + self.rec.append(("feature", self.fi, cmd.output_type, cmd.value)) + + +class FakeDevice: + def __init__(self, rec, n_vib=1): + self.rec = rec + self.features = {i: FakeFeature(rec, i, {rc.OutputType.VIBRATE}) for i in range(n_vib)} + + async def run_output(self, cmd): + self.rec.append(("all", None, cmd.output_type, cmd.value)) + + async def stop(self, **kw): + self.rec.append(("stop", None, None, None)) + + +def make_controller(devs): + c = rc.DeviceController.__new__(rc.DeviceController) + c.intiface_url = "" + c.profiles = [] + c.client = None + c.devices = {} + c._pattern_tasks = {} + c._timed_stops = {} + c._connected = True + for name, n in devs: + prof = rc.DeviceProfile(short_name=name, match_strings=[name]) + c.devices[name] = rc.ConnectedDevice(0, FakeDevice_rec[name], prof, ["vibrate"]) + return c + + +async def test_rc(): + global FakeDevice_rec + V = rc.OutputType.VIBRATE + + # pulse duration=0 indefinite + stop + rec = [] + FakeDevice_rec = {"lush": FakeDevice(rec)} + c = make_controller([("lush", 1)]) + ack = await c.execute_command({"type": "pattern", "pattern": "pulse", "device": "lush", + "intensity": 0.5, "duration": 0, "request_id": "r1"}) + await asyncio.sleep(1.0) + task = c._pattern_tasks.get("lush") + writes = [x for x in rec if x[0] == "all"] + check("rc pulse duration=0 keeps running", ack["success"] and task and not task.done() and len(writes) >= 2, + f"writes={len(writes)}") + await c.execute_command({"type": "stop", "device": "lush", "request_id": "r2"}) + await asyncio.sleep(0.05) + check("rc stop cancels indefinite pulse", not c._pattern_tasks and ("stop", None, None, None) in rec) + + # escalate hold=0 suspends at peak, error-free; explicit stop lands in finally + rec = [] + FakeDevice_rec = {"lush": FakeDevice(rec)} + c = make_controller([("lush", 1)]) + await c.execute_command({"type": "pattern", "pattern": "escalate", "device": "lush", + "intensity": 0.8, "duration": 0.2, "hold_seconds": 0, "request_id": "r3"}) + await asyncio.sleep(0.6) + task = c._pattern_tasks.get("lush") + stops = [x for x in rec if x[0] == "stop"] + check("rc escalate hold=0 suspends at peak", task and not task.done() and not stops, + f"task={bool(task)} stops={stops}") + await c.execute_command({"type": "stop", "device": "lush", "request_id": "r4"}) + await asyncio.sleep(0.05) + check("rc escalate finally stops on cancel", any(x[0] == "stop" for x in rec)) + + # escalate error mid-ramp -> device stopped (the §3 safety fix) + rec = [] + dev = FakeDevice(rec) + calls = {"n": 0} + orig = dev.run_output + + async def flaky(cmd): + calls["n"] += 1 + if calls["n"] == 3: + raise RuntimeError("BT hiccup") + await orig(cmd) + dev.run_output = flaky + FakeDevice_rec = {"lush": dev} + c = make_controller([("lush", 1)]) + await c.execute_command({"type": "pattern", "pattern": "escalate", "device": "lush", + "intensity": 0.8, "duration": 0.3, "hold_seconds": 0, "request_id": "r5"}) + await asyncio.sleep(0.5) + check("rc escalate error mid-ramp stops device", any(x[0] == "stop" for x in rec), + f"rec tail={rec[-3:]}") + + # new pattern supersedes old pattern (per-device keying) + rec = [] + FakeDevice_rec = {"lush": FakeDevice(rec)} + c = make_controller([("lush", 1)]) + await c.execute_command({"type": "pattern", "pattern": "pulse", "device": "lush", + "intensity": 0.9, "duration": 0, "request_id": "r6"}) + await asyncio.sleep(0.1) + await c.execute_command({"type": "pattern", "pattern": "wave", "device": "lush", + "intensity": 0.4, "duration": 0, "request_id": "r7"}) + await asyncio.sleep(0.3) + idx_wave_start = len(rec) # marker not needed; assert one task only + check("rc one pattern per device", len(c._pattern_tasks) == 1) + # after wave started, no more 0.9 pulse-on writes + tail = rec[-4:] + pulse_writes_late = [x for x in tail if x[0] == "all" and x[3] == 0.9] + check("rc old pattern actually cancelled", not pulse_writes_late, f"tail={tail}") + await c.execute_command({"type": "stop", "device": "all", "request_id": "r8"}) + + # direct command supersedes pattern + auto-stop bookkeeping + rec = [] + FakeDevice_rec = {"lush": FakeDevice(rec)} + c = make_controller([("lush", 1)]) + await c.execute_command({"type": "pattern", "pattern": "wave", "device": "lush", + "intensity": 0.6, "duration": 0, "request_id": "r9"}) + await asyncio.sleep(0.2) + await c.execute_command({"type": "command", "action": "vibrate", "device": "lush", + "intensity": 0.3, "duration": 0.2, "request_id": "r10"}) + check("rc direct command cancels pattern", not c._pattern_tasks and len(c._timed_stops) == 1) + await c.execute_command({"type": "command", "action": "vibrate", "device": "lush", + "intensity": 0.7, "duration": 0, "request_id": "r11"}) + await asyncio.sleep(0.4) + zeros = [x for x in rec if x[0] == "all" and x[3] == 0] + # wave writes near-zero floats but exact 0 only from timed stop / pulse-off + check("rc stale auto-stop cancelled", not c._timed_stops and not zeros, + f"zeros={zeros} pending={list(c._timed_stops)}") + + # feature_index routes through device.features[i] + rec = [] + FakeDevice_rec = {"dolce": FakeDevice(rec, n_vib=2)} + c = make_controller([("dolce", 2)]) + ack = await c.execute_command({"type": "command", "action": "vibrate", "device": "dolce", + "intensity": 0.4, "duration": 0, "feature_index": 1, "request_id": "r12"}) + feats = [x for x in rec if x[0] == "feature"] + check("rc feature_index targets single motor", ack["success"] and feats and feats[0][1] == 1 + and not [x for x in rec if x[0] == "all"], f"{rec}") + + # bad feature_index -> helpful error, no crash + ack = await c.execute_command({"type": "command", "action": "vibrate", "device": "dolce", + "intensity": 0.4, "duration": 0, "feature_index": 7, "request_id": "r13"}) + check("rc bad feature_index helpful error", not ack["success"] and "valid" in ack["message"], + ack["message"]) + + +asyncio.run(test_rc()) + +print(f"\n{len(PASS)} passed, {len(FAIL)} failed") +sys.exit(1 if FAIL else 0) diff --git a/tests/verify_server.py b/tests/verify_server.py new file mode 100644 index 0000000..1aa47fa --- /dev/null +++ b/tests/verify_server.py @@ -0,0 +1,167 @@ +"""End-to-end verification of the signal-bridge-remote server: +boot, health, MCP protocol fixes, full OAuth round-trip, safety config. + +Run from anywhere: python tests/verify_server.py +Needs the server deps plus httpx (for fastapi.testclient). No hardware, +no network — everything runs in-process against a throwaway database. +""" +import os +import sys +import tempfile +from pathlib import Path + +os.environ["SB_SECRET_KEY"] = "test-secret-key-for-verification-only" +os.environ["SB_DB_PATH"] = os.path.join(tempfile.mkdtemp(), "test.db") + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +from fastapi.testclient import TestClient # noqa: E402 +from server.app import app # noqa: E402 + +PASS = [] +FAIL = [] + + +def check(name, cond, detail=""): + (PASS if cond else FAIL).append(name) + print((" ok " if cond else " FAIL") + f" {name}" + (f" — {detail}" if detail and not cond else "")) + + +with TestClient(app) as client: + # ── Boot & health ──────────────────────────────────────────────── + r = client.get("/health") + check("health endpoint", r.status_code == 200 and r.json().get("status") == "ok", r.text) + + # ── MCP protocol fixes ─────────────────────────────────────────── + # Notification (no id) must get bare 202, even unauthenticated + r = client.post("/mcp", json={"jsonrpc": "2.0", "method": "notifications/initialized"}) + check("notification -> 202", r.status_code == 202, f"got {r.status_code}") + + # Unauthenticated request with id, no phone -> 401 + r = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + check("unauthenticated initialize -> 401", r.status_code == 401, f"got {r.status_code}") + + # ── OAuth discovery ────────────────────────────────────────────── + r = client.get("/.well-known/oauth-authorization-server") + meta = r.json() if r.status_code == 200 else {} + check("oauth metadata", r.status_code == 200 and "authorization_endpoint" in meta, r.text[:200]) + + # ── User + client registration ─────────────────────────────────── + r = client.post("/auth/register", json={"username": "testuser", "password": "hunter2hunter2"}) + check("user registration", r.status_code == 200, r.text[:200]) + + r = client.post("/oauth/register", json={ + "client_name": "Verify Client", + "redirect_uris": ["http://localhost:9999/callback"], + }) + creds = r.json() if r.status_code == 201 else {} + check("client registration", r.status_code == 201 and "client_id" in creds, r.text[:200]) + + # ── Authorize: login page renders ──────────────────────────────── + r = client.get("/oauth/authorize", params={ + "client_id": creds.get("client_id", ""), + "redirect_uri": "http://localhost:9999/callback", + "response_type": "code", + "state": "xyzzy", + }) + check("authorize login page", r.status_code == 200 and "password" in r.text.lower(), r.text[:200]) + + # ── Authorize: login submits, code issued ──────────────────────── + r = client.post("/oauth/authorize", data={ + "username": "testuser", + "password": "hunter2hunter2", + "client_id": creds.get("client_id", ""), + "redirect_uri": "http://localhost:9999/callback", + "state": "xyzzy", + }, follow_redirects=False) + loc = r.headers.get("location", "") + check("authorize -> redirect with code", r.status_code == 302 and "code=" in loc and "state=xyzzy" in loc, f"{r.status_code} {loc[:120]}") + auth_code = "" + if "code=" in loc: + from urllib.parse import parse_qs, urlparse + auth_code = parse_qs(urlparse(loc).query).get("code", [""])[0] + + # ── Token exchange (JSON body) ─────────────────────────────────── + r = client.post("/oauth/token", json={ + "grant_type": "authorization_code", + "code": auth_code, + "client_id": creds.get("client_id", ""), + "client_secret": creds.get("client_secret", ""), + }) + tok = r.json() if r.status_code == 200 else {} + access = tok.get("access_token", "") + check("token exchange", r.status_code == 200 and access, r.text[:200]) + + # ── Token endpoint parses urlencoded forms (request.form() path — + # the code path that needed python-multipart on pinned Starlette) ── + r2 = client.post("/oauth/authorize", data={ + "username": "testuser", "password": "hunter2hunter2", + "client_id": creds.get("client_id", ""), + "redirect_uri": "http://localhost:9999/callback", "state": "s2", + }, follow_redirects=False) + from urllib.parse import parse_qs, urlparse + code2 = parse_qs(urlparse(r2.headers.get("location", "")).query).get("code", [""])[0] + r2 = client.post("/oauth/token", data={ + "grant_type": "authorization_code", + "code": code2, + "client_id": creds.get("client_id", ""), + "client_secret": creds.get("client_secret", ""), + }) + check("token exchange via urlencoded form", r2.status_code == 200 and r2.json().get("access_token"), f"{r2.status_code} {r2.text[:120]}") + + # ── Authenticated MCP: initialize with version negotiation ─────── + hdrs = {"Authorization": f"Bearer {access}"} + r = client.post("/mcp", headers=hdrs, json={ + "jsonrpc": "2.0", "id": 2, "method": "initialize", + "params": {"protocolVersion": "2025-06-18"}, + }) + body = r.json() if r.status_code == 200 else {} + ver = body.get("result", {}).get("protocolVersion", "") + sess = r.headers.get("mcp-session-id", "") + check("initialize echoes supported version", r.status_code == 200 and ver == "2025-06-18", f"{r.status_code} ver={ver}") + check("session id issued", bool(sess)) + + r = client.post("/mcp", headers=hdrs, json={ + "jsonrpc": "2.0", "id": 3, "method": "initialize", + "params": {"protocolVersion": "1999-01-01"}, + }) + ver = r.json().get("result", {}).get("protocolVersion", "") if r.status_code == 200 else "" + check("unsupported version falls back", ver == "2025-03-26", f"ver={ver}") + + # ── tools/list: feature_index present, neutral terminology ─────── + r = client.post("/mcp", headers=hdrs, json={"jsonrpc": "2.0", "id": 4, "method": "tools/list"}) + tools = {t["name"]: t for t in r.json().get("result", {}).get("tools", [])} if r.status_code == 200 else {} + vib = tools.get("vibrate", {}) + props = vib.get("inputSchema", {}).get("properties", {}) + check("tools/list returns tools", len(tools) >= 10, f"{len(tools)} tools") + check("vibrate has feature_index", "feature_index" in props) + check("vibrate requires device", vib.get("inputSchema", {}).get("required") == ["device"]) + esc = tools.get("escalate", {}) + check("escalate hold contract documented", "hold indefinitely" in esc.get("description", "")) + all_text = str(tools) + check("neutral terminology", "clitoral" not in all_text and "thrusting" not in all_text) + + # ── tools/call without phone: graceful error, not a crash ──────── + r = client.post("/mcp", headers=hdrs, json={ + "jsonrpc": "2.0", "id": 5, "method": "tools/call", + "params": {"name": "list_devices", "arguments": {}}, + }) + txt = str(r.json()) if r.status_code == 200 else r.text + check("tools/call no-phone graceful", r.status_code == 200 and "No phone connected" in txt, txt[:150]) + + # ── Safety config round-trip (governor_enabled bool fix) ───────── + r = client.get("/safety/config", headers=hdrs) + check("GET /safety/config", r.status_code == 200 and r.json().get("governor_enabled") is True, r.text[:200]) + r = client.post("/safety/config", headers=hdrs, json={"governor_enabled": False, "heat_rate": 2.5}) + check("POST /safety/config", r.status_code == 200, r.text[:200]) + r = client.get("/safety/config", headers=hdrs) + j = r.json() if r.status_code == 200 else {} + check("governor_enabled returns JSON false (not 0)", j.get("governor_enabled") is False, r.text[:200]) + r = client.post("/safety/config", headers=hdrs, json={"governor_enabled": True}) + r = client.get("/safety/config", headers=hdrs) + j = r.json() if r.status_code == 200 else {} + check("governor re-enables (one-way ratchet fixed)", j.get("governor_enabled") is True, r.text[:200]) + +print(f"\n{len(PASS)} passed, {len(FAIL)} failed") +sys.exit(1 if FAIL else 0)