mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(auth): claim the first admin atomically instead of counting first POST /api/v1/auth/initialize read the admin count in one session and created the admin in another, with no lock between them. Two requests arriving together with different emails both saw an empty system and both became admins: reproduced over HTTP, two 201s and two admin rows, after which setup-status reports an initialized system. The endpoint is public and unthrottled, and during the pre-setup window anyone who can reach the gateway can claim admin with a single request anyway — that part is the first-boot design. What the race removes is the only signal: without it exactly one request wins and the loser gets 409, so an operator who loses sees "already initialized" and looks into it. With it, both succeed and the operator's setup looks ordinary. The check and the insert now share one transaction in the repository, with writers serialized first — SQLite takes the write lock up front (BEGIN IMMEDIATE) and Postgres a transaction-scoped advisory lock, the idiom the project and channel repositories already use. The same repository serves both backends, and multi-replica deployments make the race genuinely parallel rather than an event-loop interleaving. create_first_admin returns None when the claim is lost, which the route reports as 409; uniqueness conflicts still raise ValueError and stay a 400. The count in the handler is now only a fast path that avoids hashing a password for an already-initialized system. AUTH_DESIGN.md said concurrent initialization was backstopped by a database unique constraint. No such constraint exists — email uniqueness only catches the same address submitted twice — so it now describes the claim that does the work. * docs(changelog): note the atomic first-admin claim (#5776) * fix(auth): refuse a dialect the first-admin claim cannot serialize Review follow-up. The claim's serialization handled sqlite and postgresql and fell through silently for anything else, which would leave create_first_admin as the plain check-then-act it exists to prevent — the one place the abstract method's atomicity contract is enforced. An unknown dialect now raises instead. The engine builds only these two today, so this guards a future backend: it fails loudly at first boot rather than quietly reopening the race.
349 lines
13 KiB
Python
349 lines
13 KiB
Python
"""Tests for the POST /api/v1/auth/initialize endpoint.
|
|
|
|
Covers: first-boot admin creation, rejection when system already
|
|
initialized, password strength validation,
|
|
and public accessibility (no auth cookie required).
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-initialize-admin-min-32")
|
|
|
|
from app.gateway.auth.config import AuthConfig, set_auth_config
|
|
|
|
_TEST_SECRET = "test-secret-key-initialize-admin-min-32"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_auth(tmp_path):
|
|
"""Fresh SQLite engine + auth config per test."""
|
|
from app.gateway import deps
|
|
from app.gateway.routers.auth import _SETUP_STATUS_CACHE, _SETUP_STATUS_INFLIGHT
|
|
from deerflow.persistence.engine import close_engine, init_engine
|
|
|
|
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
|
url = f"sqlite+aiosqlite:///{tmp_path}/init_admin.db"
|
|
asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)))
|
|
deps._cached_local_provider = None
|
|
deps._cached_repo = None
|
|
_SETUP_STATUS_CACHE.clear()
|
|
_SETUP_STATUS_INFLIGHT.clear()
|
|
try:
|
|
yield
|
|
finally:
|
|
deps._cached_local_provider = None
|
|
deps._cached_repo = None
|
|
_SETUP_STATUS_CACHE.clear()
|
|
_SETUP_STATUS_INFLIGHT.clear()
|
|
asyncio.run(close_engine())
|
|
|
|
|
|
@pytest.fixture()
|
|
def client(_setup_auth):
|
|
from app.gateway.app import create_app
|
|
from app.gateway.auth.config import AuthConfig, set_auth_config
|
|
|
|
set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET))
|
|
app = create_app()
|
|
# Do NOT use TestClient as a context manager — that would trigger the
|
|
# full lifespan which requires config.yaml. The auth endpoints work
|
|
# without the lifespan (persistence engine is set up by _setup_auth).
|
|
yield TestClient(app)
|
|
|
|
|
|
def _init_payload(**extra):
|
|
"""Build a valid /initialize payload."""
|
|
return {
|
|
"email": "admin@example.com",
|
|
"password": "Str0ng!Pass99",
|
|
**extra,
|
|
}
|
|
|
|
|
|
# ── Happy path ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_initialize_creates_admin_and_sets_cookie(client):
|
|
"""POST /initialize when no admin exists → 201, session cookie set."""
|
|
resp = client.post("/api/v1/auth/initialize", json=_init_payload())
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["email"] == "admin@example.com"
|
|
assert data["system_role"] == "admin"
|
|
assert "access_token" in resp.cookies
|
|
|
|
|
|
def test_initialize_needs_setup_false(client):
|
|
"""Newly created admin via /initialize has needs_setup=False."""
|
|
client.post("/api/v1/auth/initialize", json=_init_payload())
|
|
me = client.get("/api/v1/auth/me")
|
|
assert me.status_code == 200
|
|
assert me.json()["needs_setup"] is False
|
|
|
|
|
|
# ── Rejection when already initialized ───────────────────────────────────
|
|
|
|
|
|
def test_initialize_rejected_when_admin_exists(client):
|
|
"""Second call to /initialize after admin exists → 409 system_already_initialized."""
|
|
client.post("/api/v1/auth/initialize", json=_init_payload())
|
|
resp2 = client.post(
|
|
"/api/v1/auth/initialize",
|
|
json={**_init_payload(), "email": "other@example.com"},
|
|
)
|
|
assert resp2.status_code == 409
|
|
body = resp2.json()
|
|
assert body["detail"]["code"] == "system_already_initialized"
|
|
|
|
|
|
def test_initialize_register_does_not_block_initialization(client):
|
|
"""/register creating a user before /initialize doesn't block admin creation."""
|
|
# Register a regular user first
|
|
client.post("/api/v1/auth/register", json={"email": "regular@example.com", "password": "Tr0ub4dor3a"})
|
|
# /initialize should still succeed (checks admin_count, not total user_count)
|
|
resp = client.post("/api/v1/auth/initialize", json=_init_payload())
|
|
assert resp.status_code == 201
|
|
assert resp.json()["system_role"] == "admin"
|
|
|
|
|
|
def test_initialize_existing_regular_user_email_reports_email_conflict(client):
|
|
"""With no admin, reusing a regular user's email is an email conflict, not initialized."""
|
|
client.post("/api/v1/auth/register", json={"email": "regular@example.com", "password": "Tr0ub4dor3a"})
|
|
|
|
resp = client.post(
|
|
"/api/v1/auth/initialize",
|
|
json={**_init_payload(), "email": "regular@example.com"},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
body = resp.json()
|
|
assert body["detail"]["code"] == "email_already_exists"
|
|
assert client.get("/api/v1/auth/setup-status").json()["needs_setup"] is True
|
|
|
|
|
|
# ── Endpoint is public (no cookie required) ───────────────────────────────
|
|
|
|
|
|
def test_initialize_accessible_without_cookie(client):
|
|
"""No access_token cookie needed for /initialize."""
|
|
resp = client.post(
|
|
"/api/v1/auth/initialize",
|
|
json=_init_payload(),
|
|
cookies={},
|
|
)
|
|
assert resp.status_code == 201
|
|
|
|
|
|
# ── Password validation ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_initialize_rejects_short_password(client):
|
|
"""Password shorter than 8 chars → 422."""
|
|
resp = client.post(
|
|
"/api/v1/auth/initialize",
|
|
json={**_init_payload(), "password": "short"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
def test_initialize_rejects_common_password(client):
|
|
"""Common password → 422."""
|
|
resp = client.post(
|
|
"/api/v1/auth/initialize",
|
|
json={**_init_payload(), "password": "password123"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
# ── setup-status reflects initialization ─────────────────────────────────
|
|
|
|
|
|
def test_setup_status_before_initialization(client):
|
|
"""setup-status returns needs_setup=True before /initialize is called."""
|
|
resp = client.get("/api/v1/auth/setup-status")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["needs_setup"] is True
|
|
|
|
|
|
def test_setup_status_after_initialization(client):
|
|
"""setup-status returns needs_setup=False after /initialize succeeds."""
|
|
client.post("/api/v1/auth/initialize", json=_init_payload())
|
|
resp = client.get("/api/v1/auth/setup-status")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["needs_setup"] is False
|
|
|
|
|
|
def test_setup_status_true_when_only_regular_user_exists(client):
|
|
"""setup-status returns needs_setup=True even when regular users exist (no admin)."""
|
|
client.post("/api/v1/auth/register", json={"email": "regular@example.com", "password": "Tr0ub4dor3a"})
|
|
resp = client.get("/api/v1/auth/setup-status")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["needs_setup"] is True
|
|
|
|
|
|
def test_setup_status_returns_cached_result_on_rapid_calls(client):
|
|
"""Rapid /setup-status calls return the cached result (200) instead of 429."""
|
|
client.post("/api/v1/auth/initialize", json=_init_payload())
|
|
|
|
# First call succeeds and computes the result.
|
|
resp1 = client.get("/api/v1/auth/setup-status")
|
|
assert resp1.status_code == 200
|
|
|
|
# Immediate second call returns cached result, not 429.
|
|
resp2 = client.get("/api/v1/auth/setup-status")
|
|
assert resp2.status_code == 200
|
|
assert resp2.json() == resp1.json()
|
|
assert resp2.json()["needs_setup"] is False
|
|
|
|
|
|
def test_setup_status_does_not_return_stale_true_after_initialize(client):
|
|
"""A pre-initialize setup-status response should not stay cached as True."""
|
|
before = client.get("/api/v1/auth/setup-status")
|
|
assert before.status_code == 200
|
|
assert before.json()["needs_setup"] is True
|
|
|
|
init = client.post("/api/v1/auth/initialize", json=_init_payload())
|
|
assert init.status_code == 201
|
|
|
|
after = client.get("/api/v1/auth/setup-status")
|
|
assert after.status_code == 200
|
|
assert after.json()["needs_setup"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_setup_status_single_flight_per_ip(monkeypatch):
|
|
"""Concurrent requests from same IP share one in-flight DB query."""
|
|
from starlette.requests import Request
|
|
|
|
from app.gateway.routers.auth import (
|
|
_SETUP_STATUS_CACHE,
|
|
_SETUP_STATUS_INFLIGHT,
|
|
setup_status,
|
|
)
|
|
|
|
class _Provider:
|
|
def __init__(self):
|
|
self.calls = 0
|
|
|
|
async def count_admin_users(self):
|
|
self.calls += 1
|
|
await asyncio.sleep(0.05)
|
|
return 0
|
|
|
|
provider = _Provider()
|
|
monkeypatch.setattr("app.gateway.routers.auth.get_local_provider", lambda: provider)
|
|
_SETUP_STATUS_CACHE.clear()
|
|
_SETUP_STATUS_INFLIGHT.clear()
|
|
|
|
def _request() -> Request:
|
|
return Request(
|
|
{
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/api/v1/auth/setup-status",
|
|
"headers": [],
|
|
"client": ("127.0.0.1", 12345),
|
|
}
|
|
)
|
|
|
|
results = await asyncio.gather(
|
|
setup_status(_request()),
|
|
setup_status(_request()),
|
|
setup_status(_request()),
|
|
)
|
|
|
|
assert all(result["needs_setup"] is True for result in results)
|
|
assert provider.calls == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_initialize_creates_only_one_admin(_setup_auth):
|
|
"""Two first-boot requests arriving together must not both become admins."""
|
|
import httpx
|
|
|
|
from app.gateway.app import create_app
|
|
from app.gateway.deps import get_local_provider
|
|
|
|
app = create_app()
|
|
transport = httpx.ASGITransport(app=app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://setup.local") as http:
|
|
first, second = await asyncio.gather(
|
|
http.post("/api/v1/auth/initialize", json=_init_payload(email="operator@example.com")),
|
|
http.post("/api/v1/auth/initialize", json=_init_payload(email="attacker@example.com")),
|
|
)
|
|
|
|
assert sorted([first.status_code, second.status_code]) == [201, 409]
|
|
assert await get_local_provider().count_admin_users() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_first_admin_claim_is_atomic(_setup_auth):
|
|
"""The storage claim itself is the guard: only one concurrent caller wins."""
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
|
from deerflow.persistence.engine import get_session_factory
|
|
|
|
repo = SQLiteUserRepository(get_session_factory())
|
|
|
|
def _admin(email: str) -> User:
|
|
return User(email=email, password_hash="hash", system_role="admin", needs_setup=False)
|
|
|
|
winner, loser = await asyncio.gather(
|
|
repo.create_first_admin(_admin("operator@example.com")),
|
|
repo.create_first_admin(_admin("attacker@example.com")),
|
|
)
|
|
|
|
created = [result for result in (winner, loser) if result is not None]
|
|
assert len(created) == 1, "exactly one caller may claim the first admin"
|
|
assert await repo.count_admin_users() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_first_admin_declines_once_an_admin_exists(_setup_auth):
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
|
from deerflow.persistence.engine import get_session_factory
|
|
|
|
repo = SQLiteUserRepository(get_session_factory())
|
|
await repo.create_user(User(email="first@example.com", password_hash="hash", system_role="admin", needs_setup=False))
|
|
|
|
result = await repo.create_first_admin(User(email="second@example.com", password_hash="hash", system_role="admin", needs_setup=False))
|
|
|
|
assert result is None
|
|
assert await repo.count_admin_users() == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_first_admin_reports_a_taken_email(_setup_auth):
|
|
"""A regular account already holding the address is still an email conflict."""
|
|
from app.gateway.auth.models import User
|
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
|
from deerflow.persistence.engine import get_session_factory
|
|
|
|
repo = SQLiteUserRepository(get_session_factory())
|
|
await repo.create_user(User(email="taken@example.com", password_hash="hash", system_role="user", needs_setup=False))
|
|
|
|
with pytest.raises(ValueError, match="Email already registered"):
|
|
await repo.create_first_admin(User(email="taken@example.com", password_hash="hash", system_role="admin", needs_setup=False))
|
|
|
|
assert await repo.count_admin_users() == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_first_admin_refuses_a_dialect_it_cannot_serialize():
|
|
"""A new backend must fail loudly rather than fall back to check-then-act."""
|
|
from types import SimpleNamespace
|
|
|
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
|
|
|
class _UnknownDialectSession:
|
|
def get_bind(self):
|
|
return SimpleNamespace(dialect=SimpleNamespace(name="mysql"))
|
|
|
|
with pytest.raises(RuntimeError, match="serializ"):
|
|
await SQLiteUserRepository._serialize_first_admin_claim(_UnknownDialectSession())
|