"""Browser PAT lifecycle and authentication boundaries, with no upstream calls.""" from datetime import timedelta import httpx import pytest from fastapi import HTTPException from sqlalchemy import select from app.mcp_api.auth import authenticate from app.models import Account, MCPToken, now async def test_token_browser_lifecycle(app, logged_in): client = logged_in # Administration remains available when the MCP transport is disabled. initial = (await client.get("/api/v1/mcp-tokens")).json() assert initial["enabled"] is False and initial["can_create"] is False assert (await client.post("/api/v1/mcp-tokens", json={"name": "reader"})).status_code == 422 async with app.state.sessions.begin() as db: account = await db.get(Account, 1) account.wq_user_id = "synthetic-user" issued = await client.post("/api/v1/mcp-tokens", json={"name": " reader "}) assert issued.status_code == 201 assert issued.headers["cache-control"] == "no-store" token = issued.json() assert token["name"] == "reader" and token["scopes"] == ["research:read"] assert token["status"] == "active" and token["token"].startswith("wqmcp_") async with app.state.sessions() as db: principal = await authenticate(db, token["token"]) row = await db.get(MCPToken, principal.token_id) assert row.token_hash != token["token"] assert 89 < (row.expires_at.replace(tzinfo=now().tzinfo) - now()).total_seconds() / 86400 <= 90 page = await client.get("/api/v1/mcp-tokens?limit=1") assert token["token"] not in page.text and "token_hash" not in page.text assert page.json()["items"][0]["id"] == token["id"] second = await client.post("/api/v1/mcp-tokens", json={"name": "executor", "days": 1, "scopes": ["research:read", "backtests:execute"]}) assert second.status_code == 201 page = (await client.get("/api/v1/mcp-tokens?limit=1")).json() assert page["total"] == 2 and page["has_more"] assert (await client.get("/api/v1/mcp-tokens?limit=1&offset=1")).json()["items"][0]["id"] == token["id"] revoked = await client.post(f'/api/v1/mcp-tokens/{token["id"]}/revoke') again = await client.post(f'/api/v1/mcp-tokens/{token["id"]}/revoke') assert revoked.json() == again.json() and revoked.json()["status"] == "revoked" assert "token" not in revoked.json() async with app.state.sessions() as db: with pytest.raises(HTTPException) as exc: await authenticate(db, token["token"]) assert exc.value.status_code == 401 assert (await client.post("/api/v1/mcp-tokens/missing/revoke")).status_code == 404 async def test_token_browser_security_and_validation(app, logged_in): client = logged_in async with app.state.sessions.begin() as db: (await db.get(Account, 1)).wq_user_id = "synthetic-user" for body in [ {"name": " "}, {"name": "x", "days": 0}, {"name": "x", "days": 366}, {"name": "x", "days": 1.5}, {"name": "x", "scopes": ["backtests:execute"]}, {"name": "x", "scopes": ["research:read", "admin"]}, {"name": "x", "account_id": 2}, ]: assert (await client.post("/api/v1/mcp-tokens", json=body)).status_code == 422 assert (await client.post("/api/v1/mcp-tokens", json={"name": "x"}, headers={"X-WQ-Request": ""})).status_code == 403 assert (await client.post("/api/v1/mcp-tokens", json={"name": "x"}, headers={"Origin": "https://evil.test"})).status_code == 403 token = (await client.post("/api/v1/mcp-tokens", json={"name": "x"})).json() async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as outsider: headers = {"Authorization": f'Bearer {token["token"]}', "X-WQ-Request": "1"} assert (await outsider.get("/api/v1/mcp-tokens")).status_code == 401 assert (await outsider.get("/api/v1/mcp-tokens", headers=headers)).status_code == 401 assert (await outsider.post("/api/v1/mcp-tokens", json={"name": "escalation"}, headers=headers)).status_code == 401 assert (await outsider.post(f'/api/v1/mcp-tokens/{token["id"]}/revoke', headers=headers)).status_code == 401 async with app.state.sessions.begin() as db: (await db.get(Account, 1)).wq_user_id = "different-user" assert (await client.get("/api/v1/mcp-tokens")).json()["items"][0]["status"] == "invalid_binding" async with app.state.sessions.begin() as db: row = await db.scalar(select(MCPToken)) row.expires_at = now() - timedelta(days=1) assert (await client.get("/api/v1/mcp-tokens")).json()["items"][0]["status"] == "expired" async def test_edit_token_permissions(app, logged_in): async with app.state.sessions.begin() as db: (await db.get(Account, 1)).wq_user_id = "synthetic-user" token = (await logged_in.post("/api/v1/mcp-tokens", json={"name": "editable"})).json() path = f'/api/v1/mcp-tokens/{token["id"]}' for scopes in [["research:read", "research:write", "backtests:execute"], ["research:read"]]: response = await logged_in.patch(path, json={"scopes": scopes}) assert response.status_code == 200 result = response.json() assert result["scopes"] == sorted(scopes) assert result["expires_at"] == token["expires_at"] assert result["name"] == token["name"] assert "token" not in result and "token_hash" not in result async with app.state.sessions() as db: assert (await authenticate(db, token["token"])).scopes == frozenset(scopes) for body in [{"scopes": []}, {"scopes": ["admin", "research:read"]}, {"scopes": ["research:write"]}, {"scopes": ["research:read"], "admin_id": 2}, {}]: assert (await logged_in.patch(path, json=body)).status_code == 422 body = {"scopes": ["research:read", "research:write"]} assert (await logged_in.patch(path, json=body, headers={"X-WQ-Request": ""})).status_code == 403 assert (await logged_in.patch(path, json=body, headers={"Origin": "https://evil.test"})).status_code == 403 async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as outsider: assert (await outsider.patch(path, json=body, headers={"Authorization": f'Bearer {token["token"]}', "X-WQ-Request": "1"})).status_code == 401 assert (await logged_in.patch("/api/v1/mcp-tokens/missing", json=body)).status_code == 404 async with app.state.sessions.begin() as db: (await db.get(Account, 1)).wq_user_id = "changed-user" assert (await logged_in.patch(path, json=body)).status_code == 409 async with app.state.sessions.begin() as db: (await db.get(Account, 1)).wq_user_id = "synthetic-user" (await db.get(MCPToken, token["id"])).expires_at = now() - timedelta(days=1) assert (await logged_in.patch(path, json=body)).status_code == 409 await logged_in.post(path + "/revoke") assert (await logged_in.patch(path, json=body)).status_code == 409