SPB Git

Repository Public

Commit

tests: comptes et ratelimit — sessions cookie, Bearer lecture seule, CSRF, anti-pré-hijack, verrouillage, session_version, compte, clés, usage/limits, XFF, throttles, cache, fail-closed, alertes quota, seed JSON

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed · Sep 6, 2026 parent e7f2d3b
7 changed files +841 −161 Browse files
modified tests/conftest.py +41 −1
@@ -71,7 +71,7 @@ def make_user(app):
71 def _make(email: str | None = None, *, tier: str = "free", role: str = "user", password: str = "correct-horse-battery", 71 def _make(email: str | None = None, *, tier: str = "free", role: str = "user", password: str = "correct-horse-battery",
72 with_key: bool = True): 72 with_key: bool = True):
73 counter["n"] += 1 73 counter["n"] += 1
74− email = email or f"user{counter['n']}-{os.getpid()}-{id(counter)}@example.com" 74+ email = email or f"user{counter['n']}-{os.getpid()}-{os.urandom(4).hex()}@example.com"
75 with session() as s: 75 with session() as s:
76 u = service.create_user(s, email, "Test User", password=password, tier=tier, role=role, status="active", actor="test") 76 u = service.create_user(s, email, "Test User", password=password, tier=tier, role=role, status="active", actor="test")
77 u.email_verified_at = service.now() 77 u.email_verified_at = service.now()
@@ -83,6 +83,46 @@ def make_user(app):
83 return _make 83 return _make
84 84
85 85
86+@pytest.fixture
87+def web(app):
88+ """Fresh TestClient (own cookie jar) for browser-session flows. Not shared with `client`."""
89+ from fastapi.testclient import TestClient
90+ with TestClient(app) as c:
91+ yield c
92+
93+
94+@pytest.fixture
95+def signin(app):
96+ """signin(client, email, password) → logs a TestClient in through POST /v1/auth/login (cookie session)."""
97+ def _signin(c, email: str, password: str):
98+ r = c.post("/v1/auth/login", json={"email": email, "password": password}, headers={"Content-Type": "application/json"})
99+ assert r.status_code == 200, r.text
100+ return r.json()["data"]
101+ return _signin
102+
103+
104+@pytest.fixture
105+def admin_web(app, make_user, signin):
106+ """TestClient signed in (cookie) as a fresh admin. Returns (client, user_id, email, password)."""
107+ from fastapi.testclient import TestClient
108+ uid, _, email, pw = make_user(role="admin", with_key=False)
109+ with TestClient(app) as c:
110+ signin(c, email, pw)
111+ yield c, uid, email, pw
112+
113+
114+@pytest.fixture
115+def outbox(app, monkeypatch):
116+ """Capture every e-mail queued by the accounts service (provider considered configured, nothing sent)."""
117+ from dataclasses import replace
118+
119+ from accounts import mailer
120+ sent: list = []
121+ monkeypatch.setattr(mailer, "settings", replace(mailer.settings, resend_api_key="re_test_capture"))
122+ monkeypatch.setattr(mailer, "dispatch", lambda msg: sent.append(msg))
123+ return sent
124+
125+
86 @pytest.fixture 126 @pytest.fixture
87 def client_hu(app, make_user): 127 def client_hu(app, make_user):
88 """Client authenticated with a high_usage API key (600 req/min, 200 000 rows/request) — for data-heavy tests.""" 128 """Client authenticated with a high_usage API key (600 req/min, 200 000 rows/request) — for data-heavy tests."""
modified tests/test_accounts_admin.py +159 −69
@@ -1,9 +1,12 @@
1−"""Admin API: invite flow → accept → login, tier/status updates, keys, global usage, audit log, role guard.""" 1+"""Admin API: session-only access, invite flow → accept → login, tier/status updates, keys, last-admin guard,
2+user deletion, global usage, audit filters."""
2 from __future__ import annotations 3 from __future__ import annotations
3 4
4 import os 5 import os
5 from urllib.parse import parse_qs, urlparse 6 from urllib.parse import parse_qs, urlparse
6 7
8+from fastapi.testclient import TestClient
9+
7 JSON = {"Content-Type": "application/json"} 10 JSON = {"Content-Type": "application/json"}
8 11
9 12
@@ -15,135 +18,222 @@ def fresh_email(tag: str) -> str:
15 return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com" 18 return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com"
16 19
17 20
18−def admin_headers(make_user) -> dict: 21+def test_admin_requires_session_and_role(client, make_user, admin_web):
19− _, key, _, _ = make_user(role="admin") 22+ """A6: an admin's API key does NOT open the admin area; plain users get FORBIDDEN."""
20− return {**JSON, "Authorization": f"Bearer {key}"}
21−
22−
23−def test_non_admin_is_forbidden(client, make_user):
24 _, key, _, _ = make_user() 23 _, key, _, _ = make_user()
25 r = client.get("/v1/admin/users", headers={"Authorization": f"Bearer {key}"}) 24 r = client.get("/v1/admin/users", headers={"Authorization": f"Bearer {key}"})
26− assert r.status_code == 403 and r.json()["error"]["code"] == "FORBIDDEN" 25+ assert r.status_code == 403 and r.json()["error"]["code"] == "SESSION_REQUIRED"
27 assert client.get("/v1/admin/users").status_code == 401 26 assert client.get("/v1/admin/users").status_code == 401
27+ _, admin_key, _, _ = make_user(role="admin")
28+ r = client.get("/v1/admin/users", headers={"Authorization": f"Bearer {admin_key}"})
29+ assert r.status_code == 403 and r.json()["error"]["code"] == "SESSION_REQUIRED"
30+ web, _, _, _ = admin_web
31+ assert web.get("/v1/admin/users").status_code == 200
32+ # a signed-in plain user → FORBIDDEN
33+ _, _, email, pw = make_user(with_key=False)
34+ plain = TestClient(web.app)
35+ plain.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)
36+ r = plain.get("/v1/admin/users")
37+ assert r.status_code == 403 and r.json()["error"]["code"] == "FORBIDDEN"
28 38
29 39
30−def test_invite_flow(client, make_user): 40+def test_invite_flow(admin_web):
31− ah = admin_headers(make_user) 41+ web, _, _, _ = admin_web
32 email = fresh_email("tim") 42 email = fresh_email("tim")
33− r = client.post("/v1/admin/users", json={"email": email, "name": "Tim", "tier": "high_usage"}, headers=ah) 43+ r = web.post("/v1/admin/users", json={"email": email, "name": "Tim", "tier": "high_usage"}, headers=JSON)
34 assert r.status_code == 201, r.text 44 assert r.status_code == 201, r.text
35 d = r.json()["data"] 45 d = r.json()["data"]
36 assert d["user"]["status"] == "invited" and d["user"]["tier"] == "high_usage" and d["user"]["keys_active"] == 1 46 assert d["user"]["status"] == "invited" and d["user"]["tier"] == "high_usage" and d["user"]["keys_active"] == 1
37 assert d["key"]["prefix"].startswith("hfmd_live_") and "key" not in d["key"] # admins never see the full key here 47 assert d["key"]["prefix"].startswith("hfmd_live_") and "key" not in d["key"] # admins never see the full key here
38− assert d["invitation"]["delivered"] is False and "/accept-invite?token=" in d["invitation"]["link"] 48+ assert d["invitation"]["delivered"] is False and d["invitation"]["queued"] is False
49+ assert "/accept-invite?token=" in d["invitation"]["link"]
39 uid = d["user"]["id"] 50 uid = d["user"]["id"]
40 # duplicate invite → 409 51 # duplicate invite → 409
41− r = client.post("/v1/admin/users", json={"email": email, "name": "Tim"}, headers=ah) 52+ r = web.post("/v1/admin/users", json={"email": email, "name": "Tim"}, headers=JSON)
42 assert r.status_code == 409 and r.json()["error"]["code"] == "EMAIL_TAKEN" 53 assert r.status_code == 409 and r.json()["error"]["code"] == "EMAIL_TAKEN"
43− # detail shows the pending link 54+ # detail shows the pending link, the keys and a 7-day usage series
44− r = client.get(f"/v1/admin/users/{uid}", headers=ah) 55+ r = web.get(f"/v1/admin/users/{uid}")
45− assert r.status_code == 200 and r.json()["data"]["pending_invite_link"] == d["invitation"]["link"] 56+ assert r.status_code == 200
46− assert len(r.json()["data"]["keys"]) == 1 57+ det = r.json()["data"]
58+ assert det["pending_invite_link"] == d["invitation"]["link"] and len(det["keys"]) == 1
59+ assert det["usage"]["range"] == "7d" and len(det["usage"]["points"]) == 168 and det["keys"][0]["scopes"] == ["data"]
47 # user cannot log in yet (no password) 60 # user cannot log in yet (no password)
48− r = client.post("/v1/auth/login", json={"email": email, "password": "whatever-long-enough"}, headers=JSON) 61+ guest = TestClient(web.app)
62+ r = guest.post("/v1/auth/login", json={"email": email, "password": "whatever-long-enough"}, headers=JSON)
49 assert r.status_code == 401 63 assert r.status_code == 401
50 # re-send voids the first token 64 # re-send voids the first token
51− r = client.post(f"/v1/admin/users/{uid}/invite", json={}, headers=ah) 65+ r = web.post(f"/v1/admin/users/{uid}/invite", json={}, headers=JSON)
52 assert r.status_code == 200 66 assert r.status_code == 200
53 link2 = r.json()["data"]["invitation"]["link"] 67 link2 = r.json()["data"]["invitation"]["link"]
54− r = client.post("/v1/auth/accept-invite", json={"token": token_of(d["invitation"]["link"]), "password": "tim-passphrase-123"}, headers=JSON) 68+ r = guest.post("/v1/auth/accept-invite", json={"token": token_of(d["invitation"]["link"]), "password": "tim-passphrase-123"}, headers=JSON)
55 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN" 69 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN"
56 # accept with the fresh one → active + session 70 # accept with the fresh one → active + session
57− r = client.post("/v1/auth/accept-invite", json={"token": token_of(link2), "password": "tim-passphrase-123"}, headers=JSON) 71+ r = guest.post("/v1/auth/accept-invite", json={"token": token_of(link2), "password": "tim-passphrase-123"}, headers=JSON)
58 assert r.status_code == 200 and r.json()["data"]["status"] == "active" and r.json()["data"]["email_verified"] is True 72 assert r.status_code == 200 and r.json()["data"]["status"] == "active" and r.json()["data"]["email_verified"] is True
59− me = client.get("/v1/me").json()["data"] 73+ me = guest.get("/v1/me").json()["data"]
60 assert me["user"]["email"] == email and me["limits"]["tier"] == "high_usage" and me["user"]["keys_active"] == 1 74 assert me["user"]["email"] == email and me["limits"]["tier"] == "high_usage" and me["user"]["keys_active"] == 1
61− client.cookies.clear() 75+ guest.cookies.clear()
62− r = client.post("/v1/auth/login", json={"email": email, "password": "tim-passphrase-123"}, headers=JSON) 76+ r = guest.post("/v1/auth/login", json={"email": email, "password": "tim-passphrase-123"}, headers=JSON)
63 assert r.status_code == 200 77 assert r.status_code == 200
64− client.cookies.clear() 78+ guest.cookies.clear()
65− # signing up again with an invited-then-active address → EMAIL_TAKEN 79+ # signing up again with an invited-then-active address → uniform 202, nothing changed
66− r = client.post("/v1/auth/signup", json={"email": email, "name": "Tim", "password": "another-passphrase"}, headers=JSON) 80+ r = guest.post("/v1/auth/signup", json={"email": email, "name": "Tim", "password": "another-passphrase"}, headers=JSON)
67− assert r.status_code == 409 81+ assert r.status_code == 202 and "debug_link" not in r.json()["data"]
82+ assert guest.post("/v1/auth/login", json={"email": email, "password": "tim-passphrase-123"}, headers=JSON).status_code == 200
68 83
69 84
70−def test_signup_on_invited_address_resends_invitation(client, make_user): 85+def test_signup_on_invited_address_resends_invitation(admin_web):
71− ah = admin_headers(make_user) 86+ web, _, _, _ = admin_web
72 email = fresh_email("luca") 87 email = fresh_email("luca")
73− client.post("/v1/admin/users", json={"email": email, "name": "Luca"}, headers=ah) 88+ web.post("/v1/admin/users", json={"email": email, "name": "Luca"}, headers=JSON)
74− r = client.post("/v1/auth/signup", json={"email": email, "name": "Luca", "password": "luca-passphrase-123"}, headers=JSON) 89+ guest = TestClient(web.app)
75− assert r.status_code == 202 and r.json()["data"]["status"] == "invitation_sent" 90+ r = guest.post("/v1/auth/signup", json={"email": email, "name": "Luca", "password": "luca-passphrase-123"}, headers=JSON)
76− assert "/accept-invite?token=" in r.json()["data"]["debug_link"] 91+ assert r.status_code == 202 and r.json()["data"]["status"] == "verification_sent" # uniform wording
92+ assert "/accept-invite?token=" in r.json()["data"]["debug_link"] # …but it IS the invitation
77 93
78 94
79−def test_create_active_user_update_tier_and_disable(client, make_user): 95+def test_create_active_user_update_tier_and_disable(admin_web):
80− ah = admin_headers(make_user) 96+ web, _, _, _ = admin_web
81 email = fresh_email("ada") 97 email = fresh_email("ada")
82− r = client.post("/v1/admin/users", json={"email": email, "name": "Ada", "password": "ada-passphrase-123"}, headers=ah) 98+ r = web.post("/v1/admin/users", json={"email": email, "name": "Ada", "password": "ada-passphrase-123"}, headers=JSON)
83 assert r.status_code == 201 and r.json()["data"]["user"]["status"] == "active" and r.json()["data"]["invitation"] is None 99 assert r.status_code == 201 and r.json()["data"]["user"]["status"] == "active" and r.json()["data"]["invitation"] is None
84 uid = r.json()["data"]["user"]["id"] 100 uid = r.json()["data"]["user"]["id"]
85− # admin creates a key for her (shown once), it honours the user tier then the override 101+ # admin creates a key for her (shown once) with a lifetime, it honours the user tier then the override
86− r = client.post(f"/v1/admin/users/{uid}/keys", json={"name": "handover"}, headers=ah) 102+ r = web.post(f"/v1/admin/users/{uid}/keys", json={"name": "handover", "expires_in_days": 90, "note": "handed over by mail"}, headers=JSON)
87− assert r.status_code == 201 103+ assert r.status_code == 201 and r.json()["data"]["expires_at"] and r.json()["data"]["note"] == "handed over by mail"
88 key = r.json()["data"]["key"] 104 key = r.json()["data"]["key"]
89− assert client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).headers["X-RateLimit-Limit-Requests"] == "120" 105+ assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).headers["X-RateLimit-Limit-Requests"] == "120"
90− r = client.patch(f"/v1/admin/users/{uid}", json={"tier": "high_usage"}, headers=ah) 106+ r = web.patch(f"/v1/admin/users/{uid}", json={"tier": "high_usage"}, headers=JSON)
91 assert r.status_code == 200 and r.json()["data"]["tier"] == "high_usage" 107 assert r.status_code == 200 and r.json()["data"]["tier"] == "high_usage"
92− assert client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).headers["X-RateLimit-Limit-Requests"] == "600" 108+ assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).headers["X-RateLimit-Limit-Requests"] == "600"
93− r = client.patch(f"/v1/admin/users/{uid}", json={"tier": "platinum"}, headers=ah) 109+ r = web.patch(f"/v1/admin/users/{uid}", json={"tier": "platinum"}, headers=JSON)
94 assert r.status_code == 422 110 assert r.status_code == 422
95 # disable → key refused, login refused 111 # disable → key refused, login refused
96− r = client.patch(f"/v1/admin/users/{uid}", json={"status": "disabled"}, headers=ah) 112+ r = web.patch(f"/v1/admin/users/{uid}", json={"status": "disabled"}, headers=JSON)
97 assert r.status_code == 200 113 assert r.status_code == 200
98− r = client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}) 114+ r = web.get("/v1/status", headers={"Authorization": f"Bearer {key}"})
99 assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED" 115 assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED"
100− r = client.post("/v1/auth/login", json={"email": email, "password": "ada-passphrase-123"}, headers=JSON) 116+ guest = TestClient(web.app)
117+ r = guest.post("/v1/auth/login", json={"email": email, "password": "ada-passphrase-123"}, headers=JSON)
101 assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED" 118 assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED"
119+ assert web.post(f"/v1/admin/users/{uid}/reset-password", json={}, headers=JSON).status_code == 409
102 # re-enable and revoke via admin 120 # re-enable and revoke via admin
103− client.patch(f"/v1/admin/users/{uid}", json={"status": "active"}, headers=ah) 121+ web.patch(f"/v1/admin/users/{uid}", json={"status": "active"}, headers=JSON)
104− kid = client.get(f"/v1/admin/users/{uid}", headers=ah).json()["data"]["keys"][0]["id"] 122+ kid = web.get(f"/v1/admin/users/{uid}").json()["data"]["keys"][0]["id"]
105− r = client.delete(f"/v1/admin/users/{uid}/keys/{kid}", headers=ah) 123+ r = web.delete(f"/v1/admin/users/{uid}/keys/{kid}", headers=JSON)
106 assert r.status_code == 200 and r.json()["data"]["status"] == "revoked" 124 assert r.status_code == 200 and r.json()["data"]["status"] == "revoked"
107− assert client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 401 125+ assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 401
108 # reset link for the user 126 # reset link for the user
109− r = client.post(f"/v1/admin/users/{uid}/reset-password", json={}, headers=ah) 127+ r = web.post(f"/v1/admin/users/{uid}/reset-password", json={}, headers=JSON)
110 assert r.status_code == 200 and "/reset-password?token=" in r.json()["data"]["reset"]["link"] 128 assert r.status_code == 200 and "/reset-password?token=" in r.json()["data"]["reset"]["link"]
111− assert client.get("/v1/admin/users/999999", headers=ah).json()["error"]["code"] == "USER_NOT_FOUND" 129+ assert web.get(f"/v1/admin/users/{uid}").json()["data"]["pending_reset_link"] == r.json()["data"]["reset"]["link"]
112− 130+ assert web.get("/v1/admin/users/999999").json()["error"]["code"] == "USER_NOT_FOUND"
113− 131+
114−def test_list_users_pagination_and_search(client, make_user): 132+
115− ah = admin_headers(make_user) 133+def test_last_admin_guard_and_self_protection(admin_web, make_user):
134+ """A19."""
135+ from accounts.models import User
136+ from core.db import session
137+ from sqlalchemy import select, update
138+ web, admin_id, _, _ = admin_web
139+ # an admin cannot touch their own role / status
140+ r = web.patch(f"/v1/admin/users/{admin_id}", json={"role": "user"}, headers=JSON)
141+ assert r.status_code == 403 and r.json()["error"]["code"] == "FORBIDDEN"
142+ assert web.patch(f"/v1/admin/users/{admin_id}", json={"status": "disabled"}, headers=JSON).status_code == 403
143+ assert web.patch(f"/v1/admin/users/{admin_id}", json={"name": "Root"}, headers=JSON).status_code == 200
144+ assert web.delete(f"/v1/admin/users/{admin_id}", headers=JSON).status_code == 403
145+ # make our admin the ONLY active admin, then try to demote another (now demoted) admin… and ourselves through the service
146+ other_id, _, _, _ = make_user(role="admin", with_key=False)
147+ with session() as s:
148+ s.execute(update(User).where(User.role == "admin", User.status == "active", User.id.notin_([admin_id, other_id])).values(role="user"))
149+ r = web.patch(f"/v1/admin/users/{other_id}", json={"role": "user"}, headers=JSON)
150+ assert r.status_code == 200 # two admins → demoting one is fine
151+ assert web.get("/v1/admin/users?limit=1").json()["meta"]["admins_active"] == 1
152+ from accounts import service
153+ from core.errors import ApiError
154+ with session() as s:
155+ me = s.get(User, admin_id)
156+ for kwargs in ({"role": "user"}, {"status": "disabled"}):
157+ try:
158+ service.update_user(s, me, actor="test", **kwargs)
159+ assert False, "last admin must be protected"
160+ except ApiError as exc:
161+ assert exc.code == "LAST_ADMIN" and exc.status == 409
162+ try:
163+ service.delete_account(s, me, actor="test")
164+ assert False
165+ except ApiError as exc:
166+ assert exc.code == "LAST_ADMIN"
167+ s.rollback()
168+ # promoting someone lifts the guard
169+ web.patch(f"/v1/admin/users/{other_id}", json={"role": "admin"}, headers=JSON)
170+ assert web.get("/v1/admin/users?limit=1").json()["meta"]["admins_active"] == 2
171+
172+
173+def test_role_change_signs_the_user_out(admin_web, make_user, signin):
174+ """A11: bumping session_version on role change."""
175+ web, _, _, _ = admin_web
176+ uid, _, email, pw = make_user(with_key=False)
177+ user_web = TestClient(web.app)
178+ signin(user_web, email, pw)
179+ assert user_web.get("/v1/me").status_code == 200
180+ assert web.patch(f"/v1/admin/users/{uid}", json={"role": "admin"}, headers=JSON).status_code == 200
181+ assert user_web.get("/v1/me").status_code == 401
182+
183+
184+def test_admin_delete_user(admin_web, make_user, signin):
185+ web, _, _, _ = admin_web
186+ uid, key, email, pw = make_user()
187+ user_web = TestClient(web.app)
188+ signin(user_web, email, pw)
189+ r = web.delete(f"/v1/admin/users/{uid}", headers=JSON)
190+ assert r.status_code == 200 and r.json()["data"]["status"] == "deleted" and r.json()["data"]["email"].endswith("@deleted.invalid")
191+ assert user_web.get("/v1/me").status_code == 401
192+ assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 401
193+ assert web.patch(f"/v1/admin/users/{uid}", json={"tier": "high_usage"}, headers=JSON).status_code == 409
194+ assert web.post(f"/v1/admin/users/{uid}/invite", json={}, headers=JSON).status_code == 409
195+ assert web.get(f"/v1/admin/users?status=deleted").json()["data"][0]["status"] == "deleted"
196+
197+
198+def test_list_users_pagination_and_search(admin_web):
199+ web, _, _, _ = admin_web
116 emails = [fresh_email("page") for _ in range(3)] 200 emails = [fresh_email("page") for _ in range(3)]
117 for e in emails: 201 for e in emails:
118− client.post("/v1/admin/users", json={"email": e, "name": "Paged Person"}, headers=ah) 202+ web.post("/v1/admin/users", json={"email": e, "name": "Paged Person"}, headers=JSON)
119− r = client.get("/v1/admin/users?search=paged&limit=2", headers=ah) 203+ r = web.get("/v1/admin/users?search=paged&limit=2")
120 assert r.status_code == 200 204 assert r.status_code == 200
121 body = r.json() 205 body = r.json()
122− assert len(body["data"]) == 2 and body["meta"]["next_cursor"] 206+ assert len(body["data"]) == 2 and body["meta"]["next_cursor"] and "admins_active" in body["meta"]
123− r2 = client.get(f"/v1/admin/users?search=paged&limit=2&cursor={body['meta']['next_cursor']}", headers=ah) 207+ r2 = web.get(f"/v1/admin/users?search=paged&limit=2&cursor={body['meta']['next_cursor']}")
124 assert r2.status_code == 200 and len(r2.json()["data"]) >= 1 208 assert r2.status_code == 200 and len(r2.json()["data"]) >= 1
125 ids = {u["id"] for u in body["data"]} | {u["id"] for u in r2.json()["data"]} 209 ids = {u["id"] for u in body["data"]} | {u["id"] for u in r2.json()["data"]}
126 assert len(ids) >= 3 210 assert len(ids) >= 3
127− assert client.get("/v1/admin/users?limit=0", headers=ah).status_code == 422 211+ assert web.get("/v1/admin/users?limit=0").status_code == 422
128 212
129 213
130−def test_global_usage_and_audit(client, make_user): 214+def test_global_usage_and_audit(admin_web, make_user):
131− ah = admin_headers(make_user) 215+ web, admin_id, _, _ = admin_web
132 uid, key, _, _ = make_user() 216 uid, key, _, _ = make_user()
133− client.get("/v1/_test/frame?n=100", headers={"Authorization": f"Bearer {key}"}) 217+ web.get("/v1/_test/frame?n=100", headers={"Authorization": f"Bearer {key}"})
134 import time 218 import time
135 219
136 from ratelimit import usage 220 from ratelimit import usage
137 usage.fold(now_s=time.time() + 120) # force the current minute to fold 221 usage.fold(now_s=time.time() + 120) # force the current minute to fold
138− r = client.get("/v1/admin/usage?days=7&top=5", headers=ah) 222+ r = web.get("/v1/admin/usage?days=7&top=5")
139 assert r.status_code == 200 223 assert r.status_code == 200
140 d = r.json()["data"] 224 d = r.json()["data"]
141− assert d["per_day"] and d["per_day"][-1]["requests"] >= 1 225+ assert d["per_day"] and d["per_day"][-1]["requests"] >= 1 and d["totals"]["requests"] >= 1
142 top = [t for t in d["top"] if t["user"] and t["user"]["id"] == uid] 226 top = [t for t in d["top"] if t["user"] and t["user"]["id"] == uid]
143 assert top and top[0]["rows"] >= 100 and top[0]["user"]["email"] 227 assert top and top[0]["rows"] >= 100 and top[0]["user"]["email"]
144− r = client.get("/v1/admin/audit?limit=5", headers=ah) 228+ r = web.get("/v1/admin/audit?limit=5")
145 assert r.status_code == 200 229 assert r.status_code == 200
146 rows = r.json()["data"] 230 rows = r.json()["data"]
147 assert rows and {"id", "ts", "actor", "action", "target", "meta"} <= set(rows[0]) 231 assert rows and {"id", "ts", "actor", "action", "target", "meta"} <= set(rows[0])
148− r = client.get("/v1/admin/audit?action=key.", headers=ah) 232+ r = web.get("/v1/admin/audit?action=key.")
149 assert all(x["action"].startswith("key.") for x in r.json()["data"]) 233 assert all(x["action"].startswith("key.") for x in r.json()["data"])
234+ # B6: filters by actor / target
235+ r = web.get(f"/v1/admin/audit?actor=user:{admin_id}")
236+ assert r.json()["data"] and all(x["actor"] == f"user:{admin_id}" for x in r.json()["data"])
237+ r = web.get(f"/v1/admin/audit?target=user:{uid}")
238+ assert r.json()["data"] and all(x["target"] == f"user:{uid}" for x in r.json()["data"])
239+ assert web.get("/v1/admin/audit?target=user:0").json()["data"] == []
modified tests/test_accounts_cli.py +47 −9
@@ -1,8 +1,18 @@
1−"""`hfmd` CLI: idempotent seed, users add/list/set-tier/disable, keys list, --show-key discipline.""" 1+"""`hfmd` CLI: idempotent seed from a JSON file / env var, users add/list/set-tier/disable, keys list/rotate,
2+--show-key discipline. No real address is hard-coded anywhere (A21)."""
2 from __future__ import annotations 3 from __future__ import annotations
3 4
5+import json
6+
4 from sqlalchemy import func, select 7 from sqlalchemy import func, select
5 8
9+SEED = [
10+ {"name": "Seed One", "email": "seed-one@example.com", "tier": "free", "role": "user"},
11+ {"name": "Seed Two", "email": "seed-two@example.com", "tier": "free", "role": "user"},
12+ {"name": "Seed Three", "email": "seed-three@example.com", "tier": "free", "role": "user"},
13+ {"name": "Seed Admin", "email": "seed-admin@example.com", "tier": "high_usage", "role": "admin"},
14+]
15+
6 16
7 def run(cli, capsys, *argv) -> tuple[int, str]: 17 def run(cli, capsys, *argv) -> tuple[int, str]:
8 rc = cli.main(list(argv)) 18 rc = cli.main(list(argv))
@@ -10,15 +20,25 @@ def run(cli, capsys, *argv) -> tuple[int, str]:
10 return rc, out.out + out.err 20 return rc, out.out + out.err
11 21
12 22
13−def test_seed_is_idempotent(app, capsys): 23+def test_no_hardcoded_addresses(app):
24+ import inspect
25+
26+ from accounts import cli
27+ src = inspect.getsource(cli)
28+ assert "gmail.com" not in src and "@spboucher.ai" not in src.split('"""', 2)[2] # author line in the docstring only
29+
30+
31+def test_seed_from_file_is_idempotent(app, capsys, tmp_path):
14 from accounts import cli 32 from accounts import cli
15 from accounts.models import ApiKey, EmailToken, User 33 from accounts.models import ApiKey, EmailToken, User
16 from core.db import session 34 from core.db import session
17 35
18− rc, out = run(cli, capsys, "seed") 36+ seed_file = tmp_path / "seed.json"
19− assert rc == 0 37+ seed_file.write_text(json.dumps(SEED))
20− for email in ("ianez84@gmail.com", "timerdmann.uni@gmail.com", "javier.slaton@gmail.com", "contact@spboucher.ai"): 38+ rc, out = run(cli, capsys, "seed", str(seed_file))
21− assert email in out 39+ assert rc == 0, out
40+ for u in SEED:
41+ assert u["email"] in out
22 assert "accept-invite?token=" in out and "hfmd_live_" in out 42 assert "accept-invite?token=" in out and "hfmd_live_" in out
23 assert out.count("hfmd_live_") == 4 # prefixes only, one per user 43 assert out.count("hfmd_live_") == 4 # prefixes only, one per user
24 assert "api_key" not in out # full keys hidden without --show-key 44 assert "api_key" not in out # full keys hidden without --show-key
@@ -33,11 +53,11 @@ def test_seed_is_idempotent(app, capsys):
33 return users, keys, toks 53 return users, keys, toks
34 54
35 users, keys, toks = counts() 55 users, keys, toks = counts()
36− seeded = {e: users[e] for e in ("ianez84@gmail.com", "timerdmann.uni@gmail.com", "javier.slaton@gmail.com", "contact@spboucher.ai")} 56+ seeded = {u["email"]: users[u["email"]] for u in SEED}
37− assert seeded["contact@spboucher.ai"].role == "admin" and seeded["ianez84@gmail.com"].role == "user" 57+ assert seeded["seed-admin@example.com"].role == "admin" and seeded["seed-one@example.com"].role == "user"
38 assert all(u.status == "invited" for u in seeded.values()) 58 assert all(u.status == "invited" for u in seeded.values())
39 59
40− rc, out2 = run(cli, capsys, "seed") 60+ rc, out2 = run(cli, capsys, "seed", str(seed_file))
41 assert rc == 0 61 assert rc == 0
42 users2, keys2, toks2 = counts() 62 users2, keys2, toks2 = counts()
43 assert len(users2) == len(users) and keys2 == keys and toks2 == toks # nothing duplicated 63 assert len(users2) == len(users) and keys2 == keys and toks2 == toks # nothing duplicated
@@ -47,6 +67,20 @@ def test_seed_is_idempotent(app, capsys):
47 assert links1 == links2 and len(links1) == 4 67 assert links1 == links2 and len(links1) == 4
48 68
49 69
70+def test_seed_from_env_and_errors(app, capsys, monkeypatch, tmp_path):
71+ from accounts import cli
72+ monkeypatch.delenv(cli.SEED_ENV, raising=False)
73+ rc, out = run(cli, capsys, "seed")
74+ assert rc == 1 and "no seed" in out
75+ monkeypatch.setenv(cli.SEED_ENV, json.dumps([{"name": "Env Person", "email": "env-person@example.com"}]))
76+ rc, out = run(cli, capsys, "seed", "--no-mail")
77+ assert rc == 0 and "env-person@example.com" in out
78+ bad = tmp_path / "bad.json"
79+ bad.write_text("{not json")
80+ rc, out = run(cli, capsys, "seed", str(bad))
81+ assert rc == 1 and "not valid JSON" in out
82+
83+
50 def test_users_add_show_key_and_management(app, capsys): 84 def test_users_add_show_key_and_management(app, capsys):
51 from accounts import cli 85 from accounts import cli
52 email = "cli-person@example.com" 86 email = "cli-person@example.com"
@@ -60,6 +94,10 @@ def test_users_add_show_key_and_management(app, capsys):
60 assert not [tok for tok in out.split() if tok.startswith("hfmd_live_") and len(tok) == 42] 94 assert not [tok for tok in out.split() if tok.startswith("hfmd_live_") and len(tok) == 42]
61 rc, out = run(cli, capsys, "keys", "list", email) 95 rc, out = run(cli, capsys, "keys", "list", email)
62 assert rc == 0 and out.count("active") == 1 96 assert rc == 0 and out.count("active") == 1
97+ rc, out = run(cli, capsys, "keys", "rotate", email, "--show-key")
98+ assert rc == 0 and len([tok for tok in out.split() if tok.startswith("hfmd_live_") and len(tok) == 42]) == 1
99+ rc, out = run(cli, capsys, "keys", "list", email)
100+ assert rc == 0 and out.count("active") == 1 and out.count("revoked") == 1
63 rc, out = run(cli, capsys, "users", "set-tier", email, "free") 101 rc, out = run(cli, capsys, "users", "set-tier", email, "free")
64 assert rc == 0 and "tier = free" in out 102 assert rc == 0 and "tier = free" in out
65 rc, out = run(cli, capsys, "users", "disable", email) 103 rc, out = run(cli, capsys, "users", "disable", email)
modified tests/test_accounts_flow.py +368 −76
@@ -1,9 +1,13 @@
1−"""Self-service account flows: signup → verify → login → key → use → revoke → 401, reset, CSRF, /me.""" 1+"""Self-service account flows: signup → verify → login → key → use → revoke → 401, reset, CSRF, /me, account security.
2+
3+Every test here that documents a fixed vulnerability is marked in its docstring with the finding id (A1…A24)."""
2 from __future__ import annotations 4 from __future__ import annotations
3 5
4 import os 6 import os
5 from urllib.parse import parse_qs, urlparse 7 from urllib.parse import parse_qs, urlparse
6 8
9+from fastapi.testclient import TestClient
10+
7 JSON = {"Content-Type": "application/json"} 11 JSON = {"Content-Type": "application/json"}
8 12
9 13
@@ -15,125 +19,237 @@ def fresh_email(tag: str) -> str:
15 return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com" 19 return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com"
16 20
17 21
18−def test_full_lifecycle(client): 22+def test_full_lifecycle(web):
19 email = fresh_email("ada") 23 email = fresh_email("ada")
20 pw = "very-secure-passphrase" 24 pw = "very-secure-passphrase"
21 # signup → 202 with (dev only) debug link 25 # signup → 202 with (dev only) debug link
22− r = client.post("/v1/auth/signup", json={"email": email, "name": "Ada", "password": pw}, headers=JSON) 26+ r = web.post("/v1/auth/signup", json={"email": email, "name": "Ada", "password": pw}, headers=JSON)
23 assert r.status_code == 202, r.text 27 assert r.status_code == 202, r.text
24 d = r.json()["data"] 28 d = r.json()["data"]
25− assert d["status"] == "verification_sent" and "debug_link" in d 29+ assert d["status"] == "verification_sent" and "debug_link" in d and "/verify?token=" in d["debug_link"]
26 # cannot log in before verifying 30 # cannot log in before verifying
27− r = client.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON) 31+ r = web.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)
28 assert r.status_code == 403 and r.json()["error"]["code"] == "EMAIL_NOT_VERIFIED" 32 assert r.status_code == 403 and r.json()["error"]["code"] == "EMAIL_NOT_VERIFIED"
29− # verify → session cookie 33+ # A9/A16: GET verify only redirects to the web page (no consumption, no cookie)
30− r = client.get("/v1/auth/verify", params={"token": token_of(d["debug_link"])}) 34+ r = web.get("/v1/auth/verify", params={"token": token_of(d["debug_link"])}, follow_redirects=False)
31− assert r.status_code == 200 and r.json()["data"]["email_verified"] is True 35+ assert r.status_code == 302 and r.headers["location"].endswith(f"/verify?token={token_of(d['debug_link'])}")
36+ assert "hfmd_session" not in r.cookies
37+ # POST verify → session cookie
38+ r = web.post("/v1/auth/verify", json={"token": token_of(d["debug_link"])}, headers=JSON)
39+ assert r.status_code == 200 and r.json()["data"]["email_verified"] is True and r.json()["data"]["kind"] == "verify"
32 assert "hfmd_session" in r.cookies 40 assert "hfmd_session" in r.cookies
33 # token is single use 41 # token is single use
34− assert client.get("/v1/auth/verify", params={"token": token_of(d["debug_link"])}).json()["error"]["code"] == "INVALID_TOKEN" 42+ assert web.post("/v1/auth/verify", json={"token": token_of(d["debug_link"])}, headers=JSON).json()["error"]["code"] == "INVALID_TOKEN"
35 # /me with the cookie 43 # /me with the cookie
36− r = client.get("/v1/me") 44+ r = web.get("/v1/me")
37 assert r.status_code == 200 45 assert r.status_code == 200
38 me = r.json()["data"] 46 me = r.json()["data"]
39 assert me["user"]["email"] == email and me["limits"]["tier"] == "free" and me["limits"]["requests"] == 120 47 assert me["user"]["email"] == email and me["limits"]["tier"] == "free" and me["limits"]["requests"] == 120
40− assert me["user"]["keys_active"] == 0 48+ assert me["user"]["keys_active"] == 0 and me["auth"] == "session"
41− # create key (shown once) 49+ # create key (shown once) with note + expiry
42− r = client.post("/v1/me/keys", json={"name": "laptop"}, headers=JSON) 50+ r = web.post("/v1/me/keys", json={"name": "laptop", "note": "CI runner", "expires_in_days": 30}, headers=JSON)
43− assert r.status_code == 201 51+ assert r.status_code == 201, r.text
44 k = r.json()["data"] 52 k = r.json()["data"]
45 assert k["key"].startswith("hfmd_live_") and len(k["key"]) == len("hfmd_live_") + 32 53 assert k["key"].startswith("hfmd_live_") and len(k["key"]) == len("hfmd_live_") + 32
46− assert k["prefix"] == k["key"][:18] and k["name"] == "laptop" 54+ assert k["prefix"] == k["key"][:18] and k["name"] == "laptop" and k["note"] == "CI runner" and k["scopes"] == ["data"]
47− listed = client.get("/v1/me/keys").json()["data"] 55+ assert k["expires_at"] and k["expired"] is False
56+ listed = web.get("/v1/me/keys").json()["data"]
48 assert len(listed) == 1 and "key" not in listed[0] and listed[0]["prefix"] == k["prefix"] 57 assert len(listed) == 1 and "key" not in listed[0] and listed[0]["prefix"] == k["prefix"]
49− # use the key on a data endpoint → free-tier headers 58+ # use the key on a data endpoint → free-tier headers, last_used_at + hashed ip recorded
50− client.cookies.clear() 59+ r = web.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}", "X-Forwarded-For": "203.0.113.7"})
51− r = client.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"})
52 assert r.status_code == 200 and r.headers["X-RateLimit-Limit-Requests"] == "120" 60 assert r.status_code == 200 and r.headers["X-RateLimit-Limit-Requests"] == "120"
53− # /me also works with the key (programmatic) 61+ listed = web.get("/v1/me/keys").json()["data"]
54− assert client.get("/v1/me", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 200 62+ assert listed[0]["last_used_at"] and len(listed[0]["last_used_ip"]) == 16
55− # rotate → new key, old one dead 63+ # A6: the key may READ the account but not mutate it
56− r = client.post(f"/v1/me/keys/{k['id']}/rotate", json={}, headers={**JSON, "Authorization": f"Bearer {k['key']}"}) 64+ anon = TestClient(web.app)
65+ r = anon.get("/v1/me", headers={"Authorization": f"Bearer {k['key']}"})
66+ assert r.status_code == 200 and r.json()["data"]["auth"] == "key"
67+ assert anon.get("/v1/me/usage?range=24h", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 200
68+ assert anon.get("/v1/me/limits", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 200
69+ r = anon.post(f"/v1/me/keys/{k['id']}/rotate", json={}, headers={**JSON, "Authorization": f"Bearer {k['key']}"})
70+ assert r.status_code == 403 and r.json()["error"]["code"] == "SESSION_REQUIRED"
71+ assert anon.get("/v1/me/keys", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 403
72+ # rotate (session) → new key keeps name/note/expiry, old one dead
73+ r = web.post(f"/v1/me/keys/{k['id']}/rotate", json={}, headers=JSON)
57 assert r.status_code == 201 74 assert r.status_code == 201
58 k2 = r.json()["data"] 75 k2 = r.json()["data"]
59− assert k2["key"] != k["key"] and k2["rotated_from"] == k["id"] and k2["name"] == "laptop" 76+ assert k2["key"] != k["key"] and k2["rotated_from"] == k["id"] and k2["name"] == "laptop" and k2["note"] == "CI runner"
60− r = client.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"}) 77+ assert k2["expires_at"] == k["expires_at"]
78+ r = web.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"})
61 assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_API_KEY" 79 assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_API_KEY"
62− # revoke the new one → 401 immediately (cache invalidated) 80+ # A3: revoke with an EMPTY body + X-Requested-With (what the SPA sends) is accepted
63− r = client.delete(f"/v1/me/keys/{k2['id']}", headers={**JSON, "Authorization": f"Bearer {k2['key']}"}) 81+ r = web.delete(f"/v1/me/keys/{k2['id']}", headers={"X-Requested-With": "hfmd"})
64 assert r.status_code == 200 and r.json()["data"]["status"] == "revoked" 82 assert r.status_code == 200 and r.json()["data"]["status"] == "revoked"
65− assert client.get("/v1/status", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 401 83+ assert web.get("/v1/status", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 401
66− # login again and check the session + usage endpoint 84+ # usage endpoint (session)
67− r = client.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON) 85+ u = web.get("/v1/me/usage?range=24h").json()["data"]
68− assert r.status_code == 200 and r.json()["data"]["last_login_at"] 86+ assert u["totals"]["requests"] >= 1 and len(u["principals"]) == 2 and u["key_id"] is None
69− u = client.get("/v1/me/usage?range=24h").json()["data"] 87+ assert {"requests", "rows", "status_429", "bytes", "rows_parquet"} <= set(u["points"][0])
70− assert u["totals"]["requests"] >= 1 and len(u["principals"]) == 2 88+ # logout with empty body + header, cookie gone
71− # logout 89+ r = web.post("/v1/auth/logout", headers={"X-Requested-With": "hfmd"})
72− r = client.post("/v1/auth/logout", json={}, headers=JSON)
73 assert r.status_code == 200 90 assert r.status_code == 200
74− assert client.get("/v1/me").status_code == 401 91+ assert web.get("/v1/me").status_code == 401
75− client.cookies.clear()
76 92
77 93
78−def test_signup_validation_and_conflicts(client, make_user): 94+def test_signup_never_touches_existing_account_and_is_uniform(web, make_user, outbox):
79− _, _, email, _ = make_user() 95+ """A4 (pre-hijack) + A10 (enumeration): signup on an existing address changes nothing and answers 202."""
80− r = client.post("/v1/auth/signup", json={"email": email, "name": "x", "password": "long-enough-password"}, headers=JSON) 96+ from accounts import security
81− assert r.status_code == 409 and r.json()["error"]["code"] == "EMAIL_TAKEN" 97+ from accounts.models import User
82− r = client.post("/v1/auth/signup", json={"email": fresh_email("w"), "name": "x", "password": "short"}, headers=JSON) 98+ from core.db import session
99+ uid, _, email, pw = make_user()
100+ with session() as s:
101+ before = (s.get(User, uid).password_hash, s.get(User, uid).name)
102+ r = web.post("/v1/auth/signup", json={"email": email, "name": "Mallory", "password": "attacker-passphrase"}, headers=JSON)
103+ assert r.status_code == 202 and r.json()["data"]["status"] == "verification_sent" and "debug_link" not in r.json()["data"]
104+ with session() as s:
105+ u = s.get(User, uid)
106+ assert (u.password_hash, u.name) == before
107+ assert security.verify_password(pw, u.password_hash) and not security.verify_password("attacker-passphrase", u.password_hash)
108+ assert [m.kind for m in outbox] == ["already_registered"] and outbox[0].to == email
109+ # unverified signup: a second signup re-sends the verification but keeps the ORIGINAL password
110+ email2 = fresh_email("unverified")
111+ web.post("/v1/auth/signup", json={"email": email2, "name": "Real", "password": "the-real-passphrase"}, headers=JSON)
112+ r = web.post("/v1/auth/signup", json={"email": email2, "name": "Mallory", "password": "attacker-passphrase"}, headers=JSON)
113+ assert r.status_code == 202 and "/verify?token=" in r.json()["data"]["debug_link"]
114+ with session() as s:
115+ u = s.execute(__import__("sqlalchemy").select(User).where(User.email == email2)).scalars().first()
116+ assert u.name == "Real" and security.verify_password("the-real-passphrase", u.password_hash)
117+ # validation errors are still explicit
118+ r = web.post("/v1/auth/signup", json={"email": fresh_email("w"), "name": "x", "password": "short"}, headers=JSON)
83 assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD" 119 assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD"
84− r = client.post("/v1/auth/signup", json={"email": "not-an-email", "name": "x", "password": "long-enough-password"}, headers=JSON) 120+ r = web.post("/v1/auth/signup", json={"email": "not-an-email", "name": "x", "password": "long-enough-password"}, headers=JSON)
85 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" 121 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER"
86 122
87 123
88−def test_csrf_requires_json_content_type(client, make_user): 124+def test_csrf_guard(web, make_user, signin):
89− _, key, _, _ = make_user() 125+ """A3: JSON always OK; empty body needs X-Requested-With / Sec-Fetch-Site / same Origin; forms are refused."""
90− r = client.post("/v1/me/keys", content="name=x", headers={"Content-Type": "application/x-www-form-urlencoded", 126+ _, _, email, pw = make_user()
91− "Authorization": f"Bearer {key}"}) 127+ signin(web, email, pw)
128+ r = web.post("/v1/me/keys", content="name=x", headers={"Content-Type": "application/x-www-form-urlencoded"})
92 assert r.status_code == 415 and r.json()["error"]["code"] == "UNSUPPORTED_MEDIA_TYPE" 129 assert r.status_code == 415 and r.json()["error"]["code"] == "UNSUPPORTED_MEDIA_TYPE"
93− r = client.post("/v1/auth/logout", headers={"Content-Type": "text/plain"}) 130+ r = web.post("/v1/auth/logout", headers={"Content-Type": "text/plain"}, content="x")
94 assert r.status_code == 415 131 assert r.status_code == 415
132+ # bodiless POST without any proof → 415 (a cross-site <form> with no fields looks like this)
133+ assert web.post("/v1/me/sessions/revoke-all", headers={"Origin": "https://evil.example"}).status_code == 415
134+ # bodiless with browser proofs → accepted
135+ assert web.post("/v1/me/sessions/revoke-all", headers={"Sec-Fetch-Site": "same-origin"}).status_code == 200
136+ assert web.post("/v1/me/sessions/revoke-all", headers={"Origin": "http://testserver"}).status_code == 200
137+ assert web.post("/v1/me/sessions/revoke-all", headers={"X-Requested-With": "hfmd"}).status_code == 200
95 138
96 139
97−def test_login_errors(client, make_user): 140+def test_login_errors_constant_cost_and_lockout(web, make_user, outbox):
98− _, _, email, pw = make_user() 141+ """A5 (lockout), A10 (dummy hash), A15 (password length), A22 (login_failed audit)."""
99− r = client.post("/v1/auth/login", json={"email": email, "password": "wrong-password"}, headers=JSON) 142+ from accounts import security
143+ from accounts.models import AuditLog, User
144+ from core.db import session
145+ from sqlalchemy import select
146+ uid, _, email, pw = make_user()
147+ assert security.verify_password("anything", None) is False # dummy-hash path returns False
148+ r = web.post("/v1/auth/login", json={"email": "ghost@example.com", "password": pw}, headers=JSON)
100 assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_CREDENTIALS" 149 assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_CREDENTIALS"
101− r = client.post("/v1/auth/login", json={"email": "ghost@example.com", "password": pw}, headers=JSON) 150+ assert web.post("/v1/auth/login", json={"email": email, "password": "x" * 300}, headers=JSON).status_code == 422
102− assert r.status_code == 401 151+ for i in range(4):
103− assert client.get("/v1/me").status_code == 401 152+ r = web.post("/v1/auth/login", json={"email": email, "password": "wrong-password"}, headers=JSON)
104− assert client.get("/v1/me").json()["error"]["code"] == "AUTH_REQUIRED" 153+ assert r.status_code == 401, i
154+ r = web.post("/v1/auth/login", json={"email": email, "password": "wrong-password"}, headers=JSON) # 5th → lock 30 s
155+ assert r.status_code == 423 and r.json()["error"]["code"] == "ACCOUNT_LOCKED" and 1 <= int(r.headers["Retry-After"]) <= 30
156+ r = web.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON) # even the right password
157+ assert r.status_code == 423
158+ with session() as s:
159+ u = s.get(User, uid)
160+ assert u.failed_logins == 5 and u.locked_until is not None
161+ fails = list(s.execute(select(AuditLog).where(AuditLog.action == "user.login_failed", AuditLog.target == f"user:{uid}")).scalars())
162+ assert len(fails) == 5 and '"ip"' in fails[-1].meta
163+ u.locked_until = None
164+ u.failed_logins = 9
165+ r = web.post("/v1/auth/login", json={"email": email, "password": "wrong-password"}, headers=JSON) # 10th → warning mail, 1 h lock
166+ assert r.status_code == 423 and int(r.headers["Retry-After"]) > 600
167+ assert any(m.kind == "suspicious_login" and m.to == email for m in outbox)
168+ with session() as s:
169+ s.get(User, uid).locked_until = None
170+ r = web.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)
171+ assert r.status_code == 200
172+ with session() as s:
173+ assert s.get(User, uid).failed_logins == 0
174+ assert web.get("/v1/me").status_code == 200
105 175
106 176
107−def test_forgot_and_reset(client, make_user): 177+def test_forgot_and_reset_revokes_sessions_and_keys(web, make_user, signin):
108− _, _, email, _ = make_user(with_key=False) 178+ """A4/A11: a password reset signs out every session and (by default) revokes the keys."""
179+ _, key, email, pw = make_user()
180+ other = TestClient(web.app)
181+ signin(other, email, pw) # a second browser
182+ assert other.get("/v1/me").status_code == 200
109 # unknown address: same 202, no link 183 # unknown address: same 202, no link
110− r = client.post("/v1/auth/forgot", json={"email": "unknown@example.com"}, headers=JSON) 184+ r = web.post("/v1/auth/forgot", json={"email": "unknown@example.com"}, headers=JSON)
111 assert r.status_code == 202 and "debug_link" not in r.json()["data"] 185 assert r.status_code == 202 and "debug_link" not in r.json()["data"]
112− r = client.post("/v1/auth/forgot", json={"email": email}, headers=JSON) 186+ r = web.post("/v1/auth/forgot", json={"email": email}, headers=JSON)
113 assert r.status_code == 202 187 assert r.status_code == 202
114 link = r.json()["data"]["debug_link"] 188 link = r.json()["data"]["debug_link"]
115 assert "/reset-password?token=" in link 189 assert "/reset-password?token=" in link
116− r = client.post("/v1/auth/reset", json={"token": token_of(link), "password": "short"}, headers=JSON) 190+ r = web.post("/v1/auth/reset", json={"token": token_of(link), "password": "short"}, headers=JSON)
117 assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD" 191 assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD"
118− r = client.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON) 192+ r = web.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON)
119− assert r.status_code == 200 193+ assert r.status_code == 200 and r.json()["data"]["keys_revoked"] == 1
120− client.cookies.clear() 194+ assert other.get("/v1/me").status_code == 401 # old session dead
121− r = client.post("/v1/auth/login", json={"email": email, "password": "brand-new-passphrase"}, headers=JSON) 195+ assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 401 # key revoked
196+ web.cookies.clear()
197+ r = web.post("/v1/auth/login", json={"email": email, "password": "brand-new-passphrase"}, headers=JSON)
122 assert r.status_code == 200 198 assert r.status_code == 200
123− client.cookies.clear() 199+ web.cookies.clear()
124− r = client.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON) 200+ r = web.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON)
125 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN" 201 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN"
202+ # revoke_keys=false keeps the keys
203+ _, key2, email2, _ = make_user()
204+ link2 = web.post("/v1/auth/forgot", json={"email": email2}, headers=JSON).json()["data"]["debug_link"]
205+ r = web.post("/v1/auth/reset", json={"token": token_of(link2), "password": "another-new-passphrase", "revoke_keys": False}, headers=JSON)
206+ assert r.status_code == 200 and r.json()["data"]["keys_revoked"] == 0
207+ assert web.get("/v1/status", headers={"Authorization": f"Bearer {key2}"}).status_code == 200
126 208
127 209
128−def test_key_limit(client, make_user): 210+def test_forgot_per_email_throttle(web, make_user):
129− _, key, _, _ = make_user() 211+ _, _, email, _ = make_user(with_key=False)
130− h = {**JSON, "Authorization": f"Bearer {key}"} 212+ for _ in range(5):
213+ assert web.post("/v1/auth/forgot", json={"email": email}, headers=JSON).status_code == 202
214+ r = web.post("/v1/auth/forgot", json={"email": email}, headers=JSON)
215+ assert r.status_code == 429 and "Retry-After" in r.headers
216+
217+
218+def test_key_limit_and_validation(web, make_user, signin):
219+ """A18: cap enforced (count checked after insert too); expiry / scopes validated."""
220+ _, key, email, pw = make_user()
221+ signin(web, email, pw)
131 for i in range(9): 222 for i in range(9):
132− assert client.post("/v1/me/keys", json={"name": f"k{i}"}, headers=h).status_code == 201 223+ assert web.post("/v1/me/keys", json={"name": f"k{i}"}, headers=JSON).status_code == 201
133− r = client.post("/v1/me/keys", json={"name": "one-too-many"}, headers=h) 224+ r = web.post("/v1/me/keys", json={"name": "one-too-many"}, headers=JSON)
134 assert r.status_code == 409 and r.json()["error"]["code"] == "KEY_LIMIT_REACHED" 225 assert r.status_code == 409 and r.json()["error"]["code"] == "KEY_LIMIT_REACHED"
135− r = client.delete("/v1/me/keys/999999", headers=h) 226+ r = web.delete("/v1/me/keys/999999", headers=JSON)
136 assert r.status_code == 404 and r.json()["error"]["code"] == "KEY_NOT_FOUND" 227 assert r.status_code == 404 and r.json()["error"]["code"] == "KEY_NOT_FOUND"
228+ r = web.post("/v1/me/keys", json={"name": "bad", "scopes": ["admin"]}, headers=JSON)
229+ assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER"
230+ assert web.post("/v1/me/keys", json={"name": "bad", "expires_in_days": 0}, headers=JSON).status_code == 422
231+ # rename + note
232+ kid = web.get("/v1/me/keys").json()["data"][0]["id"]
233+ r = web.patch(f"/v1/me/keys/{kid}", json={"name": "renamed", "note": "moved to the NAS"}, headers=JSON)
234+ assert r.status_code == 200 and r.json()["data"]["name"] == "renamed" and r.json()["data"]["note"] == "moved to the NAS"
235+
236+
237+def test_expired_key_is_refused(web, make_user, signin):
238+ from datetime import timedelta
239+
240+ from accounts.models import ApiKey
241+ from core.db import session
242+ _, _, email, pw = make_user(with_key=False)
243+ signin(web, email, pw)
244+ k = web.post("/v1/me/keys", json={"name": "short-lived", "expires_in_days": 1}, headers=JSON).json()["data"]
245+ assert web.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 200
246+ with session() as s:
247+ s.get(ApiKey, k["id"]).expires_at = s.get(ApiKey, k["id"]).created_at - timedelta(seconds=1)
248+ from ratelimit.middleware import invalidate_key_cache
249+ invalidate_key_cache()
250+ r = web.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"})
251+ assert r.status_code == 401 and "expired" in r.json()["error"]["message"]
252+ assert web.get("/v1/me/keys").json()["data"][0]["expired"] is True
137 253
138 254
139 def test_disabled_user_key_is_refused(client, make_user): 255 def test_disabled_user_key_is_refused(client, make_user):
@@ -147,18 +263,165 @@ def test_disabled_user_key_is_refused(client, make_user):
147 assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED" 263 assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED"
148 264
149 265
266+def test_disabled_user_cannot_reset_or_accept_invite(web, make_user):
267+ """A17."""
268+ from accounts import service
269+ from accounts.models import User
270+ from core.db import session
271+ uid, _, email, _ = make_user(with_key=False)
272+ link = web.post("/v1/auth/forgot", json={"email": email}, headers=JSON).json()["data"]["debug_link"]
273+ with session() as s:
274+ u = s.get(User, uid)
275+ service.update_user(s, u, actor="test", status="disabled")
276+ inv = service.issue_token(s, u, "invite", actor="test").link
277+ r = web.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON)
278+ assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED"
279+ r = web.post("/v1/auth/accept-invite", json={"token": token_of(inv), "password": "brand-new-passphrase"}, headers=JSON)
280+ assert r.status_code == 403
281+ assert web.post("/v1/auth/forgot", json={"email": email}, headers=JSON).json()["data"].get("debug_link") is None
282+
283+
284+def test_session_version_revoke_all_and_disable(web, make_user, signin):
285+ """A11: bumping session_version kills every cookie; the current browser gets a fresh one."""
286+ from accounts import service
287+ from accounts.models import User
288+ from core.db import session
289+ uid, _, email, pw = make_user()
290+ signin(web, email, pw)
291+ old_cookie = web.cookies.get("hfmd_session")
292+ r = web.post("/v1/me/sessions/revoke-all", json={}, headers=JSON)
293+ assert r.status_code == 200 and r.json()["data"]["status"] == "sessions_revoked"
294+ assert web.cookies.get("hfmd_session") != old_cookie
295+ assert web.get("/v1/me").status_code == 200 # this browser stays signed in
296+ stale = TestClient(web.app)
297+ assert stale.get("/v1/me", headers={"Cookie": f"hfmd_session={old_cookie}"}).status_code == 401 # A11
298+ # disabling the account (admin/CLI) signs it out too
299+ with session() as s:
300+ service.update_user(s, s.get(User, uid), actor="test", status="disabled")
301+ assert web.get("/v1/me").status_code == 401
302+
303+
304+def test_change_password_and_email(web, make_user, signin, outbox):
305+ """B2 + A22: password change (with mail + sessions), e-mail change via confirmation link."""
306+ from accounts.models import AuditLog
307+ from core.db import session
308+ from sqlalchemy import select
309+ uid, _, email, pw = make_user()
310+ signin(web, email, pw)
311+ other = TestClient(web.app)
312+ signin(other, email, pw)
313+ r = web.post("/v1/me/password", json={"current_password": "nope-nope-nope", "new_password": "a-brand-new-passphrase"}, headers=JSON)
314+ assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_CREDENTIALS"
315+ r = web.post("/v1/me/password", json={"current_password": pw, "new_password": "short"}, headers=JSON)
316+ assert r.status_code == 400
317+ r = web.post("/v1/me/password", json={"current_password": pw, "new_password": "a-brand-new-passphrase"}, headers=JSON)
318+ assert r.status_code == 200 and web.get("/v1/me").status_code == 200 and other.get("/v1/me").status_code == 401
319+ assert any(m.kind == "password_changed" for m in outbox)
320+ # e-mail change: link goes to the NEW address; the account keeps the old one until confirmation
321+ new_email = fresh_email("new")
322+ r = web.post("/v1/me/email", json={"new_email": new_email, "password": "a-brand-new-passphrase"}, headers=JSON)
323+ assert r.status_code == 202 and r.json()["data"]["status"] == "confirmation_sent"
324+ link = r.json()["data"]["debug_link"]
325+ mail = [m for m in outbox if m.kind == "email_change"][0]
326+ assert mail.to == new_email and "/verify?token=" in link
327+ assert web.get("/v1/me").json()["data"]["user"]["email"] == email
328+ r = web.post("/v1/auth/verify", json={"token": token_of(link)}, headers=JSON)
329+ assert r.status_code == 200 and r.json()["data"]["kind"] == "email_change" and r.json()["data"]["email"] == new_email
330+ assert any(m.kind == "email_changed" and m.to == email for m in outbox)
331+ # the address of another user: uniform 202, that user is notified, nothing changes at confirmation time
332+ _, _, taken, _ = make_user(with_key=False)
333+ r = web.post("/v1/me/email", json={"new_email": taken, "password": "a-brand-new-passphrase"}, headers=JSON)
334+ assert r.status_code == 202 and "debug_link" not in r.json()["data"]
335+ assert any(m.kind == "already_registered" and m.to == taken for m in outbox)
336+ with session() as s:
337+ actions = {a for (a,) in s.execute(select(AuditLog.action).where(AuditLog.target == f"user:{uid}")).all()}
338+ assert {"account.password_change", "account.email_change", "account.email_change_requested"} <= actions
339+
340+
341+def test_delete_account(web, make_user, signin, outbox):
342+ """B2: soft delete — keys revoked, sessions dead, e-mail anonymised, login impossible."""
343+ from accounts.models import User
344+ from core.db import session
345+ uid, key, email, pw = make_user()
346+ signin(web, email, pw)
347+ r = web.request("DELETE", "/v1/me", json={"password": "wrong-password-here"}, headers=JSON)
348+ assert r.status_code == 401
349+ r = web.request("DELETE", "/v1/me", json={"password": pw}, headers=JSON)
350+ assert r.status_code == 200 and r.json()["data"]["status"] == "deleted"
351+ assert web.get("/v1/me").status_code == 401
352+ assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 401
353+ with session() as s:
354+ u = s.get(User, uid)
355+ assert u.status == "deleted" and u.email == f"deleted-{uid}@deleted.invalid" and u.password_hash is None and u.deleted_at
356+ r = web.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)
357+ assert r.status_code == 401
358+ assert any(m.kind == "account_deleted" and m.to == email for m in outbox)
359+ # the address can sign up again
360+ assert web.post("/v1/auth/signup", json={"email": email, "name": "Again", "password": "yet-another-passphrase"}, headers=JSON).status_code == 202
361+
362+
363+def test_profile_and_quota_alert_opt_out(web, make_user, signin):
364+ _, _, email, pw = make_user(with_key=False)
365+ signin(web, email, pw)
366+ r = web.patch("/v1/me", json={"name": "Ada L.", "quota_alerts": False}, headers=JSON)
367+ assert r.status_code == 200 and r.json()["data"]["name"] == "Ada L." and r.json()["data"]["quota_alerts"] is False
368+ assert web.get("/v1/me").json()["data"]["user"]["quota_alerts"] is False
369+
370+
371+def test_usage_per_key_csv_and_live_limits(web, make_user, signin):
372+ """B1/B5: per-key usage, CSV export, live remaining quota per key."""
373+ _, _, email, pw = make_user(with_key=False)
374+ signin(web, email, pw)
375+ k1 = web.post("/v1/me/keys", json={"name": "one"}, headers=JSON).json()["data"]
376+ k2 = web.post("/v1/me/keys", json={"name": "two"}, headers=JSON).json()["data"]
377+ for _ in range(3):
378+ assert web.get("/v1/_test/frame?n=100", headers={"Authorization": f"Bearer {k1['key']}"}).status_code == 200
379+ assert web.get("/v1/_test/frame?n=50", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 200
380+ all_ = web.get("/v1/me/usage?range=24h").json()["data"]
381+ one = web.get(f"/v1/me/usage?range=24h&key_id={k1['id']}").json()["data"]
382+ assert all_["totals"]["requests"] == 4 and all_["totals"]["rows"] == 350
383+ assert one["totals"] == {"requests": 3, "rows": 300, "status_429": 0, "bytes": one["totals"]["bytes"], "rows_parquet": 0}
384+ assert one["key_id"] == k1["id"] and one["principals"] == [f"key:{k1['id']}"]
385+ assert web.get("/v1/me/usage?range=24h&key_id=999999").status_code == 404
386+ r = web.get(f"/v1/me/usage.csv?range=7d&key_id={k1['id']}")
387+ assert r.status_code == 200 and r.headers["content-type"].startswith("text/csv") and r.headers["X-Row-Count"] == "168"
388+ lines = r.text.strip().splitlines()
389+ assert lines[0] == "t,requests,rows,status_429,bytes,rows_parquet" and len(lines) == 169
390+ assert any(l.split(",")[1] == "3" for l in lines[1:])
391+ lim = web.get("/v1/me/limits").json()["data"]
392+ assert lim["tier"]["tier"] == "free" and len(lim["keys"]) == 2
393+ by_id = {x["key_id"]: x for x in lim["keys"]}
394+ assert by_id[k1["id"]]["requests"]["remaining"] == 117 and by_id[k1["id"]]["rows"]["remaining"] == 1_000_000 - 300
395+ assert by_id[k2["id"]]["requests"]["remaining"] == 119 and by_id[k1["id"]]["redis"] is True
396+ only = web.get(f"/v1/me/limits?key_id={k2['id']}").json()["data"]["keys"]
397+ assert len(only) == 1 and only[0]["name"] == "two"
398+
399+
150 def test_mailer_templates_never_leak_keys(app): 400 def test_mailer_templates_never_leak_keys(app):
151 from accounts import mailer 401 from accounts import mailer
152 full_key = "hfmd_live_ab12cd34EXAMPLEKEYnotARealOne00" 402 full_key = "hfmd_live_ab12cd34EXAMPLEKEYnotARealOne00"
153− for kind in ("verify", "invite", "reset"): 403+ for kind in ("verify", "invite", "reset", "email_change"):
154− subject, text, html = mailer.render(kind, name="Ada", link="https://x/y?token=abc") 404+ subject, text, html = mailer.render(kind, name="Ada", link="https://x/y?token=abc", new_email="n@example.com")
155 assert subject and "HF Market Data" in text and '<a href="https://x/y?token=abc"' in html 405 assert subject and "HF Market Data" in text and '<a href="https://x/y?token=abc"' in html
156− subject, text, html = mailer.render("key_created", name="Ada", key_name="k", key_prefix="hfmd_live_ab12cd34", key=full_key) 406+ for kind in ("key_created", "key_revoked", "key_rotated"):
157− assert "hfmd_live_ab12cd34" in text and full_key not in text and full_key not in html # prefix only, never the key 407+ subject, text, html = mailer.render(kind, name="Ada", key_name="k", key_prefix="hfmd_live_ab12cd34", new_prefix="hfmd_live_zz", key=full_key)
408+ assert "hfmd_live_ab12cd34" in text and full_key not in text and full_key not in html # prefix only, never the key
409+ for kind in ("already_registered", "password_changed", "email_changed", "suspicious_login", "account_deleted", "quota_alert"):
410+ assert mailer.render(kind, name="Ada", new_email="n@example.com", what="Rows quota at 80 %", detail="…")[0]
158 d = mailer.send("verify", "ada@example.com", name="Ada", link="https://x/y?token=abc") 411 d = mailer.send("verify", "ada@example.com", name="Ada", link="https://x/y?token=abc")
159 assert d.delivered is False and d.error == "no_provider" 412 assert d.delivered is False and d.error == "no_provider"
160 413
161 414
415+def test_mailer_never_logs_links(app, caplog):
416+ """A9: the action link must not end up in the logs."""
417+ import logging
418+
419+ from accounts import mailer
420+ with caplog.at_level(logging.INFO, logger="hfmarketdata.mailer"):
421+ mailer.send("verify", "ada@example.com", name="Ada", link="https://x/verify?token=SECRET-TOKEN")
422+ assert "SECRET-TOKEN" not in caplog.text and "ada@example.com" not in caplog.text and "a***@example.com" in caplog.text
423+
424+
162 def test_mailer_posts_to_resend(app, monkeypatch): 425 def test_mailer_posts_to_resend(app, monkeypatch):
163 from dataclasses import replace 426 from dataclasses import replace
164 427
@@ -176,3 +439,32 @@ def test_mailer_posts_to_resend(app, monkeypatch):
176 import json 439 import json
177 body = json.loads(sent.content) 440 body = json.loads(sent.content)
178 assert body["to"] == ["tim@example.com"] and "accept-invite?token=t" in body["text"] and body["html"] 441 assert body["to"] == ["tim@example.com"] and "accept-invite?token=t" in body["text"] and body["html"]
442+
443+
444+def test_mail_is_sent_after_commit_and_dropped_on_rollback(app, outbox):
445+ """A10/A13: the outbox is flushed by after_commit, never inside the transaction, and dropped on rollback."""
446+ from accounts import mailer, service
447+ from core.db import session
448+ with session() as s:
449+ d = mailer.queue(s, "already_registered", "x@example.com", name="X")
450+ assert d.queued and outbox == [] # nothing dispatched yet: still inside the transaction
451+ assert [m.to for m in outbox] == ["x@example.com"]
452+ outbox.clear()
453+ try:
454+ with session() as s:
455+ mailer.queue(s, "already_registered", "y@example.com", name="Y")
456+ raise RuntimeError("boom")
457+ except RuntimeError:
458+ pass
459+ assert outbox == []
460+ # a provider failure after commit stores the link for admins
461+ with session() as s:
462+ u = service.create_user(s, fresh_email("late"), "Late", actor="test")
463+ issued = service.issue_token(s, u, "invite", actor="test")
464+ uid, tok_id = u.id, issued.token.id
465+ assert issued.delivery.queued and issued.token.meta is None
466+ msg = [m for m in outbox if m.kind == "invite"][0]
467+ msg.on_failure(msg.kind, msg.to, msg.ctx, mailer.Delivery(False, error="resend_500"))
468+ with session() as s:
469+ from accounts.models import User
470+ assert service.pending_link(s, s.get(User, uid), "invite") == issued.link
modified tests/test_ratelimit_middleware.py +6 −3
@@ -6,7 +6,8 @@ HDRS = ["X-RateLimit-Limit-Requests", "X-RateLimit-Remaining-Requests", "X-RateL
6 6
7 7
8 def ip(n: int) -> dict: 8 def ip(n: int) -> dict:
9− return {"X-Forwarded-For": f"10.0.0.{n}, 172.16.0.1"} 9+ """The proxy (ngrok) APPENDS the real peer: the last hop is the client, the first one is whatever it claimed."""
10+ return {"X-Forwarded-For": f"172.16.0.1, 10.0.0.{n}"}
10 11
11 12
12 def test_headers_on_success_and_error_responses(client): 13 def test_headers_on_success_and_error_responses(client):
@@ -139,7 +140,7 @@ def test_rows_cap_per_request(client, make_user):
139 140
140 def test_auth_endpoints_throttled_per_ip(client): 141 def test_auth_endpoints_throttled_per_ip(client):
141 h = {**ip(11), "Content-Type": "application/json"} 142 h = {**ip(11), "Content-Type": "application/json"}
142− for _ in range(10): 143+ for _ in range(20):
143 r = client.post("/v1/auth/login", json={"email": "nobody@example.com", "password": "x"}, headers=h) 144 r = client.post("/v1/auth/login", json={"email": "nobody@example.com", "password": "x"}, headers=h)
144 assert r.status_code == 401 145 assert r.status_code == 401
145 r = client.post("/v1/auth/login", json={"email": "nobody@example.com", "password": "x"}, headers=h) 146 r = client.post("/v1/auth/login", json={"email": "nobody@example.com", "password": "x"}, headers=h)
@@ -193,8 +194,10 @@ def test_limits_endpoint_public_and_uncharged(client, make_user):
193 def test_openapi_lists_new_routes_with_errors(client): 194 def test_openapi_lists_new_routes_with_errors(client):
194 spec = client.get("/openapi.json").json() 195 spec = client.get("/openapi.json").json()
195 op = spec["paths"]["/v1/auth/signup"]["post"] 196 op = spec["paths"]["/v1/auth/signup"]["post"]
196− assert op["summary"] and "409" in op["responses"] and "EMAIL_TAKEN" in op["responses"]["409"]["description"] 197+ assert op["summary"] and "202" in op["responses"] and "400" in op["responses"] and "WEAK_PASSWORD" in op["responses"]["400"]["description"]
197 assert "429" in op["responses"] 198 assert "429" in op["responses"]
199+ op = spec["paths"]["/v1/admin/users"]["post"]
200+ assert "409" in op["responses"] and "EMAIL_TAKEN" in op["responses"]["409"]["description"]
198 assert "X-RateLimit-Limit-Requests" in spec["paths"]["/v1/limits"]["get"]["responses"]["200"]["headers"] 201 assert "X-RateLimit-Limit-Requests" in spec["paths"]["/v1/limits"]["get"]["responses"]["200"]["headers"]
199 assert "EMAIL_TAKEN" in spec["components"]["schemas"]["Error"]["properties"]["error"]["properties"]["code"]["enum"] 202 assert "EMAIL_TAKEN" in spec["components"]["schemas"]["Error"]["properties"]["error"]["properties"]["code"]["enum"]
200 assert spec["paths"]["/v1/me/keys"]["post"]["responses"]["201"]["content"]["application/json"]["example"]["data"]["key"].startswith("hfmd_live_") 203 assert spec["paths"]["/v1/me/keys"]["post"]["responses"]["201"]["content"]["application/json"]["example"]["data"]["key"].startswith("hfmd_live_")
added tests/test_ratelimit_security.py +181 −0
@@ -0,0 +1,181 @@
1+"""Security hardening of the rate-limit layer: X-Forwarded-For handling (A1), per-endpoint auth throttles and
2+fail-closed behaviour (A5/A14), bounded key cache + cross-worker invalidation (A12/A20), key-lookup throttle."""
3+from __future__ import annotations
4+
5+from dataclasses import replace
6+
7+from starlette.datastructures import Headers
8+
9+JSON = {"Content-Type": "application/json"}
10+
11+
12+def scope_with(client_host: str | None, **headers) -> tuple[dict, Headers]:
13+ raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
14+ scope = {"type": "http", "headers": raw, "client": (client_host, 1234) if client_host else None}
15+ return scope, Headers(scope=scope)
16+
17+
18+def test_client_ip_takes_the_hop_appended_by_the_proxy(app):
19+ """A1: the FIRST hop is attacker-controlled; ngrok appends the real peer at the END."""
20+ from ratelimit import middleware as mw
21+ scope, h = scope_with("127.0.0.1", **{"X-Forwarded-For": "6.6.6.6, 198.51.100.23"})
22+ assert mw.client_ip(scope, h) == "198.51.100.23"
23+ scope, h = scope_with("10.0.0.2", **{"X-Forwarded-For": "198.51.100.23"})
24+ assert mw.client_ip(scope, h) == "198.51.100.23"
25+ # two trusted proxies → second from the end
26+ scope, h = scope_with("10.0.0.2", **{"X-Forwarded-For": "6.6.6.6, 198.51.100.23, 10.0.0.9"})
27+ assert mw.client_ip(scope, h) == "10.0.0.9"
28+ old = mw.settings
29+ mw.settings = replace(old, trusted_proxy_hops=2)
30+ try:
31+ assert mw.client_ip(scope, h) == "198.51.100.23"
32+ # hops=0 → the header is ignored entirely
33+ mw.settings = replace(old, trusted_proxy_hops=0)
34+ assert mw.client_ip(scope, h) == "10.0.0.2"
35+ finally:
36+ mw.settings = old
37+ # direct peer is a public address (no proxy in front) → X-Forwarded-For is not trusted
38+ scope, h = scope_with("8.8.8.8", **{"X-Forwarded-For": "6.6.6.6"}) # (203.0.113.x is "private" for ipaddress)
39+ assert mw.client_ip(scope, h) == "8.8.8.8"
40+ # X-Real-IP only when trusted
41+ scope, h = scope_with("127.0.0.1", **{"X-Real-IP": "198.51.100.77"})
42+ assert mw.client_ip(scope, h) == "198.51.100.77"
43+ scope, h = scope_with("8.8.8.8", **{"X-Real-IP": "198.51.100.77"})
44+ assert mw.client_ip(scope, h) == "8.8.8.8"
45+ scope, h = scope_with(None)
46+ assert mw.client_ip(scope, h) == "unknown"
47+
48+
49+def test_spoofed_first_hop_cannot_escape_the_keyless_quota(client):
50+ """A1 (live reproduction): varying the first hop while ngrok appends the same peer = one principal."""
51+ for i in range(30):
52+ r = client.get("/v1/status", headers={"X-Forwarded-For": f"1.2.3.{i}, 198.51.100.200"})
53+ assert r.status_code == 200, i
54+ r = client.get("/v1/status", headers={"X-Forwarded-For": "9.9.9.9, 198.51.100.200"})
55+ assert r.status_code == 429
56+ # …and the auth throttle cannot be bypassed either
57+ for i in range(20):
58+ r = client.post("/v1/auth/login", json={"email": "nobody@example.com", "password": "x"},
59+ headers={**JSON, "X-Forwarded-For": f"1.2.3.{i}, 198.51.100.201"})
60+ assert r.status_code == 401, i
61+ r = client.post("/v1/auth/login", json={"email": "nobody@example.com", "password": "x"},
62+ headers={**JSON, "X-Forwarded-For": "7.7.7.7, 198.51.100.201"})
63+ assert r.status_code == 429
64+
65+
66+def test_auth_throttle_is_per_endpoint(client):
67+ """A5/A14: signup 5/h, forgot 5/h, login 20/h, verify 30/h — separate buckets per IP."""
68+ h = {**JSON, "X-Forwarded-For": "198.51.100.10"}
69+ for i in range(5):
70+ assert client.post("/v1/auth/signup", json={"email": "bad", "name": "x", "password": "long-enough-password"}, headers=h).status_code == 400, i
71+ r = client.post("/v1/auth/signup", json={"email": "bad", "name": "x", "password": "long-enough-password"}, headers=h)
72+ assert r.status_code == 429 and r.json()["error"]["details"]["endpoint"] == "signup" and "Retry-After" in r.headers
73+ # the login bucket of the same IP is untouched
74+ assert client.post("/v1/auth/login", json={"email": "nobody@example.com", "password": "x"}, headers=h).status_code == 401
75+ for i in range(5):
76+ assert client.post("/v1/auth/forgot", json={"email": f"n{i}@example.com"}, headers=h).status_code == 202
77+ assert client.post("/v1/auth/forgot", json={"email": "n9@example.com"}, headers=h).status_code == 429
78+ for i in range(30):
79+ assert client.post("/v1/auth/verify", json={"token": "nope"}, headers=h).status_code == 400, i
80+ assert client.post("/v1/auth/verify", json={"token": "nope"}, headers=h).status_code == 429
81+ # logout is never throttled
82+ for _ in range(40):
83+ assert client.post("/v1/auth/logout", json={}, headers=h).status_code == 200
84+
85+
86+def test_auth_fails_closed_when_redis_is_down_but_data_fails_open(client, monkeypatch):
87+ """A5: no Redis → login/signup/forgot answer 429 (Retry-After 30); data endpoints keep working."""
88+ import redis as redis_lib
89+
90+ from ratelimit import redis_limiter as rl
91+
92+ class Broken:
93+ def __getattr__(self, name):
94+ def _raise(*a, **k):
95+ raise redis_lib.exceptions.ConnectionError("down")
96+ return _raise
97+
98+ monkeypatch.setattr(rl, "_client", Broken())
99+ monkeypatch.setattr(rl, "_sha", None)
100+ monkeypatch.setattr(rl, "_down_until", 0.0)
101+ h = {**JSON, "X-Forwarded-For": "198.51.100.11"}
102+ for path, body in (("/v1/auth/login", {"email": "a@example.com", "password": "x"}),
103+ ("/v1/auth/signup", {"email": "a@example.com", "name": "x", "password": "long-enough-password"}),
104+ ("/v1/auth/forgot", {"email": "a@example.com"})):
105+ r = client.post(path, json=body, headers=h)
106+ assert r.status_code == 429 and r.headers["Retry-After"] == "30", path
107+ assert client.post("/v1/auth/logout", json={}, headers=h).status_code == 200
108+ assert client.post("/v1/auth/verify", json={"token": "nope"}, headers=h).status_code == 400 # not in the fail-closed set
109+ r = client.get("/v1/status", headers={"X-Forwarded-For": "198.51.100.11"})
110+ assert r.status_code == 200 and "X-RateLimit-Limit-Requests" not in r.headers
111+
112+
113+def test_key_cache_is_bounded_and_invalidated_across_workers(client, make_user, monkeypatch):
114+ """A12/A20: LRU 10 000 with TTL; a Redis generation bump makes every worker miss."""
115+ from ratelimit import middleware as mw
116+ from ratelimit import redis_limiter as rl
117+ lru = mw._LRU(maxsize=3, ttl_s=60)
118+ for i in range(5):
119+ lru.put(f"k{i}", i, generation=1)
120+ assert len(lru) == 3 and lru.get("k0", 1) == (False, None) and lru.get("k4", 1) == (True, 4)
121+ assert lru.get("k4", 2) == (False, None) # generation changed → miss
122+ lru.put("neg", None, generation=None)
123+ assert lru.get("neg", 7) == (True, None) # negatives are cached, None generation = not tracked
124+ assert mw._key_cache.maxsize == 10_000 and mw._key_cache.ttl_s == 60
125+
126+ _, key, _, _ = make_user()
127+ calls = {"n": 0}
128+ real = mw._lookup_key
129+
130+ def counting(h, ip_hash=None):
131+ calls["n"] += 1
132+ return real(h, ip_hash)
133+
134+ monkeypatch.setattr(mw, "_lookup_key", counting)
135+ for _ in range(3):
136+ assert client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 200
137+ assert calls["n"] == 1 # cached
138+ rl.bump_keys_version() # what another worker does after a revoke / tier change
139+ assert client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 200
140+ assert calls["n"] == 2
141+ # unknown keys are cached negatively: 3 requests, 1 lookup
142+ unknown = "hfmd_live_" + "z" * 32
143+ for _ in range(3):
144+ assert client.get("/v1/status", headers={"Authorization": f"Bearer {unknown}"}).status_code == 401
145+ assert calls["n"] == 3
146+
147+
148+def test_unknown_key_lookups_are_throttled_before_sqlite(client, monkeypatch):
149+ """A12: brute-forcing keys hits a per-IP throttle (120/min) before the database."""
150+ from ratelimit import middleware as mw
151+ from ratelimit.tiers import KEY_LOOKUPS_PER_MINUTE_PER_IP
152+ calls = {"n": 0}
153+ real = mw._lookup_key
154+
155+ def counting(h, ip_hash=None):
156+ calls["n"] += 1
157+ return real(h, ip_hash)
158+
159+ monkeypatch.setattr(mw, "_lookup_key", counting)
160+ h = {"X-Forwarded-For": "198.51.100.12"}
161+ for i in range(KEY_LOOKUPS_PER_MINUTE_PER_IP):
162+ r = client.get("/v1/status", headers={**h, "Authorization": f"Bearer hfmd_live_{i:032d}"})
163+ assert r.status_code == 401, i
164+ r = client.get("/v1/status", headers={**h, "Authorization": "Bearer hfmd_live_" + "q" * 32})
165+ assert r.status_code == 429 and "Retry-After" in r.headers
166+ assert calls["n"] == KEY_LOOKUPS_PER_MINUTE_PER_IP # the 121st never reached SQLite
167+ # another IP is unaffected
168+ assert client.get("/v1/status", headers={"X-Forwarded-For": "198.51.100.13", "Authorization": "Bearer hfmd_live_" + "q" * 32}).status_code == 401
169+
170+
171+def test_new_error_codes_are_documented(client):
172+ spec = client.get("/openapi.json").json()
173+ codes = spec["components"]["schemas"]["Error"]["properties"]["error"]["properties"]["code"]["enum"]
174+ assert {"ACCOUNT_LOCKED", "SESSION_REQUIRED", "LAST_ADMIN"} <= set(codes)
175+ login = spec["paths"]["/v1/auth/login"]["post"]
176+ assert "423" in login["responses"] and "ACCOUNT_LOCKED" in login["responses"]["423"]["description"]
177+ assert "SESSION_REQUIRED" in spec["paths"]["/v1/me/keys"]["post"]["responses"]["403"]["description"]
178+ assert spec["paths"]["/v1/auth/verify"]["post"]["summary"] and spec["paths"]["/v1/auth/verify"]["get"]["responses"]["302"]
179+ for p in ("/v1/me/password", "/v1/me/email", "/v1/me/sessions/revoke-all", "/v1/me/limits", "/v1/me/usage.csv", "/v1/admin/users/{user_id}"):
180+ assert p in spec["paths"], p
181+ assert "delete" in spec["paths"]["/v1/me"] and "delete" in spec["paths"]["/v1/admin/users/{user_id}"]
modified tests/test_ratelimit_usage.py +39 −3
@@ -32,14 +32,18 @@ def test_record_fold_and_series(app):
32 32
33 series = usage.usage_series([p], "24h", now_s=now + 10) 33 series = usage.usage_series([p], "24h", now_s=now + 10)
34 assert series["step_seconds"] == 60 and len(series["points"]) == 1440 34 assert series["step_seconds"] == 60 and len(series["points"]) == 1440
35− assert series["totals"] == {"requests": 4, "rows": 1510} # folded + live minute 35+ assert series["totals"] == {"requests": 4, "rows": 1510, "status_429": 1, "bytes": 1234, "rows_parquet": 500} # folded + live minute
36+ assert m.status_429 == 0 and m.rows_parquet == 500 and m.bytes == 1234 # usage_minute carries the new columns (the 429 is in the next minute)
36 by_t = {pt["t"]: pt for pt in series["points"]} 37 by_t = {pt["t"]: pt for pt in series["points"]}
37 assert by_t[datetime.fromtimestamp(now - 180, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")]["rows"] == 1500 38 assert by_t[datetime.fromtimestamp(now - 180, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")]["rows"] == 1500
38 week = usage.usage_series([p], "7d", now_s=now + 10) 39 week = usage.usage_series([p], "7d", now_s=now + 10)
39 assert week["step_seconds"] == 3600 and week["totals"]["requests"] == 4 40 assert week["step_seconds"] == 3600 and week["totals"]["requests"] == 4
40 month = usage.usage_series([p], "30d", now_s=now + 10) 41 month = usage.usage_series([p], "30d", now_s=now + 10)
41 assert month["step_seconds"] == 86400 and month["totals"]["rows"] == 1510 42 assert month["step_seconds"] == 86400 and month["totals"]["rows"] == 1510
42− assert usage.usage_series([], "24h", now_s=now)["totals"] == {"requests": 0, "rows": 0} 43+ assert usage.usage_series([], "24h", now_s=now)["totals"] == {"requests": 0, "rows": 0, "status_429": 0, "bytes": 0, "rows_parquet": 0}
44+ csv = usage.usage_csv(series).splitlines()
45+ assert csv[0] == "t,requests,rows,status_429,bytes,rows_parquet" and len(csv) == 1441
46+ assert any(line.endswith(",3,1500,0,1234,500") for line in csv[1:])
43 assert any(t["principal"] == p and t["requests"] >= 3 for t in usage.top_principals(days=100_000)) 47 assert any(t["principal"] == p and t["requests"] >= 3 for t in usage.top_principals(days=100_000))
44 48
45 49
@@ -54,10 +58,42 @@ def test_middleware_records_usage(client, make_user):
54 from accounts.models import User 58 from accounts.models import User
55 principals = [k.principal for k in service.list_keys(s, s.get(User, uid))] 59 principals = [k.principal for k in service.list_keys(s, s.get(User, uid))]
56 series = usage.usage_series(principals, "24h") 60 series = usage.usage_series(principals, "24h")
57− assert series["totals"] == {"requests": 2, "rows": 375} 61+ assert (series["totals"]["requests"], series["totals"]["rows"], series["totals"]["rows_parquet"]) == (2, 375, 125)
62+ assert series["totals"]["bytes"] > 0 and series["totals"]["status_429"] == 0
58 63
59 64
60 def test_invalid_range_is_uniform_error(client, make_user): 65 def test_invalid_range_is_uniform_error(client, make_user):
61 _, key, _, _ = make_user() 66 _, key, _, _ = make_user()
62 r = client.get("/v1/me/usage?range=1y", headers={"Authorization": f"Bearer {key}"}) 67 r = client.get("/v1/me/usage?range=1y", headers={"Authorization": f"Bearer {key}"})
63 assert r.status_code == 422 and r.json()["error"]["code"] == "VALIDATION_ERROR" 68 assert r.status_code == 422 and r.json()["error"]["code"] == "VALIDATION_ERROR"
69+
70+
71+def test_quota_alerts_once_per_day_and_opt_out(app, make_user, outbox):
72+ """B4: 80 % / 100 % of the rows window or a 429 → one mail per user per day; opt-out honoured."""
73+ from accounts.models import User
74+ from core.db import session
75+ from ratelimit import redis_limiter as rl
76+ from ratelimit import usage
77+ from ratelimit.tiers import TIERS
78+ uid, key, email, _ = make_user()
79+ with session() as s:
80+ from accounts import service
81+ principal = service.active_keys(s, s.get(User, uid))[0].principal
82+ rl.reset_for_tests()
83+ free = TIERS["free"]
84+ rl.apply_tier(principal, free, req_cost=1, rows_cost=int(free.rows * 0.85), force=True) # 85 % of the window
85+ usage.record(principal, requests=1, rows=10, status=200) # "recent" principal
86+ assert usage.quota_alerts() == 1
87+ alert = [m for m in outbox if m.kind == "quota_alert"][-1]
88+ assert alert.to == email and "80 %" in alert.ctx["what"]
89+ assert usage.quota_alerts() == 0 # deduped for today
90+ with session() as s:
91+ s.get(User, uid).quota_alerts = 0
92+ rl._call(lambda: rl.client().delete(*[k for k in rl.client().keys("alert:*")]) if rl.client().keys("alert:*") else None)
93+ assert usage.quota_alerts() == 0 # opt-out
94+ with session() as s:
95+ s.get(User, uid).quota_alerts = 1
96+ usage.record(principal, requests=0, status=429)
97+ assert usage.quota_alerts() == 1
98+ alert = [m for m in outbox if m.kind == "quota_alert"][-1]
99+ assert "100 %" in alert.ctx["what"] or "429" in alert.ctx["what"] or "80 %" in alert.ctx["what"]
64 100
Menu

Theme