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:
@@ -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",
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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,8 +89,10 @@ 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()
|
|
||||||
|
|
||||||
|
# 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(
|
mailer.send(
|
||||||
to=email,
|
to=email,
|
||||||
subject=f"Your ecomm code: {code}",
|
subject=f"Your ecomm code: {code}",
|
||||||
@@ -93,6 +102,10 @@ def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None:
|
|||||||
"If you didn't request this, ignore this message."
|
"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]:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user