feat(slice-2): BFF /api/auth/* — request-code, verify, me, logout (§6.4)
Screen-shaped JSON endpoints; signed-cookie session on verify; §6.4 error envelope; storefront hard-wired null behind the _storefront_for seam (SLICE-3 fills it). No enumeration: uniform 204. PUC-9 logout clears the cookie. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import re
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import create_app
|
||||
|
||||
|
||||
def _client(fresh_db_url) -> TestClient:
|
||||
# A LogMailer on app.state lets the test read back the issued code (§6.8).
|
||||
return TestClient(create_app(database_url=fresh_db_url))
|
||||
|
||||
|
||||
def _last_code(client) -> str:
|
||||
outbox = client.app.state.mailer.outbox
|
||||
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
|
||||
|
||||
|
||||
def test_request_code_returns_204(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
def test_request_code_invalid_email_is_400(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/request-code", json={"email": "nope"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "invalid_email"
|
||||
|
||||
|
||||
def test_request_code_uniform_204_no_enumeration(fresh_db_url):
|
||||
# New and known emails both return 204 with no distinguishing body (§6.6).
|
||||
with _client(fresh_db_url) as client:
|
||||
r_new = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
|
||||
# sign the newcomer up so the email becomes "known"
|
||||
code = _last_code(client)
|
||||
client.post("/api/auth/verify", json={"email": "newcomer@example.com", "code": code})
|
||||
r_known = client.post("/api/auth/request-code", json={"email": "newcomer@example.com"})
|
||||
assert r_new.status_code == r_known.status_code == 204
|
||||
assert r_new.content == r_known.content == b""
|
||||
|
||||
|
||||
def test_resend_cooldown_is_429_with_retry_after(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
assert resp.status_code == 429
|
||||
body = resp.json()["error"]
|
||||
assert body["code"] == "resend_cooldown"
|
||||
assert body["retry_after_s"] > 0
|
||||
|
||||
|
||||
def test_verify_sets_cookie_and_returns_shape(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
code = _last_code(client)
|
||||
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": code})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data == {"account": {"email": "merchant@example.com"}, "storefront": None, "created": True}
|
||||
assert "ecomm_session" in resp.cookies
|
||||
|
||||
|
||||
def test_verify_returning_account_created_false(fresh_db_url):
|
||||
# BUC-2 over HTTP: a returning merchant resumes the same account, created=False. The
|
||||
# second request-code would hit the 60s cooldown, so backdate the consumed code's
|
||||
# created_at to satisfy it (rather than sleeping 60s in a test).
|
||||
import psycopg
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "m@example.com"})
|
||||
r1 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
|
||||
assert r1.json()["created"] is True
|
||||
with psycopg.connect(fresh_db_url) as c:
|
||||
c.execute(
|
||||
"UPDATE auth_code SET created_at = %s WHERE email = %s",
|
||||
(datetime.now(timezone.utc) - timedelta(minutes=2), "m@example.com"),
|
||||
)
|
||||
c.commit()
|
||||
client.post("/api/auth/request-code", json={"email": "m@example.com"})
|
||||
r2 = client.post("/api/auth/verify", json={"email": "m@example.com", "code": _last_code(client)})
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["created"] is False
|
||||
assert r2.json()["account"] == {"email": "m@example.com"}
|
||||
|
||||
|
||||
def test_verify_wrong_code_is_400_code_mismatch(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
resp = client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": "000000"})
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()["error"]
|
||||
assert body["code"] == "code_mismatch"
|
||||
assert body["attempts_remaining"] == 4
|
||||
|
||||
|
||||
def test_verify_no_code_is_400_code_expired(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.post("/api/auth/verify", json={"email": "stranger@example.com", "code": "123456"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["error"]["code"] == "code_expired"
|
||||
|
||||
|
||||
def test_me_requires_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
resp = client.get("/api/auth/me")
|
||||
assert resp.status_code == 401
|
||||
assert resp.json()["error"]["code"] == "unauthenticated"
|
||||
|
||||
|
||||
def test_me_returns_account_with_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
|
||||
resp = client.get("/api/auth/me") # TestClient carries the cookie set by verify
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"account": {"email": "merchant@example.com"}, "storefront": None}
|
||||
|
||||
|
||||
def test_puc_09_logout_clears_session(fresh_db_url):
|
||||
with _client(fresh_db_url) as client:
|
||||
client.post("/api/auth/request-code", json={"email": "merchant@example.com"})
|
||||
client.post("/api/auth/verify", json={"email": "merchant@example.com", "code": _last_code(client)})
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
logout = client.post("/api/auth/logout")
|
||||
assert logout.status_code == 204
|
||||
# after logout the cookie is cleared -> /me is unauthenticated again
|
||||
client.cookies.clear()
|
||||
assert client.get("/api/auth/me").status_code == 401
|
||||
Reference in New Issue
Block a user