SPB Git forge

spb/hfmarketdata

Public

Open high-frequency market data platform — FirstRate full-history downloader, DuckDB/Parquet lake, open REST API and React docs platform (www.hfmarketdata.io)

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%

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 18 days ago (Sep 6, 2026) parent e7f2d3b

7 changed files +841 −161

modified tests/conftest.py +41 −1
@@ -71,7 +71,7 @@ def make_user(app):
71 71 def _make(email: str | None = None, *, tier: str = "free", role: str = "user", password: str = "correct-horse-battery",
72 72 with_key: bool = True):
73 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 75 with session() as s:
76 76 u = service.create_user(s, email, "Test User", password=password, tier=tier, role=role, status="active", actor="test")
77 77 u.email_verified_at = service.now()
@@ -83,6 +83,46 @@ def make_user(app):
83 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 126 @pytest.fixture
87 127 def client_hu(app, make_user):
88 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 3 from __future__ import annotations
3 4
4 5 import os
5 6 from urllib.parse import parse_qs, urlparse
6 7
8 +from fastapi.testclient import TestClient
9 +
7 10 JSON = {"Content-Type": "application/json"}
8 11
9 12
@@ -15,135 +18,222 @@ def fresh_email(tag: str) -> str:
15 18 return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com"
16 19
17 20
18 −def admin_headers(make_user) -> dict:
19 − _, key, _, _ = make_user(role="admin")
20 − return {**JSON, "Authorization": f"Bearer {key}"}
21 −
22 −
23 −def test_non_admin_is_forbidden(client, make_user):
21 +def test_admin_requires_session_and_role(client, make_user, admin_web):
22 + """A6: an admin's API key does NOT open the admin area; plain users get FORBIDDEN."""
24 23 _, key, _, _ = make_user()
25 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 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):
31 − ah = admin_headers(make_user)
40 +def test_invite_flow(admin_web):
41 + web, _, _, _ = admin_web
32 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 44 assert r.status_code == 201, r.text
35 45 d = r.json()["data"]
36 46 assert d["user"]["status"] == "invited" and d["user"]["tier"] == "high_usage" and d["user"]["keys_active"] == 1
37 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 50 uid = d["user"]["id"]
40 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 53 assert r.status_code == 409 and r.json()["error"]["code"] == "EMAIL_TAKEN"
43 − # detail shows the pending link
44 − r = client.get(f"/v1/admin/users/{uid}", headers=ah)
45 − assert r.status_code == 200 and r.json()["data"]["pending_invite_link"] == d["invitation"]["link"]
46 − assert len(r.json()["data"]["keys"]) == 1
54 + # detail shows the pending link, the keys and a 7-day usage series
55 + r = web.get(f"/v1/admin/users/{uid}")
56 + assert r.status_code == 200
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 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 63 assert r.status_code == 401
50 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 66 assert r.status_code == 200
53 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 69 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN"
56 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 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 74 assert me["user"]["email"] == email and me["limits"]["tier"] == "high_usage" and me["user"]["keys_active"] == 1
61 − client.cookies.clear()
62 − r = client.post("/v1/auth/login", json={"email": email, "password": "tim-passphrase-123"}, headers=JSON)
75 + guest.cookies.clear()
76 + r = guest.post("/v1/auth/login", json={"email": email, "password": "tim-passphrase-123"}, headers=JSON)
63 77 assert r.status_code == 200
64 − client.cookies.clear()
65 − # signing up again with an invited-then-active address → EMAIL_TAKEN
66 − r = client.post("/v1/auth/signup", json={"email": email, "name": "Tim", "password": "another-passphrase"}, headers=JSON)
67 − assert r.status_code == 409
78 + guest.cookies.clear()
79 + # signing up again with an invited-then-active address → uniform 202, nothing changed
80 + r = guest.post("/v1/auth/signup", json={"email": email, "name": "Tim", "password": "another-passphrase"}, headers=JSON)
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):
71 − ah = admin_headers(make_user)
85 +def test_signup_on_invited_address_resends_invitation(admin_web):
86 + web, _, _, _ = admin_web
72 87 email = fresh_email("luca")
73 − client.post("/v1/admin/users", json={"email": email, "name": "Luca"}, headers=ah)
74 − r = client.post("/v1/auth/signup", json={"email": email, "name": "Luca", "password": "luca-passphrase-123"}, headers=JSON)
75 − assert r.status_code == 202 and r.json()["data"]["status"] == "invitation_sent"
76 − assert "/accept-invite?token=" in r.json()["data"]["debug_link"]
88 + web.post("/v1/admin/users", json={"email": email, "name": "Luca"}, headers=JSON)
89 + guest = TestClient(web.app)
90 + r = guest.post("/v1/auth/signup", json={"email": email, "name": "Luca", "password": "luca-passphrase-123"}, headers=JSON)
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):
80 − ah = admin_headers(make_user)
95 +def test_create_active_user_update_tier_and_disable(admin_web):
96 + web, _, _, _ = admin_web
81 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 99 assert r.status_code == 201 and r.json()["data"]["user"]["status"] == "active" and r.json()["data"]["invitation"] is None
84 100 uid = r.json()["data"]["user"]["id"]
85 − # admin creates a key for her (shown once), it honours the user tier then the override
86 − r = client.post(f"/v1/admin/users/{uid}/keys", json={"name": "handover"}, headers=ah)
87 − assert r.status_code == 201
101 + # admin creates a key for her (shown once) with a lifetime, it honours the user tier then the override
102 + r = web.post(f"/v1/admin/users/{uid}/keys", json={"name": "handover", "expires_in_days": 90, "note": "handed over by mail"}, headers=JSON)
103 + assert r.status_code == 201 and r.json()["data"]["expires_at"] and r.json()["data"]["note"] == "handed over by mail"
88 104 key = r.json()["data"]["key"]
89 − assert client.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)
105 + assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).headers["X-RateLimit-Limit-Requests"] == "120"
106 + r = web.patch(f"/v1/admin/users/{uid}", json={"tier": "high_usage"}, headers=JSON)
91 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"
93 − r = client.patch(f"/v1/admin/users/{uid}", json={"tier": "platinum"}, headers=ah)
108 + assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).headers["X-RateLimit-Limit-Requests"] == "600"
109 + r = web.patch(f"/v1/admin/users/{uid}", json={"tier": "platinum"}, headers=JSON)
94 110 assert r.status_code == 422
95 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 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 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 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 120 # re-enable and revoke via admin
103 − client.patch(f"/v1/admin/users/{uid}", json={"status": "active"}, headers=ah)
104 − kid = client.get(f"/v1/admin/users/{uid}", headers=ah).json()["data"]["keys"][0]["id"]
105 − r = client.delete(f"/v1/admin/users/{uid}/keys/{kid}", headers=ah)
121 + web.patch(f"/v1/admin/users/{uid}", json={"status": "active"}, headers=JSON)
122 + kid = web.get(f"/v1/admin/users/{uid}").json()["data"]["keys"][0]["id"]
123 + r = web.delete(f"/v1/admin/users/{uid}/keys/{kid}", headers=JSON)
106 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 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 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"
112 −
113 −
114 −def test_list_users_pagination_and_search(client, make_user):
115 − ah = admin_headers(make_user)
129 + assert web.get(f"/v1/admin/users/{uid}").json()["data"]["pending_reset_link"] == r.json()["data"]["reset"]["link"]
130 + assert web.get("/v1/admin/users/999999").json()["error"]["code"] == "USER_NOT_FOUND"
131 +
132 +
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 200 emails = [fresh_email("page") for _ in range(3)]
117 201 for e in emails:
118 − client.post("/v1/admin/users", json={"email": e, "name": "Paged Person"}, headers=ah)
119 − r = client.get("/v1/admin/users?search=paged&limit=2", headers=ah)
202 + web.post("/v1/admin/users", json={"email": e, "name": "Paged Person"}, headers=JSON)
203 + r = web.get("/v1/admin/users?search=paged&limit=2")
120 204 assert r.status_code == 200
121 205 body = r.json()
122 − assert len(body["data"]) == 2 and body["meta"]["next_cursor"]
123 − r2 = client.get(f"/v1/admin/users?search=paged&limit=2&cursor={body['meta']['next_cursor']}", headers=ah)
206 + assert len(body["data"]) == 2 and body["meta"]["next_cursor"] and "admins_active" in body["meta"]
207 + r2 = web.get(f"/v1/admin/users?search=paged&limit=2&cursor={body['meta']['next_cursor']}")
124 208 assert r2.status_code == 200 and len(r2.json()["data"]) >= 1
125 209 ids = {u["id"] for u in body["data"]} | {u["id"] for u in r2.json()["data"]}
126 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):
131 − ah = admin_headers(make_user)
214 +def test_global_usage_and_audit(admin_web, make_user):
215 + web, admin_id, _, _ = admin_web
132 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 218 import time
135 219
136 220 from ratelimit import usage
137 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 223 assert r.status_code == 200
140 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 226 top = [t for t in d["top"] if t["user"] and t["user"]["id"] == uid]
143 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 229 assert r.status_code == 200
146 230 rows = r.json()["data"]
147 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 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 3 from __future__ import annotations
3 4
5 +import json
6 +
4 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 17 def run(cli, capsys, *argv) -> tuple[int, str]:
8 18 rc = cli.main(list(argv))
@@ -10,15 +20,25 @@ def run(cli, capsys, *argv) -> tuple[int, str]:
10 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 32 from accounts import cli
15 33 from accounts.models import ApiKey, EmailToken, User
16 34 from core.db import session
17 35
18 − rc, out = run(cli, capsys, "seed")
19 − assert rc == 0
20 − for email in ("ianez84@gmail.com", "timerdmann.uni@gmail.com", "javier.slaton@gmail.com", "contact@spboucher.ai"):
21 − assert email in out
36 + seed_file = tmp_path / "seed.json"
37 + seed_file.write_text(json.dumps(SEED))
38 + rc, out = run(cli, capsys, "seed", str(seed_file))
39 + assert rc == 0, out
40 + for u in SEED:
41 + assert u["email"] in out
22 42 assert "accept-invite?token=" in out and "hfmd_live_" in out
23 43 assert out.count("hfmd_live_") == 4 # prefixes only, one per user
24 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 53 return users, keys, toks
34 54
35 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")}
37 − assert seeded["contact@spboucher.ai"].role == "admin" and seeded["ianez84@gmail.com"].role == "user"
56 + seeded = {u["email"]: users[u["email"]] for u in SEED}
57 + assert seeded["seed-admin@example.com"].role == "admin" and seeded["seed-one@example.com"].role == "user"
38 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 61 assert rc == 0
42 62 users2, keys2, toks2 = counts()
43 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 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 84 def test_users_add_show_key_and_management(app, capsys):
51 85 from accounts import cli
52 86 email = "cli-person@example.com"
@@ -60,6 +94,10 @@ def test_users_add_show_key_and_management(app, capsys):
60 94 assert not [tok for tok in out.split() if tok.startswith("hfmd_live_") and len(tok) == 42]
61 95 rc, out = run(cli, capsys, "keys", "list", email)
62 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 101 rc, out = run(cli, capsys, "users", "set-tier", email, "free")
64 102 assert rc == 0 and "tier = free" in out
65 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 4 from __future__ import annotations
3 5
4 6 import os
5 7 from urllib.parse import parse_qs, urlparse
6 8
9 +from fastapi.testclient import TestClient
10 +
7 11 JSON = {"Content-Type": "application/json"}
8 12
9 13
@@ -15,125 +19,237 @@ def fresh_email(tag: str) -> str:
15 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 23 email = fresh_email("ada")
20 24 pw = "very-secure-passphrase"
21 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 27 assert r.status_code == 202, r.text
24 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 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 32 assert r.status_code == 403 and r.json()["error"]["code"] == "EMAIL_NOT_VERIFIED"
29 − # verify → session cookie
30 − r = client.get("/v1/auth/verify", params={"token": token_of(d["debug_link"])})
31 − assert r.status_code == 200 and r.json()["data"]["email_verified"] is True
33 + # A9/A16: GET verify only redirects to the web page (no consumption, no cookie)
34 + r = web.get("/v1/auth/verify", params={"token": token_of(d["debug_link"])}, follow_redirects=False)
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 40 assert "hfmd_session" in r.cookies
33 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 43 # /me with the cookie
36 − r = client.get("/v1/me")
44 + r = web.get("/v1/me")
37 45 assert r.status_code == 200
38 46 me = r.json()["data"]
39 47 assert me["user"]["email"] == email and me["limits"]["tier"] == "free" and me["limits"]["requests"] == 120
40 − assert me["user"]["keys_active"] == 0
41 − # create key (shown once)
42 − r = client.post("/v1/me/keys", json={"name": "laptop"}, headers=JSON)
43 − assert r.status_code == 201
48 + assert me["user"]["keys_active"] == 0 and me["auth"] == "session"
49 + # create key (shown once) with note + expiry
50 + r = web.post("/v1/me/keys", json={"name": "laptop", "note": "CI runner", "expires_in_days": 30}, headers=JSON)
51 + assert r.status_code == 201, r.text
44 52 k = r.json()["data"]
45 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"
47 − listed = client.get("/v1/me/keys").json()["data"]
54 + assert k["prefix"] == k["key"][:18] and k["name"] == "laptop" and k["note"] == "CI runner" and k["scopes"] == ["data"]
55 + assert k["expires_at"] and k["expired"] is False
56 + listed = web.get("/v1/me/keys").json()["data"]
48 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
50 − client.cookies.clear()
51 − r = client.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"})
58 + # use the key on a data endpoint → free-tier headers, last_used_at + hashed ip recorded
59 + r = web.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}", "X-Forwarded-For": "203.0.113.7"})
52 60 assert r.status_code == 200 and r.headers["X-RateLimit-Limit-Requests"] == "120"
53 − # /me also works with the key (programmatic)
54 − assert client.get("/v1/me", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 200
55 − # rotate → new key, old one dead
56 − r = client.post(f"/v1/me/keys/{k['id']}/rotate", json={}, headers={**JSON, "Authorization": f"Bearer {k['key']}"})
61 + listed = web.get("/v1/me/keys").json()["data"]
62 + assert listed[0]["last_used_at"] and len(listed[0]["last_used_ip"]) == 16
63 + # A6: the key may READ the account but not mutate it
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 74 assert r.status_code == 201
58 75 k2 = r.json()["data"]
59 − assert k2["key"] != k["key"] and k2["rotated_from"] == k["id"] and k2["name"] == "laptop"
60 − r = client.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"})
76 + assert k2["key"] != k["key"] and k2["rotated_from"] == k["id"] and k2["name"] == "laptop" and k2["note"] == "CI runner"
77 + assert k2["expires_at"] == k["expires_at"]
78 + r = web.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}"})
61 79 assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_API_KEY"
62 − # revoke the new one → 401 immediately (cache invalidated)
63 − r = client.delete(f"/v1/me/keys/{k2['id']}", headers={**JSON, "Authorization": f"Bearer {k2['key']}"})
80 + # A3: revoke with an EMPTY body + X-Requested-With (what the SPA sends) is accepted
81 + r = web.delete(f"/v1/me/keys/{k2['id']}", headers={"X-Requested-With": "hfmd"})
64 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
66 − # login again and check the session + usage endpoint
67 − r = client.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)
68 − assert r.status_code == 200 and r.json()["data"]["last_login_at"]
69 − u = client.get("/v1/me/usage?range=24h").json()["data"]
70 − assert u["totals"]["requests"] >= 1 and len(u["principals"]) == 2
71 − # logout
72 − r = client.post("/v1/auth/logout", json={}, headers=JSON)
83 + assert web.get("/v1/status", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 401
84 + # usage endpoint (session)
85 + u = web.get("/v1/me/usage?range=24h").json()["data"]
86 + assert u["totals"]["requests"] >= 1 and len(u["principals"]) == 2 and u["key_id"] is None
87 + assert {"requests", "rows", "status_429", "bytes", "rows_parquet"} <= set(u["points"][0])
88 + # logout with empty body + header, cookie gone
89 + r = web.post("/v1/auth/logout", headers={"X-Requested-With": "hfmd"})
73 90 assert r.status_code == 200
74 − assert client.get("/v1/me").status_code == 401
75 − client.cookies.clear()
91 + assert web.get("/v1/me").status_code == 401
76 92
77 93
78 −def test_signup_validation_and_conflicts(client, make_user):
79 − _, _, email, _ = make_user()
80 − r = client.post("/v1/auth/signup", json={"email": email, "name": "x", "password": "long-enough-password"}, headers=JSON)
81 − assert r.status_code == 409 and r.json()["error"]["code"] == "EMAIL_TAKEN"
82 − r = client.post("/v1/auth/signup", json={"email": fresh_email("w"), "name": "x", "password": "short"}, headers=JSON)
94 +def test_signup_never_touches_existing_account_and_is_uniform(web, make_user, outbox):
95 + """A4 (pre-hijack) + A10 (enumeration): signup on an existing address changes nothing and answers 202."""
96 + from accounts import security
97 + from accounts.models import User
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 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 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):
89 − _, key, _, _ = make_user()
90 − r = client.post("/v1/me/keys", content="name=x", headers={"Content-Type": "application/x-www-form-urlencoded",
91 − "Authorization": f"Bearer {key}"})
124 +def test_csrf_guard(web, make_user, signin):
125 + """A3: JSON always OK; empty body needs X-Requested-With / Sec-Fetch-Site / same Origin; forms are refused."""
126 + _, _, email, pw = make_user()
127 + signin(web, email, pw)
128 + r = web.post("/v1/me/keys", content="name=x", headers={"Content-Type": "application/x-www-form-urlencoded"})
92 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 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):
98 − _, _, email, pw = make_user()
99 − r = client.post("/v1/auth/login", json={"email": email, "password": "wrong-password"}, headers=JSON)
140 +def test_login_errors_constant_cost_and_lockout(web, make_user, outbox):
141 + """A5 (lockout), A10 (dummy hash), A15 (password length), A22 (login_failed audit)."""
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 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)
102 − assert r.status_code == 401
103 − assert client.get("/v1/me").status_code == 401
104 − assert client.get("/v1/me").json()["error"]["code"] == "AUTH_REQUIRED"
150 + assert web.post("/v1/auth/login", json={"email": email, "password": "x" * 300}, headers=JSON).status_code == 422
151 + for i in range(4):
152 + r = web.post("/v1/auth/login", json={"email": email, "password": "wrong-password"}, headers=JSON)
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):
108 − _, _, email, _ = make_user(with_key=False)
177 +def test_forgot_and_reset_revokes_sessions_and_keys(web, make_user, signin):
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 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 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 187 assert r.status_code == 202
114 188 link = r.json()["data"]["debug_link"]
115 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 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)
119 − assert r.status_code == 200
120 − client.cookies.clear()
121 − r = client.post("/v1/auth/login", json={"email": email, "password": "brand-new-passphrase"}, headers=JSON)
192 + r = web.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON)
193 + assert r.status_code == 200 and r.json()["data"]["keys_revoked"] == 1
194 + assert other.get("/v1/me").status_code == 401 # old session dead
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 198 assert r.status_code == 200
123 − client.cookies.clear()
124 − r = client.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON)
199 + web.cookies.clear()
200 + r = web.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON)
125 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):
129 − _, key, _, _ = make_user()
130 − h = {**JSON, "Authorization": f"Bearer {key}"}
210 +def test_forgot_per_email_throttle(web, make_user):
211 + _, _, email, _ = make_user(with_key=False)
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 222 for i in range(9):
132 − assert client.post("/v1/me/keys", json={"name": f"k{i}"}, headers=h).status_code == 201
133 − r = client.post("/v1/me/keys", json={"name": "one-too-many"}, headers=h)
223 + assert web.post("/v1/me/keys", json={"name": f"k{i}"}, headers=JSON).status_code == 201
224 + r = web.post("/v1/me/keys", json={"name": "one-too-many"}, headers=JSON)
134 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 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 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 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 400 def test_mailer_templates_never_leak_keys(app):
151 401 from accounts import mailer
152 402 full_key = "hfmd_live_ab12cd34EXAMPLEKEYnotARealOne00"
153 − for kind in ("verify", "invite", "reset"):
154 − subject, text, html = mailer.render(kind, name="Ada", link="https://x/y?token=abc")
403 + for kind in ("verify", "invite", "reset", "email_change"):
404 + subject, text, html = mailer.render(kind, name="Ada", link="https://x/y?token=abc", new_email="n@example.com")
155 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)
157 − assert "hfmd_live_ab12cd34" in text and full_key not in text and full_key not in html # prefix only, never the key
406 + for kind in ("key_created", "key_revoked", "key_rotated"):
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 411 d = mailer.send("verify", "ada@example.com", name="Ada", link="https://x/y?token=abc")
159 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 425 def test_mailer_posts_to_resend(app, monkeypatch):
163 426 from dataclasses import replace
164 427
@@ -176,3 +439,32 @@ def test_mailer_posts_to_resend(app, monkeypatch):
176 439 import json
177 440 body = json.loads(sent.content)
178 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 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 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 141 def test_auth_endpoints_throttled_per_ip(client):
141 142 h = {**ip(11), "Content-Type": "application/json"}
142 − for _ in range(10):
143 + for _ in range(20):
143 144 r = client.post("/v1/auth/login", json={"email": "nobody@example.com", "password": "x"}, headers=h)
144 145 assert r.status_code == 401
145 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 194 def test_openapi_lists_new_routes_with_errors(client):
194 195 spec = client.get("/openapi.json").json()
195 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 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 201 assert "X-RateLimit-Limit-Requests" in spec["paths"]["/v1/limits"]["get"]["responses"]["200"]["headers"]
199 202 assert "EMAIL_TAKEN" in spec["components"]["schemas"]["Error"]["properties"]["error"]["properties"]["code"]["enum"]
200 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 33 series = usage.usage_series([p], "24h", now_s=now + 10)
34 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 37 by_t = {pt["t"]: pt for pt in series["points"]}
37 38 assert by_t[datetime.fromtimestamp(now - 180, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")]["rows"] == 1500
38 39 week = usage.usage_series([p], "7d", now_s=now + 10)
39 40 assert week["step_seconds"] == 3600 and week["totals"]["requests"] == 4
40 41 month = usage.usage_series([p], "30d", now_s=now + 10)
41 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 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 58 from accounts.models import User
55 59 principals = [k.principal for k in service.list_keys(s, s.get(User, uid))]
56 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 65 def test_invalid_range_is_uniform_error(client, make_user):
61 66 _, key, _, _ = make_user()
62 67 r = client.get("/v1/me/usage?range=1y", headers={"Authorization": f"Bearer {key}"})
63 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