Add admin-only companion pairing
Split 3/4 of the companion bridge (#863, #871 landed 1/4 and 2/4). Adds admin-only device pairing to the companion router. - GET /api/companion/pair -- renders a form; never mints (a GET must not mint a credential: SameSite=Lax session cookies ride top-level GET navigations, so GET-minting would be CSRF-triggerable via a link/<img>) - POST /api/companion/pair -- mints a one-time chat-scoped token. Admin-cookie only; CSRF-safe because a SameSite=Lax cookie is not sent on a cross-site POST, the same protection POST /api/tokens relies on. ?format=json returns the pairing payload for an in-app screen. Minting invalidates the auth middleware's token cache so the code works on the next request with no restart. companion/pairing.py holds the mint/LAN/QR helpers; the token is shown once and stored only as a bcrypt hash + prefix (mirrors routes/api_token_routes.py). Tests (tests/test_companion_pairing.py): - a bearer/'api' caller and a non-admin user are rejected by require_admin (403); an admin passes - the token is returned once and persisted only as a hash - minting invalidates the cache (works without restart) - minting is exposed on POST, never GET (CSRF)
This commit is contained in:
@@ -1,20 +1,28 @@
|
||||
# Companion bridge (read-only)
|
||||
# Companion bridge
|
||||
|
||||
A thin, additive layer so a LAN client can discover what an Odysseus server
|
||||
offers, without duplicating any LLM logic. Reachable with either a logged-in
|
||||
cookie session or a Bearer `ody_` API token (auth is enforced globally by
|
||||
`AuthMiddleware`).
|
||||
A thin, additive layer so a LAN client (e.g. a phone) can discover what an
|
||||
Odysseus server offers and pair to it, without duplicating any LLM logic.
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/companion/ping` | cheap, auth-validated health check |
|
||||
| GET | `/api/companion/info` | server identity + capability flags |
|
||||
| GET | `/api/companion/models` | the **caller's own** model endpoints |
|
||||
| Method | Path | Auth | Purpose |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/companion/ping` | session or token | cheap, auth-validated health check |
|
||||
| GET | `/api/companion/info` | session or token | server identity + capability flags |
|
||||
| GET | `/api/companion/models` | session or token | the **caller's own** model endpoints |
|
||||
| GET | `/api/companion/pair` | **admin cookie** | pairing page (a form; never mints) |
|
||||
| POST | `/api/companion/pair` | **admin cookie** | mint a one-time pairing token (`?format=json` for an in-app screen) |
|
||||
|
||||
`/models` scopes to the caller's real owner (the token's owner for bearer
|
||||
callers) plus legacy null-owner shared rows, the same rule as `owner_filter`. It
|
||||
never returns API-key material. The owner rule lives in two pure, tested helpers
|
||||
(`token_owner`, `owner_can_see`) — see `tests/test_companion_readonly.py`.
|
||||
`/models` scopes to the caller's real owner plus legacy null-owner shared rows
|
||||
(same rule as `owner_filter`) and never returns API-key material.
|
||||
|
||||
This module is intentionally read-only. Pairing/token-minting, token-owner
|
||||
session attribution, and any mutation endpoints are proposed in separate PRs.
|
||||
## Pairing CSRF posture
|
||||
|
||||
Minting happens **only on POST**. The session cookie is `SameSite=Lax`
|
||||
(`routes/auth_routes.py`), so a browser will not send it on a cross-site POST —
|
||||
the same protection `POST /api/tokens` relies on. A `GET` would be unsafe (Lax
|
||||
cookies ride top-level GET navigations), so `GET /pair` only renders a form.
|
||||
Minting invalidates the auth middleware's token cache, so a freshly minted token
|
||||
works on the next request without a restart.
|
||||
|
||||
The pairing/scoping rules live in small, tested units (`token_owner`,
|
||||
`owner_can_see`, `mint_pairing_token`, `pairing.*`) — see
|
||||
`tests/test_companion_readonly.py` and `tests/test_companion_pairing.py`.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Odysseus companion bridge — additive, read-only LAN endpoints.
|
||||
"""Odysseus companion bridge — additive LAN endpoints.
|
||||
|
||||
Exposes /api/companion/ping, /info, and an owner-scoped /models so a LAN client
|
||||
can discover what a server offers. No new LLM logic; auth is enforced by the
|
||||
existing AuthMiddleware. See companion/README.md.
|
||||
Read endpoints (/api/companion/ping, /info, owner-scoped /models) so a LAN
|
||||
client can discover what a server offers, plus admin-only pairing
|
||||
(/api/companion/pair) that mints a one-time chat-scoped token on POST. No new LLM
|
||||
logic; auth is enforced by the existing AuthMiddleware. See companion/README.md.
|
||||
"""
|
||||
|
||||
from companion.routes import setup_companion_routes
|
||||
|
||||
121
companion/pairing.py
Normal file
121
companion/pairing.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""Shared pairing helpers for the companion bridge.
|
||||
|
||||
Token minting + LAN discovery + QR rendering, kept here as small, importable
|
||||
units so the route layer stays thin and the logic is directly testable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import uuid
|
||||
|
||||
import bcrypt
|
||||
|
||||
PAIRING_VERSION = 1
|
||||
COMPANION_SCOPE = "chat"
|
||||
|
||||
|
||||
def default_port() -> int:
|
||||
"""Best guess at the port the server is reachable on. Callers that know the
|
||||
real request port should pass it explicitly."""
|
||||
try:
|
||||
return int(os.environ.get("APP_PORT", "7000"))
|
||||
except ValueError:
|
||||
return 7000
|
||||
|
||||
|
||||
def lan_ip_candidates() -> list[str]:
|
||||
"""Likely LAN IPv4 addresses for this host, best candidate first.
|
||||
|
||||
The UDP-connect trick reveals the egress interface the OS would use to reach
|
||||
the default gateway -- i.e. the address a phone on the same Wi-Fi should
|
||||
target. No packets are actually sent. Loopback is dropped.
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
|
||||
def _add(ip):
|
||||
if ip and ip not in candidates and not ip.startswith("127."):
|
||||
candidates.append(ip)
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(("8.8.8.8", 80))
|
||||
_add(s.getsockname()[0])
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
s.close()
|
||||
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
_add(info[4][0])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def find_admin_user() -> str | None:
|
||||
"""Resolve an admin username from data/auth.json (schema uses is_admin),
|
||||
falling back to the first user."""
|
||||
auth_path = os.path.join("data", "auth.json")
|
||||
try:
|
||||
with open(auth_path, "r", encoding="utf-8") as f:
|
||||
users = (json.load(f) or {}).get("users", {})
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
for uname, udata in users.items():
|
||||
if udata.get("is_admin") is True:
|
||||
return uname
|
||||
return next(iter(users), None)
|
||||
|
||||
|
||||
def mint_token(owner: str, name: str = "companion") -> tuple[str, str]:
|
||||
"""Create a chat-scoped API token row and return (token_id, raw_token).
|
||||
|
||||
The raw token is returned ONCE -- only its bcrypt hash + an 8-char prefix
|
||||
are persisted. Mirrors routes/api_token_routes.py so cookie- and
|
||||
companion-minted tokens are indistinguishable to the auth middleware.
|
||||
"""
|
||||
from core.database import get_db_session, ApiToken
|
||||
|
||||
raw_token = "ody_" + secrets.token_urlsafe(32)
|
||||
token_hash = bcrypt.hashpw(raw_token.encode(), bcrypt.gensalt()).decode()
|
||||
token_id = str(uuid.uuid4())[:8]
|
||||
|
||||
with get_db_session() as db:
|
||||
db.add(ApiToken(
|
||||
id=token_id,
|
||||
owner=owner,
|
||||
name=name,
|
||||
token_hash=token_hash,
|
||||
token_prefix=raw_token[:8],
|
||||
scopes=COMPANION_SCOPE,
|
||||
is_active=True,
|
||||
))
|
||||
return token_id, raw_token
|
||||
|
||||
|
||||
def pairing_payload(host: str, port: int, token: str) -> dict:
|
||||
"""The exact JSON a client scans / accepts. Keep keys stable."""
|
||||
return {"v": PAIRING_VERSION, "host": host, "port": port, "token": token}
|
||||
|
||||
|
||||
def pairing_qr_png_data_uri(payload: dict) -> str | None:
|
||||
"""Render the pairing payload as a QR `data:` URI for an <img>. Returns None
|
||||
if the optional qrcode dep is unavailable."""
|
||||
try:
|
||||
import base64
|
||||
import io
|
||||
|
||||
import qrcode
|
||||
|
||||
img = qrcode.make(json.dumps(payload, separators=(",", ":")))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
except Exception:
|
||||
return None
|
||||
@@ -1,19 +1,30 @@
|
||||
"""Companion bridge — read-only endpoints (/api/companion/*).
|
||||
"""Companion bridge — /api/companion/*.
|
||||
|
||||
A thin, additive layer so a LAN client (e.g. a phone) can discover what a server
|
||||
offers without duplicating any LLM logic. This module is intentionally
|
||||
read-only: it exposes a cheap health check, server identity, and the caller's
|
||||
own model list. Pairing/token-minting and any mutation live in separate changes.
|
||||
offers and pair to it, without duplicating any LLM logic.
|
||||
|
||||
Auth is enforced globally by AuthMiddleware (app.py), so reaching a handler here
|
||||
means the caller is authenticated by either a cookie session or a Bearer `ody_`
|
||||
API token.
|
||||
API token. The read endpoints (ping/info/models) accept either; the pairing
|
||||
endpoints are admin-cookie only.
|
||||
|
||||
Pairing CSRF posture: minting happens ONLY on POST. The session cookie is
|
||||
SameSite=Lax (routes/auth_routes.py), which a browser does not send on a
|
||||
cross-site POST, so an admin's cookie can't be used by a malicious page to mint
|
||||
a token -- the same protection the existing POST /api/tokens relies on. Minting
|
||||
on a GET would be unsafe (Lax cookies ride top-level GET navigations), so GET
|
||||
/pair only renders a form.
|
||||
"""
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from src.auth_helpers import get_current_user
|
||||
|
||||
from companion import pairing as _pairing
|
||||
|
||||
|
||||
def token_owner(request: Request) -> str | None:
|
||||
"""The real owner to attribute a request to, for read-scoping.
|
||||
@@ -40,6 +51,20 @@ def owner_can_see(row_owner, owner) -> bool:
|
||||
return row_owner is None or row_owner == owner
|
||||
|
||||
|
||||
def mint_pairing_token(owner: str, invalidate=None) -> tuple[str, str]:
|
||||
"""Mint a pairing token AND invalidate the auth middleware's in-memory token
|
||||
cache, so the new token is accepted on the very next request without a server
|
||||
restart. Returns (token_id, raw_token); the raw token is shown once.
|
||||
|
||||
`invalidate` is the app's request.app.state.invalidate_token_cache callable
|
||||
(passed in so this stays a pure, testable unit).
|
||||
"""
|
||||
token_id, raw_token = _pairing.mint_token(owner)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
return token_id, raw_token
|
||||
|
||||
|
||||
def setup_companion_routes() -> APIRouter:
|
||||
router = APIRouter(prefix="/api/companion", tags=["companion"])
|
||||
|
||||
@@ -93,8 +118,6 @@ def setup_companion_routes() -> APIRouter:
|
||||
if owner:
|
||||
q = q.filter((ModelEndpoint.owner == owner) | (ModelEndpoint.owner == None)) # noqa: E711
|
||||
for ep in q.all():
|
||||
# Defence in depth: never emit a row the owner rule rejects, even
|
||||
# if the SQL filter above were ever loosened.
|
||||
if not owner_can_see(ep.owner, owner):
|
||||
continue
|
||||
try:
|
||||
@@ -121,4 +144,92 @@ def setup_companion_routes() -> APIRouter:
|
||||
db.close()
|
||||
return {"endpoints": out}
|
||||
|
||||
@router.get("/pair")
|
||||
def pair_page(request: Request):
|
||||
"""Admin-only pairing page. Renders a form that POSTs to mint a code.
|
||||
|
||||
A GET never mints a credential: SameSite=Lax session cookies ride
|
||||
top-level GET navigations, so minting on GET would be triggerable by a
|
||||
link or <img> (CSRF). The actual mint is the POST handler below.
|
||||
"""
|
||||
require_admin(request)
|
||||
page = """<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Pair a device</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:48px auto;padding:0 20px;color:#e8e8e8;background:#16161a}
|
||||
.card{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:28px;text-align:center}
|
||||
button{background:#7c9cff;color:#0e0e12;border:none;border-radius:10px;padding:12px 20px;font-size:15px;font-weight:600;cursor:pointer}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<h2>Pair a device</h2>
|
||||
<p>Generate a one-time pairing code (a chat-scoped API token) for a LAN client.</p>
|
||||
<form method="POST" action="/api/companion/pair">
|
||||
<button type="submit">Generate pairing code</button>
|
||||
</form>
|
||||
<p style="color:#8a8a96;font-size:12px;margin-top:18px">Admin only. Each code mints a new token, shown once. Manage or revoke under Settings → API tokens.</p>
|
||||
</div></body></html>"""
|
||||
return HTMLResponse(page)
|
||||
|
||||
@router.post("/pair")
|
||||
def pair_create(request: Request):
|
||||
"""Mint a pairing code. Admin-cookie only; CSRF-safe because the
|
||||
SameSite=Lax session cookie is not sent on a cross-site POST (same
|
||||
protection as POST /api/tokens). Minting invalidates the token cache so
|
||||
the code works immediately, no restart. `?format=json` returns the
|
||||
payload for an in-app pairing screen."""
|
||||
require_admin(request)
|
||||
owner = get_current_user(request)
|
||||
invalidate = getattr(request.app.state, "invalidate_token_cache", None)
|
||||
token_id, raw_token = mint_pairing_token(owner, invalidate)
|
||||
|
||||
hosts = _pairing.lan_ip_candidates()
|
||||
host = hosts[0] if hosts else "127.0.0.1"
|
||||
port = request.url.port or _pairing.default_port()
|
||||
payload = _pairing.pairing_payload(host, port, raw_token)
|
||||
qr = _pairing.pairing_qr_png_data_uri(payload)
|
||||
qr_ok = bool(qr and qr.startswith("data:image/png;base64,"))
|
||||
|
||||
if (request.query_params.get("format") or "").lower() == "json":
|
||||
return {
|
||||
"host": host,
|
||||
"port": port,
|
||||
"token": raw_token,
|
||||
"token_id": token_id,
|
||||
"hosts": hosts,
|
||||
"payload": payload,
|
||||
"qr": qr if qr_ok else None,
|
||||
}
|
||||
|
||||
import json as _json
|
||||
payload_json = _json.dumps(payload, separators=(",", ":"))
|
||||
# Only ever emit a known PNG data-URI into the src; every other value is
|
||||
# html.escaped.
|
||||
qr_block = (
|
||||
f'<img src="{html.escape(qr)}" alt="Pairing QR" width="260" height="260">'
|
||||
if qr_ok else "<p><em>QR rendering unavailable -- enter the details manually.</em></p>"
|
||||
)
|
||||
page = f"""<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Pairing code</title>
|
||||
<style>
|
||||
body{{font-family:-apple-system,system-ui,sans-serif;max-width:520px;margin:40px auto;padding:0 20px;color:#e8e8e8;background:#16161a}}
|
||||
.card{{background:#1f1f25;border:1px solid #2c2c35;border-radius:14px;padding:24px;text-align:center}}
|
||||
code{{background:#0e0e12;padding:2px 6px;border-radius:6px;word-break:break-all}}
|
||||
.row{{text-align:left;margin:10px 0;font-size:14px;color:#bdbdc7}}
|
||||
.warn{{color:#e0a85e;font-size:13px;margin-top:18px}}
|
||||
</style></head>
|
||||
<body><div class="card">
|
||||
<h2>Pairing code</h2>
|
||||
{qr_block}
|
||||
<div class="row"><strong>Host:</strong> <code>{html.escape(host)}</code></div>
|
||||
<div class="row"><strong>Port:</strong> <code>{html.escape(str(port))}</code></div>
|
||||
<div class="row"><strong>Token:</strong> <code>{html.escape(raw_token)}</code></div>
|
||||
<div class="row"><strong>Payload:</strong> <code>{html.escape(payload_json)}</code></div>
|
||||
<p class="warn">Shown once. This grants chat access to your Odysseus; revoke it
|
||||
in Settings → API tokens (id <code>{html.escape(token_id)}</code>). The
|
||||
device must be on the same network, and the server must bind to your LAN.</p>
|
||||
</div></body></html>"""
|
||||
return HTMLResponse(page)
|
||||
|
||||
return router
|
||||
|
||||
156
tests/test_companion_pairing.py
Normal file
156
tests/test_companion_pairing.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Tests for the companion pairing endpoints (split 3/4).
|
||||
|
||||
Covers what the review asked for:
|
||||
- a non-admin / bearer caller cannot call /api/companion/pair (admin-only)
|
||||
- the pairing token is minted once (hashed at rest) and the mint invalidates
|
||||
the auth cache so it works immediately, no restart
|
||||
- minting is a POST, never a GET (CSRF: a SameSite=Lax cookie rides a
|
||||
top-level GET, so GET-minting would be triggerable by a link / <img>)
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Capture what mint_token would persist, via a stubbed core.database.
|
||||
_CAPTURED = {}
|
||||
|
||||
|
||||
class _ApiToken:
|
||||
def __init__(self, **kw):
|
||||
_CAPTURED.clear()
|
||||
_CAPTURED.update(kw)
|
||||
self.__dict__.update(kw)
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _get_db_session():
|
||||
yield MagicMock()
|
||||
|
||||
|
||||
# core/__init__ pulls in models/session_manager which import many ORM names from
|
||||
# core.database; under conftest's sqlalchemy stubs the real module can't load.
|
||||
# A __getattr__ module resolves ANY requested name to a MagicMock, while keeping
|
||||
# our real get_db_session/ApiToken for the mint test.
|
||||
class _DBStub(types.ModuleType):
|
||||
def __getattr__(self, name): # noqa: D401
|
||||
return MagicMock()
|
||||
|
||||
|
||||
_db = _DBStub("core.database")
|
||||
_db.get_db_session = _get_db_session
|
||||
_db.ApiToken = _ApiToken
|
||||
sys.modules["core.database"] = _db # overwrite any minimal stub from a sibling test
|
||||
|
||||
for _name, _attrs in {
|
||||
"core.auth": {"AuthManager": MagicMock()},
|
||||
"src.endpoint_resolver": {"build_chat_url": (lambda u: u)},
|
||||
}.items():
|
||||
if _name not in sys.modules:
|
||||
_mm = types.ModuleType(_name)
|
||||
for _k, _v in _attrs.items():
|
||||
setattr(_mm, _k, _v)
|
||||
sys.modules[_name] = _mm
|
||||
|
||||
from fastapi import HTTPException # noqa: E402
|
||||
|
||||
import companion.pairing as P # noqa: E402
|
||||
from companion.routes import mint_pairing_token, setup_companion_routes # noqa: E402
|
||||
from core.middleware import require_admin # noqa: E402
|
||||
|
||||
|
||||
# --- token minting: shown once, hashed at rest -----------------------------
|
||||
|
||||
def test_mint_token_returns_raw_once_and_stores_only_a_hash():
|
||||
token_id, raw = P.mint_token("alice")
|
||||
assert raw.startswith("ody_")
|
||||
# The persisted row stores a bcrypt hash + prefix, never the plaintext.
|
||||
assert _CAPTURED["token_hash"] != raw
|
||||
assert _CAPTURED["token_hash"].startswith("$2") # bcrypt
|
||||
assert _CAPTURED["token_prefix"] == raw[:8]
|
||||
assert _CAPTURED["owner"] == "alice"
|
||||
assert _CAPTURED["scopes"] == "chat"
|
||||
assert _CAPTURED["is_active"] is True
|
||||
|
||||
|
||||
def test_mint_pairing_token_invalidates_cache(monkeypatch):
|
||||
# The mint must flip the auth middleware's cache so the token works on the
|
||||
# very next request, with no restart.
|
||||
monkeypatch.setattr(P, "mint_token", lambda owner, name="companion": ("id1", "ody_demo"))
|
||||
invalidate = MagicMock()
|
||||
token_id, raw = mint_pairing_token("alice", invalidate)
|
||||
assert (token_id, raw) == ("id1", "ody_demo")
|
||||
invalidate.assert_called_once()
|
||||
|
||||
|
||||
def test_mint_pairing_token_tolerates_no_invalidator(monkeypatch):
|
||||
monkeypatch.setattr(P, "mint_token", lambda owner, name="companion": ("id1", "ody_demo"))
|
||||
# Must not blow up if the app didn't expose an invalidator.
|
||||
assert mint_pairing_token("alice", None) == ("id1", "ody_demo")
|
||||
|
||||
|
||||
def test_pairing_payload_shape():
|
||||
p = P.pairing_payload("192.168.1.9", 7000, "ody_x")
|
||||
assert p == {"v": 1, "host": "192.168.1.9", "port": 7000, "token": "ody_x"}
|
||||
|
||||
|
||||
# --- admin-only gate: a bearer/non-admin caller is rejected ----------------
|
||||
|
||||
def _admin_mgr(is_admin):
|
||||
return SimpleNamespace(is_admin=lambda u: is_admin, is_configured=True)
|
||||
|
||||
|
||||
def _req(current_user, *, api_token=False, is_admin=False):
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(current_user=current_user, api_token=api_token),
|
||||
headers={},
|
||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=_admin_mgr(is_admin))),
|
||||
)
|
||||
|
||||
|
||||
def test_bearer_token_caller_cannot_pair(monkeypatch):
|
||||
# Bearer callers come through as the "api" pseudo-user, which is not admin.
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_admin(_req("api", api_token=True, is_admin=False))
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_non_admin_user_cannot_pair(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
require_admin(_req("bob", is_admin=False))
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_admin_user_passes_the_gate(monkeypatch):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
# Should not raise.
|
||||
require_admin(_req("alice", is_admin=True))
|
||||
|
||||
|
||||
# --- CSRF: minting is POST, never GET --------------------------------------
|
||||
|
||||
def _pair_methods():
|
||||
router = setup_companion_routes()
|
||||
methods = set()
|
||||
for r in router.routes:
|
||||
path = getattr(r, "path", "")
|
||||
if path.endswith("/pair"):
|
||||
methods |= set(getattr(r, "methods", set()) or set())
|
||||
return methods
|
||||
|
||||
|
||||
def test_pair_is_minted_via_post_not_get():
|
||||
methods = _pair_methods()
|
||||
assert "POST" in methods, "pairing must accept POST (the mint)"
|
||||
assert "GET" in methods, "GET should render the form page"
|
||||
# The distinction is enforced in the handlers: GET renders a form and never
|
||||
# mints; only POST calls mint_pairing_token.
|
||||
Reference in New Issue
Block a user