diff --git a/backend/tests/test_auth_endpoints.py b/backend/tests/test_auth_endpoints.py new file mode 100644 index 0000000..1766fd5 --- /dev/null +++ b/backend/tests/test_auth_endpoints.py @@ -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