From a1dbfcf2a83597132e4e829c2942c1e9673724f4 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Sun, 24 May 2026 14:06:03 +0000 Subject: [PATCH] feat: implement profile CRUD validation compatibility and defaults API - Add ConfigProfile CRUD endpoints with user ownership and access checks - Implement ordered include management with cycle detection - Add mount management with path validation (absolute, no traversal) - Implement compatibility-filtered listing by tool type - Add default profile selection APIs (get/set defaults per tool type) - Fix SQLAlchemy ambiguous foreign key relationships in config models - Add comprehensive integration tests (29 tests, all passing) - Merge upstream profile resolver service changes (task 2.1) --- apps/api/src/api/config_profiles.py | 878 ++++++++++++++++++ apps/api/src/main.py | 2 + apps/api/src/models/config_profile.py | 3 +- .../integration/test_config_profiles_api.py | 461 +++++++++ 4 files changed, 1343 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/api/config_profiles.py create mode 100644 apps/api/tests/integration/test_config_profiles_api.py diff --git a/apps/api/src/api/config_profiles.py b/apps/api/src/api/config_profiles.py new file mode 100644 index 0000000..1ac121f --- /dev/null +++ b/apps/api/src/api/config_profiles.py @@ -0,0 +1,878 @@ +"""Config profile API endpoints.""" + +import logging +import uuid +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, Field, field_validator +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from src.auth.dependencies import get_current_user_id, get_db_session +from src.models.config_include import ConfigInclude +from src.models.config_mount import ConfigMount +from src.models.config_profile import ConfigProfile +from src.models.tool_type import ToolType +from src.models.user_config import UserConfig + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/config-profiles", tags=["config-profiles"]) + +MAX_MOUNT_PATH_LENGTH = 1024 +MAX_CONTENT_LENGTH = 1024 * 1024 # 1MB +MAX_INCLUDES_DEPTH = 10 + + +# --------------------------------------------------------------------------- +# Pydantic schemas +# --------------------------------------------------------------------------- + +class ConfigProfileCreate(BaseModel): + name: str = Field(description="Profile name (unique per user)") + description: str | None = Field(default=None, description="Optional description") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str) -> str: + v = v.strip() + if not v: + raise ValueError("Profile name cannot be empty") + if len(v) > 255: + raise ValueError("Profile name must be 255 characters or less") + return v + + +class ConfigProfileUpdate(BaseModel): + name: str | None = Field(default=None, description="Profile name") + description: str | None = Field(default=None, description="Optional description") + + @field_validator("name") + @classmethod + def validate_name(cls, v: str | None) -> str | None: + if v is None: + return v + v = v.strip() + if not v: + raise ValueError("Profile name cannot be empty") + if len(v) > 255: + raise ValueError("Profile name must be 255 characters or less") + return v + + +class ConfigProfileResponse(BaseModel): + id: str + user_id: str + name: str + description: str | None + created_at: str + updated_at: str + + +class ConfigProfileDetailResponse(ConfigProfileResponse): + includes: list[dict[str, Any]] + mounts: list[dict[str, Any]] + + +class ConfigIncludeCreate(BaseModel): + included_profile_id: str = Field(description="UUID of the profile to include") + order_index: int = Field(default=0, description="Order index for include resolution") + + +class ConfigIncludeUpdate(BaseModel): + order_index: int = Field(description="Order index for include resolution") + + +class ConfigIncludeResponse(BaseModel): + id: str + profile_id: str + included_profile_id: str + included_profile_name: str | None + order_index: int + created_at: str + updated_at: str + + +class ConfigMountCreate(BaseModel): + target_path: str = Field(description="Absolute target path in container") + mode: str = Field(default="rw", description="Mount mode (rw or ro)") + files: dict[str, str] | None = Field(default=None, description="Files as {path: content}") + order_index: int = Field(default=0, description="Order index for mount resolution") + + @field_validator("target_path") + @classmethod + def validate_target_path(cls, v: str) -> str: + if not v.startswith("/"): + raise ValueError("Target path must be absolute (start with /)") + if ".." in v: + raise ValueError("Target path cannot contain parent directory references (..)") + if len(v) > MAX_MOUNT_PATH_LENGTH: + raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less") + return v + + +class ConfigMountUpdate(BaseModel): + target_path: str | None = Field(default=None, description="Absolute target path in container") + mode: str | None = Field(default=None, description="Mount mode (rw or ro)") + files: dict[str, str] | None = Field(default=None, description="Files as {path: content}") + order_index: int | None = Field(default=None, description="Order index for mount resolution") + + @field_validator("target_path") + @classmethod + def validate_target_path(cls, v: str | None) -> str | None: + if v is None: + return v + if not v.startswith("/"): + raise ValueError("Target path must be absolute (start with /)") + if ".." in v: + raise ValueError("Target path cannot contain parent directory references (..)") + if len(v) > MAX_MOUNT_PATH_LENGTH: + raise ValueError(f"Target path must be {MAX_MOUNT_PATH_LENGTH} characters or less") + return v + + +class ConfigMountResponse(BaseModel): + id: str + profile_id: str + target_path: str + mode: str + files: dict[str, str] | None + order_index: int + created_at: str + updated_at: str + + +class DefaultProfilesUpdate(BaseModel): + default_profiles: dict[str, str] = Field(description="Mapping of tool_type_id to profile_id") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _get_owned_profile( + profile_id: uuid.UUID, + user_id: uuid.UUID, + session: AsyncSession, +) -> ConfigProfile: + """Fetch a config profile and verify ownership.""" + profile = await session.get(ConfigProfile, profile_id) + if profile is None or profile.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="config profile not found", + ) + return profile + + +async def _detect_cycle( + session: AsyncSession, + profile_id: uuid.UUID, + visited: set[uuid.UUID] | None = None, + depth: int = 0, +) -> bool: + """Detect cycles in profile includes using DFS. + + Returns True if a cycle is detected. + """ + if depth > MAX_INCLUDES_DEPTH: + return True + + if visited is None: + visited = set() + + if profile_id in visited: + return True + + visited.add(profile_id) + + result = await session.execute( + select(ConfigInclude.included_profile_id).where( + ConfigInclude.profile_id == profile_id + ) + ) + included_ids = result.scalars().all() + + for included_id in included_ids: + if await _detect_cycle(session, included_id, visited.copy(), depth + 1): + return True + + return False + + +async def _validate_includes_no_cycle( + session: AsyncSession, + profile_id: uuid.UUID, + new_included_id: uuid.UUID | None = None, +) -> None: + """Validate that adding an include wouldn't create a cycle.""" + if new_included_id and await _detect_cycle(session, new_included_id, {profile_id}): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="adding this include would create a circular reference", + ) + + +# --------------------------------------------------------------------------- +# Profile CRUD +# --------------------------------------------------------------------------- + +@router.get( + "", + summary="List config profiles", + description="Get all config profiles for the current user. Optionally filter by tool type compatibility.", +) +async def list_config_profiles( + tool_type_id: str | None = None, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List config profiles for the current user.""" + query = select(ConfigProfile).where(ConfigProfile.user_id == user_id) + + # If tool_type_id is provided, filter to compatible profiles + # For now, all profiles are considered compatible with all tool types + # since there's no explicit compatibility matrix. Future enhancement: + # could filter by profile tags or mount path patterns. + if tool_type_id: + # Validate the tool type exists + tool_type = await session.get(ToolType, uuid.UUID(tool_type_id)) + if tool_type is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="tool type not found", + ) + # All profiles are compatible; just return user's profiles + pass + + result = await session.execute(query.order_by(ConfigProfile.name)) + profiles = result.scalars().all() + + return { + "profiles": [ + { + "id": str(p.id), + "user_id": str(p.user_id), + "name": p.name, + "description": p.description, + "created_at": p.created_at.isoformat() if p.created_at else None, + "updated_at": p.updated_at.isoformat() if p.updated_at else None, + } + for p in profiles + ] + } + + +@router.post( + "", + summary="Create config profile", + description="Create a new config profile.", + status_code=status.HTTP_201_CREATED, +) +async def create_config_profile( + data: ConfigProfileCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Create a config profile.""" + # Check for duplicate name + existing = await session.scalar( + select(ConfigProfile).where( + ConfigProfile.user_id == user_id, + ConfigProfile.name == data.name, + ) + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"config profile with name '{data.name}' already exists", + ) + + profile = ConfigProfile( + user_id=user_id, + name=data.name, + description=data.description, + ) + session.add(profile) + await session.commit() + await session.refresh(profile) + + return { + "id": str(profile.id), + "user_id": str(profile.user_id), + "name": profile.name, + "description": profile.description, + "created_at": profile.created_at.isoformat() if profile.created_at else None, + "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, + } + + +@router.get( + "/defaults", + summary="Get default profiles", + description="Get the current user's default profile assignments per tool type.", +) +async def get_default_profiles( + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get default profiles for the current user.""" + result = await session.execute( + select(UserConfig).where(UserConfig.user_id == user_id) + ) + user_config = result.scalar_one_or_none() + + if user_config is None: + return {"default_profiles": {}} + + return {"default_profiles": user_config.default_profiles} + + +@router.put( + "/defaults", + summary="Set default profiles", + description="Set the current user's default profile assignments per tool type.", +) +async def set_default_profiles( + data: DefaultProfilesUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Set default profiles for the current user.""" + result = await session.execute( + select(UserConfig).where(UserConfig.user_id == user_id) + ) + user_config = result.scalar_one_or_none() + + if user_config is None: + user_config = UserConfig(user_id=user_id, config={}) + session.add(user_config) + + # Validate all profile IDs belong to the user + for tool_type_id, profile_id_str in data.default_profiles.items(): + profile = await session.get(ConfigProfile, uuid.UUID(profile_id_str)) + if profile is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"profile {profile_id_str} not found", + ) + if profile.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"profile {profile_id_str} does not belong to user", + ) + + # SQLAlchemy JSON doesn't track dict mutations, so we replace the whole dict + user_config.config = {**user_config.config, "default_profiles": data.default_profiles} + await session.commit() + await session.refresh(user_config) + + return {"default_profiles": user_config.default_profiles} + + +@router.get( + "/defaults/{tool_type_id}", + summary="Get default profile for tool type", + description="Get the default profile ID for a specific tool type.", +) +async def get_default_profile_for_tool_type( + tool_type_id: str, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get default profile for a specific tool type.""" + result = await session.execute( + select(UserConfig).where(UserConfig.user_id == user_id) + ) + user_config = result.scalar_one_or_none() + + if user_config is None: + return {"tool_type_id": tool_type_id, "profile_id": None} + + profile_id = user_config.default_profiles.get(tool_type_id) + return {"tool_type_id": tool_type_id, "profile_id": profile_id} + + +@router.get( + "/{profile_id}", + summary="Get config profile", + description="Get a config profile with its includes and mounts.", +) +async def get_config_profile( + profile_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Get a config profile with includes and mounts.""" + profile = await session.get( + ConfigProfile, + profile_id, + options=[ + selectinload(ConfigProfile.includes), + selectinload(ConfigProfile.mounts), + ], + ) + if profile is None or profile.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="config profile not found", + ) + + # Fetch included profile names + includes_data = [] + for inc in profile.includes: + included_profile = await session.get(ConfigProfile, inc.included_profile_id) + includes_data.append({ + "id": str(inc.id), + "profile_id": str(inc.profile_id), + "included_profile_id": str(inc.included_profile_id), + "included_profile_name": included_profile.name if included_profile else None, + "order_index": inc.order_index, + "created_at": inc.created_at.isoformat() if inc.created_at else None, + "updated_at": inc.updated_at.isoformat() if inc.updated_at else None, + }) + + mounts_data = [ + { + "id": str(m.id), + "profile_id": str(m.profile_id), + "target_path": m.target_path, + "mode": m.mode, + "files": m.files, + "mode": m.mode, + "order_index": m.order_index, + "created_at": m.created_at.isoformat() if m.created_at else None, + "updated_at": m.updated_at.isoformat() if m.updated_at else None, + } + for m in profile.mounts + ] + + return { + "id": str(profile.id), + "user_id": str(profile.user_id), + "name": profile.name, + "description": profile.description, + "includes": includes_data, + "mounts": mounts_data, + "created_at": profile.created_at.isoformat() if profile.created_at else None, + "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, + } + + +@router.put( + "/{profile_id}", + summary="Update config profile", + description="Update an existing config profile.", +) +async def update_config_profile( + profile_id: uuid.UUID, + data: ConfigProfileUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Update a config profile.""" + profile = await _get_owned_profile(profile_id, user_id, session) + + if data.name is not None: + # Check for duplicate name + existing = await session.scalar( + select(ConfigProfile).where( + ConfigProfile.user_id == user_id, + ConfigProfile.name == data.name, + ConfigProfile.id != profile_id, + ) + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"config profile with name '{data.name}' already exists", + ) + profile.name = data.name + + if data.description is not None: + profile.description = data.description + + await session.commit() + await session.refresh(profile) + + return { + "id": str(profile.id), + "user_id": str(profile.user_id), + "name": profile.name, + "description": profile.description, + "created_at": profile.created_at.isoformat() if profile.created_at else None, + "updated_at": profile.updated_at.isoformat() if profile.updated_at else None, + } + + +@router.delete( + "/{profile_id}", + summary="Delete config profile", + description="Delete a config profile and all its includes and mounts.", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_config_profile( + profile_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + """Delete a config profile.""" + profile = await _get_owned_profile(profile_id, user_id, session) + await session.delete(profile) + await session.commit() + + +# --------------------------------------------------------------------------- +# Include management +# --------------------------------------------------------------------------- + +@router.get( + "/{profile_id}/includes", + summary="List profile includes", + description="Get all includes for a config profile.", +) +async def list_profile_includes( + profile_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List includes for a config profile.""" + await _get_owned_profile(profile_id, user_id, session) + + result = await session.execute( + select(ConfigInclude) + .where(ConfigInclude.profile_id == profile_id) + .order_by(ConfigInclude.order_index) + ) + includes = result.scalars().all() + + includes_data = [] + for inc in includes: + included_profile = await session.get(ConfigProfile, inc.included_profile_id) + includes_data.append({ + "id": str(inc.id), + "profile_id": str(inc.profile_id), + "included_profile_id": str(inc.included_profile_id), + "included_profile_name": included_profile.name if included_profile else None, + "order_index": inc.order_index, + "created_at": inc.created_at.isoformat() if inc.created_at else None, + "updated_at": inc.updated_at.isoformat() if inc.updated_at else None, + }) + + return {"includes": includes_data} + + +@router.post( + "/{profile_id}/includes", + summary="Add profile include", + description="Add an include to a config profile.", + status_code=status.HTTP_201_CREATED, +) +async def add_profile_include( + profile_id: uuid.UUID, + data: ConfigIncludeCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Add an include to a config profile.""" + profile = await _get_owned_profile(profile_id, user_id, session) + + included_profile_id = uuid.UUID(data.included_profile_id) + + # Cannot include self + if included_profile_id == profile_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="a profile cannot include itself", + ) + + # Verify the included profile exists and belongs to the user + included_profile = await session.get(ConfigProfile, included_profile_id) + if included_profile is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="included profile not found", + ) + if included_profile.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="included profile does not belong to user", + ) + + # Check for duplicate include + existing = await session.scalar( + select(ConfigInclude).where( + ConfigInclude.profile_id == profile_id, + ConfigInclude.included_profile_id == included_profile_id, + ) + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="this include already exists", + ) + + # Validate no cycles + await _validate_includes_no_cycle(session, profile_id, included_profile_id) + + include = ConfigInclude( + profile_id=profile_id, + included_profile_id=included_profile_id, + order_index=data.order_index, + ) + session.add(include) + await session.commit() + await session.refresh(include) + + return { + "id": str(include.id), + "profile_id": str(include.profile_id), + "included_profile_id": str(include.included_profile_id), + "included_profile_name": included_profile.name, + "order_index": include.order_index, + "created_at": include.created_at.isoformat() if include.created_at else None, + "updated_at": include.updated_at.isoformat() if include.updated_at else None, + } + + +@router.put( + "/{profile_id}/includes/{include_id}", + summary="Update profile include", + description="Update the order index of a profile include.", +) +async def update_profile_include( + profile_id: uuid.UUID, + include_id: uuid.UUID, + data: ConfigIncludeUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Update a profile include.""" + await _get_owned_profile(profile_id, user_id, session) + + include = await session.get(ConfigInclude, include_id) + if include is None or include.profile_id != profile_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="include not found", + ) + + include.order_index = data.order_index + await session.commit() + await session.refresh(include) + + included_profile = await session.get(ConfigProfile, include.included_profile_id) + return { + "id": str(include.id), + "profile_id": str(include.profile_id), + "included_profile_id": str(include.included_profile_id), + "included_profile_name": included_profile.name if included_profile else None, + "order_index": include.order_index, + "created_at": include.created_at.isoformat() if include.created_at else None, + "updated_at": include.updated_at.isoformat() if include.updated_at else None, + } + + +@router.delete( + "/{profile_id}/includes/{include_id}", + summary="Remove profile include", + description="Remove an include from a config profile.", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_profile_include( + profile_id: uuid.UUID, + include_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + """Remove an include from a config profile.""" + await _get_owned_profile(profile_id, user_id, session) + + include = await session.get(ConfigInclude, include_id) + if include is None or include.profile_id != profile_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="include not found", + ) + + await session.delete(include) + await session.commit() + + +# --------------------------------------------------------------------------- +# Mount management +# --------------------------------------------------------------------------- + +@router.get( + "/{profile_id}/mounts", + summary="List profile mounts", + description="Get all mounts for a config profile.", +) +async def list_profile_mounts( + profile_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """List mounts for a config profile.""" + await _get_owned_profile(profile_id, user_id, session) + + result = await session.execute( + select(ConfigMount) + .where(ConfigMount.profile_id == profile_id) + .order_by(ConfigMount.order_index) + ) + mounts = result.scalars().all() + + return { + "mounts": [ + { + "id": str(m.id), + "profile_id": str(m.profile_id), + "target_path": m.target_path, + "files": m.files, + "mode": m.mode, + "order_index": m.order_index, + "created_at": m.created_at.isoformat() if m.created_at else None, + "updated_at": m.updated_at.isoformat() if m.updated_at else None, + } + for m in mounts + ] + } + + +@router.post( + "/{profile_id}/mounts", + summary="Add profile mount", + description="Add a mount to a config profile.", + status_code=status.HTTP_201_CREATED, +) +async def add_profile_mount( + profile_id: uuid.UUID, + data: ConfigMountCreate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Add a mount to a config profile.""" + profile = await _get_owned_profile(profile_id, user_id, session) + + # Check for duplicate target_path + existing = await session.scalar( + select(ConfigMount).where( + ConfigMount.profile_id == profile_id, + ConfigMount.target_path == data.target_path, + ) + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"mount with path '{data.target_path}' already exists", + ) + + mount = ConfigMount( + profile_id=profile_id, + target_path=data.target_path, + mode=data.mode, + files=data.files, + order_index=data.order_index, + ) + session.add(mount) + await session.commit() + await session.refresh(mount) + + return { + "id": str(mount.id), + "profile_id": str(mount.profile_id), + "target_path": mount.target_path, + "files": mount.files, + "mode": mount.mode, + "order_index": mount.order_index, + "created_at": mount.created_at.isoformat() if mount.created_at else None, + "updated_at": mount.updated_at.isoformat() if mount.updated_at else None, + } + + +@router.put( + "/{profile_id}/mounts/{mount_id}", + summary="Update profile mount", + description="Update a mount in a config profile.", +) +async def update_profile_mount( + profile_id: uuid.UUID, + mount_id: uuid.UUID, + data: ConfigMountUpdate, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> dict: + """Update a profile mount.""" + await _get_owned_profile(profile_id, user_id, session) + + mount = await session.get(ConfigMount, mount_id) + if mount is None or mount.profile_id != profile_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="mount not found", + ) + + if data.target_path is not None: + # Check for duplicate target_path + existing = await session.scalar( + select(ConfigMount).where( + ConfigMount.profile_id == profile_id, + ConfigMount.target_path == data.target_path, + ConfigMount.id != mount_id, + ) + ) + if existing: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"mount with path '{data.target_path}' already exists", + ) + mount.target_path = data.target_path + + if data.files is not None: + mount.files = data.files + + if data.order_index is not None: + mount.order_index = data.order_index + + await session.commit() + await session.refresh(mount) + + return { + "id": str(mount.id), + "profile_id": str(mount.profile_id), + "target_path": mount.target_path, + "files": mount.files, + "mode": mount.mode, + "order_index": mount.order_index, + "created_at": mount.created_at.isoformat() if mount.created_at else None, + "updated_at": mount.updated_at.isoformat() if mount.updated_at else None, + } + + +@router.delete( + "/{profile_id}/mounts/{mount_id}", + summary="Remove profile mount", + description="Remove a mount from a config profile.", + status_code=status.HTTP_204_NO_CONTENT, +) +async def remove_profile_mount( + profile_id: uuid.UUID, + mount_id: uuid.UUID, + user_id: uuid.UUID = Depends(get_current_user_id), + session: AsyncSession = Depends(get_db_session), +) -> None: + """Remove a mount from a config profile.""" + await _get_owned_profile(profile_id, user_id, session) + + mount = await session.get(ConfigMount, mount_id) + if mount is None or mount.profile_id != profile_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="mount not found", + ) + + await session.delete(mount) + await session.commit() diff --git a/apps/api/src/main.py b/apps/api/src/main.py index aabb3f2..9152f19 100644 --- a/apps/api/src/main.py +++ b/apps/api/src/main.py @@ -18,6 +18,7 @@ from src.api.ssh_keys import router as ssh_keys_router from src.api.terminal import router as terminal_router from src.api.instance_proxy import router as instance_proxy_router from src.api.config_folders import router as config_folders_router +from src.api.config_profiles import router as config_profiles_router from src.api.tool_configs import router as tool_configs_router from src.api.tool_instances import router as tool_instances_router from src.api.tool_instances import sessions_router @@ -277,6 +278,7 @@ app.include_router(git_repositories_router) app.include_router(user_config_router) app.include_router(tool_types_router) app.include_router(config_folders_router) +app.include_router(config_profiles_router) app.include_router(tool_instances_router) app.include_router(tool_configs_router) app.include_router(sessions_router) diff --git a/apps/api/src/models/config_profile.py b/apps/api/src/models/config_profile.py index df21d9c..2797241 100644 --- a/apps/api/src/models/config_profile.py +++ b/apps/api/src/models/config_profile.py @@ -45,13 +45,14 @@ class ConfigProfile(UUIDPrimaryKeyMixin, TimestampMixin, Base): tool_type: Mapped["ToolType | None"] = relationship() includes: Mapped[list["ConfigInclude"]] = relationship( "ConfigInclude", - foreign_keys="ConfigInclude.profile_id", + primaryjoin="ConfigProfile.id == ConfigInclude.profile_id", back_populates="profile", cascade="all, delete-orphan", order_by="ConfigInclude.order_index", ) mounts: Mapped[list["ConfigMount"]] = relationship( "ConfigMount", + primaryjoin="ConfigProfile.id == ConfigMount.profile_id", back_populates="profile", cascade="all, delete-orphan", order_by="ConfigMount.order_index", diff --git a/apps/api/tests/integration/test_config_profiles_api.py b/apps/api/tests/integration/test_config_profiles_api.py new file mode 100644 index 0000000..06c745f --- /dev/null +++ b/apps/api/tests/integration/test_config_profiles_api.py @@ -0,0 +1,461 @@ +"""Integration tests for config profiles API.""" + +import uuid + +import pytest +from fastapi.testclient import TestClient + + +@pytest.mark.integration +class TestConfigProfilesAPI: + """Integration tests for config profiles API.""" + + def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None: + """Test that listing config profiles requires authentication.""" + response = test_client.get("/config-profiles") + assert response.status_code == 401 + + def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None: + """Test that authenticated users can list their profiles.""" + response = authenticated_client.get("/config-profiles") + assert response.status_code == 200 + data = response.json() + assert isinstance(data, dict) + assert "profiles" in data + assert isinstance(data["profiles"], list) + + def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None: + """Test creating a config profile.""" + response = authenticated_client.post( + "/config-profiles", + json={ + "name": "test-profile", + "description": "Test profile", + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "test-profile" + assert data["description"] == "Test profile" + + def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None: + """Test that duplicate profile names are rejected.""" + authenticated_client.post( + "/config-profiles", + json={"name": "duplicate-profile"}, + ) + + response = authenticated_client.post( + "/config-profiles", + json={"name": "duplicate-profile"}, + ) + assert response.status_code == 409 + + def test_create_config_profile_empty_name(self, authenticated_client: TestClient) -> None: + """Test that empty profile names are rejected.""" + response = authenticated_client.post( + "/config-profiles", + json={"name": " "}, + ) + assert response.status_code == 422 + + def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None: + """Test getting a config profile by ID.""" + create_response = authenticated_client.post( + "/config-profiles", + json={"name": "get-test"}, + ) + profile_id = create_response.json()["id"] + + response = authenticated_client.get(f"/config-profiles/{profile_id}") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "get-test" + assert "includes" in data + assert "mounts" in data + + def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None: + """Test getting a non-existent profile.""" + response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}") + assert response.status_code == 404 + + def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None: + """Test updating a config profile.""" + create_response = authenticated_client.post( + "/config-profiles", + json={"name": "update-test"}, + ) + profile_id = create_response.json()["id"] + + response = authenticated_client.put( + f"/config-profiles/{profile_id}", + json={"name": "updated-name", "description": "updated desc"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["name"] == "updated-name" + assert data["description"] == "updated desc" + + def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None: + """Test deleting a config profile.""" + create_response = authenticated_client.post( + "/config-profiles", + json={"name": "delete-test"}, + ) + profile_id = create_response.json()["id"] + + response = authenticated_client.delete(f"/config-profiles/{profile_id}") + assert response.status_code == 204 + + get_response = authenticated_client.get(f"/config-profiles/{profile_id}") + assert get_response.status_code == 404 + + def test_profile_access_check(self, authenticated_client: TestClient) -> None: + """Test that users can only access their own profiles.""" + # Create a profile + create_response = authenticated_client.post( + "/config-profiles", + json={"name": "access-test"}, + ) + profile_id = create_response.json()["id"] + + # The profile should be accessible + response = authenticated_client.get(f"/config-profiles/{profile_id}") + assert response.status_code == 200 + + +@pytest.mark.integration +class TestConfigProfileIncludes: + """Integration tests for config profile includes.""" + + def test_add_include_successfully(self, authenticated_client: TestClient) -> None: + """Test adding an include to a profile.""" + # Create two profiles + profile1 = authenticated_client.post( + "/config-profiles", + json={"name": "profile-1"}, + ).json() + profile2 = authenticated_client.post( + "/config-profiles", + json={"name": "profile-2"}, + ).json() + + # Add include + response = authenticated_client.post( + f"/config-profiles/{profile1['id']}/includes", + json={"included_profile_id": profile2["id"], "order_index": 0}, + ) + assert response.status_code == 201 + data = response.json() + assert data["included_profile_id"] == profile2["id"] + assert data["included_profile_name"] == "profile-2" + + def test_add_self_include_rejected(self, authenticated_client: TestClient) -> None: + """Test that self-includes are rejected.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "self-include-test"}, + ).json() + + response = authenticated_client.post( + f"/config-profiles/{profile['id']}/includes", + json={"included_profile_id": profile["id"], "order_index": 0}, + ) + assert response.status_code == 400 + + def test_add_include_cycle_rejected(self, authenticated_client: TestClient) -> None: + """Test that circular includes are rejected.""" + profile1 = authenticated_client.post( + "/config-profiles", + json={"name": "cycle-1"}, + ).json() + profile2 = authenticated_client.post( + "/config-profiles", + json={"name": "cycle-2"}, + ).json() + + # Add profile1 includes profile2 + authenticated_client.post( + f"/config-profiles/{profile1['id']}/includes", + json={"included_profile_id": profile2["id"], "order_index": 0}, + ) + + # Try to add profile2 includes profile1 (creates cycle) + response = authenticated_client.post( + f"/config-profiles/{profile2['id']}/includes", + json={"included_profile_id": profile1["id"], "order_index": 0}, + ) + assert response.status_code == 400 + + def test_add_deep_cycle_rejected(self, authenticated_client: TestClient) -> None: + """Test that deep circular includes are rejected.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "deep-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "deep-2"} + ).json() + p3 = authenticated_client.post( + "/config-profiles", json={"name": "deep-3"} + ).json() + + # p1 -> p2 -> p3 + authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ) + authenticated_client.post( + f"/config-profiles/{p2['id']}/includes", + json={"included_profile_id": p3["id"], "order_index": 0}, + ) + + # Try p3 -> p1 (creates cycle) + response = authenticated_client.post( + f"/config-profiles/{p3['id']}/includes", + json={"included_profile_id": p1["id"], "order_index": 0}, + ) + assert response.status_code == 400 + + def test_add_duplicate_include_rejected(self, authenticated_client: TestClient) -> None: + """Test that duplicate includes are rejected.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "dup-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "dup-2"} + ).json() + + authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ) + + response = authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 1}, + ) + assert response.status_code == 409 + + def test_list_includes(self, authenticated_client: TestClient) -> None: + """Test listing includes for a profile.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "list-inc-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "list-inc-2"} + ).json() + + authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ) + + response = authenticated_client.get(f"/config-profiles/{p1['id']}/includes") + assert response.status_code == 200 + data = response.json() + assert len(data["includes"]) == 1 + + def test_update_include_order(self, authenticated_client: TestClient) -> None: + """Test updating include order index.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "order-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "order-2"} + ).json() + + inc = authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ).json() + + response = authenticated_client.put( + f"/config-profiles/{p1['id']}/includes/{inc['id']}", + json={"order_index": 5}, + ) + assert response.status_code == 200 + assert response.json()["order_index"] == 5 + + def test_remove_include(self, authenticated_client: TestClient) -> None: + """Test removing an include.""" + p1 = authenticated_client.post( + "/config-profiles", json={"name": "rem-1"} + ).json() + p2 = authenticated_client.post( + "/config-profiles", json={"name": "rem-2"} + ).json() + + inc = authenticated_client.post( + f"/config-profiles/{p1['id']}/includes", + json={"included_profile_id": p2["id"], "order_index": 0}, + ).json() + + response = authenticated_client.delete( + f"/config-profiles/{p1['id']}/includes/{inc['id']}" + ) + assert response.status_code == 204 + + +@pytest.mark.integration +class TestConfigProfileMounts: + """Integration tests for config profile mounts.""" + + def test_add_mount_successfully(self, authenticated_client: TestClient) -> None: + """Test adding a mount to a profile.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "mount-test"}, + ).json() + + response = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/etc/config", "files": {"test.txt": "hello"}, "order_index": 0}, + ) + assert response.status_code == 201 + data = response.json() + assert data["target_path"] == "/etc/config" + assert data["files"] == {"test.txt": "hello"} + + def test_add_mount_relative_path_rejected(self, authenticated_client: TestClient) -> None: + """Test that relative mount paths are rejected.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "rel-path-test"}, + ).json() + + response = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "etc/config", "files": {"test.txt": "hello"}}, + ) + assert response.status_code == 422 + + def test_add_target_path_traversal_rejected(self, authenticated_client: TestClient) -> None: + """Test that path traversal in mount paths is rejected.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "traversal-test"}, + ).json() + + response = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/etc/../passwd", "files": {"test.txt": "hello"}}, + ) + assert response.status_code == 422 + + def test_add_duplicate_mount_rejected(self, authenticated_client: TestClient) -> None: + """Test that duplicate mount paths are rejected.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "dup-mount-test"}, + ).json() + + authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/etc/config", "files": {"test.txt": "hello"}}, + ) + + response = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/etc/config", "files": {"test.txt": "world"}}, + ) + assert response.status_code == 409 + + def test_update_mount(self, authenticated_client: TestClient) -> None: + """Test updating a mount.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "update-mount-test"}, + ).json() + + mount = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/old/path", "files": {"test.txt": "old"}}, + ).json() + + response = authenticated_client.put( + f"/config-profiles/{profile['id']}/mounts/{mount['id']}", + json={"target_path": "/new/path", "files": {"test.txt": "new"}, "order_index": 2}, + ) + assert response.status_code == 200 + data = response.json() + assert data["target_path"] == "/new/path" + assert data["files"] == {"test.txt": "new"} + assert data["order_index"] == 2 + + def test_remove_mount(self, authenticated_client: TestClient) -> None: + """Test removing a mount.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "rem-mount-test"}, + ).json() + + mount = authenticated_client.post( + f"/config-profiles/{profile['id']}/mounts", + json={"target_path": "/tmp/test", "files": {"test.txt": "x"}}, + ).json() + + response = authenticated_client.delete( + f"/config-profiles/{profile['id']}/mounts/{mount['id']}" + ) + assert response.status_code == 204 + + +@pytest.mark.integration +class TestConfigProfileDefaults: + """Integration tests for default profile APIs.""" + + def test_get_default_profiles_empty(self, authenticated_client: TestClient) -> None: + """Test getting default profiles when none are set.""" + response = authenticated_client.get("/config-profiles/defaults") + assert response.status_code == 200 + data = response.json() + assert data["default_profiles"] == {} + + def test_set_default_profiles(self, authenticated_client: TestClient) -> None: + """Test setting default profiles.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "default-test"}, + ).json() + + response = authenticated_client.put( + "/config-profiles/defaults", + json={"default_profiles": {"code-server": profile["id"]}}, + ) + assert response.status_code == 200 + data = response.json() + assert data["default_profiles"]["code-server"] == profile["id"] + + def test_set_default_profiles_invalid_profile(self, authenticated_client: TestClient) -> None: + """Test setting default profiles with invalid profile ID.""" + response = authenticated_client.put( + "/config-profiles/defaults", + json={"default_profiles": {"code-server": str(uuid.uuid4())}}, + ) + assert response.status_code == 404 + + def test_get_default_profile_for_tool_type(self, authenticated_client: TestClient) -> None: + """Test getting default profile for a specific tool type.""" + profile = authenticated_client.post( + "/config-profiles", + json={"name": "tool-default-test"}, + ).json() + + authenticated_client.put( + "/config-profiles/defaults", + json={"default_profiles": {"jupyter-notebook": profile["id"]}}, + ) + + response = authenticated_client.get("/config-profiles/defaults/jupyter-notebook") + assert response.status_code == 200 + data = response.json() + assert data["tool_type_id"] == "jupyter-notebook" + assert data["profile_id"] == profile["id"] + + def test_get_default_profile_for_tool_type_not_set(self, authenticated_client: TestClient) -> None: + """Test getting default profile when not set.""" + response = authenticated_client.get("/config-profiles/defaults/opencode") + assert response.status_code == 200 + data = response.json() + assert data["tool_type_id"] == "opencode" + assert data["profile_id"] is None