diff --git a/backend/app/domains/accounts/__init__.py b/backend/app/domains/accounts/__init__.py index 986d2a4..5987594 100644 --- a/backend/app/domains/accounts/__init__.py +++ b/backend/app/domains/accounts/__init__.py @@ -11,6 +11,7 @@ from .errors import ( CodeExhausted, CodeExpired, CodeMismatch, + DeliveryFailed, InvalidEmail, ResendCooldown, ) @@ -25,6 +26,7 @@ __all__ = [ "CodeMismatch", "CodeExpired", "CodeExhausted", + "DeliveryFailed", "request_code", "verify", "get_account", diff --git a/backend/app/domains/accounts/errors.py b/backend/app/domains/accounts/errors.py index 68ca901..5d68742 100644 --- a/backend/app/domains/accounts/errors.py +++ b/backend/app/domains/accounts/errors.py @@ -36,3 +36,7 @@ class CodeExpired(AccountsError): class CodeExhausted(AccountsError): """The code's attempt budget is spent; it is invalidated (INV-3 → §6.4 400 code_exhausted).""" + + +class DeliveryFailed(AccountsError): + """The relay refused the code email; nothing was committed (INV-9 → §6.4 502 delivery_failed).""" diff --git a/backend/app/domains/accounts/service.py b/backend/app/domains/accounts/service.py index b6e5874..e9bc953 100644 --- a/backend/app/domains/accounts/service.py +++ b/backend/app/domains/accounts/service.py @@ -18,9 +18,16 @@ from datetime import datetime, timedelta, timezone import psycopg from app.platform import config -from app.platform.mailer import Mailer +from app.platform.mailer import Mailer, MailerError -from .errors import CodeExhausted, CodeExpired, CodeMismatch, InvalidEmail, ResendCooldown +from .errors import ( + CodeExhausted, + CodeExpired, + CodeMismatch, + DeliveryFailed, + InvalidEmail, + ResendCooldown, +) from .models import Account # INV-3 constants — the one-time-code policy. @@ -82,17 +89,23 @@ def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None: "INSERT INTO auth_code (email, code_hash, expires_at) VALUES (%s, %s, %s)", (email, _hash_code(code), now + CODE_TTL), ) - conn.commit() - mailer.send( - to=email, - subject=f"Your ecomm code: {code}", - body=( - f"Your ecomm one-time code is {code}.\n" - f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n" - "If you didn't request this, ignore this message." - ), - ) + # Send BEFORE commit (ecomm#7): a refused delivery rolls the row back, so no orphan + # code blocks the 60s cooldown and the caller can honestly retry at once (INV-9). + try: + mailer.send( + to=email, + subject=f"Your ecomm code: {code}", + body=( + f"Your ecomm one-time code is {code}.\n" + f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n" + "If you didn't request this, ignore this message." + ), + ) + except MailerError as exc: + conn.rollback() + raise DeliveryFailed(str(exc)) from exc + conn.commit() def verify(conn: psycopg.Connection, email: str, code: str) -> tuple[Account, bool]: diff --git a/backend/app/main.py b/backend/app/main.py index 88e5157..c2c0c69 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -138,6 +138,11 @@ def create_app(database_url: str | None = None, static_dir: str | Path | None = f"Please wait {exc.retry_after_s}s before requesting another code.", retry_after_s=exc.retry_after_s, ) + except accounts.DeliveryFailed: + return _error( + 502, "delivery_failed", + "We couldn't send the code — try again in a moment.", + ) return Response(status_code=204) @app.post("/api/auth/verify") diff --git a/backend/tests/test_accounts_request_code.py b/backend/tests/test_accounts_request_code.py index e7ac722..34a1b9d 100644 --- a/backend/tests/test_accounts_request_code.py +++ b/backend/tests/test_accounts_request_code.py @@ -64,3 +64,28 @@ def test_request_code_rejects_invalid_email(conn): with pytest.raises(accounts.InvalidEmail): accounts.request_code(conn, m, "not-an-email") assert m.outbox == [] + + +class _FailingMailer: + """A mailer whose relay always refuses — the INV-9 honest-failure path.""" + + def send(self, to: str, subject: str, body: str) -> None: + raise mailer.MailerError("relay refused") + + +def test_delivery_failure_leaves_no_orphan_code(conn): + # ecomm#7: send happens BEFORE commit — a failed delivery must not strand an + # auth_code row (which would also trip the resend cooldown for 60s). + with pytest.raises(accounts.DeliveryFailed): + accounts.request_code(conn, _FailingMailer(), "merchant@example.com") + rows = conn.execute("SELECT count(*) FROM auth_code").fetchone()[0] + assert rows == 0 + + +def test_delivery_failure_does_not_trip_cooldown(conn): + with pytest.raises(accounts.DeliveryFailed): + accounts.request_code(conn, _FailingMailer(), "merchant@example.com") + # the immediate retry (relay back up) succeeds — no ResendCooldown + m = mailer.LogMailer() + accounts.request_code(conn, m, "merchant@example.com") + assert len(m.outbox) == 1 diff --git a/backend/tests/test_auth_endpoints.py b/backend/tests/test_auth_endpoints.py index 1766fd5..aaea47c 100644 --- a/backend/tests/test_auth_endpoints.py +++ b/backend/tests/test_auth_endpoints.py @@ -128,3 +128,19 @@ def test_puc_09_logout_clears_session(fresh_db_url): # 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"