"""Server-wide secrets (Turnstile site key/secret) admin-managed from the Settings page instead of only .env: repository/service behavior, endpoint auth/admin-gating (deliberately NOT localhost-gated, unlike the old /system/api-keys this replaces), /system/status exposing the site key live, and an end-to-end proof that a DB-only (no .env) secret actually drives Turnstile enforcement on register.""" from __future__ import annotations import uuid import httpx import pytest import respx from fastapi.testclient import TestClient from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings from app.models.enums import SystemSecretKey from app.repositories.system_secret_repository import SystemSecretRepository from app.repositories.user_repository import UserRepository from app.services import system_secret_service @pytest.fixture(autouse=True) async def _clean_system_secrets(db_session): """SystemSecret rows are true global singletons (one per key, not per-user like UserApiKey) - unlike other tests in this suite that dodge cross-test pollution by randomizing an id, there's no such trick here. Every test starts from a clean slate, and leaves one behind for whatever test file runs next in a full-suite run.""" async def _clear() -> None: repo = SystemSecretRepository(db_session) for row in await repo.list_all(): await db_session.delete(row) await db_session.commit() await _clear() yield await _clear() def _unique_email() -> str: return f"user-{uuid.uuid4().hex[:12]}@example.com" def _register_and_login(client: TestClient) -> dict[str, str]: email = _unique_email() client.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "T"}, ) tokens = client.post( "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} ).json() return {"Authorization": f"Bearer {tokens['access_token']}"} async def _register_admin_and_login(client: TestClient, db_session: AsyncSession) -> dict[str, str]: email = f"admin-{uuid.uuid4().hex[:12]}@example.com" client.post( "/api/v1/auth/register", json={"email": email, "password": "correct-horse-1", "display_name": "Admin"}, ) user = await UserRepository(db_session).get_by_email(email) user.is_admin = True await db_session.commit() tokens = client.post( "/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"} ).json() return {"Authorization": f"Bearer {tokens['access_token']}"} # --- Repository -------------------------------------------------------- async def test_repository_upsert_then_get_then_delete(db_session): repo = SystemSecretRepository(db_session) await repo.upsert(SystemSecretKey.TURNSTILE_SECRET, "encrypted-1") row = await repo.get(SystemSecretKey.TURNSTILE_SECRET) assert row is not None assert row.encrypted_value == "encrypted-1" await repo.upsert(SystemSecretKey.TURNSTILE_SECRET, "encrypted-2") row = await repo.get(SystemSecretKey.TURNSTILE_SECRET) assert row.encrypted_value == "encrypted-2" # updated in place, not duplicated await repo.delete(SystemSecretKey.TURNSTILE_SECRET) assert await repo.get(SystemSecretKey.TURNSTILE_SECRET) is None # --- Service ------------------------------------------------------------- async def test_list_status_shows_both_keys_unconfigured_by_default(db_session): settings = get_settings() statuses = await system_secret_service.list_status(db_session, settings) assert {s["key"] for s in statuses} == {"turnstile_site_key", "turnstile_secret"} assert all(s["configured"] is False for s in statuses) assert all(s["value"] is None for s in statuses) async def test_set_secret_then_list_status_shows_it_configured(db_session): settings = get_settings() await system_secret_service.set_secret( db_session, SystemSecretKey.TURNSTILE_SITE_KEY, "0x-my-site-key", settings, admin_user_id=uuid.uuid4(), client_ip="10.0.0.1", ) statuses = await system_secret_service.list_status(db_session, settings) site_key = next(s for s in statuses if s["key"] == "turnstile_site_key") assert site_key["configured"] is True assert site_key["value"] == "0x-my-site-key" async def test_set_blank_secret_clears_a_previously_set_one(db_session): settings = get_settings() await system_secret_service.set_secret( db_session, SystemSecretKey.TURNSTILE_SECRET, "0x-my-secret", settings, admin_user_id=uuid.uuid4(), client_ip="10.0.0.1", ) await system_secret_service.set_secret( db_session, SystemSecretKey.TURNSTILE_SECRET, " ", settings, admin_user_id=uuid.uuid4(), client_ip="10.0.0.1", ) statuses = await system_secret_service.list_status(db_session, settings) secret = next(s for s in statuses if s["key"] == "turnstile_secret") assert secret["configured"] is False assert secret["value"] is None async def test_get_effective_settings_falls_back_to_global_when_unset(db_session): settings = get_settings().model_copy(update={"turnstile_site_key": "global-site-key"}) effective = await system_secret_service.get_effective_settings(db_session, settings) assert effective.turnstile_site_key == "global-site-key" async def test_get_effective_settings_overrides_only_the_keys_that_were_set(db_session): settings = get_settings().model_copy( update={"turnstile_site_key": "global-site-key", "turnstile_secret": "global-secret"} ) await system_secret_service.set_secret( db_session, SystemSecretKey.TURNSTILE_SITE_KEY, "admin-site-key", settings, admin_user_id=uuid.uuid4(), client_ip="10.0.0.1", ) effective = await system_secret_service.get_effective_settings(db_session, settings) assert effective.turnstile_site_key == "admin-site-key" assert effective.turnstile_secret == "global-secret" # untouched, no override set # --- Endpoints ----------------------------------------------------------- def test_list_system_secrets_requires_auth(client: TestClient): resp = client.get("/api/v1/system/secrets") assert resp.status_code == 401 def test_list_system_secrets_non_admin_forbidden(client: TestClient): headers = _register_and_login(client) resp = client.get("/api/v1/system/secrets", headers=headers) assert resp.status_code == 403 async def test_admin_can_list_and_set_secrets_even_from_a_non_loopback_client( client: TestClient, db_session: AsyncSession ): """Deliberately different from the old /system/api-keys this replaces: admin-gated only, no additional is_localhost requirement - the default TestClient here has a non-loopback fake peer.""" headers = await _register_admin_and_login(client, db_session) initial = client.get("/api/v1/system/secrets", headers=headers) assert initial.status_code == 200 assert all(not s["configured"] for s in initial.json()) set_resp = client.put( "/api/v1/system/secrets/turnstile_secret", json={"value": "sk-set-via-api"}, headers=headers, ) assert set_resp.status_code == 200 assert set_resp.json()["configured"] is True assert set_resp.json()["value"] == "sk-set-via-api" after = client.get("/api/v1/system/secrets", headers=headers) secret = next(s for s in after.json() if s["key"] == "turnstile_secret") assert secret["configured"] is True assert secret["value"] == "sk-set-via-api" async def test_updating_a_server_secret_is_logged_to_the_acting_admins_account_activity( client: TestClient, db_session: AsyncSession ): headers = await _register_admin_and_login(client, db_session) client.put( "/api/v1/system/secrets/turnstile_secret", json={"value": "sk-set-via-api"}, headers=headers, ) events = client.get("/api/v1/auth/security-events", headers=headers).json() assert any(e["event_type"] == "server_secret_updated" for e in events) async def test_set_system_secret_rejects_unknown_key(client: TestClient, db_session: AsyncSession): headers = await _register_admin_and_login(client, db_session) resp = client.put("/api/v1/system/secrets/not-a-real-key", json={"value": "x"}, headers=headers) assert resp.status_code == 422 # --- /system/status exposes the site key live ------------------------- async def test_system_status_exposes_admin_configured_turnstile_site_key( client: TestClient, db_session: AsyncSession ): settings = get_settings() await system_secret_service.set_secret( db_session, SystemSecretKey.TURNSTILE_SITE_KEY, "admin-set-site-key", settings, admin_user_id=uuid.uuid4(), client_ip="10.0.0.1", ) resp = client.get("/api/v1/system/status") assert resp.status_code == 200 assert resp.json()["turnstile_site_key"] == "admin-set-site-key" def test_system_status_turnstile_site_key_is_null_when_unconfigured(client: TestClient): resp = client.get("/api/v1/system/status") assert resp.status_code == 200 assert resp.json()["turnstile_site_key"] is None # --- End-to-end: a DB-only secret (no .env value) drives enforcement ---- async def test_register_requires_turnstile_when_only_db_configured_secret_exists( client: TestClient, db_session: AsyncSession ): """Proves _enforce_turnstile actually resolves effective (DB-aware) settings, not just the raw .env-backed global Settings object.""" settings = get_settings() assert not settings.turnstile_secret # sanity: nothing set in .env for this test run await system_secret_service.set_secret( db_session, SystemSecretKey.TURNSTILE_SECRET, "admin-set-secret", settings, admin_user_id=uuid.uuid4(), client_ip="10.0.0.1", ) no_token_resp = client.post( "/api/v1/auth/register", json={"email": _unique_email(), "password": "correct-horse-1", "display_name": "T"}, ) assert no_token_resp.status_code == 400 with respx.mock: respx.post("https://challenges.cloudflare.com/turnstile/v0/siteverify").mock( return_value=httpx.Response(200, json={"success": True}) ) with_token_resp = client.post( "/api/v1/auth/register", json={ "email": _unique_email(), "password": "correct-horse-1", "display_name": "T", "turnstile_token": "good-token", }, ) assert with_token_resp.status_code == 201