mirror of
https://github.com/AletheiaVox/signal_bridge_remote.git
synced 2026-10-07 03:18:17 +08:00
- Remove the authless "sole connected phone" fallback and SB_REQUIRE_MCP_AUTH: an unauthenticated request no longer reaches whichever phone is online alone. - Mcp-Session-Id is no longer a credential; the Bearer token is checked on every request (MCP auth spec). - Refresh tokens live 90 days (was 30, equal to the access token, so they were always dead when first needed). - A rejected refresh no longer counts toward the IP ban: a client with an expired token was retrying into a self-renewing ban on its own IP. - 401s carry the RFC 9728 WWW-Authenticate discovery header.
212 lines
11 KiB
Python
212 lines
11 KiB
Python
"""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])
|
|
|
|
# ── No authless fallback: a lone connected phone is NOT a credential ──
|
|
import asyncio
|
|
from server.session_registry import registry
|
|
from server.auth import verify_token as _vt
|
|
|
|
class _FakeWS:
|
|
async def send(self, data): pass
|
|
async def close(self, code=1000, reason=""): pass
|
|
|
|
uid = _vt(access)["user_id"]
|
|
asyncio.run(registry.register(uid, _FakeWS()))
|
|
r = client.post("/mcp", json={"jsonrpc": "2.0", "id": 20, "method": "initialize", "params": {}})
|
|
check("sole phone online: unauthenticated initialize still 401", r.status_code == 401, f"got {r.status_code}")
|
|
r = client.post("/mcp", headers={"mcp-session-id": sess}, json={
|
|
"jsonrpc": "2.0", "id": 21, "method": "tools/call",
|
|
"params": {"name": "list_devices", "arguments": {}},
|
|
})
|
|
check("session id without token -> 401", r.status_code == 401, f"got {r.status_code}")
|
|
check("401 carries WWW-Authenticate discovery header",
|
|
"resource_metadata=" in r.headers.get("www-authenticate", ""), str(dict(r.headers))[:200])
|
|
asyncio.run(registry.unregister(uid))
|
|
|
|
# ── Refresh: rotation works, dead refresh tokens don't ban the IP ──
|
|
from server import oauth as _oauth
|
|
from server import config as _cfg
|
|
check("refresh token outlives access token",
|
|
_oauth.REFRESH_TOKEN_EXPIRY_S > _cfg.TOKEN_EXPIRY_HOURS * 3600 * 2,
|
|
f"refresh={_oauth.REFRESH_TOKEN_EXPIRY_S}s access={_cfg.TOKEN_EXPIRY_HOURS}h")
|
|
rt = tok.get("refresh_token", "")
|
|
r = client.post("/oauth/token", json={
|
|
"grant_type": "refresh_token", "refresh_token": rt,
|
|
"client_id": creds.get("client_id", ""), "client_secret": creds.get("client_secret", ""),
|
|
})
|
|
check("refresh grant rotates", r.status_code == 200 and r.json().get("refresh_token") not in ("", rt), r.text[:150])
|
|
for _ in range(_cfg.BAN_THRESHOLD + 5):
|
|
r = client.post("/oauth/token", json={
|
|
"grant_type": "refresh_token", "refresh_token": rt, # now revoked
|
|
"client_id": creds.get("client_id", ""), "client_secret": creds.get("client_secret", ""),
|
|
})
|
|
check("stale refresh -> invalid_grant, not a ban",
|
|
r.status_code == 400 and r.json().get("error") == "invalid_grant", f"{r.status_code} {r.text[:100]}")
|
|
r = client.post("/mcp", headers=hdrs, json={"jsonrpc": "2.0", "id": 22, "method": "tools/list"})
|
|
check("same IP still served after repeated stale refreshes", r.status_code == 200, f"got {r.status_code}")
|
|
|
|
print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
|
|
sys.exit(1 if FAIL else 0)
|