"""Self-service account flows: signup → verify → login → key → use → revoke → 401, reset, CSRF, /me, account security. Every test here that documents a fixed vulnerability is marked in its docstring with the finding id (A1…A24).""" from __future__ import annotations import os from urllib.parse import parse_qs, urlparse from fastapi.testclient import TestClient JSON = {"Content-Type": "application/json"} def token_of(link: str) -> str: return parse_qs(urlparse(link).query)["token"][0] def fresh_email(tag: str) -> str: return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com" def test_full_lifecycle(web): email = fresh_email("ada") pw = "very-secure-passphrase" # signup → 202 with (dev only) debug link r = web.post("/v1/auth/signup", json={"email": email, "name": "Ada", "password": pw}, headers=JSON) assert r.status_code == 202, r.text d = r.json()["data"] assert d["status"] == "verification_sent" and "debug_link" in d and "/verify?token=" in d["debug_link"] # cannot log in before verifying r = web.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON) assert r.status_code == 403 and r.json()["error"]["code"] == "EMAIL_NOT_VERIFIED" # A9/A16: GET verify only redirects to the web page (no consumption, no cookie) r = web.get("/v1/auth/verify", params={"token": token_of(d["debug_link"])}, follow_redirects=False) assert r.status_code == 302 and r.headers["location"].endswith(f"/verify?token={token_of(d['debug_link'])}") assert "hfmd_session" not in r.cookies # POST verify → session cookie r = web.post("/v1/auth/verify", json={"token": token_of(d["debug_link"])}, headers=JSON) assert r.status_code == 200 and r.json()["data"]["email_verified"] is True and r.json()["data"]["kind"] == "verify" assert "hfmd_session" in r.cookies # token is single use assert web.post("/v1/auth/verify", json={"token": token_of(d["debug_link"])}, headers=JSON).json()["error"]["code"] == "INVALID_TOKEN" # /me with the cookie r = web.get("/v1/me") assert r.status_code == 200 me = r.json()["data"] assert me["user"]["email"] == email and me["limits"]["tier"] == "free" and me["limits"]["requests"] == 120 assert me["user"]["keys_active"] == 0 and me["auth"] == "session" # create key (shown once) with note + expiry r = web.post("/v1/me/keys", json={"name": "laptop", "note": "CI runner", "expires_in_days": 30}, headers=JSON) assert r.status_code == 201, r.text k = r.json()["data"] assert k["key"].startswith("hfmd_live_") and len(k["key"]) == len("hfmd_live_") + 32 assert k["prefix"] == k["key"][:18] and k["name"] == "laptop" and k["note"] == "CI runner" and k["scopes"] == ["data"] assert k["expires_at"] and k["expired"] is False listed = web.get("/v1/me/keys").json()["data"] assert len(listed) == 1 and "key" not in listed[0] and listed[0]["prefix"] == k["prefix"] # use the key on a data endpoint → free-tier headers, last_used_at + hashed ip recorded r = web.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}", "X-Forwarded-For": "203.0.113.7"}) assert r.status_code == 200 and r.headers["X-RateLimit-Limit-Requests"] == "120" listed = web.get("/v1/me/keys").json()["data"] assert listed[0]["last_used_at"] and len(listed[0]["last_used_ip"]) == 16 # A6: the key may READ the account but not mutate it anon = TestClient(web.app) r = anon.get("/v1/me", headers={"Authorization": f"Bearer {k['key']}"}) assert r.status_code == 200 and r.json()["data"]["auth"] == "key" assert anon.get("/v1/me/usage?range=24h", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 200 assert anon.get("/v1/me/limits", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 200 r = anon.post(f"/v1/me/keys/{k['id']}/rotate", json={}, headers={**JSON, "Authorization": f"Bearer {k['key']}"}) assert r.status_code == 403 and r.json()["error"]["code"] == "SESSION_REQUIRED" assert anon.get("/v1/me/keys", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 403 # rotate (session) → new key keeps name/note/expiry, old one dead r = web.post(f"/v1/me/keys/{k['id']}/rotate", json={}, headers=JSON) assert r.status_code == 201 k2 = r.json()["data"] assert k2["key"] != k["key"] and k2["rotated_from"] == k["id"] and k2["name"] == "laptop" and k2["note"] == "CI runner" assert k2["expires_at"] == k["expires_at"] r = web.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"}) assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_API_KEY" # A3: revoke with an EMPTY body + X-Requested-With (what the SPA sends) is accepted r = web.delete(f"/v1/me/keys/{k2['id']}", headers={"X-Requested-With": "hfmd"}) assert r.status_code == 200 and r.json()["data"]["status"] == "revoked" assert web.get("/v1/status", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 401 # usage endpoint (session) u = web.get("/v1/me/usage?range=24h").json()["data"] assert u["totals"]["requests"] >= 1 and len(u["principals"]) == 3 and any(p.startswith("user:") for p in u["principals"]) and u["key_id"] is None assert {"requests", "rows", "status_429", "bytes", "rows_parquet"} <= set(u["points"][0]) # logout with empty body + header, cookie gone r = web.post("/v1/auth/logout", headers={"X-Requested-With": "hfmd"}) assert r.status_code == 200 assert web.get("/v1/me").status_code == 401 def test_signup_never_touches_existing_account_and_is_uniform(web, make_user, outbox): """A4 (pre-hijack) + A10 (enumeration): signup on an existing address changes nothing and answers 202.""" from accounts import security from accounts.models import User from core.db import session uid, _, email, pw = make_user() with session() as s: before = (s.get(User, uid).password_hash, s.get(User, uid).name) r = web.post("/v1/auth/signup", json={"email": email, "name": "Mallory", "password": "attacker-passphrase"}, headers=JSON) assert r.status_code == 202 and r.json()["data"]["status"] == "verification_sent" and "debug_link" not in r.json()["data"] with session() as s: u = s.get(User, uid) assert (u.password_hash, u.name) == before assert security.verify_password(pw, u.password_hash) and not security.verify_password("attacker-passphrase", u.password_hash) assert [m.kind for m in outbox] == ["already_registered"] and outbox[0].to == email # unverified signup: a second signup re-sends the verification but keeps the ORIGINAL password email2 = fresh_email("unverified") web.post("/v1/auth/signup", json={"email": email2, "name": "Real", "password": "the-real-passphrase"}, headers=JSON) r = web.post("/v1/auth/signup", json={"email": email2, "name": "Mallory", "password": "attacker-passphrase"}, headers=JSON) assert r.status_code == 202 and "/verify?token=" in r.json()["data"]["debug_link"] with session() as s: u = s.execute(__import__("sqlalchemy").select(User).where(User.email == email2)).scalars().first() assert u.name == "Real" and security.verify_password("the-real-passphrase", u.password_hash) # validation errors are still explicit r = web.post("/v1/auth/signup", json={"email": fresh_email("w"), "name": "x", "password": "short"}, headers=JSON) assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD" r = web.post("/v1/auth/signup", json={"email": "not-an-email", "name": "x", "password": "long-enough-password"}, headers=JSON) assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" def test_csrf_guard(web, make_user, signin): """A3: JSON always OK; empty body needs X-Requested-With / Sec-Fetch-Site / same Origin; forms are refused.""" _, _, email, pw = make_user() signin(web, email, pw) r = web.post("/v1/me/keys", content="name=x", headers={"Content-Type": "application/x-www-form-urlencoded"}) assert r.status_code == 415 and r.json()["error"]["code"] == "UNSUPPORTED_MEDIA_TYPE" r = web.post("/v1/auth/logout", headers={"Content-Type": "text/plain"}, content="x") assert r.status_code == 415 # bodiless POST without any proof → 415 (a cross-site