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>
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 | |