feat(slice-4): honest delivery failure — send-before-commit + 502 delivery_failed (INV-9; closes #7)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 23:35:01 -07:00
parent 1b8c60fcb5
commit 22d738fc74
6 changed files with 77 additions and 12 deletions
+2
View File
@@ -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",
+4
View File
@@ -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)."""
+25 -12
View File
@@ -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]:
+5
View File
@@ -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")
@@ -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
+16
View File
@@ -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"