e9fdc478f6
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.
400 lines
16 KiB
Python
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
|