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 def test_request_code_delivery_failure_is_502(fresh_db_url, monkeypatch): # INV-9 honest failure over HTTP (§6.4): the relay refused -> 502 delivery_failed, # never a fake 204. Force the app's mailer to fail after startup. from app.platform import mailer as mailer_mod class _FailingMailer: def send(self, to, subject, body): raise mailer_mod.MailerError("relay refused") with _client(fresh_db_url) as client: client.app.state.mailer = _FailingMailer() resp = client.post("/api/auth/request-code", json={"email": "merchant@example.com"}) assert resp.status_code == 502 assert resp.json()["error"]["code"] == "delivery_failed"