827 lines
44 KiB
Python
827 lines
44 KiB
Python
"""Authentication policy, password/passphrase parity and session revocation."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock
|
||
|
||
import httpx
|
||
import pytest
|
||
import pytest_asyncio
|
||
from fastapi import FastAPI
|
||
from sqlalchemy import event, select, update
|
||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||
|
||
from app.gateway import deps, langgraph_auth
|
||
from app.gateway.auth import config as auth_config
|
||
from app.gateway.auth import password as password_utils
|
||
from app.gateway.auth.disabled_mode import is_auth_disabled
|
||
from app.gateway.auth.jwt import create_access_token, decode_token
|
||
from app.gateway.auth.local_provider import LocalAuthProvider
|
||
from app.gateway.auth.models import User
|
||
from app.gateway.auth.password import hash_password, verify_password
|
||
from app.gateway.auth.repositories.base import CredentialConflictError
|
||
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
||
from app.gateway.auth_middleware import AuthMiddleware
|
||
from app.gateway.csrf_middleware import CSRFMiddleware
|
||
from app.gateway.routers import auth, parallel_agents, service_tokens, system_settings
|
||
from deerflow.config import system_settings as settings
|
||
from deerflow.config.login_config import AuthLoginConfig
|
||
from deerflow.persistence.user.model import AuthInitializationRow, AuthUsernameRow, UserRow
|
||
|
||
PASSWORD = "Account-s3cret!"
|
||
NEW_PASSWORD = "Changed-s3cret!"
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def ctx(monkeypatch, tmp_path):
|
||
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'users.db'}")
|
||
async with engine.begin() as connection:
|
||
await connection.run_sync(lambda conn: UserRow.metadata.create_all(conn, tables=[UserRow.__table__, AuthInitializationRow.__table__, AuthUsernameRow.__table__]))
|
||
sf = async_sessionmaker(engine, expire_on_commit=False)
|
||
repository = SQLiteUserRepository(sf)
|
||
provider = LocalAuthProvider(repository)
|
||
monkeypatch.setattr(settings, "runtime_home", lambda: tmp_path)
|
||
monkeypatch.setattr(auth, "get_local_provider", lambda: provider)
|
||
monkeypatch.setattr(service_tokens, "get_local_provider", lambda: provider)
|
||
monkeypatch.setattr(deps, "get_local_provider", lambda: provider)
|
||
monkeypatch.setattr(langgraph_auth, "get_local_provider", lambda: provider)
|
||
monkeypatch.setattr(auth_config, "_auth_config", auth_config.AuthConfig(jwt_secret="test-auth-key-with-sufficient-entropy-0123456789"))
|
||
login_config = AuthLoginConfig()
|
||
monkeypatch.setattr(auth, "get_app_config", lambda: SimpleNamespace(auth_login=login_config))
|
||
monkeypatch.setattr(password_utils, "get_app_config", lambda: SimpleNamespace(auth_login=login_config))
|
||
settings.save_system_settings(settings.SystemSettings(authentication=settings.AuthenticationSettings(password_login_enabled=True)))
|
||
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
|
||
monkeypatch.delenv("DEER_FLOW_USERNAME_LOGIN_DOMAIN", raising=False)
|
||
monkeypatch.setattr(auth, "_login_attempts", {})
|
||
monkeypatch.setattr(auth, "_SETUP_STATUS_COOLDOWN", {})
|
||
app = FastAPI()
|
||
app.include_router(auth.router)
|
||
app.include_router(service_tokens.router)
|
||
app.include_router(system_settings.router)
|
||
app.include_router(parallel_agents.router)
|
||
app.add_middleware(CSRFMiddleware)
|
||
app.add_middleware(AuthMiddleware)
|
||
counter = SimpleNamespace(count_owned=AsyncMock(return_value=0))
|
||
app.state.skill_store = counter
|
||
app.state.agent_store = counter
|
||
app.dependency_overrides[deps.get_skill_store] = lambda: counter
|
||
app.dependency_overrides[deps.get_agent_store] = lambda: counter
|
||
monkeypatch.setattr(auth, "get_run_store", lambda _: SimpleNamespace(count_by_user=AsyncMock(return_value={})))
|
||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||
yield SimpleNamespace(client=client, app=app, provider=provider, repository=repository, sf=sf, login_config=login_config)
|
||
await engine.dispose()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_service_exchange_requires_shared_token(ctx):
|
||
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
|
||
denied = await ctx.client.post(
|
||
"/api/v1/auth/service/exchange",
|
||
json={"username": "alice", "scope": ["llmwiki:sync"]},
|
||
)
|
||
assert denied.status_code == 401
|
||
assert denied.json()["detail"] == "Invalid service token"
|
||
|
||
wrong = await ctx.client.post(
|
||
"/api/v1/auth/service/exchange",
|
||
json={"username": "alice", "scope": ["llmwiki:sync"]},
|
||
headers={"X-Service-Token": "wrong-token"},
|
||
)
|
||
assert wrong.status_code == 401
|
||
|
||
response = await ctx.client.post(
|
||
"/api/v1/auth/service/exchange",
|
||
json={"username": "alice", "scope": ["llmwiki:sync"]},
|
||
headers={"X-Service-Token": service_tokens._SERVICE_TOKEN_SECRET},
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
token = decode_token(response.json()["access_token"])
|
||
assert token.sub == str(user.id)
|
||
assert token.auth_method == "service"
|
||
assert token.client_id is None
|
||
assert token.scope == ["llmwiki:sync"]
|
||
assert response.json()["expires_in"] == 300
|
||
|
||
repeated = await ctx.client.post(
|
||
"/api/v1/auth/service/exchange",
|
||
json={"username": "alice", "scope": ["llmwiki:sync"]},
|
||
headers={"X-Service-Token": service_tokens._SERVICE_TOKEN_SECRET},
|
||
)
|
||
assert repeated.status_code == 200
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_service_exchange_rejects_unsupported_scope(ctx):
|
||
response = await ctx.client.post(
|
||
"/api/v1/auth/service/exchange",
|
||
json={"username": "alice", "scope": ["admin"]},
|
||
headers={"X-Service-Token": service_tokens._SERVICE_TOKEN_SECRET},
|
||
)
|
||
assert response.status_code == 403
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_user_owner_lookup_batches_ids(ctx):
|
||
first = await ctx.provider.create_user("first-owner@cm.com", password=PASSWORD)
|
||
second = await ctx.provider.create_user("second-owner@cm.com", password=PASSWORD)
|
||
|
||
users = await ctx.provider.get_users_by_ids([
|
||
str(first.id), str(second.id), str(first.id), "missing-user",
|
||
])
|
||
|
||
assert {str(user.id) for user in users} == {str(first.id), str(second.id)}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_username_login_uses_exact_indexed_key_and_backfills_legacy_rows(ctx):
|
||
user = await ctx.provider.create_user("quick-login@legacy-domain.com", password=PASSWORD)
|
||
statements: list[str] = []
|
||
engine = ctx.sf.kw["bind"]
|
||
|
||
def capture_sql(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||
if statement.lstrip().upper().startswith("SELECT") and "users" in statement.lower():
|
||
statements.append(statement.lower())
|
||
|
||
event.listen(engine.sync_engine, "before_cursor_execute", capture_sql)
|
||
try:
|
||
found = await ctx.provider.find_user_by_email_local_part("quick-login")
|
||
finally:
|
||
event.remove(engine.sync_engine, "before_cursor_execute", capture_sql)
|
||
assert found is not None and found.id == user.id
|
||
assert statements and "username_key =" in statements[0]
|
||
assert "lower(users.email)" not in statements[0]
|
||
|
||
async with ctx.sf() as session:
|
||
await session.execute(update(UserRow).where(UserRow.id == str(user.id)).values(username_key=None))
|
||
await session.commit()
|
||
found_after_legacy_fallback = await ctx.provider.find_user_by_email_local_part("QUICK-LOGIN")
|
||
assert found_after_legacy_fallback is not None and found_after_legacy_fallback.id == user.id
|
||
async with ctx.sf() as session:
|
||
saved_key = await session.scalar(select(UserRow.username_key).where(UserRow.id == str(user.id)))
|
||
assert saved_key == "quick-login"
|
||
@pytest.mark.parametrize(
|
||
("method", "path", "allowed"),
|
||
[
|
||
("GET", "/api/llmwiki/knowledge-bases", True),
|
||
("POST", "/api/llmwiki/knowledge-bases/kb/documents", True),
|
||
("POST", "/api/llmwiki/knowledge-bases/kb/documents/manual", True),
|
||
("POST", "/api/llmwiki/search", False),
|
||
("POST", "/api/threads/thread/runs", False),
|
||
("GET", "/api/v1/auth/me", False),
|
||
],
|
||
)
|
||
def test_service_scope_path_allowlist(method, path, allowed):
|
||
from app.gateway.auth_middleware import _is_llmwiki_sync_request
|
||
|
||
assert _is_llmwiki_sync_request(method, path) is allowed
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("scopes", "method", "path", "all_requested", "allowed"),
|
||
[
|
||
(["recommended-questions:read"], "GET", "/api/recommended-questions", False, True),
|
||
(["recommended-questions:read"], "GET", "/api/recommended-questions", True, False),
|
||
(["recommended-questions:read"], "POST", "/api/recommended-questions", False, False),
|
||
(["recommended-questions:read"], "GET", "/api/recommended-questions-reorder", False, False),
|
||
(["recommended-questions:read"], "GET", "/api/llmwiki/knowledge-bases", False, False),
|
||
(["llmwiki:sync"], "GET", "/api/recommended-questions", False, False),
|
||
],
|
||
)
|
||
def test_recommended_question_service_scope_is_read_only(scopes, method, path, all_requested, allowed):
|
||
from app.gateway.auth_middleware import _is_service_scope_request
|
||
|
||
assert _is_service_scope_request(scopes, method, path, all_requested=all_requested) is allowed
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_service_token_is_rejected_outside_llmwiki_sync(ctx):
|
||
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
token = create_access_token(
|
||
str(user.id),
|
||
expires_delta=service_tokens.timedelta(seconds=300),
|
||
token_version=user.token_version,
|
||
auth_method="service",
|
||
client_id="surfsense",
|
||
scope=["llmwiki:sync"],
|
||
)
|
||
response = await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})
|
||
assert response.status_code == 403
|
||
|
||
|
||
def enable_registration():
|
||
current = settings.load_system_settings()
|
||
settings.save_system_settings(current.model_copy(update={"authentication": settings.AuthenticationSettings(password_login_enabled=True, registration_enabled=True)}))
|
||
|
||
|
||
def bearer(user, *, auth_method="password"):
|
||
return {"Authorization": f"Bearer {create_access_token(str(user.id), token_version=user.token_version, auth_method=auth_method)}"}
|
||
|
||
|
||
async def make_admin(ctx, *, password=PASSWORD):
|
||
return await ctx.provider.create_user("operator@cm.com", password=password, system_role="admin")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_login_identity_and_user_management_do_not_use_checkpointer(ctx, monkeypatch):
|
||
def forbidden(*args, **kwargs):
|
||
raise AssertionError("Authentication accessed the chat checkpoint backend")
|
||
|
||
monkeypatch.setattr(deps, "get_checkpointer", forbidden)
|
||
ctx.app.state.checkpointer = SimpleNamespace(aget_tuple=forbidden, alist=forbidden, aput=forbidden)
|
||
user = await make_admin(ctx)
|
||
ctx.login_config.token_login.enabled = True
|
||
ctx.login_config.token_login.mock_tokens = {"audit-token": "operator"}
|
||
assert (await ctx.client.get("/api/v1/auth/options")).status_code == 200
|
||
for route, kwargs in [
|
||
("local", {"data": {"username": "operator", "password": PASSWORD}}),
|
||
("username", {"json": {"username": "operator"}, "params": {"password": PASSWORD}}),
|
||
("token", {"params": {"token": "audit-token"}}),
|
||
]:
|
||
response = await ctx.client.post(f"/api/v1/auth/login/{route}", **kwargs)
|
||
assert response.status_code == 200, response.text
|
||
headers = {"Authorization": f"Bearer {response.json()['access_token']}"}
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers=headers)).json()["id"] == str(user.id)
|
||
assert (await ctx.client.get("/api/v1/auth/users", headers=headers)).status_code == 200
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_users_paged_shape_and_search(ctx):
|
||
"""传 limit → UserListPage(items/total/approved_count);不传 → 旧全量列表。"""
|
||
admin = await make_admin(ctx)
|
||
for name in ("bob@cm.com", "carol@cm.com"):
|
||
await ctx.provider.create_user(name, password=PASSWORD)
|
||
await ctx.provider.create_user("dave@cm.com", password=PASSWORD) # 将被取消放行
|
||
dave = await ctx.provider.get_user_by_email("dave@cm.com")
|
||
dave.approved = False
|
||
await ctx.provider.update_user(dave)
|
||
headers = bearer(admin)
|
||
|
||
legacy = await ctx.client.get("/api/v1/auth/users", headers=headers)
|
||
assert legacy.status_code == 200
|
||
assert isinstance(legacy.json(), list) and len(legacy.json()) == 4
|
||
|
||
paged = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 2, "offset": 0, "include_counts": "false"})
|
||
assert paged.status_code == 200, paged.text
|
||
body = paged.json()
|
||
assert body["total"] == 4 and body["approved_count"] == 3 # admin+bob+carol 可登录,dave 被拦
|
||
assert body["limit"] == 2 and body["offset"] == 0
|
||
assert len(body["items"]) == 2
|
||
assert all(item["qa_count"] == 0 and item["skill_count"] == 0 for item in body["items"])
|
||
|
||
page2 = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 2, "offset": 2})
|
||
assert page2.status_code == 200 and len(page2.json()["items"]) == 2
|
||
|
||
searched = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 10, "q": "dave"})
|
||
assert searched.status_code == 200
|
||
assert searched.json()["total"] == 1 and searched.json()["items"][0]["email"] == "dave@cm.com"
|
||
assert searched.json()["approved_count"] == 0 # 搜索范围内唯一用户已被取消放行
|
||
|
||
miss = await ctx.client.get("/api/v1/auth/users", headers=headers, params={"limit": 10, "offset": 999})
|
||
assert miss.status_code == 200 and miss.json()["items"] == [] and miss.json()["total"] == 4
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_anonymous_options_defaults_and_closed_registration(ctx):
|
||
settings.save_system_settings(settings.SystemSettings())
|
||
options = await ctx.client.get("/api/v1/auth/options")
|
||
assert options.status_code == 200
|
||
assert options.json() == {"password_login_enabled": False, "registration_enabled": False, "sso_enabled": True, "single_session_enabled": False, "needs_setup": True}
|
||
assert options.headers["cache-control"] == "no-store"
|
||
response = await ctx.client.post("/api/v1/auth/register", json={"username": "alice", "password": PASSWORD})
|
||
assert response.status_code == 403
|
||
assert await ctx.provider.count_users() == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_register_login_and_sso_share_password_and_user_role(ctx):
|
||
enable_registration()
|
||
response = await ctx.client.post("/api/v1/auth/register", json={"username": "b1admin", "password": PASSWORD})
|
||
assert response.status_code == 201, response.text
|
||
assert response.json()["system_role"] == "user"
|
||
assert response.json()["access_token"]
|
||
assert decode_token(response.json()["access_token"]).auth_method == "password"
|
||
user = await ctx.provider.get_user(response.json()["user_id"])
|
||
assert user.password_hash == user.login_password
|
||
assert user.login_password != PASSWORD
|
||
assert verify_password(PASSWORD, user.password_hash)
|
||
for username in ("b1admin", "B1ADMIN@CM.COM"):
|
||
login = await ctx.client.post("/api/v1/auth/login/local", data={"username": username, "password": PASSWORD})
|
||
assert login.status_code == 200
|
||
assert login.json()["user_id"] == str(user.id)
|
||
denied = await ctx.client.post("/api/v1/auth/login/username", json={"username": "b1admin"})
|
||
assert denied.status_code == 401
|
||
sso = await ctx.client.post("/api/v1/auth/login/username", params={"password": PASSWORD}, json={"username": "b1admin"})
|
||
assert sso.status_code == 200
|
||
assert decode_token(sso.json()["access_token"]).auth_method == "passphrase"
|
||
assert sso.json()["system_role"] == "user"
|
||
me = await ctx.client.get("/api/v1/auth/me", headers=bearer(user))
|
||
assert me.json()["has_password"] is True
|
||
assert "password_hash" not in me.json()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("username", ["alice", "ALICE", "alice@other.org"])
|
||
async def test_registration_rejects_legacy_username_collisions(ctx, username):
|
||
enable_registration()
|
||
legacy = await ctx.provider.create_user("alice@legacy.com", password=PASSWORD)
|
||
response = await ctx.client.post("/api/v1/auth/register", json={"username": username, "password": NEW_PASSWORD})
|
||
assert response.status_code == 409
|
||
assert await ctx.provider.count_users() == 1
|
||
assert verify_password(PASSWORD, (await ctx.provider.get_user(str(legacy.id))).password_hash)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize(
|
||
"body",
|
||
[
|
||
{"username": "alice", "password": "password123"},
|
||
{"username": "alice", "password": "short"},
|
||
{"username": "alice", "password": "x" * 256},
|
||
{"username": "alice", "password": " Account-s3cret!"},
|
||
{"username": "bad name", "password": PASSWORD},
|
||
{"username": "alice", "password": PASSWORD, "system_role": "admin"},
|
||
{"username": "alice", "password": PASSWORD, "email": "different@example.com"},
|
||
],
|
||
)
|
||
async def test_register_validates_credentials_and_rejects_role_injection(ctx, body):
|
||
enable_registration()
|
||
response = await ctx.client.post("/api/v1/auth/register", json=body)
|
||
assert response.status_code == 422
|
||
assert await ctx.provider.count_users() == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_policy_admin_only_and_lockout_protection(ctx):
|
||
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
policy = {"password_login_enabled": True, "registration_enabled": True, "sso_enabled": False}
|
||
assert (await ctx.client.get("/api/system-settings/authentication")).status_code == 401
|
||
assert (await ctx.client.put("/api/system-settings/authentication", headers=bearer(user), json=policy)).status_code == 403
|
||
admin = await make_admin(ctx, password=None)
|
||
# The existing shared/default passphrase remains usable without a personal password.
|
||
assert (await ctx.client.put("/api/system-settings/authentication", headers=bearer(admin), json=policy)).status_code == 200
|
||
for invalid in [dict(policy, password_login_enabled=False), dict(policy, registration_enabled=False, password_login_enabled=False)]:
|
||
assert (await ctx.client.put("/api/system-settings/authentication", headers=bearer(admin), json=invalid)).status_code == 400
|
||
changed = await ctx.client.post("/api/v1/auth/change-password", headers=bearer(admin), json={"current_password": "123ewq", "new_password": PASSWORD})
|
||
assert changed.status_code == 200
|
||
assert decode_token(changed.json()["access_token"]).auth_method == "password"
|
||
admin = await ctx.provider.get_user(str(admin.id))
|
||
assert (await ctx.client.put("/api/system-settings/authentication", headers=bearer(admin), json=policy)).status_code == 200
|
||
for path, kwargs in [
|
||
("/api/v1/auth/login/username", {"json": {"username": "newbie"}}),
|
||
("/api/v1/auth/login/token", {"params": {"token": "upstream"}}),
|
||
]:
|
||
assert (await ctx.client.post(path, **kwargs)).status_code == 403
|
||
# Detail integrations use the legacy token endpoint as an unconditional
|
||
# preflight. Policy rejection returns a non-JWT compatibility value.
|
||
compat = await ctx.client.post("/api/parallel-agents/auth/token", json={"username": "newbie"})
|
||
assert compat.status_code == 200
|
||
assert compat.json()["access_token"] == "force-situation-detail-compat-newbie"
|
||
assert await ctx.provider.count_users() == 2
|
||
assert (await ctx.client.get("/api/v1/auth/oauth/github", headers=bearer(admin))).status_code == 403
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_disabling_password_login_blocks_login_and_registration(ctx):
|
||
admin = await make_admin(ctx)
|
||
response = await ctx.client.put("/api/system-settings/authentication", headers=bearer(admin), json={"password_login_enabled": False, "registration_enabled": False, "sso_enabled": True})
|
||
assert response.status_code == 200
|
||
assert (await ctx.client.post("/api/v1/auth/login/local", data={"username": "operator", "password": PASSWORD})).status_code == 403
|
||
assert (await ctx.client.post("/api/v1/auth/register", json={"username": "alice", "password": PASSWORD})).status_code == 403
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_change_password_revokes_old_sessions_and_keeps_current_bearer(ctx):
|
||
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
old_headers = bearer(user)
|
||
wrong = await ctx.client.post("/api/v1/auth/change-password", headers=old_headers, json={"current_password": "wrong", "new_password": NEW_PASSWORD})
|
||
assert wrong.status_code == 400
|
||
changed = await ctx.client.post("/api/v1/auth/change-password", headers=old_headers, json={"current_password": PASSWORD, "new_password": NEW_PASSWORD})
|
||
assert changed.status_code == 200
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers=old_headers)).status_code == 401
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": "Bearer " + changed.json()["access_token"]})).status_code == 200
|
||
saved = await ctx.provider.get_user(str(user.id))
|
||
assert saved.token_version == 1
|
||
assert saved.login_password == saved.password_hash
|
||
for password, expected in [(PASSWORD, 401), (NEW_PASSWORD, 200)]:
|
||
assert (await ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": password})).status_code == expected
|
||
assert (await ctx.client.post("/api/v1/auth/login/username", params={"password": password}, json={"username": "alice"})).status_code == expected
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_admin_reset_unified_password_no_hash_leak_and_self_session(ctx):
|
||
admin = await make_admin(ctx)
|
||
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
old_headers = bearer(user)
|
||
endpoint = f"/api/v1/auth/users/{user.id}/reset-password"
|
||
assert (await ctx.client.put(endpoint, headers=old_headers, json={"password": NEW_PASSWORD})).status_code == 403
|
||
response = await ctx.client.put(endpoint, headers=bearer(admin), json={"password": NEW_PASSWORD})
|
||
assert response.status_code == 200
|
||
assert response.json()["login_password"] is None
|
||
assert response.json()["has_login_password"] is True
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers=old_headers)).status_code == 401
|
||
listing = await ctx.client.get("/api/v1/auth/users", headers=bearer(admin))
|
||
assert listing.status_code == 200
|
||
assert "$dfv" not in listing.text and NEW_PASSWORD not in listing.text
|
||
assert (await ctx.client.put(endpoint, headers=bearer(admin), json={"password": None})).status_code == 400
|
||
old_admin = bearer(admin)
|
||
self_reset = await ctx.client.put(f"/api/v1/auth/users/{admin.id}/login-password", headers=old_admin, json={"password": NEW_PASSWORD})
|
||
assert self_reset.status_code == 200
|
||
assert self_reset.json()["access_token"]
|
||
assert decode_token(self_reset.json()["access_token"]).auth_method == "password"
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers=old_admin)).status_code == 401
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": "Bearer " + self_reset.json()["access_token"]})).status_code == 200
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("legacy_password", ["old-code", "$dfv-legacy-plaintext"])
|
||
async def test_historical_plaintext_passphrase_wins_and_changes_without_old_hash(ctx, legacy_password):
|
||
user = User(email="legacy@cm.com", password_hash=hash_password("different-old-password"), login_password=legacy_password)
|
||
await ctx.repository.create_user(user)
|
||
login = await ctx.client.post("/api/v1/auth/login/local", data={"username": "legacy", "password": legacy_password})
|
||
assert login.status_code == 200
|
||
assert (await ctx.client.post("/api/v1/auth/login/local", data={"username": "legacy", "password": "different-old-password"})).status_code == 401
|
||
change = await ctx.client.post("/api/v1/auth/change-password", headers=bearer(user), json={"current_password": legacy_password, "new_password": NEW_PASSWORD})
|
||
assert change.status_code == 200
|
||
saved = await ctx.provider.get_user(str(user.id))
|
||
assert saved.login_password == saved.password_hash
|
||
assert verify_password(NEW_PASSWORD, saved.login_password)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parallel_token_endpoint_returns_non_jwt_compat_value_when_login_is_rejected(ctx):
|
||
await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
endpoint = "/api/parallel-agents/auth/token"
|
||
fallback = await ctx.client.post(endpoint, json={"username": "alice"})
|
||
assert fallback.status_code == 200
|
||
assert fallback.json()["access_token"] == "force-situation-detail-compat-alice"
|
||
assert (await ctx.client.post(endpoint, params={"password": PASSWORD}, json={"username": "alice"})).status_code == 200
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parallel_token_endpoint_returns_compat_value_for_any_rejected_detail_preflight(ctx):
|
||
"""Every old detail client passes its preflight without a real login session."""
|
||
from app.gateway.auth import TokenError, decode_token
|
||
|
||
endpoint = "/api/parallel-agents/auth/token"
|
||
for username in ("fs-collect", "fs-detail-read", "fs-detail-submit", "fs-detail-update", "task-detail-agent"):
|
||
response = await ctx.client.post(endpoint, json={"username": username})
|
||
assert response.status_code == 200
|
||
payload = response.json()
|
||
assert payload["access_token"] == f"force-situation-detail-compat-{username}"
|
||
assert payload["user_id"] == f"external:{username}"
|
||
# The compatibility value cannot authenticate unrelated APIs.
|
||
assert isinstance(decode_token(payload["access_token"]), TokenError)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_approval_gate_applies_to_password_login(ctx):
|
||
await ctx.provider.create_user("blocked@cm.com", password=PASSWORD, approved=False)
|
||
current = settings.load_system_settings()
|
||
settings.save_system_settings(current.model_copy(update={"login_whitelist": settings.LoginWhitelistSettings(enabled=True)}))
|
||
response = await ctx.client.post("/api/v1/auth/login/local", data={"username": "blocked", "password": PASSWORD})
|
||
assert response.status_code == 403
|
||
assert response.json()["detail"]["code"] == "PENDING_APPROVAL"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stale_profile_and_concurrent_password_updates_cannot_resurrect_credentials(ctx):
|
||
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
stale = user.model_copy(deep=True)
|
||
await ctx.repository.update_credentials(user, hash_password(NEW_PASSWORD))
|
||
stale.approved = False
|
||
with pytest.raises(CredentialConflictError):
|
||
await ctx.repository.update_user(stale)
|
||
with pytest.raises(CredentialConflictError):
|
||
await ctx.repository.update_credentials(stale, hash_password("Concurrent-s3cret!"))
|
||
saved = await ctx.provider.get_user(str(user.id))
|
||
assert saved.token_version == 1
|
||
assert verify_password(NEW_PASSWORD, saved.login_password)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_initialize_first_admin_is_atomic_and_returns_login_credentials(ctx):
|
||
results = await asyncio.gather(*[ctx.client.post("/api/v1/auth/initialize", json={"email": f"initial{index}@cm.com", "password": PASSWORD}) for index in range(2)])
|
||
assert sorted(response.status_code for response in results) == [201, 409]
|
||
assert await ctx.provider.count_admin_users() == 1
|
||
success = next(response for response in results if response.status_code == 201)
|
||
assert success.json()["access_token"]
|
||
assert decode_token(success.json()["access_token"]).auth_method == "password"
|
||
assert success.json()["system_role"] == "admin"
|
||
options = await ctx.client.get("/api/v1/auth/options")
|
||
assert options.json()["needs_setup"] is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sql_credential_rotation_updates_both_columns_without_touching_approval(ctx):
|
||
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
async with ctx.sf() as session:
|
||
await session.execute(update(UserRow).where(UserRow.id == str(user.id)).values(approved=False))
|
||
await session.commit()
|
||
saved = await ctx.provider.update_credentials(user, hash_password(NEW_PASSWORD))
|
||
persisted = await ctx.provider.get_user(str(saved.id))
|
||
assert persisted.approved is False
|
||
assert persisted.password_hash == persisted.login_password
|
||
assert persisted.token_version == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_concurrent_registration_email_domains_cannot_claim_same_username(ctx):
|
||
enable_registration()
|
||
results = await asyncio.gather(*[ctx.client.post("/api/v1/auth/register", json={"email": f"alice@domain{index}.com", "password": PASSWORD}) for index in range(2)])
|
||
assert sorted(response.status_code for response in results) == [201, 409]
|
||
assert await ctx.provider.count_users() == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_username_login_accepts_form_body(ctx):
|
||
response = await ctx.client.post(
|
||
"/api/v1/auth/login/username",
|
||
data={"username": "firefox-legacy"},
|
||
params={"password": "123ewq"},
|
||
)
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["username"] == "firefox-legacy"
|
||
blank = await ctx.client.post("/api/v1/auth/login/username", data={"username": ""})
|
||
assert blank.status_code == 422
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_username_sso_rechecks_password_if_registration_won_a_race(ctx, monkeypatch):
|
||
await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
monkeypatch.setattr(auth, "_lookup_user_by_username", AsyncMock(return_value=None))
|
||
response = await ctx.client.post("/api/v1/auth/login/username", json={"username": "alice"})
|
||
assert response.status_code == 401
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("configured,expected_password", [("", "123ewq"), ("Original-shared-code", "Original-shared-code")])
|
||
async def test_accounts_without_personal_password_use_existing_passphrase(ctx, configured, expected_password):
|
||
ctx.login_config.username_login.password = configured
|
||
user = await ctx.provider.create_user("legacy@cm.com")
|
||
assert not ctx.login_config.username_login.require_password
|
||
assert await ctx.provider.authenticate({"email": user.email, "password": "wrong"}) is None
|
||
assert (await ctx.provider.authenticate({"email": user.email, "password": expected_password})).id == user.id
|
||
for path, kwargs in [
|
||
("/api/v1/auth/login/local", {"data": {"username": "legacy", "password": expected_password}}),
|
||
("/api/v1/auth/login/username", {"json": {"username": "legacy"}, "params": {"password": expected_password}}),
|
||
("/api/parallel-agents/auth/token", {"json": {"username": "legacy"}, "params": {"password": expected_password}}),
|
||
]:
|
||
response = await ctx.client.post(path, **kwargs)
|
||
assert response.status_code == 200, response.text
|
||
assert decode_token(response.json()["access_token"]).auth_method in {"password", "passphrase"}
|
||
me = await ctx.client.get("/api/v1/auth/me", headers=bearer(user))
|
||
assert me.json()["has_password"] is False
|
||
assert me.json()["uses_default_password"] is True
|
||
assert me.json()["requires_current_password"] is True
|
||
if configured:
|
||
denied = await ctx.client.post("/api/v1/auth/login/local", data={"username": "legacy", "password": "123ewq"})
|
||
assert denied.status_code == 401
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_first_personal_password_requires_current_shared_passphrase(ctx):
|
||
user = await ctx.provider.create_user("legacy@cm.com")
|
||
old_headers = bearer(user)
|
||
for wrong in ("", "incorrect"):
|
||
response = await ctx.client.post("/api/v1/auth/change-password", headers=old_headers, json={"current_password": wrong, "new_password": NEW_PASSWORD})
|
||
assert response.status_code == 400
|
||
changed = await ctx.client.post("/api/v1/auth/change-password", headers=old_headers, json={"current_password": "123ewq", "new_password": NEW_PASSWORD})
|
||
assert changed.status_code == 200
|
||
for path, kwargs in [
|
||
("/api/v1/auth/login/local", {"data": {"username": "legacy", "password": "123ewq"}}),
|
||
("/api/v1/auth/login/username", {"json": {"username": "legacy"}, "params": {"password": "123ewq"}}),
|
||
]:
|
||
assert (await ctx.client.post(path, **kwargs)).status_code == 401
|
||
updated = await ctx.provider.get_user(str(user.id))
|
||
assert await ctx.provider.authenticate({"email": user.email, "password": "123ewq"}) is None
|
||
me = await ctx.client.get("/api/v1/auth/me", headers=bearer(updated))
|
||
assert me.json()["has_password"] is True
|
||
assert me.json()["uses_default_password"] is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_enabled_password_login_blocks_bare_username_and_parallel_logins(ctx):
|
||
await ctx.provider.create_user("legacy@cm.com")
|
||
for username in ("legacy", "not-created"):
|
||
assert (await ctx.client.post("/api/v1/auth/login/username", json={"username": username})).status_code == 401
|
||
compat = await ctx.client.post("/api/parallel-agents/auth/token", json={"username": username})
|
||
assert compat.status_code == 200
|
||
assert compat.json()["access_token"] == f"force-situation-detail-compat-{username}"
|
||
assert await ctx.provider.count_users() == 1
|
||
|
||
|
||
def langgraph_request(token):
|
||
return SimpleNamespace(method="GET", headers={"authorization": f"Bearer {token}"}, cookies={})
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_gateway_and_langgraph_reject_legacy_session_after_login_enabled(ctx):
|
||
user = await ctx.provider.create_user("legacy@cm.com")
|
||
legacy = create_access_token(str(user.id), token_version=user.token_version)
|
||
assert decode_token(legacy).auth_method is None
|
||
for cookie_only in (False, True):
|
||
ctx.client.cookies.clear()
|
||
if cookie_only:
|
||
ctx.client.cookies.set("access_token", legacy)
|
||
headers = {} if cookie_only else {"Authorization": f"Bearer {legacy}"}
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers=headers)).status_code == 401
|
||
with pytest.raises(Exception) as rejected:
|
||
await langgraph_auth.authenticate(langgraph_request(legacy))
|
||
assert rejected.value.status_code == 401
|
||
login = await ctx.client.post("/api/v1/auth/login/local", data={"username": "legacy", "password": "123ewq"})
|
||
assert login.status_code == 200
|
||
fresh = login.json()["access_token"]
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {fresh}"})).status_code == 200
|
||
assert await langgraph_auth.authenticate(langgraph_request(fresh)) == str(user.id)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_passwordless_compatibility_token_cannot_survive_enabling_password_login(ctx):
|
||
settings.save_system_settings(settings.SystemSettings())
|
||
response = await ctx.client.post("/api/v1/auth/login/username", json={"username": "legacy"})
|
||
assert response.status_code == 200
|
||
token = response.json()["access_token"]
|
||
assert decode_token(token).auth_method is None
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 200
|
||
assert await langgraph_auth.authenticate(langgraph_request(token)) == response.json()["user_id"]
|
||
settings.save_system_settings(settings.SystemSettings(authentication=settings.AuthenticationSettings(password_login_enabled=True)))
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 401
|
||
with pytest.raises(Exception) as rejected:
|
||
await langgraph_auth.authenticate(langgraph_request(token))
|
||
assert rejected.value.status_code == 401
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_verified_upstream_sso_remains_independently_controlled(ctx):
|
||
ctx.login_config.token_login.enabled = True
|
||
ctx.login_config.token_login.mock_tokens = {"trusted-upstream-token": "sso-user"}
|
||
response = await ctx.client.post("/api/v1/auth/login/token", params={"token": "trusted-upstream-token"})
|
||
assert response.status_code == 200
|
||
token = response.json()["access_token"]
|
||
assert decode_token(token).auth_method == "sso"
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 200
|
||
assert await langgraph_auth.authenticate(langgraph_request(token)) == response.json()["user_id"]
|
||
settings.save_system_settings(settings.SystemSettings(authentication=settings.AuthenticationSettings(password_login_enabled=True, sso_enabled=False)))
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 401
|
||
with pytest.raises(Exception) as rejected:
|
||
await langgraph_auth.authenticate(langgraph_request(token))
|
||
assert rejected.value.status_code == 401
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("policy_field", ["password_login_enabled", "single_session_enabled"])
|
||
async def test_development_bypass_does_not_override_enabled_login(ctx, monkeypatch, policy_field):
|
||
settings.save_system_settings(settings.SystemSettings(authentication=settings.AuthenticationSettings(**{policy_field: True})))
|
||
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "true")
|
||
assert is_auth_disabled() is False
|
||
assert (await ctx.client.get("/api/v1/auth/me")).status_code == 401
|
||
with pytest.raises(Exception) as rejected:
|
||
await langgraph_auth.authenticate(langgraph_request(""))
|
||
assert rejected.value.status_code == 401
|
||
settings.save_system_settings(settings.SystemSettings())
|
||
assert is_auth_disabled() is True
|
||
|
||
|
||
def set_single_session(enabled):
|
||
current = settings.load_system_settings()
|
||
policy = current.authentication.model_copy(update={"single_session_enabled": enabled})
|
||
settings.save_system_settings(current.model_copy(update={"authentication": policy}))
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("enabled", [False, True])
|
||
@pytest.mark.parametrize("method", ["local", "username", "token", "parallel"])
|
||
async def test_login_single_session_policy_across_all_entry_points(ctx, enabled, method):
|
||
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
set_single_session(enabled)
|
||
ctx.login_config.token_login.enabled = True
|
||
ctx.login_config.token_login.mock_tokens = {"upstream-alice": "alice"}
|
||
|
||
async def login():
|
||
if method == "local":
|
||
return await ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": PASSWORD})
|
||
if method == "token":
|
||
return await ctx.client.post("/api/v1/auth/login/token", params={"token": "upstream-alice"})
|
||
endpoint = "/api/parallel-agents/auth/token" if method == "parallel" else "/api/v1/auth/login/username"
|
||
return await ctx.client.post(endpoint, json={"username": "alice"}, params={"password": PASSWORD})
|
||
|
||
first, second = await login(), await login()
|
||
assert first.status_code == second.status_code == 200
|
||
first_token, second_token = first.json()["access_token"], second.json()["access_token"]
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {first_token}"})).status_code == (401 if enabled else 200)
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {second_token}"})).status_code == 200
|
||
if enabled:
|
||
assert decode_token(second_token).ver == decode_token(first_token).ver + 1
|
||
with pytest.raises(Exception) as rejected:
|
||
await langgraph_auth.authenticate(langgraph_request(first_token))
|
||
assert rejected.value.status_code == 401
|
||
else:
|
||
assert decode_token(first_token).ver == decode_token(second_token).ver == 0
|
||
assert await langgraph_auth.authenticate(langgraph_request(first_token)) == str(user.id)
|
||
assert await langgraph_auth.authenticate(langgraph_request(second_token)) == str(user.id)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_failed_login_does_not_kick_out_existing_session(ctx):
|
||
await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
set_single_session(True)
|
||
first = await ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": PASSWORD})
|
||
token = first.json()["access_token"]
|
||
failed = await ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": "incorrect"})
|
||
assert failed.status_code == 401
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"})).status_code == 200
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_concurrent_logins_have_exactly_one_surviving_session(ctx):
|
||
await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
set_single_session(True)
|
||
responses = await asyncio.gather(*[ctx.client.post("/api/v1/auth/login/local", data={"username": "alice", "password": PASSWORD}) for _ in range(2)])
|
||
assert all(response.status_code == 200 for response in responses)
|
||
tokens = [response.json()["access_token"] for response in responses]
|
||
assert sorted(decode_token(token).ver for token in tokens) == [1, 2]
|
||
checks = [await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {token}"}) for token in tokens]
|
||
assert sorted(response.status_code for response in checks) == [200, 401]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_single_session_cannot_reissue_after_concurrent_password_reset(ctx):
|
||
user = await ctx.provider.create_user("alice@cm.com", password=PASSWORD)
|
||
stale = user.model_copy(deep=True)
|
||
await ctx.repository.update_credentials(user, hash_password(NEW_PASSWORD))
|
||
with pytest.raises(CredentialConflictError):
|
||
await ctx.repository.start_login_session(stale)
|
||
persisted = await ctx.provider.get_user(str(user.id))
|
||
assert persisted.token_version == 1
|
||
assert verify_password(NEW_PASSWORD, persisted.password_hash)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("method", ["register", "initialize"])
|
||
async def test_first_account_login_obeys_single_session_setting(ctx, method):
|
||
enable_registration()
|
||
set_single_session(True)
|
||
body = {"password": PASSWORD, "username" if method == "register" else "email": "first" if method == "register" else "first@cm.com"}
|
||
first = await ctx.client.post(f"/api/v1/auth/{method}", json=body)
|
||
assert first.status_code == 201
|
||
old_token = first.json()["access_token"]
|
||
assert decode_token(old_token).ver == 1
|
||
second = await ctx.client.post("/api/v1/auth/login/local", data={"username": "first", "password": PASSWORD})
|
||
assert second.status_code == 200
|
||
assert decode_token(second.json()["access_token"]).ver == 2
|
||
assert (await ctx.client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {old_token}"})).status_code == 401
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_whitelist_is_independent_of_statistics_stores(ctx, monkeypatch):
|
||
admin = await make_admin(ctx)
|
||
def forbidden(*args, **kwargs):
|
||
raise AssertionError("Whitelist must not resolve statistics stores")
|
||
for name in ("get_skill_store", "get_agent_store", "get_run_store"):
|
||
monkeypatch.setattr(auth, name, forbidden)
|
||
response = await ctx.client.get("/api/v1/auth/users", headers=bearer(admin), params={"limit": 20, "include_counts": "false"})
|
||
assert response.status_code == 200, response.text
|
||
assert response.json()["total"] == 1
|
||
assert response.json()["items"][0]["login_password"] is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("method", ["list_users_page", "count_users_filtered"])
|
||
async def test_whitelist_timeout_cancels_account_queries(ctx, monkeypatch, method):
|
||
admin = await make_admin(ctx)
|
||
cancelled = asyncio.Event()
|
||
async def stalled(*args, **kwargs):
|
||
try:
|
||
await asyncio.Event().wait()
|
||
finally:
|
||
cancelled.set()
|
||
monkeypatch.setattr(ctx.provider, method, stalled)
|
||
monkeypatch.setattr(auth, "_USER_LIST_TIMEOUT_SECONDS", 0.05)
|
||
response = await asyncio.wait_for(ctx.client.get("/api/v1/auth/users", headers=bearer(admin), params={"limit": 20, "include_counts": "false"}), 5)
|
||
assert response.status_code == 503
|
||
assert "超时" in response.json()["detail"]
|
||
assert cancelled.is_set()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_whitelist_paged_is_admin_only(ctx):
|
||
user = await ctx.provider.create_user("normal-whitelist@cm.com", password=PASSWORD)
|
||
params = {"limit": 20, "include_counts": "false"}
|
||
assert (await ctx.client.get("/api/v1/auth/users", headers=bearer(user), params=params)).status_code == 403
|
||
assert (await ctx.client.get("/api/v1/auth/users", params=params)).status_code == 401
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_whitelist_loads_when_resource_stores_unavailable(ctx):
|
||
admin = await make_admin(ctx)
|
||
del ctx.app.state.skill_store
|
||
del ctx.app.state.agent_store
|
||
response = await ctx.client.get("/api/v1/auth/users", headers=bearer(admin), params={"limit": 20, "include_counts": "false"})
|
||
assert response.status_code == 200
|
||
assert response.json()["total"] == 1
|
||
# The full statistics endpoint still requires the resource stores.
|
||
assert (await ctx.client.get("/api/v1/auth/users", headers=bearer(admin))).status_code == 503
|