diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py index 3ee3978e..767a2e21 100644 --- a/backend/app/gateway/auth/repositories/sqlite.py +++ b/backend/app/gateway/auth/repositories/sqlite.py @@ -46,6 +46,7 @@ class SQLiteUserRepository(UserRepository): oauth_id=row.oauth_id, needs_setup=row.needs_setup, token_version=row.token_version, + default_workspace_id=row.default_workspace_id, ) @staticmethod @@ -60,6 +61,7 @@ class SQLiteUserRepository(UserRepository): oauth_id=user.oauth_id, needs_setup=user.needs_setup, token_version=user.token_version, + default_workspace_id=user.default_workspace_id, ) # ── CRUD ────────────────────────────────────────────────────────── @@ -106,6 +108,7 @@ class SQLiteUserRepository(UserRepository): row.oauth_id = user.oauth_id row.needs_setup = user.needs_setup row.token_version = user.token_version + row.default_workspace_id = user.default_workspace_id await session.commit() return user diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index 3a41e13e..c2c36612 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -15,11 +15,59 @@ from app.gateway.auth import ( ) from app.gateway.auth.config import get_auth_config from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse +from app.gateway.auth.workspace_slug import auto_slug_from_email, next_available_slug from app.gateway.csrf_middleware import is_secure_request from app.gateway.deps import get_current_user_from_request, get_local_provider logger = logging.getLogger(__name__) + +async def _ensure_default_workspace(user) -> str: + """Create the user's personal workspace + owner membership, set default_workspace_id. + + Returns the new workspace id. Idempotent for users who already + have a default_workspace_id (used by both the registration flow + and the lifespan backfill in app.py). + """ + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.workspace import WorkspaceRepository + from deerflow.persistence.workspace.sql import SLUG_BLACKLIST + from deerflow.persistence.workspace_membership import WorkspaceMembershipRepository + + if user.default_workspace_id: + return user.default_workspace_id + + sf = get_session_factory() + ws_repo = WorkspaceRepository(sf) + m_repo = WorkspaceMembershipRepository(sf) + + base_slug = auto_slug_from_email(user.email) + + async def slug_exists(s: str) -> bool: + # Treat blacklisted slugs as "taken" so the walker skips them + # instead of letting WorkspaceRepository.create raise after a + # successful slug computation (the user picked a reserved name + # like "admin@example.com" → base slug "admin"). + if s in SLUG_BLACKLIST: + return True + return (await ws_repo.get_by_slug(s)) is not None + + unique_slug = await next_available_slug(base_slug, exists_check=slug_exists) + + display_local = user.email.split("@", 1)[0] + workspace = await ws_repo.create( + name=f"{display_local}'s Workspace"[:64], + slug=unique_slug, + owner_id=str(user.id), + ) + await m_repo.add(workspace_id=workspace["id"], user_id=str(user.id), role="owner") + + user.default_workspace_id = workspace["id"] + await get_local_provider().update_user(user) + + return workspace["id"] + + router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) @@ -452,7 +500,14 @@ async def initialize_admin(request: Request, response: Response, body: Initializ detail=AuthErrorResponse(code=AuthErrorCode.SYSTEM_ALREADY_INITIALIZED, message="System already initialized").model_dump(), ) - token = create_access_token(str(user.id), token_version=user.token_version) + workspace_id = await _ensure_default_workspace(user) + + token = create_access_token( + str(user.id), + token_version=user.token_version, + workspace_id=workspace_id, + role="owner", + ) _set_session_cookie(response, token, request) return UserResponse(id=str(user.id), email=user.email, system_role=user.system_role) diff --git a/backend/packages/harness/deerflow/persistence/workspace/sql.py b/backend/packages/harness/deerflow/persistence/workspace/sql.py index 5a213633..6aad5108 100644 --- a/backend/packages/harness/deerflow/persistence/workspace/sql.py +++ b/backend/packages/harness/deerflow/persistence/workspace/sql.py @@ -32,7 +32,7 @@ _SLUG_MAX_LEN = 32 # slug 黑名单(应用层校验,不写 DB constraint)。包含 ADR-007 §4 保留 slug # + 路径 + Next.js 保留 + 业务保留词。 -_SLUG_BLACKLIST = frozenset( +SLUG_BLACKLIST = frozenset( { "admin", "api", @@ -77,7 +77,7 @@ def _validate_slug(slug: str) -> None: raise WorkspaceValidationError(f"slug length must be between {_SLUG_MIN_LEN} and {_SLUG_MAX_LEN}, got {len(slug)}") if not _SLUG_PATTERN.fullmatch(slug): raise WorkspaceValidationError(f"slug {slug!r} does not match required pattern ^[a-z0-9](-?[a-z0-9])*$") - if slug in _SLUG_BLACKLIST: + if slug in SLUG_BLACKLIST: raise WorkspaceValidationError(f"slug {slug!r} is reserved") diff --git a/backend/tests/test_register_creates_workspace.py b/backend/tests/test_register_creates_workspace.py new file mode 100644 index 00000000..51718d25 --- /dev/null +++ b/backend/tests/test_register_creates_workspace.py @@ -0,0 +1,172 @@ +"""Registration endpoints (POST /initialize, POST /register) auto-create a workspace. + +Stage 0 PR4 T4.8 + T4.9. Every newly registered user must end up with: + +- a single ``workspaces`` row (their personal workspace), +- a single ``workspace_memberships`` row with ``role='owner'``, +- ``users.default_workspace_id`` pointing at that workspace, +- a session cookie whose JWT carries the workspace as the ``wid`` claim. + +Tests run against a per-test SQLite engine bootstrapped by the +fixture; the registration router is exercised through the real +TestClient so the full handler + DB transaction path is covered. +""" + +from __future__ import annotations + +import asyncio +import os + +import jwt +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-register-workspace-32+") + +from app.gateway.auth.config import AuthConfig, set_auth_config + +_TEST_SECRET = "test-secret-key-register-workspace-32+" + + +@pytest.fixture(autouse=True) +def _setup_auth(tmp_path): + from app.gateway import deps + from app.gateway.routers.auth import _SETUP_STATUS_COOLDOWN + from deerflow.persistence.engine import close_engine, init_engine + + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + url = f"sqlite+aiosqlite:///{tmp_path}/register_ws.db" + asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))) + deps._cached_local_provider = None + deps._cached_repo = None + _SETUP_STATUS_COOLDOWN.clear() + try: + yield + finally: + deps._cached_local_provider = None + deps._cached_repo = None + _SETUP_STATUS_COOLDOWN.clear() + asyncio.run(close_engine()) + + +@pytest.fixture() +def client(_setup_auth): + from app.gateway.app import create_app + + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + app = create_app() + yield TestClient(app) + + +def _init_payload(**extra): + return {"email": "admin@example.com", "password": "Str0ng!Pass99", **extra} + + +def _register_payload(email: str = "alice@example.com", **extra): + return {"email": email, "password": "Tr0ub4dor3a-strong!", **extra} + + +def _decode(token: str) -> dict: + """Decode a JWT (signature-checked) and return the raw payload.""" + return jwt.decode(token, _TEST_SECRET, algorithms=["HS256"]) + + +async def _read_workspace_state(user_id: str) -> dict: + """Inspect the per-user workspace state after a registration call. + + Returns a dict with the workspace row, the owner membership row and + the user's default_workspace_id, so each test can pick what it + cares about. + """ + from sqlalchemy import select + + from deerflow.persistence.engine import get_session_factory + from deerflow.persistence.user.model import UserRow + from deerflow.persistence.workspace.model import WorkspaceRow + from deerflow.persistence.workspace_membership.model import WorkspaceMembershipRow + + sf = get_session_factory() + async with sf() as session: + user = await session.get(UserRow, user_id) + memberships = (await session.execute(select(WorkspaceMembershipRow).where(WorkspaceMembershipRow.user_id == user_id))).scalars().all() + workspaces = [] + if memberships: + workspaces = (await session.execute(select(WorkspaceRow).where(WorkspaceRow.id.in_([m.workspace_id for m in memberships])))).scalars().all() + return { + "user_default_workspace_id": getattr(user, "default_workspace_id", None) if user else None, + "memberships": [(m.workspace_id, m.user_id, m.role) for m in memberships], + "workspaces": [(w.id, w.slug, w.owner_id) for w in workspaces], + } + + +# ---------- T4.8 — /initialize --------------------------------------------- + + +def test_initialize_creates_admin_with_default_workspace(client): + """POST /initialize → admin + workspace + owner membership + wid cookie.""" + resp = client.post("/api/v1/auth/initialize", json=_init_payload()) + assert resp.status_code == 201, resp.text + user_id = resp.json()["id"] + + state = asyncio.run(_read_workspace_state(user_id)) + + assert len(state["workspaces"]) == 1, state + ws_id, ws_slug, ws_owner = state["workspaces"][0] + assert ws_owner == user_id + # base slug "admin" is reserved, walker bumps to first free suffix + assert ws_slug == "admin-2" + + assert state["memberships"] == [(ws_id, user_id, "owner")] + assert state["user_default_workspace_id"] == ws_id + + token = resp.cookies["access_token"] + claims = _decode(token) + assert claims["wid"] == ws_id + assert claims["role"] == "owner" + + +# ---------- T4.9 — /register ----------------------------------------------- + + +def test_register_creates_user_with_default_workspace(client): + """POST /register → user + workspace + owner membership + wid cookie.""" + # Initialize an admin first so the system is past first-boot. + client.post("/api/v1/auth/initialize", json=_init_payload()) + + resp = client.post("/api/v1/auth/register", json=_register_payload()) + assert resp.status_code == 201, resp.text + user_id = resp.json()["id"] + + state = asyncio.run(_read_workspace_state(user_id)) + + assert len(state["workspaces"]) == 1, state + ws_id, ws_slug, ws_owner = state["workspaces"][0] + assert ws_owner == user_id + assert ws_slug == "alice" + + assert state["memberships"] == [(ws_id, user_id, "owner")] + assert state["user_default_workspace_id"] == ws_id + + token = resp.cookies["access_token"] + claims = _decode(token) + assert claims["wid"] == ws_id + assert claims["role"] == "owner" + + +def test_two_registrations_isolate_workspaces_and_avoid_slug_collision(client): + """Two users with colliding email local-parts → distinct workspaces, slug suffix bump.""" + client.post("/api/v1/auth/initialize", json=_init_payload()) + + r1 = client.post("/api/v1/auth/register", json=_register_payload(email="alice@example.com")) + r2 = client.post("/api/v1/auth/register", json=_register_payload(email="alice@somewhere.else")) + assert r1.status_code == 201, r1.text + assert r2.status_code == 201, r2.text + + s1 = asyncio.run(_read_workspace_state(r1.json()["id"])) + s2 = asyncio.run(_read_workspace_state(r2.json()["id"])) + + ws1 = s1["workspaces"][0] + ws2 = s2["workspaces"][0] + assert ws1[0] != ws2[0], "workspaces must be distinct" + assert ws1[1] == "alice" + assert ws2[1] == "alice-2", "slug collision walker should land on -2"