SPB Git forge

spb/hfmarketdata

Public

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

127commits 1branches 0releases
24.7 MBsize
maindefault branch
11 days agolast push
JavaScript 53.7% Python 38.3% CSS 4.6% TypeScript 3.1%
28.0 KB · 471 lines python
Raw Blame History
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