mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-04-25 11:18:22 +00:00
Backend: - Port auth_middleware, csrf_middleware, langgraph_auth, routers/auth - Port authz decorator (owner_filter_key defaults to 'owner_id') - Merge app.py: register AuthMiddleware + CSRFMiddleware + CORS, add _ensure_admin_user lifespan hook, _migrate_orphaned_threads helper, register auth router - Merge deps.py: add get_local_provider, get_current_user_from_request, get_optional_user_from_request; keep get_current_user as thin str|None adapter for feedback router - langgraph.json: add auth path pointing to langgraph_auth.py:auth - Rename metadata['user_id'] -> metadata['owner_id'] in langgraph_auth (both metadata write and LangGraph filter dict) + test fixtures Frontend: - Delete better-auth library and api catch-all route - Remove better-auth npm dependency and env vars (BETTER_AUTH_SECRET, BETTER_AUTH_GITHUB_*) from env.js - Port frontend/src/core/auth/* (AuthProvider, gateway-config, proxy-policy, server-side getServerSideUser, types) - Port frontend/src/core/api/fetcher.ts - Port (auth)/layout, (auth)/login, (auth)/setup pages - Rewrite workspace/layout.tsx as server component that calls getServerSideUser and wraps in AuthProvider - Port workspace/workspace-content.tsx for the client-side sidebar logic Tests: - Port 5 auth test files (test_auth, test_auth_middleware, test_auth_type_system, test_ensure_admin, test_langgraph_auth) - 176 auth tests PASS After this commit: login/logout/registration flow works, but persistence layer does not yet filter by owner_id. Commit 4 closes that gap.
215 lines
7.6 KiB
Python
215 lines
7.6 KiB
Python
"""Tests for _ensure_admin_user() in app.py.
|
|
|
|
Covers: first-boot admin creation, auto-reset on needs_setup=True,
|
|
no-op on needs_setup=False, migration, and edge cases.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-ensure-admin-testing-min-32")
|
|
|
|
from app.gateway.auth.config import AuthConfig, set_auth_config
|
|
from app.gateway.auth.models import User
|
|
|
|
_JWT_SECRET = "test-secret-key-ensure-admin-testing-min-32"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup_auth_config():
|
|
set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET))
|
|
yield
|
|
set_auth_config(AuthConfig(jwt_secret=_JWT_SECRET))
|
|
|
|
|
|
def _make_app_stub(store=None):
|
|
"""Minimal app-like object with state.store."""
|
|
app = SimpleNamespace()
|
|
app.state = SimpleNamespace()
|
|
app.state.store = store
|
|
return app
|
|
|
|
|
|
def _make_provider(user_count=0, admin_user=None):
|
|
p = AsyncMock()
|
|
p.count_users = AsyncMock(return_value=user_count)
|
|
p.create_user = AsyncMock(
|
|
side_effect=lambda **kw: User(
|
|
email=kw["email"],
|
|
password_hash="hashed",
|
|
system_role=kw.get("system_role", "user"),
|
|
needs_setup=kw.get("needs_setup", False),
|
|
)
|
|
)
|
|
p.get_user_by_email = AsyncMock(return_value=admin_user)
|
|
p.update_user = AsyncMock(side_effect=lambda u: u)
|
|
return p
|
|
|
|
|
|
# ── First boot: no users ─────────────────────────────────────────────────
|
|
|
|
|
|
def test_first_boot_creates_admin():
|
|
"""count_users==0 → create admin with needs_setup=True."""
|
|
provider = _make_provider(user_count=0)
|
|
app = _make_app_stub()
|
|
|
|
with patch("app.gateway.deps.get_local_provider", return_value=provider):
|
|
with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"):
|
|
from app.gateway.app import _ensure_admin_user
|
|
|
|
asyncio.run(_ensure_admin_user(app))
|
|
|
|
provider.create_user.assert_called_once()
|
|
call_kwargs = provider.create_user.call_args[1]
|
|
assert call_kwargs["email"] == "admin@deerflow.dev"
|
|
assert call_kwargs["system_role"] == "admin"
|
|
assert call_kwargs["needs_setup"] is True
|
|
assert len(call_kwargs["password"]) > 10 # random password generated
|
|
|
|
|
|
def test_first_boot_triggers_migration_if_store_present():
|
|
"""First boot with store → _migrate_orphaned_threads called."""
|
|
provider = _make_provider(user_count=0)
|
|
store = AsyncMock()
|
|
store.asearch = AsyncMock(return_value=[])
|
|
app = _make_app_stub(store=store)
|
|
|
|
with patch("app.gateway.deps.get_local_provider", return_value=provider):
|
|
with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"):
|
|
from app.gateway.app import _ensure_admin_user
|
|
|
|
asyncio.run(_ensure_admin_user(app))
|
|
|
|
store.asearch.assert_called_once()
|
|
|
|
|
|
def test_first_boot_no_store_skips_migration():
|
|
"""First boot without store → no crash, migration skipped."""
|
|
provider = _make_provider(user_count=0)
|
|
app = _make_app_stub(store=None)
|
|
|
|
with patch("app.gateway.deps.get_local_provider", return_value=provider):
|
|
with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"):
|
|
from app.gateway.app import _ensure_admin_user
|
|
|
|
asyncio.run(_ensure_admin_user(app))
|
|
|
|
provider.create_user.assert_called_once()
|
|
|
|
|
|
# ── Subsequent boot: needs_setup=True → auto-reset ───────────────────────
|
|
|
|
|
|
def test_needs_setup_true_resets_password():
|
|
"""Existing admin with needs_setup=True → password reset + token_version bumped."""
|
|
admin = User(
|
|
email="admin@deerflow.dev",
|
|
password_hash="old-hash",
|
|
system_role="admin",
|
|
needs_setup=True,
|
|
token_version=0,
|
|
created_at=datetime.now(UTC) - timedelta(seconds=30),
|
|
)
|
|
provider = _make_provider(user_count=1, admin_user=admin)
|
|
app = _make_app_stub()
|
|
|
|
with patch("app.gateway.deps.get_local_provider", return_value=provider):
|
|
with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="new-hash"):
|
|
from app.gateway.app import _ensure_admin_user
|
|
|
|
asyncio.run(_ensure_admin_user(app))
|
|
|
|
# Password was reset
|
|
provider.update_user.assert_called_once()
|
|
updated = provider.update_user.call_args[0][0]
|
|
assert updated.password_hash == "new-hash"
|
|
assert updated.token_version == 1
|
|
|
|
|
|
def test_needs_setup_true_consecutive_resets_increment_version():
|
|
"""Two boots with needs_setup=True → token_version increments each time."""
|
|
admin = User(
|
|
email="admin@deerflow.dev",
|
|
password_hash="hash",
|
|
system_role="admin",
|
|
needs_setup=True,
|
|
token_version=3,
|
|
created_at=datetime.now(UTC) - timedelta(seconds=30),
|
|
)
|
|
provider = _make_provider(user_count=1, admin_user=admin)
|
|
app = _make_app_stub()
|
|
|
|
with patch("app.gateway.deps.get_local_provider", return_value=provider):
|
|
with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="new-hash"):
|
|
from app.gateway.app import _ensure_admin_user
|
|
|
|
asyncio.run(_ensure_admin_user(app))
|
|
|
|
updated = provider.update_user.call_args[0][0]
|
|
assert updated.token_version == 4
|
|
|
|
|
|
# ── Subsequent boot: needs_setup=False → no-op ──────────────────────────
|
|
|
|
|
|
def test_needs_setup_false_no_reset():
|
|
"""Admin with needs_setup=False → no password reset, no update."""
|
|
admin = User(
|
|
email="admin@deerflow.dev",
|
|
password_hash="stable-hash",
|
|
system_role="admin",
|
|
needs_setup=False,
|
|
token_version=2,
|
|
)
|
|
provider = _make_provider(user_count=1, admin_user=admin)
|
|
app = _make_app_stub()
|
|
|
|
with patch("app.gateway.deps.get_local_provider", return_value=provider):
|
|
from app.gateway.app import _ensure_admin_user
|
|
|
|
asyncio.run(_ensure_admin_user(app))
|
|
|
|
provider.update_user.assert_not_called()
|
|
assert admin.password_hash == "stable-hash"
|
|
assert admin.token_version == 2
|
|
|
|
|
|
# ── Edge cases ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_no_admin_email_found_no_crash():
|
|
"""Users exist but no admin@deerflow.dev → no crash, no reset."""
|
|
provider = _make_provider(user_count=3, admin_user=None)
|
|
app = _make_app_stub()
|
|
|
|
with patch("app.gateway.deps.get_local_provider", return_value=provider):
|
|
from app.gateway.app import _ensure_admin_user
|
|
|
|
asyncio.run(_ensure_admin_user(app))
|
|
|
|
provider.update_user.assert_not_called()
|
|
provider.create_user.assert_not_called()
|
|
|
|
|
|
def test_migration_failure_is_non_fatal():
|
|
"""_migrate_orphaned_threads exception is caught and logged."""
|
|
provider = _make_provider(user_count=0)
|
|
store = AsyncMock()
|
|
store.asearch = AsyncMock(side_effect=RuntimeError("store crashed"))
|
|
app = _make_app_stub(store=store)
|
|
|
|
with patch("app.gateway.deps.get_local_provider", return_value=provider):
|
|
with patch("app.gateway.auth.password.hash_password_async", new_callable=AsyncMock, return_value="hashed"):
|
|
from app.gateway.app import _ensure_admin_user
|
|
|
|
# Should not raise
|
|
asyncio.run(_ensure_admin_user(app))
|
|
|
|
provider.create_user.assert_called_once()
|