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)
JavaScript 53.7%
Python 38.3%
CSS 4.6%
TypeScript 3.1%
1"""Self-service account flows: signup → verify → login → key → use → revoke → 401, reset, CSRF, /me, account security.23Every test here that documents a fixed vulnerability is marked in its docstring with the finding id (A1…A24)."""4from __future__ import annotations56import os7from urllib.parse import parse_qs, urlparse89from fastapi.testclient import TestClient1011JSON = {"Content-Type": "application/json"}121314def token_of(link: str) -> str:15 return parse_qs(urlparse(link).query)["token"][0]161718def fresh_email(tag: str) -> str:19 return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com"202122def test_full_lifecycle(web):23 email = fresh_email("ada")24 pw = "very-secure-passphrase"25 # signup → 202 with (dev only) debug link26 r = web.post("/v1/auth/signup", json={"email": email, "name": "Ada", "password": pw}, headers=JSON)27 assert r.status_code == 202, r.text28 d = r.json()["data"]29 assert d["status"] == "verification_sent" and "debug_link" in d and "/verify?token=" in d["debug_link"]30 # cannot log in before verifying31 r = web.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)32 assert r.status_code == 403 and r.json()["error"]["code"] == "EMAIL_NOT_VERIFIED"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.cookies37 # POST verify → session cookie38 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"40 assert "hfmd_session" in r.cookies41 # token is single use42 assert web.post("/v1/auth/verify", json={"token": token_of(d["debug_link"])}, headers=JSON).json()["error"]["code"] == "INVALID_TOKEN"43 # /me with the cookie44 r = web.get("/v1/me")45 assert r.status_code == 20046 me = r.json()["data"]47 assert me["user"]["email"] == email and me["limits"]["tier"] == "free" and me["limits"]["requests"] == 12048 assert me["user"]["keys_active"] == 0 and me["auth"] == "session"49 # create key (shown once) with note + expiry50 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.text52 k = r.json()["data"]53 assert k["key"].startswith("hfmd_live_") and len(k["key"]) == len("hfmd_live_") + 3254 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 False56 listed = web.get("/v1/me/keys").json()["data"]57 assert len(listed) == 1 and "key" not in listed[0] and listed[0]["prefix"] == k["prefix"]58 # use the key on a data endpoint → free-tier headers, last_used_at + hashed ip recorded59 r = web.get("/v1/status", headers={"Authorization": f"Bearer {k['key']}", "X-Forwarded-For": "203.0.113.7"})60 assert r.status_code == 200 and r.headers["X-RateLimit-Limit-Requests"] == "120"61 listed = web.get("/v1/me/keys").json()["data"]62 assert listed[0]["last_used_at"] and len(listed[0]["last_used_ip"]) == 1663 # A6: the key may READ the account but not mutate it64 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 == 20068 assert anon.get("/v1/me/limits", headers={"Authorization": f"Bearer {k['key']}"}).status_code == 20069 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 == 40372 # rotate (session) → new key keeps name/note/expiry, old one dead73 r = web.post(f"/v1/me/keys/{k['id']}/rotate", json={}, headers=JSON)74 assert r.status_code == 20175 k2 = r.json()["data"]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']}"})79 assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_API_KEY"80 # A3: revoke with an EMPTY body + X-Requested-With (what the SPA sends) is accepted81 r = web.delete(f"/v1/me/keys/{k2['id']}", headers={"X-Requested-With": "hfmd"})82 assert r.status_code == 200 and r.json()["data"]["status"] == "revoked"83 assert web.get("/v1/status", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 40184 # usage endpoint (session)85 u = web.get("/v1/me/usage?range=24h").json()["data"]86 assert u["totals"]["requests"] >= 1 and len(u["principals"]) == 3 and any(p.startswith("user:") for p in u["principals"]) and u["key_id"] is None87 assert {"requests", "rows", "status_429", "bytes", "rows_parquet"} <= set(u["points"][0])88 # logout with empty body + header, cookie gone89 r = web.post("/v1/auth/logout", headers={"X-Requested-With": "hfmd"})90 assert r.status_code == 20091 assert web.get("/v1/me").status_code == 401929394def 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 security97 from accounts.models import User98 from core.db import session99 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) == before107 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 == email109 # unverified signup: a second signup re-sends the verification but keeps the ORIGINAL password110 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 explicit118 r = web.post("/v1/auth/signup", json={"email": fresh_email("w"), "name": "x", "password": "short"}, headers=JSON)119 assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD"120 r = web.post("/v1/auth/signup", json={"email": "not-an-email", "name": "x", "password": "long-enough-password"}, headers=JSON)121 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER"122123124def 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"})129 assert r.status_code == 415 and r.json()["error"]["code"] == "UNSUPPORTED_MEDIA_TYPE"130 r = web.post("/v1/auth/logout", headers={"Content-Type": "text/plain"}, content="x")131 assert r.status_code == 415132 # 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 == 415134 # bodiless with browser proofs → accepted135 assert web.post("/v1/me/sessions/revoke-all", headers={"Sec-Fetch-Site": "same-origin"}).status_code == 200136 assert web.post("/v1/me/sessions/revoke-all", headers={"Origin": "http://testserver"}).status_code == 200137 assert web.post("/v1/me/sessions/revoke-all", headers={"X-Requested-With": "hfmd"}).status_code == 200138139140def 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 security143 from accounts.models import AuditLog, User144 from core.db import session145 from sqlalchemy import select146 uid, _, email, pw = make_user()147 assert security.verify_password("anything", None) is False # dummy-hash path returns False148 r = web.post("/v1/auth/login", json={"email": "ghost@example.com", "password": pw}, headers=JSON)149 assert r.status_code == 401 and r.json()["error"]["code"] == "INVALID_CREDENTIALS"150 assert web.post("/v1/auth/login", json={"email": email, "password": "x" * 300}, headers=JSON).status_code == 422151 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, i154 r = web.post("/v1/auth/login", json={"email": email, "password": "wrong-password"}, headers=JSON) # 5th → lock 30 s155 assert r.status_code == 423 and r.json()["error"]["code"] == "ACCOUNT_LOCKED" and 1 <= int(r.headers["Retry-After"]) <= 30156 r = web.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON) # even the right password157 assert r.status_code == 423158 with session() as s:159 u = s.get(User, uid)160 assert u.failed_logins == 5 and u.locked_until is not None161 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].meta163 u.locked_until = None164 u.failed_logins = 9165 r = web.post("/v1/auth/login", json={"email": email, "password": "wrong-password"}, headers=JSON) # 10th → warning mail, 1 h lock166 assert r.status_code == 423 and int(r.headers["Retry-After"]) > 600167 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 = None170 r = web.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)171 assert r.status_code == 200172 with session() as s:173 assert s.get(User, uid).failed_logins == 0174 assert web.get("/v1/me").status_code == 200175176177def 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 browser182 assert other.get("/v1/me").status_code == 200183 # unknown address: same 202, no link184 r = web.post("/v1/auth/forgot", json={"email": "unknown@example.com"}, headers=JSON)185 assert r.status_code == 202 and "debug_link" not in r.json()["data"]186 r = web.post("/v1/auth/forgot", json={"email": email}, headers=JSON)187 assert r.status_code == 202188 link = r.json()["data"]["debug_link"]189 assert "/reset-password?token=" in link190 r = web.post("/v1/auth/reset", json={"token": token_of(link), "password": "short"}, headers=JSON)191 assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD"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"] == 1194 assert other.get("/v1/me").status_code == 401 # old session dead195 assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 401 # key revoked196 web.cookies.clear()197 r = web.post("/v1/auth/login", json={"email": email, "password": "brand-new-passphrase"}, headers=JSON)198 assert r.status_code == 200199 web.cookies.clear()200 r = web.post("/v1/auth/reset", json={"token": token_of(link), "password": "brand-new-passphrase"}, headers=JSON)201 assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN"202 # revoke_keys=false keeps the keys203 _, 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"] == 0207 assert web.get("/v1/status", headers={"Authorization": f"Bearer {key2}"}).status_code == 200208209210def 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 == 202214 r = web.post("/v1/auth/forgot", json={"email": email}, headers=JSON)215 assert r.status_code == 429 and "Retry-After" in r.headers216217218def 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)222 for i in range(9):223 assert web.post("/v1/me/keys", json={"name": f"k{i}"}, headers=JSON).status_code == 201224 r = web.post("/v1/me/keys", json={"name": "one-too-many"}, headers=JSON)225 assert r.status_code == 409 and r.json()["error"]["code"] == "KEY_LIMIT_REACHED"226 r = web.delete("/v1/me/keys/999999", headers=JSON)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 == 422231 # rename + note232 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"235236237def test_expired_key_is_refused(web, make_user, signin):238 from datetime import timedelta239240 from accounts.models import ApiKey241 from core.db import session242 _, _, 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 == 200246 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_cache249 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 True253254255def test_disabled_user_key_is_refused(client, make_user):256 uid, key, _, _ = make_user()257 from accounts import service258 from accounts.models import User259 from core.db import session260 with session() as s:261 service.update_user(s, s.get(User, uid), actor="test", status="disabled")262 r = client.get("/v1/status", headers={"Authorization": f"Bearer {key}"})263 assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED"264265266def test_disabled_user_cannot_reset_or_accept_invite(web, make_user):267 """A17."""268 from accounts import service269 from accounts.models import User270 from core.db import session271 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").link277 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 == 403281 assert web.post("/v1/auth/forgot", json={"email": email}, headers=JSON).json()["data"].get("debug_link") is None282283284def 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 service287 from accounts.models import User288 from core.db import session289 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_cookie295 assert web.get("/v1/me").status_code == 200 # this browser stays signed in296 stale = TestClient(web.app)297 assert stale.get("/v1/me", headers={"Cookie": f"hfmd_session={old_cookie}"}).status_code == 401 # A11298 # disabling the account (admin/CLI) signs it out too299 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 == 401302303304def 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 AuditLog307 from core.db import session308 from sqlalchemy import select309 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 == 400317 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 == 401319 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 confirmation321 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 link327 assert web.get("/v1/me").json()["data"]["user"]["email"] == email328 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_email330 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 time332 _, _, 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"} <= actions339340341def 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 User344 from core.db import session345 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 == 401349 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 == 401352 assert web.get("/v1/status", headers={"Authorization": f"Bearer {key}"}).status_code == 401353 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_at356 r = web.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON)357 assert r.status_code == 401358 assert any(m.kind == "account_deleted" and m.to == email for m in outbox)359 # the address can sign up again360 assert web.post("/v1/auth/signup", json={"email": email, "name": "Again", "password": "yet-another-passphrase"}, headers=JSON).status_code == 202361362363def 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 False368 assert web.get("/v1/me").json()["data"]["user"]["quota_alerts"] is False369370371def 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 == 200379 assert web.get("/v1/_test/frame?n=50", headers={"Authorization": f"Bearer {k2['key']}"}).status_code == 200380 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"] == 350383 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 == 404386 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) == 169390 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"]) == 2393 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 - 300395 assert by_id[k2["id"]]["requests"]["remaining"] == 119 and by_id[k1["id"]]["redis"] is True396 only = web.get(f"/v1/me/limits?key_id={k2['id']}").json()["data"]["keys"]397 assert len(only) == 1 and only[0]["name"] == "two"398399400def test_mailer_templates_never_leak_keys(app):401 from accounts import mailer402 full_key = "hfmd_live_ab12cd34EXAMPLEKEYnotARealOne00"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")405 assert subject and "HF Market Data" in text and '<a href="https://x/y?token=abc"' in html406 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 key409 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]411 d = mailer.send("verify", "ada@example.com", name="Ada", link="https://x/y?token=abc")412 assert d.delivered is False and d.error == "no_provider"413414415def test_mailer_never_logs_links(app, caplog):416 """A9: the action link must not end up in the logs."""417 import logging418419 from accounts import mailer420 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.text423424425def test_mailer_posts_to_resend(app, monkeypatch):426 from dataclasses import replace427428 import respx429 from httpx import Response430431 from accounts import mailer432 monkeypatch.setattr(mailer, "settings", replace(mailer.settings, resend_api_key="re_test_key"))433 with respx.mock(assert_all_called=True) as mock:434 route = mock.post(mailer.RESEND_URL).mock(return_value=Response(200, json={"id": "email_123"}))435 d = mailer.send("invite", "tim@example.com", name="Tim", link="https://x/accept-invite?token=t")436 assert d.delivered and d.provider_id == "email_123"437 sent = route.calls[0].request438 assert sent.headers["authorization"] == "Bearer re_test_key"439 import json440 body = json.loads(sent.content)441 assert body["to"] == ["tim@example.com"] and "accept-invite?token=t" in body["text"] and body["html"]442443444def 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, service447 from core.db import session448 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 transaction451 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 pass459 assert outbox == []460 # a provider failure after commit stores the link for admins461 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.id465 assert issued.delivery.queued and issued.token.meta is None466 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 User470 assert service.pending_link(s, s.get(User, uid), "invite") == issued.link471