"""End-to-end integration tests for the v0.12.0 CloudFlare Turnstile gate on `/auth/otc/request` (§6.2 / roadmap item #10). The release gates the OTC request endpoint behind a one-step browser-side Turnstile challenge before the bcrypt hash + SMTP send. The tests prove: * Happy path: with the secret set, a valid token admits the request and the OTC envelope lands. * Failure path: with the secret set, a token siteverify rejects refuses the request with 400 and produces no envelope. * Missing-token: with the secret set, a request without a token refuses with 400. * Missing-secret-soft: with the secret unset AND `TURNSTILE_REQUIRED=false` (the v0.12.0 default), the request admits — this is the dev / "operator hasn't wired it yet" path. * Missing-secret-hard: with the secret unset AND `TURNSTILE_REQUIRED=true`, the request refuses with 500 "auth misconfigured" — the production fail-closed path once the operator has flipped the policy. The Turnstile siteverify call is mocked at the `httpx.post` boundary inside `app.turnstile` so no real keys are needed and no real CloudFlare call is made. The Gitea fakes from `test_propose_vertical` remain in scope so the rest of the app boots cleanly. """ from __future__ import annotations from types import SimpleNamespace import pytest from test_propose_vertical import ( # noqa: F401 FakeGitea, app_with_fake_gitea, tmp_env, ) def _reset_outbound(): from app import email as email_mod email_mod.reset_sent_envelopes() def _outbound_otc_envelopes(to_address: str | None = None) -> list[dict]: 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 out.append(env) return out def _patch_siteverify(monkeypatch, *, success: bool, error_codes: list[str] | None = None): """Replace `httpx.post` inside `app.turnstile` with a stub that returns the requested success shape. The stub does not touch the real CloudFlare endpoint and never sees a real secret. """ captured = {} def fake_post(url, *, data=None, timeout=None, **kwargs): captured["url"] = url captured["data"] = data body = {"success": bool(success)} if error_codes is not None: body["error-codes"] = error_codes return SimpleNamespace(json=lambda: body) from app import turnstile as turnstile_mod monkeypatch.setattr(turnstile_mod.httpx, "post", fake_post) return captured # --------------------------------------------------------------------------- # Happy path: secret set, token valid → admit + OTC envelope lands # --------------------------------------------------------------------------- def test_otc_request_admits_when_turnstile_token_is_valid(app_with_fake_gitea, monkeypatch): from fastapi.testclient import TestClient monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real") monkeypatch.setenv("TURNSTILE_REQUIRED", "true") captured = _patch_siteverify(monkeypatch, success=True) app, _fake = app_with_fake_gitea with TestClient(app) as client: _reset_outbound() r = client.post( "/auth/otc/request", json={"email": "alice@example.com", "turnstile_token": "fake-token-abc"}, ) assert r.status_code == 200, r.text # The siteverify call was made with the secret + the token we sent. assert captured["data"]["secret"] == "test-secret-not-real" assert captured["data"]["response"] == "fake-token-abc" # And the OTC dispatch ran — exactly one envelope to the address. envs = _outbound_otc_envelopes("alice@example.com") assert len(envs) == 1 # --------------------------------------------------------------------------- # Failure path: secret set, siteverify says success=false → 400 + no envelope # --------------------------------------------------------------------------- def test_otc_request_refuses_when_turnstile_siteverify_fails(app_with_fake_gitea, monkeypatch): from fastapi.testclient import TestClient monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real") monkeypatch.setenv("TURNSTILE_REQUIRED", "true") _patch_siteverify(monkeypatch, success=False, error_codes=["invalid-input-response"]) app, _fake = app_with_fake_gitea with TestClient(app) as client: _reset_outbound() r = client.post( "/auth/otc/request", json={"email": "alice@example.com", "turnstile_token": "fake-bad-token"}, ) assert r.status_code == 400, r.text # The OTC bcrypt + SMTP path did not run — no envelope was buffered. assert _outbound_otc_envelopes("alice@example.com") == [] # --------------------------------------------------------------------------- # Missing-token: secret set, no token → 400 + no envelope # --------------------------------------------------------------------------- def test_otc_request_refuses_when_turnstile_token_is_missing(app_with_fake_gitea, monkeypatch): from fastapi.testclient import TestClient monkeypatch.setenv("CLOUDFLARE_TURNSTILE_SECRET", "test-secret-not-real") monkeypatch.setenv("TURNSTILE_REQUIRED", "true") # Even though we patch httpx.post, the missing-token check fires # before the siteverify call — so the patch is here only as a # safety net in case the implementation regresses to making the # network call anyway. _patch_siteverify(monkeypatch, success=False) app, _fake = app_with_fake_gitea with TestClient(app) as client: _reset_outbound() r = client.post( "/auth/otc/request", json={"email": "alice@example.com"}, # no turnstile_token field at all ) assert r.status_code == 400, r.text assert _outbound_otc_envelopes("alice@example.com") == [] # --------------------------------------------------------------------------- # Missing-secret-soft: no secret, TURNSTILE_REQUIRED=false (default) → admit # --------------------------------------------------------------------------- def test_otc_request_admits_when_secret_unset_and_not_required(app_with_fake_gitea, monkeypatch): """v0.12.0 default: the operator has not yet wired the Turnstile secret and has not enabled `TURNSTILE_REQUIRED`. The gate stays open — this is the dev / test / pre-rollout path. Once the operator confirms the secret is in place and flips `TURNSTILE_REQUIRED=true`, missing-secret becomes fail-closed (covered in test_otc_request_refuses_when_required_but_secret_unset). """ from fastapi.testclient import TestClient monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False) monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False) # The httpx.post inside turnstile must not be called in this path — # patch it to a sentinel that explodes if it ever runs. from app import turnstile as turnstile_mod def must_not_be_called(*a, **kw): raise AssertionError("siteverify should not run when no secret is configured") monkeypatch.setattr(turnstile_mod.httpx, "post", must_not_be_called) app, _fake = app_with_fake_gitea with TestClient(app) as client: _reset_outbound() r = client.post( "/auth/otc/request", json={"email": "alice@example.com"}, ) assert r.status_code == 200, r.text # The OTC path ran end-to-end — one envelope to the address. assert len(_outbound_otc_envelopes("alice@example.com")) == 1 # --------------------------------------------------------------------------- # Missing-secret-hard: no secret, TURNSTILE_REQUIRED=true → 500 "misconfigured" # --------------------------------------------------------------------------- def test_otc_request_refuses_when_required_but_secret_unset(app_with_fake_gitea, monkeypatch): """Once the operator has flipped `TURNSTILE_REQUIRED=true` to lock down production, a missing secret stops being a soft-fail and becomes a fail-closed 500. This is the regression-detection shape the §20.4 upgrade-steps MAY block calls out — flip the flag once the secret is wired so a future config drift fails loudly instead of silently disabling abuse defense. """ from fastapi.testclient import TestClient monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False) monkeypatch.setenv("TURNSTILE_REQUIRED", "true") app, _fake = app_with_fake_gitea with TestClient(app) as client: _reset_outbound() r = client.post( "/auth/otc/request", json={"email": "alice@example.com", "turnstile_token": "doesnt-matter"}, ) assert r.status_code == 500, r.text assert _outbound_otc_envelopes("alice@example.com") == []