"""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=@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