79a447c77b
Two informational findings from the Session-0026 audit, both framework-internal defense-in-depth. No operator action: no migration, no schema/config/overlay change, no deployment-facing surface. - I3: guard the dead text/html branch in email_envelope.build_envelope. No send path passes body_html; the unused branch would emit HTML built from possibly-unescaped user content (C1 stored-XSS class in the mail channel). Passing body_html now raises NotImplementedError; the arg is kept for documented future symmetry, enabling HTML mail becomes a deliberate escape-then-unguard change. - I4: make turnstile.verify_token async. The sync httpx.post ran inside the async /auth/otc/request handler, blocking the event loop up to the 10s timeout on a slow CloudFlare call. It now awaits httpx.AsyncClient via a narrow _siteverify_post seam (tests patch the seam, not the shared AsyncClient). The sole caller (main.py) now awaits it. Tests: full backend suite 365 passed. Added a coroutine-contract unit test for verify_token and flipped the email_envelope HTML test to assert the guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
252 lines
10 KiB
Python
252 lines
10 KiB
Python
"""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 `turnstile._siteverify_post` with an async stub that
|
|
returns the requested success shape. The stub does not touch the
|
|
real CloudFlare endpoint and never sees a real secret.
|
|
|
|
I4 (security-audit-0026): the siteverify call is now awaited on an
|
|
`httpx.AsyncClient`, isolated behind the `_siteverify_post` seam.
|
|
Patching that narrow function (rather than the shared
|
|
`httpx.AsyncClient`, which gitea/docs also construct) keeps app boot
|
|
intact.
|
|
"""
|
|
captured = {}
|
|
|
|
async def fake_post(url, data):
|
|
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, "_siteverify_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 siteverify call inside turnstile must not be made in this path —
|
|
# patch the seam to a sentinel that explodes if it ever runs
|
|
# (I4: the seam is now `_siteverify_post`, not module-level httpx.post).
|
|
from app import turnstile as turnstile_mod
|
|
|
|
async def must_not_be_called(*a, **kw):
|
|
raise AssertionError("siteverify should not run when no secret is configured")
|
|
|
|
monkeypatch.setattr(turnstile_mod, "_siteverify_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") == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# I4 (security-audit-0026): verify_token is a coroutine — calling it returns
|
|
# an awaitable, not a VerifyOutcome. Locks the async contract so a revert to
|
|
# the synchronous event-loop-blocking shape fails here, not just in the
|
|
# integration paths.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_verify_token_is_async_and_soft_skips_without_secret(monkeypatch):
|
|
import asyncio
|
|
|
|
from app import turnstile as turnstile_mod
|
|
|
|
monkeypatch.delenv("CLOUDFLARE_TURNSTILE_SECRET", raising=False)
|
|
monkeypatch.delenv("TURNSTILE_REQUIRED", raising=False)
|
|
|
|
coro = turnstile_mod.verify_token("any-token")
|
|
assert asyncio.iscoroutine(coro), "verify_token must be a coroutine (I4)"
|
|
outcome = asyncio.run(coro)
|
|
# No secret + not required → the gate stays open without any network call.
|
|
assert outcome.ok is True
|
|
assert outcome.reason == "skipped"
|