Bug: SEED_ADMIN_EMAIL creates a password-less user at app startup, which made
/auth/bootstrap return 403 '1 users already exist' on a fresh deployment —
leaving the operator no way to log in (the seed user has no password, and
/auth/token requires one).
Fix: bootstrap is now disabled only when at least one user has a
password_hash set. On a fresh deploy with a seed user:
- POST /auth/bootstrap { email: <matches seed>, password: X } → sets the
password on the seed user, promotes to admin, returns token.
- With a non-matching email, a new admin is created alongside the seed user.
Lock semantics: bootstrap self-deactivates as soon as any password is set.
Tests: 8 passing, including new test_bootstrap_activates_seed_user and
test_bootstrap_disabled_when_password_user_exists covering the two halves.
179 lines
6.5 KiB
Python
179 lines
6.5 KiB
Python
"""Tests for bootstrap endpoint — first admin user creation."""
|
|
|
|
import os
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_client(tmp_path, monkeypatch):
|
|
"""Client with EMPTY database — no users."""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-32chars-minimum!!!!!")
|
|
from app.main import create_app
|
|
app = create_app()
|
|
return TestClient(app)
|
|
|
|
|
|
@pytest.fixture
|
|
def seeded_client(tmp_path, monkeypatch):
|
|
"""Client with one existing seed user (no password_hash — like SEED_ADMIN_EMAIL seeding)."""
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-32chars-minimum!!!!!")
|
|
from app.main import create_app
|
|
from src.db import get_system_db
|
|
from src.repositories.users import UserRepository
|
|
conn = get_system_db()
|
|
UserRepository(conn).create(id="existing", email="existing@test.com", name="E", role="admin")
|
|
conn.close()
|
|
return TestClient(create_app())
|
|
|
|
|
|
@pytest.fixture
|
|
def password_user_client(tmp_path, monkeypatch):
|
|
"""Client with a user who already has a password set — bootstrap must be disabled."""
|
|
from argon2 import PasswordHasher
|
|
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
|
monkeypatch.setenv("JWT_SECRET_KEY", "test-secret-32chars-minimum!!!!!")
|
|
from app.main import create_app
|
|
from src.db import get_system_db
|
|
from src.repositories.users import UserRepository
|
|
conn = get_system_db()
|
|
UserRepository(conn).create(
|
|
id="existing",
|
|
email="existing@test.com",
|
|
name="E",
|
|
role="admin",
|
|
password_hash=PasswordHasher().hash("pre-existing-pass"),
|
|
)
|
|
conn.close()
|
|
return TestClient(create_app())
|
|
|
|
|
|
class TestBootstrap:
|
|
def test_bootstrap_on_empty_db(self, fresh_client):
|
|
"""First call creates admin and returns token."""
|
|
resp = fresh_client.post("/auth/bootstrap", json={
|
|
"email": "admin@test.com",
|
|
"name": "Admin",
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["email"] == "admin@test.com"
|
|
assert data["role"] == "admin"
|
|
assert "access_token" in data
|
|
|
|
def test_bootstrap_with_password(self, fresh_client):
|
|
"""Bootstrap with password sets password hash."""
|
|
resp = fresh_client.post("/auth/bootstrap", json={
|
|
"email": "admin@test.com",
|
|
"password": "securepass123",
|
|
})
|
|
assert resp.status_code == 200
|
|
|
|
# Token works
|
|
token = resp.json()["access_token"]
|
|
resp2 = fresh_client.get("/api/health")
|
|
assert resp2.status_code == 200
|
|
|
|
def test_bootstrap_activates_seed_user(self, seeded_client):
|
|
"""Bootstrap activates a password-less seed user (SEED_ADMIN_EMAIL scenario)."""
|
|
resp = seeded_client.post("/auth/bootstrap", json={
|
|
"email": "existing@test.com",
|
|
"password": "newpass123",
|
|
})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["role"] == "admin"
|
|
|
|
# Login now works
|
|
login = seeded_client.post("/auth/password/login", json={
|
|
"email": "existing@test.com",
|
|
"password": "newpass123",
|
|
})
|
|
assert login.status_code == 200
|
|
|
|
def test_bootstrap_disabled_when_password_user_exists(self, password_user_client):
|
|
"""Bootstrap fails with 403 when any user already has a password set."""
|
|
resp = password_user_client.post("/auth/bootstrap", json={
|
|
"email": "hacker@evil.com",
|
|
"password": "should-not-work",
|
|
})
|
|
assert resp.status_code == 403
|
|
assert "already have passwords" in resp.json()["detail"]
|
|
|
|
def test_bootstrap_then_login(self, fresh_client):
|
|
"""After bootstrap with password, /auth/token login works; without password it requires OAuth."""
|
|
# Bootstrap with a password
|
|
fresh_client.post("/auth/bootstrap", json={
|
|
"email": "admin@test.com",
|
|
"password": "adminpass123",
|
|
})
|
|
|
|
# Normal password login succeeds
|
|
resp = fresh_client.post("/auth/token", json={
|
|
"email": "admin@test.com",
|
|
"password": "adminpass123",
|
|
})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["role"] == "admin"
|
|
|
|
def test_bootstrap_no_password_token_rejected(self, fresh_client):
|
|
"""After passwordless bootstrap, /auth/token must reject the user (OAuth-only flow)."""
|
|
fresh_client.post("/auth/bootstrap", json={
|
|
"email": "admin@test.com",
|
|
})
|
|
|
|
resp = fresh_client.post("/auth/token", json={
|
|
"email": "admin@test.com",
|
|
})
|
|
assert resp.status_code == 401
|
|
|
|
def test_bootstrap_second_call_fails_once_password_set(self, fresh_client):
|
|
"""Endpoint self-deactivates once any user has a password."""
|
|
# First call WITH password — locks bootstrap
|
|
fresh_client.post("/auth/bootstrap", json={
|
|
"email": "admin@test.com",
|
|
"password": "realpass123",
|
|
})
|
|
|
|
# Any subsequent bootstrap attempt fails
|
|
resp = fresh_client.post("/auth/bootstrap", json={
|
|
"email": "second@test.com",
|
|
"password": "other-pass",
|
|
})
|
|
assert resp.status_code == 403
|
|
|
|
def test_full_agent_flow(self, fresh_client):
|
|
"""Simulate full AI agent deployment flow."""
|
|
# 1. Health check (no auth)
|
|
resp = fresh_client.get("/api/health")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "healthy"
|
|
|
|
# 2. Bootstrap admin
|
|
resp = fresh_client.post("/auth/bootstrap", json={
|
|
"email": "agent@company.com", "name": "AI Agent",
|
|
})
|
|
assert resp.status_code == 200
|
|
token = resp.json()["access_token"]
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
# 3. Check manifest (empty, no data yet)
|
|
resp = fresh_client.get("/api/sync/manifest", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()["tables"]) == 0
|
|
|
|
# 4. List users
|
|
resp = fresh_client.get("/api/users", headers=headers)
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 1
|
|
|
|
# 5. Add analyst user
|
|
resp = fresh_client.post("/api/users", json={
|
|
"email": "analyst@company.com", "name": "Analyst",
|
|
}, headers=headers)
|
|
assert resp.status_code == 201
|
|
|
|
# 6. Verify
|
|
resp = fresh_client.get("/api/health")
|
|
assert resp.json()["services"]["users"]["count"] == 2
|