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, CodeExhausted,
CodeExpired, CodeExpired,
CodeMismatch, CodeMismatch,
DeliveryFailed,
InvalidEmail, InvalidEmail,
ResendCooldown, ResendCooldown,
) )
@@ -25,6 +26,7 @@ __all__ = [
"CodeMismatch", "CodeMismatch",
"CodeExpired", "CodeExpired",
"CodeExhausted", "CodeExhausted",
"DeliveryFailed",
"request_code", "request_code",
"verify", "verify",
"get_account", "get_account",
+4
View File
@@ -36,3 +36,7 @@ class CodeExpired(AccountsError):
class CodeExhausted(AccountsError): class CodeExhausted(AccountsError):
"""The code's attempt budget is spent; it is invalidated (INV-3 → §6.4 400 code_exhausted).""" """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 import psycopg
from app.platform import config 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 from .models import Account
# INV-3 constants — the one-time-code policy. # 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)", "INSERT INTO auth_code (email, code_hash, expires_at) VALUES (%s, %s, %s)",
(email, _hash_code(code), now + CODE_TTL), (email, _hash_code(code), now + CODE_TTL),
) )
conn.commit()
mailer.send( # Send BEFORE commit (ecomm#7): a refused delivery rolls the row back, so no orphan
to=email, # code blocks the 60s cooldown and the caller can honestly retry at once (INV-9).
subject=f"Your ecomm code: {code}", try:
body=( mailer.send(
f"Your ecomm one-time code is {code}.\n" to=email,
f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n" subject=f"Your ecomm code: {code}",
"If you didn't request this, ignore this message." 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]: 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.", f"Please wait {exc.retry_after_s}s before requesting another code.",
retry_after_s=exc.retry_after_s, 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) return Response(status_code=204)
@app.post("/api/auth/verify") @app.post("/api/auth/verify")
@@ -64,3 +64,28 @@ def test_request_code_rejects_invalid_email(conn):
with pytest.raises(accounts.InvalidEmail): with pytest.raises(accounts.InvalidEmail):
accounts.request_code(conn, m, "not-an-email") accounts.request_code(conn, m, "not-an-email")
assert m.outbox == [] 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 # after logout the cookie is cleared -> /me is unauthenticated again
client.cookies.clear() client.cookies.clear()
assert client.get("/api/auth/me").status_code == 401 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"