Release 0.12.0: CloudFlare Turnstile on OTC email-entry
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""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") == []
|
||||
Reference in New Issue
Block a user