accounts: tests des flux (inscription→vérification→clé→révocation), admin/invitation, CLI seed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 changed files +401 −0
added
tests/test_accounts_admin.py
+149 −0
@@ -0,0 +1,149 @@ | ||
| 1 | +"""Admin API: invite flow → accept → login, tier/status updates, keys, global usage, audit log, role guard.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +import os | |
| 5 | +from urllib.parse import parse_qs, urlparse | |
| 6 | + | |
| 7 | +JSON = {"Content-Type": "application/json"} | |
| 8 | + | |
| 9 | + | |
| 10 | +def token_of(link: str) -> str: | |
| 11 | + return parse_qs(urlparse(link).query)["token"][0] | |
| 12 | + | |
| 13 | + | |
| 14 | +def fresh_email(tag: str) -> str: | |
| 15 | + return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com" | |
| 16 | + | |
| 17 | + | |
| 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): | |
| 24 | + _, key, _, _ = make_user() | |
| 25 | + r = client.get("/v1/admin/users", headers={"Authorization": f"Bearer {key}"}) | |
| 26 | + assert r.status_code == 403 and r.json()["error"]["code"] == "FORBIDDEN" | |
| 27 | + assert client.get("/v1/admin/users").status_code == 401 | |
| 28 | + | |
| 29 | + | |
| 30 | +def test_invite_flow(client, make_user): | |
| 31 | + ah = admin_headers(make_user) | |
| 32 | + email = fresh_email("tim") | |
| 33 | + r = client.post("/v1/admin/users", json={"email": email, "name": "Tim", "tier": "high_usage"}, headers=ah) | |
| 34 | + assert r.status_code == 201, r.text | |
| 35 | + d = r.json()["data"] | |
| 36 | + assert d["user"]["status"] == "invited" and d["user"]["tier"] == "high_usage" and d["user"]["keys_active"] == 1 | |
| 37 | + assert d["key"]["prefix"].startswith("hfmd_live_") and "key" not in d["key"] # admins never see the full key here | |
| 38 | + assert d["invitation"]["delivered"] is False and "/accept-invite?token=" in d["invitation"]["link"] | |
| 39 | + uid = d["user"]["id"] | |
| 40 | + # duplicate invite → 409 | |
| 41 | + r = client.post("/v1/admin/users", json={"email": email, "name": "Tim"}, headers=ah) | |
| 42 | + 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 | |
| 47 | + # user cannot log in yet (no password) | |
| 48 | + r = client.post("/v1/auth/login", json={"email": email, "password": "whatever-long-enough"}, headers=JSON) | |
| 49 | + assert r.status_code == 401 | |
| 50 | + # re-send voids the first token | |
| 51 | + r = client.post(f"/v1/admin/users/{uid}/invite", json={}, headers=ah) | |
| 52 | + assert r.status_code == 200 | |
| 53 | + 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) | |
| 55 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN" | |
| 56 | + # 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) | |
| 58 | + 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"] | |
| 60 | + 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) | |
| 63 | + 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 | |
| 68 | + | |
| 69 | + | |
| 70 | +def test_signup_on_invited_address_resends_invitation(client, make_user): | |
| 71 | + ah = admin_headers(make_user) | |
| 72 | + 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"] | |
| 77 | + | |
| 78 | + | |
| 79 | +def test_create_active_user_update_tier_and_disable(client, make_user): | |
| 80 | + ah = admin_headers(make_user) | |
| 81 | + email = fresh_email("ada") | |
| 82 | + r = client.post("/v1/admin/users", json={"email": email, "name": "Ada", "password": "ada-passphrase-123"}, headers=ah) | |
| 83 | + assert r.status_code == 201 and r.json()["data"]["user"]["status"] == "active" and r.json()["data"]["invitation"] is None | |
| 84 | + uid = r.json()["data"]["user"]["id"] | |
| 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 | |
| 88 | + 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) | |
| 91 | + 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) | |
| 94 | + assert r.status_code == 422 | |
| 95 | + # disable → key refused, login refused | |
| 96 | + r = client.patch(f"/v1/admin/users/{uid}", json={"status": "disabled"}, headers=ah) | |
| 97 | + assert r.status_code == 200 | |
| 98 | + r = client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}) | |
| 99 | + 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) | |
| 101 | + assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED" | |
| 102 | + # 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) | |
| 106 | + 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 | |
| 108 | + # reset link for the user | |
| 109 | + r = client.post(f"/v1/admin/users/{uid}/reset-password", json={}, headers=ah) | |
| 110 | + 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) | |
| 116 | + emails = [fresh_email("page") for _ in range(3)] | |
| 117 | + 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) | |
| 120 | + assert r.status_code == 200 | |
| 121 | + 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) | |
| 124 | + assert r2.status_code == 200 and len(r2.json()["data"]) >= 1 | |
| 125 | + ids = {u["id"] for u in body["data"]} | {u["id"] for u in r2.json()["data"]} | |
| 126 | + assert len(ids) >= 3 | |
| 127 | + assert client.get("/v1/admin/users?limit=0", headers=ah).status_code == 422 | |
| 128 | + | |
| 129 | + | |
| 130 | +def test_global_usage_and_audit(client, make_user): | |
| 131 | + ah = admin_headers(make_user) | |
| 132 | + uid, key, _, _ = make_user() | |
| 133 | + client.get("/v1/_test/frame?n=100", headers={"Authorization": f"Bearer {key}"}) | |
| 134 | + import time | |
| 135 | + | |
| 136 | + from ratelimit import usage | |
| 137 | + 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) | |
| 139 | + assert r.status_code == 200 | |
| 140 | + d = r.json()["data"] | |
| 141 | + assert d["per_day"] and d["per_day"][-1]["requests"] >= 1 | |
| 142 | + top = [t for t in d["top"] if t["user"] and t["user"]["id"] == uid] | |
| 143 | + assert top and top[0]["rows"] >= 100 and top[0]["user"]["email"] | |
| 144 | + r = client.get("/v1/admin/audit?limit=5", headers=ah) | |
| 145 | + assert r.status_code == 200 | |
| 146 | + rows = r.json()["data"] | |
| 147 | + assert rows and {"id", "ts", "actor", "action", "target", "meta"} <= set(rows[0]) | |
| 148 | + r = client.get("/v1/admin/audit?action=key.", headers=ah) | |
| 149 | + assert all(x["action"].startswith("key.") for x in r.json()["data"]) | |
added
tests/test_accounts_cli.py
+74 −0
@@ -0,0 +1,74 @@ | ||
| 1 | +"""`hfmd` CLI: idempotent seed, users add/list/set-tier/disable, keys list, --show-key discipline.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +from sqlalchemy import func, select | |
| 5 | + | |
| 6 | + | |
| 7 | +def run(cli, capsys, *argv) -> tuple[int, str]: | |
| 8 | + rc = cli.main(list(argv)) | |
| 9 | + out = capsys.readouterr() | |
| 10 | + return rc, out.out + out.err | |
| 11 | + | |
| 12 | + | |
| 13 | +def test_seed_is_idempotent(app, capsys): | |
| 14 | + from accounts import cli | |
| 15 | + from accounts.models import ApiKey, EmailToken, User | |
| 16 | + from core.db import session | |
| 17 | + | |
| 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 | |
| 22 | + assert "accept-invite?token=" in out and "hfmd_live_" in out | |
| 23 | + assert out.count("hfmd_live_") == 4 # prefixes only, one per user | |
| 24 | + assert "api_key" not in out # full keys hidden without --show-key | |
| 25 | + | |
| 26 | + def counts(): | |
| 27 | + with session() as s: | |
| 28 | + users = {u.email: u for u in s.execute(select(User)).scalars()} | |
| 29 | + keys = s.execute(select(func.count()).select_from(ApiKey).where(ApiKey.status == "active", | |
| 30 | + ApiKey.user_id.in_([u.id for u in users.values()]))).scalar_one() | |
| 31 | + toks = s.execute(select(func.count()).select_from(EmailToken).where(EmailToken.used_at.is_(None), | |
| 32 | + EmailToken.user_id.in_([u.id for u in users.values()]))).scalar_one() | |
| 33 | + return users, keys, toks | |
| 34 | + | |
| 35 | + 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" | |
| 38 | + assert all(u.status == "invited" for u in seeded.values()) | |
| 39 | + | |
| 40 | + rc, out2 = run(cli, capsys, "seed") | |
| 41 | + assert rc == 0 | |
| 42 | + users2, keys2, toks2 = counts() | |
| 43 | + assert len(users2) == len(users) and keys2 == keys and toks2 == toks # nothing duplicated | |
| 44 | + # the same pending invitation links are printed again | |
| 45 | + links1 = sorted(line.split()[-1] for line in out.splitlines() if "accept-invite?token=" in line) | |
| 46 | + links2 = sorted(line.split()[-1] for line in out2.splitlines() if "accept-invite?token=" in line) | |
| 47 | + assert links1 == links2 and len(links1) == 4 | |
| 48 | + | |
| 49 | + | |
| 50 | +def test_users_add_show_key_and_management(app, capsys): | |
| 51 | + from accounts import cli | |
| 52 | + email = "cli-person@example.com" | |
| 53 | + rc, out = run(cli, capsys, "users", "add", "CLI Person", email, "--tier", "high_usage", "--show-key", "--no-mail") | |
| 54 | + assert rc == 0 and email in out and "high_usage" in out | |
| 55 | + full = [tok for tok in out.split() if tok.startswith("hfmd_live_") and len(tok) == 42] | |
| 56 | + assert len(full) == 1 # the full key, exactly once | |
| 57 | + # second add: no new key, no key printed, tier untouched | |
| 58 | + rc, out = run(cli, capsys, "users", "add", "CLI Person", email, "--tier", "free", "--show-key", "--no-mail") | |
| 59 | + assert rc == 0 and "high_usage" in out | |
| 60 | + assert not [tok for tok in out.split() if tok.startswith("hfmd_live_") and len(tok) == 42] | |
| 61 | + rc, out = run(cli, capsys, "keys", "list", email) | |
| 62 | + assert rc == 0 and out.count("active") == 1 | |
| 63 | + rc, out = run(cli, capsys, "users", "set-tier", email, "free") | |
| 64 | + assert rc == 0 and "tier = free" in out | |
| 65 | + rc, out = run(cli, capsys, "users", "disable", email) | |
| 66 | + assert rc == 0 and "status = disabled" in out | |
| 67 | + rc, out = run(cli, capsys, "users", "enable", email) | |
| 68 | + assert rc == 0 and "status = active" in out | |
| 69 | + rc, out = run(cli, capsys, "users", "invite-resend", email, "--no-mail") | |
| 70 | + assert rc == 0 and "accept-invite?token=" in out | |
| 71 | + rc, out = run(cli, capsys, "users", "list") | |
| 72 | + assert rc == 0 and email in out | |
| 73 | + rc, out = run(cli, capsys, "keys", "list", "nobody@example.com") | |
| 74 | + assert rc == 1 and "USER_NOT_FOUND" in out | |
added
tests/test_accounts_flow.py
+178 −0
@@ -0,0 +1,178 @@ | ||
| 1 | +"""Self-service account flows: signup → verify → login → key → use → revoke → 401, reset, CSRF, /me.""" | |
| 2 | +from __future__ import annotations | |
| 3 | + | |
| 4 | +import os | |
| 5 | +from urllib.parse import parse_qs, urlparse | |
| 6 | + | |
| 7 | +JSON = {"Content-Type": "application/json"} | |
| 8 | + | |
| 9 | + | |
| 10 | +def token_of(link: str) -> str: | |
| 11 | + return parse_qs(urlparse(link).query)["token"][0] | |
| 12 | + | |
| 13 | + | |
| 14 | +def fresh_email(tag: str) -> str: | |
| 15 | + return f"{tag}-{os.getpid()}-{os.urandom(3).hex()}@example.com" | |
| 16 | + | |
| 17 | + | |
| 18 | +def test_full_lifecycle(client): | |
| 19 | + email = fresh_email("ada") | |
| 20 | + pw = "very-secure-passphrase" | |
| 21 | + # signup → 202 with (dev only) debug link | |
| 22 | + r = client.post("/v1/auth/signup", json={"email": email, "name": "Ada", "password": pw}, headers=JSON) | |
| 23 | + assert r.status_code == 202, r.text | |
| 24 | + d = r.json()["data"] | |
| 25 | + assert d["status"] == "verification_sent" and "debug_link" in d | |
| 26 | + # cannot log in before verifying | |
| 27 | + r = client.post("/v1/auth/login", json={"email": email, "password": pw}, headers=JSON) | |
| 28 | + assert r.status_code == 403 and r.json()["error"]["code"] == "EMAIL_NOT_VERIFIED" | |
| 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 | |
| 32 | + assert "hfmd_session" in r.cookies | |
| 33 | + # token is single use | |
| 34 | + assert client.get("/v1/auth/verify", params={"token": token_of(d["debug_link"])}).json()["error"]["code"] == "INVALID_TOKEN" | |
| 35 | + # /me with the cookie | |
| 36 | + r = client.get("/v1/me") | |
| 37 | + assert r.status_code == 200 | |
| 38 | + me = r.json()["data"] | |
| 39 | + 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 | |
| 44 | + k = r.json()["data"] | |
| 45 | + 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"] | |
| 48 | + 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']}"}) | |
| 52 | + 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']}"}) | |
| 57 | + assert r.status_code == 201 | |
| 58 | + 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']}"}) | |
| 61 | + 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']}"}) | |
| 64 | + 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) | |
| 73 | + assert r.status_code == 200 | |
| 74 | + assert client.get("/v1/me").status_code == 401 | |
| 75 | + client.cookies.clear() | |
| 76 | + | |
| 77 | + | |
| 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) | |
| 83 | + 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) | |
| 85 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_PARAMETER" | |
| 86 | + | |
| 87 | + | |
| 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}"}) | |
| 92 | + 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"}) | |
| 94 | + assert r.status_code == 415 | |
| 95 | + | |
| 96 | + | |
| 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) | |
| 100 | + 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" | |
| 105 | + | |
| 106 | + | |
| 107 | +def test_forgot_and_reset(client, make_user): | |
| 108 | + _, _, email, _ = make_user(with_key=False) | |
| 109 | + # unknown address: same 202, no link | |
| 110 | + r = client.post("/v1/auth/forgot", json={"email": "unknown@example.com"}, headers=JSON) | |
| 111 | + assert r.status_code == 202 and "debug_link" not in r.json()["data"] | |
| 112 | + r = client.post("/v1/auth/forgot", json={"email": email}, headers=JSON) | |
| 113 | + assert r.status_code == 202 | |
| 114 | + link = r.json()["data"]["debug_link"] | |
| 115 | + assert "/reset-password?token=" in link | |
| 116 | + r = client.post("/v1/auth/reset", json={"token": token_of(link), "password": "short"}, headers=JSON) | |
| 117 | + assert r.status_code == 400 and r.json()["error"]["code"] == "WEAK_PASSWORD" | |
| 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) | |
| 122 | + 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) | |
| 125 | + assert r.status_code == 400 and r.json()["error"]["code"] == "INVALID_TOKEN" | |
| 126 | + | |
| 127 | + | |
| 128 | +def test_key_limit(client, make_user): | |
| 129 | + _, key, _, _ = make_user() | |
| 130 | + h = {**JSON, "Authorization": f"Bearer {key}"} | |
| 131 | + 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) | |
| 134 | + assert r.status_code == 409 and r.json()["error"]["code"] == "KEY_LIMIT_REACHED" | |
| 135 | + r = client.delete("/v1/me/keys/999999", headers=h) | |
| 136 | + assert r.status_code == 404 and r.json()["error"]["code"] == "KEY_NOT_FOUND" | |
| 137 | + | |
| 138 | + | |
| 139 | +def test_disabled_user_key_is_refused(client, make_user): | |
| 140 | + uid, key, _, _ = make_user() | |
| 141 | + from accounts import service | |
| 142 | + from accounts.models import User | |
| 143 | + from core.db import session | |
| 144 | + with session() as s: | |
| 145 | + service.update_user(s, s.get(User, uid), actor="test", status="disabled") | |
| 146 | + r = client.get("/v1/status", headers={"Authorization": f"Bearer {key}"}) | |
| 147 | + assert r.status_code == 403 and r.json()["error"]["code"] == "ACCOUNT_DISABLED" | |
| 148 | + | |
| 149 | + | |
| 150 | +def test_mailer_templates_never_leak_keys(app): | |
| 151 | + from accounts import mailer | |
| 152 | + 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") | |
| 155 | + 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 | |
| 158 | + d = mailer.send("verify", "ada@example.com", name="Ada", link="https://x/y?token=abc") | |
| 159 | + assert d.delivered is False and d.error == "no_provider" | |
| 160 | + | |
| 161 | + | |
| 162 | +def test_mailer_posts_to_resend(app, monkeypatch): | |
| 163 | + from dataclasses import replace | |
| 164 | + | |
| 165 | + import respx | |
| 166 | + from httpx import Response | |
| 167 | + | |
| 168 | + from accounts import mailer | |
| 169 | + monkeypatch.setattr(mailer, "settings", replace(mailer.settings, resend_api_key="re_test_key")) | |
| 170 | + with respx.mock(assert_all_called=True) as mock: | |
| 171 | + route = mock.post(mailer.RESEND_URL).mock(return_value=Response(200, json={"id": "email_123"})) | |
| 172 | + d = mailer.send("invite", "tim@example.com", name="Tim", link="https://x/accept-invite?token=t") | |
| 173 | + assert d.delivered and d.provider_id == "email_123" | |
| 174 | + sent = route.calls[0].request | |
| 175 | + assert sent.headers["authorization"] == "Bearer re_test_key" | |
| 176 | + import json | |
| 177 | + body = json.loads(sent.content) | |
| 178 | + assert body["to"] == ["tim@example.com"] and "accept-invite?token=t" in body["text"] and body["html"] | |
| 179 | ||