Phase O Task 46 needs internal validation of the full self-serve flow against the prod backend before flipping SELF_SERVE_ENABLED public. This adds the per-email allowlist that bypasses the global flag for specific authenticated users. - INTERNAL_TESTER_EMAILS: comma-separated list, parsed by a Pydantic field_validator into a normalized lowercase list. Settings.is_internal_tester and Settings.is_self_serve_active_for centralize the allowlist + global-flag check; both endpoints below call the latter. - New get_current_user_optional dep — best-effort auth that returns None on missing/invalid token instead of 401. Used by /config/public so the same endpoint serves anonymous public callers and authenticated allowlist members. - /config/public now accepts optional auth and returns self_serve_enabled=True for authenticated allowlist members even when the global flag is off. Anonymous callers always see the global flag. - /auth/register replaces the SELF_SERVE_ENABLED check with the helper so a registering email on the allowlist can join without an invite code. Non-allowlist emails still 400 when self-serve is off. - docker-compose.dev.yml passes SELF_SERVE_ENABLED + INTERNAL_TESTER_EMAILS through; backend/.env.example documents both. Tests cover: allowlisted authenticated user sees true, non-allowlisted authenticated user sees the global flag, anonymous calls ignore the allowlist, allowlisted email registers without invite code, non-allowlisted email still blocked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
205 lines
8.3 KiB
Python
205 lines
8.3 KiB
Python
"""Integration tests for the public runtime config endpoint.
|
|
|
|
Covers GET /api/v1/config/public and the SELF_SERVE_ENABLED interaction
|
|
with the existing /auth/register invite-code gate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
class TestConfigPublic:
|
|
"""GET /api/v1/config/public — anonymous, no auth."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_config_public_returns_self_serve_flag(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""Endpoint reflects the current SELF_SERVE_ENABLED setting and the
|
|
configured OAuth providers, with no auth required."""
|
|
# Default-off: SELF_SERVE_ENABLED is False unless explicitly set.
|
|
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
|
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
|
|
|
|
response = await client.get("/api/v1/config/public")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body == {"self_serve_enabled": False, "oauth_providers": []}
|
|
|
|
# Flip it on, with both OAuth providers configured.
|
|
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True)
|
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "google-test-id")
|
|
monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id")
|
|
|
|
response = await client.get("/api/v1/config/public")
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["self_serve_enabled"] is True
|
|
assert body["oauth_providers"] == ["google", "microsoft"]
|
|
|
|
# Only Microsoft configured.
|
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
|
monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id")
|
|
response = await client.get("/api/v1/config/public")
|
|
assert response.status_code == 200
|
|
assert response.json()["oauth_providers"] == ["microsoft"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_config_public_returns_true_for_internal_tester(
|
|
self,
|
|
client: AsyncClient,
|
|
auth_headers: dict,
|
|
test_user: dict,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Authenticated user whose email is on INTERNAL_TESTER_EMAILS sees
|
|
self_serve_enabled=True even when the global flag is off."""
|
|
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
|
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
|
|
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", [test_user["email"].lower()])
|
|
|
|
response = await client.get("/api/v1/config/public", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
assert response.json()["self_serve_enabled"] is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_config_public_returns_false_for_non_tester_when_global_off(
|
|
self,
|
|
client: AsyncClient,
|
|
auth_headers: dict,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
"""Authenticated user NOT on the allowlist sees the global flag —
|
|
prevents accidental opt-in via stale credentials or empty allowlist."""
|
|
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
|
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
|
|
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["someone-else@example.com"])
|
|
|
|
response = await client.get("/api/v1/config/public", headers=auth_headers)
|
|
assert response.status_code == 200
|
|
assert response.json()["self_serve_enabled"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_config_public_anonymous_ignores_allowlist(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""Anonymous callers always see the global flag — the allowlist is
|
|
keyed on authenticated identity, not request content."""
|
|
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
|
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
|
|
monkeypatch.setattr(settings, "INTERNAL_TESTER_EMAILS", ["anon-tester@example.com"])
|
|
|
|
response = await client.get("/api/v1/config/public")
|
|
assert response.status_code == 200
|
|
assert response.json()["self_serve_enabled"] is False
|
|
|
|
|
|
class TestRegisterInviteCodeGate:
|
|
"""Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_invite_code_required_when_self_serve_disabled(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""Pre-self-serve behavior: REQUIRE_INVITE_CODE=True without an
|
|
invite code (and no account-invite) must still 400."""
|
|
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
|
|
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
|
|
response = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "no-invite@example.com",
|
|
"password": "SecurePass123!",
|
|
"name": "No Invite",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "invite code is required" in response.json()["detail"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_invite_code_optional_when_self_serve_enabled(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""Self-serve on: registration succeeds with no invite code even
|
|
when REQUIRE_INVITE_CODE is True. The user, personal account, and
|
|
a Pro trial subscription are all created."""
|
|
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
|
|
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True)
|
|
|
|
response = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "self-serve@example.com",
|
|
"password": "SecurePass123!",
|
|
"name": "Self Serve",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201, response.text
|
|
body = response.json()
|
|
assert body["email"] == "self-serve@example.com"
|
|
assert body["account_role"] == "owner"
|
|
assert "account_id" in body
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_invite_code_optional_for_internal_tester(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""SELF_SERVE_ENABLED is False but the registering email is on
|
|
INTERNAL_TESTER_EMAILS — registration should succeed without an
|
|
invite code, matching the per-email soft-cutover behavior."""
|
|
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
|
|
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
monkeypatch.setattr(
|
|
settings, "INTERNAL_TESTER_EMAILS", ["tester@example.com"]
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "tester@example.com",
|
|
"password": "SecurePass123!",
|
|
"name": "Internal Tester",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201, response.text
|
|
body = response.json()
|
|
assert body["email"] == "tester@example.com"
|
|
assert body["account_role"] == "owner"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_blocked_for_non_tester_when_self_serve_disabled(
|
|
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""Registering with an email NOT on the allowlist still 400s when
|
|
self-serve is off and no invite code is provided. Prevents the
|
|
allowlist from leaking to public users."""
|
|
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
|
|
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
|
monkeypatch.setattr(
|
|
settings, "INTERNAL_TESTER_EMAILS", ["other@example.com"]
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/v1/auth/register",
|
|
json={
|
|
"email": "outsider@example.com",
|
|
"password": "SecurePass123!",
|
|
"name": "Outsider",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "invite code is required" in response.json()["detail"].lower()
|