mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 11:28:16 +08:00
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 <noreply@anthropic.com>
This commit is contained in:
313
tests/verify_relays.py
Normal file
313
tests/verify_relays.py
Normal file
@@ -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)
|
||||||
167
tests/verify_server.py
Normal file
167
tests/verify_server.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user