Files
rfc-app/backend/tests/test_otc_vertical.py
Ben Stull e9fdc478f6 v0.18.0 Slice 2: migrate send paths to build_envelope + POST unsubscribe
Five send sites now construct their envelopes through
`email_envelope.build_envelope` instead of building `EmailMessage`
ad hoc:

  * `email_otc.py` — OTC mail (no List-Unsubscribe per the
    proposal's tradeoff: the recipient explicitly requested the
    code, so a list semantic would be wrong).
  * `email_invite.py` — admin/per-RFC invite mail (mailto:-only
    List-Unsubscribe — the invitee isn't a user yet, so no
    per-user opt-out URL exists).
  * `email._deliver` — watcher notifications (full one-click
    unsubscribe: mailto + signed URL + List-Unsubscribe-Post per
    RFC 8058, required by Gmail/Yahoo).
  * `email._send_bundle` — the "while you were away" bundle,
    one-click to the new `all` synthetic category (which lands
    `email_opt_out_all = 1` because the bundle spans multiple
    per-category flags).
  * `digest.py` — same as the bundle: bulk-adjacent, one-click to
    `all`.

`api_notifications.py` gains the POST `/api/email/unsubscribe`
endpoint (the matching receiver for `List-Unsubscribe-Post:
List-Unsubscribe=One-Click`) and accepts the `all` category in
both GET and POST handlers.

`EmailConfig` adds `unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`,
default = `EMAIL_FROM`) so deployments can route unsubscribe
courtesy mail to a humans-monitored mailbox distinct from the
no-reply sender.

The `_SENT` test buffer now also carries `envelope["message"]`
(the `EmailMessage` itself) so new tests can assert on headers
directly. Legacy `to`/`from`/`subject`/`body` keys remain for
backward compatibility.

10 new integration tests across test_otc_vertical /
test_admin_create_user_invite_vertical / test_notifications_vertical
covering: OTC has no List-Unsubscribe; invite has mailto: only;
notification has full one-click; POST one-click flips the
category; `all` category sets global opt-out via both GET and
POST.

Full suite: 277 passed.
2026-05-28 07:24:03 -07:00

400 lines
16 KiB
Python

"""End-to-end integration tests for the v0.7.0 email/OTC sign-in
vertical (§6.2).
The release replaces the Gitea OAuth gesture as the primary human
sign-in path. The tests prove:
* `/auth/otc/request` is rate-limited per-email — back-to-back
requests inside `OTC_REQUEST_COOLDOWN_SECONDS` are refused with
429 (the loud-failure shape the spec calls out).
* The happy path: request → code lands in the outbound buffer →
verify with the code → session cookie surfaces an authenticated
user via `/api/auth/me`.
* Expired codes refuse with 400.
* Already-consumed codes refuse with 400 on re-use.
* Wrong codes refuse with 400.
* Allowlist gate (v0.8.0 update): v0.7.0 silently dropped requests
for emails not on `allowed_emails`. v0.8.0 (item #6) removed
that gate from the request path; the admission gate is now
`permission_state` on the freshly-provisioned `users` row,
asserted in test_beta_access_vertical.py. The tests below
confirm v0.8.0's open-request shape for both on-list and
off-list emails.
* Migration link: an existing OAuth-era user (with a `users.email`
row) is linked by email on first OTC sign-in — `gitea_id` is
preserved.
* Provisioning path: an unrecognized email creates a fresh
contributor row with `gitea_id = NULL`.
The Gitea bot user + token are still required at process construction
(every test harness sets the same `GITEA_*` env vars); the OTC flow
itself never reaches Gitea. The fakes from `test_propose_vertical`
remain in scope so the rest of the app boots cleanly.
"""
from __future__ import annotations
import pytest
from test_propose_vertical import ( # noqa: F401
FakeGitea,
app_with_fake_gitea,
provision_user_row,
tmp_env,
)
def _reset_outbound():
from app import email as email_mod
email_mod.reset_sent_envelopes()
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
"""Pluck the `code` line out of every OTC email in the test buffer.
The OTC mailer stamps `kind='otc'` on the envelope so the §15.4
notification mailer's envelopes (the unsubscribe-footer shape)
don't accidentally satisfy the assertion. Each envelope's body
carries the code on its own indented line; this helper extracts
just that token so the test reads the same way the user would
read the email.
"""
from app import email as email_mod
out = []
for env in email_mod.sent_envelopes():
if env.get("kind") != "otc":
continue
if to_address is not None and env["to"] != to_address:
continue
for line in env["body"].splitlines():
tok = line.strip()
if tok.isdigit() and len(tok) == 6:
out.append(tok)
break
return out
# ---------------------------------------------------------------------------
# Happy path
# ---------------------------------------------------------------------------
def test_otc_request_then_verify_signs_in_a_fresh_user(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Request: 202 + a single OTC envelope to the requested address.
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
assert r.status_code == 200, r.text
codes = _outbound_otc_codes("newcomer@example.com")
assert len(codes) == 1
code = codes[0]
# Verify: 200 + session cookie + me-shape now reads authenticated.
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
assert r.status_code == 200, r.text
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["email"] == "newcomer@example.com"
# Fresh provisioning: no gitea linker. The display name is the
# local part of the email per §6.2.
assert me["user"]["role"] == "contributor"
assert me["user"]["display_name"] == "newcomer"
# The `users` row reflects the same: gitea_id NULL, email set.
from app import db
row = db.conn().execute(
"SELECT gitea_id, email FROM users WHERE email = ? COLLATE NOCASE",
("newcomer@example.com",),
).fetchone()
assert row is not None
assert row["gitea_id"] is None
assert row["email"] == "newcomer@example.com"
# ---------------------------------------------------------------------------
# Failure modes on verify
# ---------------------------------------------------------------------------
def test_otc_verify_refuses_wrong_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": "000000"})
assert r.status_code == 400
def test_otc_verify_refuses_consumed_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
# First verify succeeds.
r1 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r1.status_code == 200
# Drop the session cookie so the re-verify reads as fresh.
client.cookies.clear()
# Second verify with the same code is refused — `consumed_at`
# stamped on the row blocks the replay.
r2 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r2.status_code == 400
def test_otc_verify_refuses_expired_code(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
code = _outbound_otc_codes("alice@example.com")[-1]
# Backdate the row's expires_at to the past. The TTL setting is
# an env var (default 10 min); rather than waiting, the test
# rewrites the row.
db.conn().execute(
"UPDATE otc_codes SET expires_at = datetime('now', '-1 minute') WHERE email = ?",
("alice@example.com",),
)
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# Rate limiting
# ---------------------------------------------------------------------------
def test_otc_request_rate_limited_per_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r1.status_code == 200
# Cooldown defaults to 60s; the second back-to-back call is
# refused with a loud 429.
r2 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r2.status_code == 429
# The buffer still has exactly one envelope — the rate-limited
# call didn't double-send.
assert len(_outbound_otc_codes("alice@example.com")) == 1
def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
assert r1.status_code == 200
# Different email, fresh cooldown.
r2 = client.post("/auth/otc/request", json={"email": "bob@example.com"})
assert r2.status_code == 200
# ---------------------------------------------------------------------------
# Allowlist gate — v0.8.0 update
#
# v0.7.0 gated the OTC request endpoint on the `allowed_emails` table:
# emails not on the list got a silent drop (still 202, but no code).
# v0.8.0 (roadmap item #6) reverses this: the request endpoint
# accepts any valid email and sends a code. The admission gate moves
# to `permission_state` on the freshly-provisioned `users` row,
# which the next-tier tests in test_beta_access_vertical.py cover.
# The `allowed_emails` table stays in the schema as a fast-path
# bypass for admin convenience.
# ---------------------------------------------------------------------------
def test_otc_request_admits_emails_regardless_of_allowlist_population(app_with_fake_gitea):
"""v0.8.0: the OTC request path no longer consults `allowed_emails`.
Whether the allowlist is empty or populated, every valid email
receives a code; admission gates at `permission_state` post-verify.
"""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Populate the allowlist with one specific email; the v0.7.0
# gate would have engaged here.
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
# The not-on-list email still gets a code under v0.8.0.
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
assert r.status_code == 200
assert len(_outbound_otc_codes("stranger@example.com")) == 1
def test_otc_request_admits_allowlisted_email(app_with_fake_gitea):
"""v0.8.0: still works for emails that happen to be on the legacy
allowlist — the table is no longer consulted at request time but
populated rows are admitted alongside everyone else (since the
gate is now open at the request surface)."""
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
r = client.post("/auth/otc/request", json={"email": "invited@example.com"})
assert r.status_code == 200
assert len(_outbound_otc_codes("invited@example.com")) == 1
# ---------------------------------------------------------------------------
# Migration path — link by email to an OAuth-era user
# ---------------------------------------------------------------------------
def test_otc_links_to_existing_oauth_user_by_email(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
# Seed an OAuth-era row. `provision_user_row` writes
# email=<login>@test, so we sign in via OTC with the matching
# email and expect the same `users.id` to come back.
provision_user_row(user_id=42, login="legacyuser", role="contributor")
existing = db.conn().execute(
"SELECT id, gitea_id FROM users WHERE id = ?", (42,)
).fetchone()
assert existing["gitea_id"] == 42 # OAuth linker is set.
r = client.post("/auth/otc/request", json={"email": "legacyuser@test"})
assert r.status_code == 200
code = _outbound_otc_codes("legacyuser@test")[-1]
r = client.post("/auth/otc/verify", json={"email": "legacyuser@test", "code": code})
assert r.status_code == 200
# /api/auth/me reports the linked user — same id, original role.
me = client.get("/api/auth/me").json()
assert me["authenticated"] is True
assert me["user"]["id"] == 42
assert me["user"]["role"] == "contributor"
# gitea_id is preserved on the linked row — the migration path
# doesn't disturb the OAuth linker.
row = db.conn().execute(
"SELECT gitea_id FROM users WHERE id = ?", (42,)
).fetchone()
assert row["gitea_id"] == 42
def test_otc_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea):
from fastapi.testclient import TestClient
from app import db
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
r = client.post("/auth/otc/request", json={"email": "newperson@example.com"})
assert r.status_code == 200
code = _outbound_otc_codes("newperson@example.com")[-1]
r = client.post("/auth/otc/verify", json={"email": "newperson@example.com", "code": code})
assert r.status_code == 200
# A fresh row landed with NULL gitea_id (no OAuth linker).
row = db.conn().execute(
"SELECT id, gitea_id, gitea_login, role FROM users WHERE email = ? COLLATE NOCASE",
("newperson@example.com",),
).fetchone()
assert row is not None
assert row["gitea_id"] is None
assert row["gitea_login"] is None
assert row["role"] == "contributor"
# ---------------------------------------------------------------------------
# Re-request invalidates prior code
# ---------------------------------------------------------------------------
def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monkeypatch):
from fastapi.testclient import TestClient
# Drop the cooldown so the second request lands instead of 429ing.
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "alice@example.com"})
first = _outbound_otc_codes("alice@example.com")[-1]
client.post("/auth/otc/request", json={"email": "alice@example.com"})
second = _outbound_otc_codes("alice@example.com")[-1]
assert first != second
# The old code is invalidated — verify with `first` now refuses.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": first})
assert r.status_code == 400
# The new code still works.
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
assert r.status_code == 200
# ---------------------------------------------------------------------------
# v0.18.0: envelope headers — Slice 2
#
# OTC mail goes through `build_envelope` and MUST land Date,
# Message-ID, and Auto-Submitted but MUST NOT carry a
# List-Unsubscribe header (the recipient explicitly requested the
# code; advertising a list semantic would be wrong).
# ---------------------------------------------------------------------------
def _last_otc_envelope():
from app import email as email_mod
otc = [e for e in email_mod.sent_envelopes() if e.get("kind") == "otc"]
assert otc, "no OTC envelope in the buffer"
return otc[-1]
def test_otc_envelope_sets_date_messageid_autosubmitted(app_with_fake_gitea):
from fastapi.testclient import TestClient
from email.utils import parsedate_to_datetime
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "headers@example.com"})
msg = _last_otc_envelope()["message"]
# Date is RFC 5322 parseable.
assert parsedate_to_datetime(msg["Date"]) is not None
# Message-ID is bracketed and carries the From-address @-domain.
mid = msg["Message-ID"]
assert mid.startswith("<") and mid.endswith(">")
# Auto-Submitted prevents auto-responder loops.
assert msg["Auto-Submitted"] == "auto-generated"
def test_otc_envelope_has_no_list_unsubscribe(app_with_fake_gitea):
"""The recipient explicitly typed their email and asked for a
code; the framework MUST NOT advertise a list semantic on this
mail. Per the v0.18.0 proposal's tradeoff discussion."""
from fastapi.testclient import TestClient
app, _fake = app_with_fake_gitea
with TestClient(app) as client:
_reset_outbound()
client.post("/auth/otc/request", json={"email": "headers@example.com"})
msg = _last_otc_envelope()["message"]
assert msg["List-Unsubscribe"] is None
assert msg["List-Unsubscribe-Post"] is None