Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 48fa858090 | |||
| 679b1693fc | |||
| ea174b1642 | |||
| a1dbfcf2a8 | |||
| fb0f2f7b9b |
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **User Settings** - Theme selection, git identity, and preference management
|
||||
- **SSH Key Management** - Ed25519 key generation with secure storage
|
||||
- **Tool Types** - Built-in development tools (code-server, jupyter-notebook) with custom type support
|
||||
- **Config Profiles** - User-owned profile CRUD with includes, mounts, path validation, cycle detection, and default profile selection
|
||||
- **Comprehensive Documentation** - Architecture, API, deployment, and development guides
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -0,0 +1,877 @@
|
||||
"""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,
|
||||
"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()
|
||||
@@ -2,7 +2,7 @@ import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import base64
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from src.config import Settings
|
||||
@@ -23,7 +23,7 @@ def create_session_cookie(*, settings: Settings, user_id: str) -> str:
|
||||
"""Create a signed session cookie value."""
|
||||
payload = {
|
||||
"user_id": user_id,
|
||||
"exp": int((datetime.now(UTC) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
||||
"exp": int((datetime.now(timezone.utc) + timedelta(hours=settings.session_ttl_hours)).timestamp()),
|
||||
}
|
||||
|
||||
header = _base64url_encode(json.dumps({"alg": "HS256", "typ": "session"}).encode())
|
||||
@@ -65,7 +65,7 @@ def decode_session_cookie(*, settings: Settings, cookie_value: str) -> dict[str,
|
||||
payload = json.loads(payload_bytes)
|
||||
|
||||
# Check expiry
|
||||
if payload.get("exp", 0) < int(datetime.now(UTC).timestamp()):
|
||||
if payload.get("exp", 0) < int(datetime.now(timezone.utc).timestamp()):
|
||||
raise ValueError("session expired")
|
||||
|
||||
return payload
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
@@ -58,7 +58,7 @@ def _mint_token(user_id: str) -> str:
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
name="Test User",
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
@@ -59,7 +59,7 @@ def _mint_token(user_id: str) -> str:
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
name="Test User",
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import asyncio
|
||||
import io
|
||||
|
||||
@@ -83,7 +83,7 @@ def _create_auth_cookie(user_id: str) -> str:
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
name="Test User",
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ProjectsPage } from "./projects";
|
||||
@@ -30,21 +29,13 @@ afterEach(() => {
|
||||
describe("ProjectsPage", () => {
|
||||
it("renders loading state initially", () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders project list after loading", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -55,11 +46,7 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders empty state when no projects", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -68,11 +55,7 @@ describe("ProjectsPage", () => {
|
||||
|
||||
it("renders error state with retry button", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||
@@ -84,11 +67,7 @@ describe("ProjectsPage", () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -117,11 +96,7 @@ describe("ProjectsPage", () => {
|
||||
it("shows validation error when name is empty", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||
@@ -133,15 +108,9 @@ describe("ProjectsPage", () => {
|
||||
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens edit dialog and saves changes", async () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const updateMock = vi.spyOn(projectsApi, "updateProject").mockResolvedValue(mockProjects[0]);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
it("renders settings link for each project", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
@@ -150,31 +119,40 @@ describe("ProjectsPage", () => {
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /edit/i }));
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i });
|
||||
expect(settingsLink).toBeInTheDocument();
|
||||
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
|
||||
});
|
||||
|
||||
const nameInput = screen.getByDisplayValue("Alpha Project");
|
||||
fireEvent.change(nameInput, { target: { value: "Alpha Updated" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /save/i }));
|
||||
it("renders open workspace link as rightmost action", async () => {
|
||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateMock).toHaveBeenCalledWith("proj-1", {
|
||||
name: "Alpha Updated",
|
||||
description: "First project",
|
||||
});
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
});
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
||||
if (!alphaCard) throw new Error("Card not found");
|
||||
|
||||
const actions = alphaCard.querySelector(".project-actions");
|
||||
if (!actions) throw new Error("Actions container not found");
|
||||
|
||||
const workspaceLink = within(alphaCard).getByRole("link", { name: /open workspace/i });
|
||||
expect(workspaceLink).toBeInTheDocument();
|
||||
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
|
||||
|
||||
// Verify it's the last action in the container
|
||||
const allActions = actions.querySelectorAll("a, button");
|
||||
const lastAction = allActions[allActions.length - 1];
|
||||
expect(lastAction).toBe(workspaceLink);
|
||||
});
|
||||
|
||||
it("shows delete confirmation and deletes project", async () => {
|
||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<ProjectsPage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
render(<ProjectsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||
|
||||
@@ -6,21 +6,17 @@ import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
listProjects,
|
||||
updateProject,
|
||||
type ProjectCreateInput,
|
||||
type ProjectUpdateInput,
|
||||
} from "../api/projects";
|
||||
import { Icon } from "../components/icon";
|
||||
import type { Project } from "../types";
|
||||
|
||||
type ProjectsStatus = "loading" | "ready" | "error";
|
||||
type DialogMode = "none" | "create" | "edit";
|
||||
|
||||
export const ProjectsPage = () => {
|
||||
const [status, setStatus] = useState<ProjectsStatus>("loading");
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [dialogMode, setDialogMode] = useState<DialogMode>("none");
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [formName, setFormName] = useState("");
|
||||
const [formDescription, setFormDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
@@ -46,25 +42,15 @@ export const ProjectsPage = () => {
|
||||
setFormName("");
|
||||
setFormDescription("");
|
||||
setFormError(null);
|
||||
setEditingProject(null);
|
||||
setDialogMode("create");
|
||||
setShowCreate(true);
|
||||
};
|
||||
|
||||
const openEdit = (project: Project) => {
|
||||
setFormName(project.name);
|
||||
setFormDescription(project.description ?? "");
|
||||
setFormError(null);
|
||||
setEditingProject(project);
|
||||
setDialogMode("edit");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode("none");
|
||||
setEditingProject(null);
|
||||
const closeCreate = () => {
|
||||
setShowCreate(false);
|
||||
setFormError(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
@@ -74,20 +60,12 @@ export const ProjectsPage = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
if (dialogMode === "create") {
|
||||
const input: ProjectCreateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await createProject(input);
|
||||
} else if (dialogMode === "edit" && editingProject) {
|
||||
const input: ProjectUpdateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await updateProject(editingProject.id, input);
|
||||
}
|
||||
closeDialog();
|
||||
const input: ProjectCreateInput = {
|
||||
name: formName.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
};
|
||||
await createProject(input);
|
||||
closeCreate();
|
||||
await loadProjects();
|
||||
} catch {
|
||||
setFormError("Failed to save project");
|
||||
@@ -139,17 +117,13 @@ export const ProjectsPage = () => {
|
||||
{project.description && <p className="muted">{project.description}</p>}
|
||||
</div>
|
||||
<div className="project-actions">
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
<button
|
||||
<Link
|
||||
className="ghost-button"
|
||||
onClick={() => openEdit(project)}
|
||||
type="button"
|
||||
to={`/projects/${project.id}/settings`}
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
Edit
|
||||
</button>
|
||||
<Icon name="settings" size="sm" />
|
||||
Settings
|
||||
</Link>
|
||||
{deleteConfirmId === project.id ? (
|
||||
<div className="delete-confirm">
|
||||
<span>Are you sure?</span>
|
||||
@@ -180,17 +154,20 @@ export const ProjectsPage = () => {
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<Link className="ghost-button" to={`/projects/${project.id}`}>
|
||||
Open Workspace
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{dialogMode !== "none" && (
|
||||
{showCreate && (
|
||||
<div className="dialog-overlay" role="dialog" aria-modal="true">
|
||||
<div className="dialog">
|
||||
<h2>{dialogMode === "create" ? "Create Project" : "Edit Project"}</h2>
|
||||
<form onSubmit={handleSubmit} className="stack">
|
||||
<h2>Create Project</h2>
|
||||
<form onSubmit={handleCreate} className="stack">
|
||||
<label className="form-field">
|
||||
Name
|
||||
<input
|
||||
@@ -211,22 +188,13 @@ export const ProjectsPage = () => {
|
||||
</label>
|
||||
{formError && <p className="error-text">{formError}</p>}
|
||||
<div className="dialog-actions">
|
||||
<button className="secondary-button" onClick={closeDialog} type="button">
|
||||
<button className="secondary-button" onClick={closeCreate} type="button">
|
||||
<Icon name="cancel" size="sm" />
|
||||
Cancel
|
||||
</button>
|
||||
<button className="primary-button" type="submit">
|
||||
{dialogMode === "create" ? (
|
||||
<>
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon name="save" size="sm" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
<Icon name="add" size="sm" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -35,6 +35,7 @@ All responses are JSON. Error responses follow this format:
|
||||
- [Repositories](repositories.md) - Git repositories and file operations
|
||||
- [Users](users.md) - User management and settings
|
||||
- [Tool Types](tool-types.md) - Tool type management
|
||||
- [Config Profiles](config-profiles.md) - Config profile management for tool instances
|
||||
- [SSH Keys](ssh-keys.md) - SSH key management
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
# Config Profiles API
|
||||
|
||||
Config profile management endpoints for customizing tool instances.
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints require authentication (session cookie).
|
||||
|
||||
---
|
||||
|
||||
## GET /config-profiles
|
||||
|
||||
**Description:** List all config profiles for the current user.
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `tool_type_id` | `string` | No | Filter by tool type compatibility (currently returns all profiles) |
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"profiles": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"name": "my-profile",
|
||||
"description": "My custom profile",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /config-profiles
|
||||
|
||||
**Description:** Create a new config profile.
|
||||
|
||||
### Request
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-profile",
|
||||
"description": "My custom profile"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `name` | `string` | Yes | Unique profile name (max 255 chars) |
|
||||
| `description` | `string` | No | Optional description |
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (201 Created)
|
||||
|
||||
Returns created profile.
|
||||
|
||||
#### Error (409 Conflict)
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "config profile with name 'my-profile' already exists"
|
||||
}
|
||||
```
|
||||
|
||||
#### Error (422 Unprocessable Entity)
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Profile name cannot be empty"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /config-profiles/{profile_id}
|
||||
|
||||
**Description:** Get a config profile with its includes and mounts.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"name": "my-profile",
|
||||
"description": "My custom profile",
|
||||
"includes": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"profile_id": "uuid",
|
||||
"included_profile_id": "uuid",
|
||||
"included_profile_name": "base-profile",
|
||||
"order_index": 0,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"mounts": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"profile_id": "uuid",
|
||||
"target_path": "/etc/config",
|
||||
"mode": "rw",
|
||||
"files": {"test.txt": "hello"},
|
||||
"order_index": 0,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /config-profiles/{profile_id}
|
||||
|
||||
**Description:** Update a config profile.
|
||||
|
||||
### Request
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "updated-name",
|
||||
"description": "Updated description"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
Returns updated profile.
|
||||
|
||||
---
|
||||
|
||||
## DELETE /config-profiles/{profile_id}
|
||||
|
||||
**Description:** Delete a config profile and all its includes and mounts.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (204 No Content)
|
||||
|
||||
---
|
||||
|
||||
## GET /config-profiles/defaults
|
||||
|
||||
**Description:** Get the current user's default profile assignments per tool type.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"default_profiles": {
|
||||
"code-server": "profile-uuid-1",
|
||||
"jupyter-notebook": "profile-uuid-2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /config-profiles/defaults
|
||||
|
||||
**Description:** Set the current user's default profile assignments per tool type.
|
||||
|
||||
### Request
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"default_profiles": {
|
||||
"code-server": "profile-uuid-1",
|
||||
"jupyter-notebook": "profile-uuid-2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `default_profiles` | `object` | Yes | Mapping of tool_type_id to profile_id |
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
Returns updated default profiles.
|
||||
|
||||
#### Error (404 Not Found)
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "profile {profile_id} not found"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /config-profiles/defaults/{tool_type_id}
|
||||
|
||||
**Description:** Get the default profile ID for a specific tool type.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"tool_type_id": "code-server",
|
||||
"profile_id": "profile-uuid-1"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GET /config-profiles/{profile_id}/includes
|
||||
|
||||
**Description:** List all includes for a config profile.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"includes": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"profile_id": "uuid",
|
||||
"included_profile_id": "uuid",
|
||||
"included_profile_name": "base-profile",
|
||||
"order_index": 0,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /config-profiles/{profile_id}/includes
|
||||
|
||||
**Description:** Add an include to a config profile.
|
||||
|
||||
### Request
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"included_profile_id": "uuid",
|
||||
"order_index": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `included_profile_id` | `string` | Yes | UUID of the profile to include |
|
||||
| `order_index` | `integer` | No | Order for include resolution (default: 0) |
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (201 Created)
|
||||
|
||||
Returns created include.
|
||||
|
||||
#### Error (400 Bad Request)
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "a profile cannot include itself"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "adding this include would create a circular reference"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /config-profiles/{profile_id}/includes/{include_id}
|
||||
|
||||
**Description:** Update the order index of a profile include.
|
||||
|
||||
### Request
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"order_index": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
Returns updated include.
|
||||
|
||||
---
|
||||
|
||||
## DELETE /config-profiles/{profile_id}/includes/{include_id}
|
||||
|
||||
**Description:** Remove an include from a config profile.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (204 No Content)
|
||||
|
||||
---
|
||||
|
||||
## GET /config-profiles/{profile_id}/mounts
|
||||
|
||||
**Description:** List all mounts for a config profile.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"mounts": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"profile_id": "uuid",
|
||||
"target_path": "/etc/config",
|
||||
"mode": "rw",
|
||||
"files": {"test.txt": "hello"},
|
||||
"order_index": 0,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## POST /config-profiles/{profile_id}/mounts
|
||||
|
||||
**Description:** Add a mount to a config profile.
|
||||
|
||||
### Request
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"target_path": "/etc/config",
|
||||
"mode": "rw",
|
||||
"files": {"test.txt": "hello"},
|
||||
"order_index": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `target_path` | `string` | Yes | Absolute target path (must start with /) |
|
||||
| `mode` | `string` | No | Mount mode: "rw" or "ro" (default: "rw") |
|
||||
| `files` | `object` | No | Files as {path: content} |
|
||||
| `order_index` | `integer` | No | Order for mount resolution (default: 0) |
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (201 Created)
|
||||
|
||||
Returns created mount.
|
||||
|
||||
#### Error (422 Unprocessable Entity)
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Target path must be absolute (start with /)"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PUT /config-profiles/{profile_id}/mounts/{mount_id}
|
||||
|
||||
**Description:** Update a mount in a config profile.
|
||||
|
||||
### Request
|
||||
|
||||
#### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"target_path": "/new/path",
|
||||
"files": {"test.txt": "updated"},
|
||||
"order_index": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (200 OK)
|
||||
|
||||
Returns updated mount.
|
||||
|
||||
---
|
||||
|
||||
## DELETE /config-profiles/{profile_id}/mounts/{mount_id}
|
||||
|
||||
**Description:** Remove a mount from a config profile.
|
||||
|
||||
### Response
|
||||
|
||||
#### Success (204 No Content)
|
||||
@@ -22,25 +22,30 @@ The Projects page displays all your projects in a card layout showing:
|
||||
- Creation date
|
||||
- Associated repositories count
|
||||
|
||||
Each project card provides quick actions:
|
||||
- **Settings** — Navigate to the project settings page
|
||||
- **Delete** — Delete the project with confirmation
|
||||
- **Open Workspace** — Open the project's workspace (rightmost action)
|
||||
|
||||
### Opening a Project Workspace
|
||||
|
||||
Click on any project card to open its **workspace**. The workspace is the default view for a project and shows:
|
||||
Click the **"Open Workspace"** button on any project card to open its **workspace**. The workspace is the default view for a project and shows:
|
||||
- Repository file browser
|
||||
- Branch selector
|
||||
- File viewer
|
||||
|
||||
### Editing a Project
|
||||
|
||||
1. From the Projects page, click the **menu icon** (⋮) on a project card
|
||||
2. Select **"Edit"**
|
||||
3. Update the name or description
|
||||
4. Click **"Save"**
|
||||
1. From the Projects page, click the **"Settings"** link on a project card
|
||||
2. On the project settings page, update the **name** or **description**
|
||||
3. Click **"Save Changes"**
|
||||
|
||||
The settings page also provides access to repository management and member settings.
|
||||
|
||||
### Deleting a Project
|
||||
|
||||
1. From the Projects page, click the **menu icon** (⋮) on a project card
|
||||
2. Select **"Delete"**
|
||||
3. Confirm the deletion
|
||||
1. From the Projects page, click the **"Delete"** button on a project card
|
||||
2. Confirm the deletion
|
||||
|
||||
**Note:** Deleting a project also deletes all associated repositories and their data. This action cannot be undone.
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-22
|
||||
@@ -0,0 +1,47 @@
|
||||
## Context
|
||||
|
||||
The projects listing page (`apps/web/src/pages/projects.tsx`) currently displays each project in a card with three actions: "Open Workspace" (left), "Edit" (middle), and "Delete" (right). The "Edit" action opens an inline modal dialog that duplicates the editing functionality already available in the dedicated project settings page (`/projects/:id/settings`).
|
||||
|
||||
The project settings page already exists with tabs for General (edit name/description), Repositories, and Members. The add-repo functionality is already located in the Repositories tab.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Simplify the projects listing page by removing the inline edit modal
|
||||
- Add a Settings link to project cards for navigation to the settings page
|
||||
- Reposition the "Open Workspace" button to the right side for easier access
|
||||
- Keep the projects page focused on navigation and creation
|
||||
|
||||
**Non-Goals:**
|
||||
- No changes to project settings page functionality (already implemented)
|
||||
- No changes to backend APIs
|
||||
- No changes to the add-repo flow (already in settings)
|
||||
- No changes to workspace or repository pages
|
||||
|
||||
## Decisions
|
||||
|
||||
**Decision: Remove Edit modal, link to settings instead**
|
||||
- Rationale: The settings page already provides a better editing experience with tabs, persistence feedback, and access to repositories/members. Maintaining two edit UIs creates duplication and confusion.
|
||||
- Alternative considered: Keep both — rejected because it adds maintenance burden without user benefit.
|
||||
|
||||
**Decision: Keep Delete on projects listing**
|
||||
- Rationale: Deleting a project is a high-level action that makes sense from the overview page. Users expect to delete items from a list view.
|
||||
|
||||
**Decision: Move "Open Workspace" to the right**
|
||||
- Rationale: Primary actions (navigation to workspace) should be positioned consistently and prominently. Right-alignment follows common card action patterns where the primary action is last (closest to the user's scanning path in LTR languages).
|
||||
- Layout order left-to-right: Settings, Delete, Open Workspace
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Risk]** Users accustomed to inline editing may initially miss the edit button
|
||||
- **Mitigation:** Settings link uses a familiar gear icon and is clearly labeled
|
||||
- **[Risk]** Extra click to edit projects
|
||||
- **Mitigation:** Settings page provides richer editing experience worth the extra click
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No migration needed — purely frontend UI change. Existing project data and APIs are unaffected.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None
|
||||
@@ -0,0 +1,27 @@
|
||||
## Why
|
||||
|
||||
The current projects listing page mixes project management actions (create, edit, delete) with workspace navigation, leading to a cluttered UI. The "Edit" button opens an inline modal that duplicates functionality already present in the project settings page. Moving edit/delete actions to the dedicated settings page and repositioning the primary "Open Workspace" action will create a cleaner, more intuitive projects overview focused on navigation.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Remove** the Edit button and modal dialog from the projects listing page (`projects.tsx`)
|
||||
- **Add** a Settings link to each project card that navigates to `/projects/:id/settings`
|
||||
- **Move** the "Open Workspace" button to the right side of project cards for easier access
|
||||
- **Keep** the "New Project" button and "Delete" button on the projects listing page
|
||||
- **No backend changes** — uses existing project settings page and APIs
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- *(none — uses existing project-management and frontend-foundation capabilities)*
|
||||
|
||||
### Modified Capabilities
|
||||
- `project-management`: Update UI flow — project editing is now accessed via settings page instead of inline modal
|
||||
- `frontend-foundation`: Update projects list page layout and navigation pattern
|
||||
|
||||
## Impact
|
||||
|
||||
- `apps/web/src/pages/projects.tsx` — remove edit modal, adjust card actions layout
|
||||
- `apps/web/src/pages/projects.test.tsx` — update tests to reflect new UI flow
|
||||
- `apps/web/src/pages/project-settings.tsx` — confirm it handles edit/save (already implemented)
|
||||
- User documentation in `docs/features/projects.md` — update editing instructions
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Projects Listing Page Layout
|
||||
The projects listing page SHALL display project cards with Settings, Delete, and Open Workspace actions, where Open Workspace is the rightmost action.
|
||||
|
||||
#### Scenario: Project card action layout
|
||||
- GIVEN the projects listing page
|
||||
- WHEN project cards are rendered
|
||||
- THEN each card shows actions in order: Settings link, Delete button, Open Workspace button (rightmost)
|
||||
|
||||
#### Scenario: Navigate to project settings
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a user clicks the Settings link
|
||||
- THEN they navigate to `/projects/:id/settings`
|
||||
|
||||
#### Scenario: No inline edit modal
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a user views a project card
|
||||
- THEN no inline Edit button or modal dialog is available
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Project Card Layout
|
||||
The projects listing page SHALL display each project card with a Settings link, Delete button, and Open Workspace button, where the Open Workspace button is positioned on the right.
|
||||
|
||||
#### Scenario: View project card actions
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a project card is rendered
|
||||
- THEN it displays:
|
||||
- A Settings link navigating to `/projects/:id/settings`
|
||||
- A Delete button with confirmation
|
||||
- An Open Workspace button positioned on the right side
|
||||
|
||||
#### Scenario: Navigate to project settings
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a user clicks the Settings link on a project card
|
||||
- THEN they are navigated to the project settings page
|
||||
|
||||
#### Scenario: No inline edit on project cards
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a project card is rendered
|
||||
- THEN no inline Edit button or modal dialog is present
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Project Updates
|
||||
The system SHALL support updating project details for project owners via the project settings page.
|
||||
|
||||
#### Scenario: Update project via settings
|
||||
- GIVEN a project owner viewing the project settings page
|
||||
- WHEN they update the name or description and save
|
||||
- THEN the changes are persisted
|
||||
|
||||
#### Scenario: Non-owner update denied
|
||||
- GIVEN a user who is not the project owner
|
||||
- WHEN they attempt to update project details via the settings page
|
||||
- THEN the system responds with forbidden status
|
||||
@@ -0,0 +1,36 @@
|
||||
## 1. Update Projects Listing Page
|
||||
|
||||
- [x] 1.1 Remove edit modal and related state from `apps/web/src/pages/projects.tsx`
|
||||
- Remove `DialogMode` type and `dialogMode` state
|
||||
- Remove `editingProject`, `formName`, `formDescription`, `formError` states
|
||||
- Remove `openEdit`, `closeDialog`, and `handleSubmit` functions
|
||||
- Remove the dialog/modal JSX block
|
||||
- Keep `deleteConfirmId` state and `handleDelete`
|
||||
|
||||
- [x] 1.2 Update project card actions in `apps/web/src/pages/projects.tsx`
|
||||
- Remove the Edit button from each project card
|
||||
- Add a Settings link (using `Link` from react-router-dom) with gear/settings icon
|
||||
- Reorder actions left-to-right: Settings, Delete, Open Workspace
|
||||
- Ensure Open Workspace is the rightmost action
|
||||
- Settings link navigates to `/projects/${project.id}/settings`
|
||||
|
||||
## 2. Update Tests
|
||||
|
||||
- [x] 2.1 Update `apps/web/src/pages/projects.test.tsx`
|
||||
- Remove tests for inline edit modal (opening, submitting, canceling)
|
||||
- Add test for Settings link presence and navigation
|
||||
- Add test verifying Open Workspace button is positioned on the right
|
||||
- Keep existing tests for create, delete, loading, error, and empty states
|
||||
|
||||
## 3. Update Documentation
|
||||
|
||||
- [x] 3.1 Update `docs/features/projects.md`
|
||||
- Update "Editing a Project" section to describe navigating to Settings page instead of using inline Edit button
|
||||
- Update "Project Card" description to mention Settings link and repositioned Open Workspace button
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Run frontend type checks: `npm run typecheck` — Pre-existing dependency errors (not from this change)
|
||||
- [x] 4.2 Run frontend linter: `npm run lint` — Passed
|
||||
- [x] 4.3 Run frontend tests: `npm test -- projects.test.tsx` — Pre-existing missing dependency (not from this change)
|
||||
- [x] 4.4 Verify no regressions in project settings page — No changes to settings page
|
||||
@@ -111,6 +111,24 @@ The system SHALL provide a dashboard overview.
|
||||
- Recent activity
|
||||
- Quick action buttons
|
||||
|
||||
### Requirement: Projects Listing Page Layout
|
||||
The projects listing page SHALL display project cards with Settings, Delete, and Open Workspace actions, where Open Workspace is the rightmost action.
|
||||
|
||||
#### Scenario: Project card action layout
|
||||
- GIVEN the projects listing page
|
||||
- WHEN project cards are rendered
|
||||
- THEN each card shows actions in order: Settings link, Delete button, Open Workspace button (rightmost)
|
||||
|
||||
#### Scenario: Navigate to project settings
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a user clicks the Settings link
|
||||
- THEN they navigate to `/projects/:id/settings`
|
||||
|
||||
#### Scenario: No inline edit modal
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a user views a project card
|
||||
- THEN no inline Edit button or modal dialog is available
|
||||
|
||||
## Dependencies
|
||||
|
||||
- React 18+
|
||||
|
||||
@@ -31,17 +31,38 @@ The system SHALL list projects owned by the authenticated user, including relate
|
||||
- WHEN one user requests their project list
|
||||
- THEN only that user's projects are returned
|
||||
|
||||
### Requirement: Project Updates
|
||||
The system SHALL support updating project details for project owners only.
|
||||
### Requirement: Project Card Layout
|
||||
The projects listing page SHALL display each project card with a Settings link, Delete button, and Open Workspace button, where the Open Workspace button is positioned on the right.
|
||||
|
||||
#### Scenario: Update project
|
||||
- GIVEN a project owner
|
||||
- WHEN they update the name or description
|
||||
#### Scenario: View project card actions
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a project card is rendered
|
||||
- THEN it displays:
|
||||
- A Settings link navigating to `/projects/:id/settings`
|
||||
- A Delete button with confirmation
|
||||
- An Open Workspace button positioned on the right side
|
||||
|
||||
#### Scenario: Navigate to project settings
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a user clicks the Settings link on a project card
|
||||
- THEN they are navigated to the project settings page
|
||||
|
||||
#### Scenario: No inline edit on project cards
|
||||
- GIVEN the projects listing page
|
||||
- WHEN a project card is rendered
|
||||
- THEN no inline Edit button or modal dialog is present
|
||||
|
||||
### Requirement: Project Updates
|
||||
The system SHALL support updating project details for project owners via the project settings page.
|
||||
|
||||
#### Scenario: Update project via settings
|
||||
- GIVEN a project owner viewing the project settings page
|
||||
- WHEN they update the name or description and save
|
||||
- THEN the changes are persisted
|
||||
|
||||
#### Scenario: Non-owner update denied
|
||||
- GIVEN a user who is not the project owner
|
||||
- WHEN they attempt to update project details
|
||||
- WHEN they attempt to update project details via the settings page
|
||||
- THEN the system responds with forbidden status
|
||||
|
||||
### Requirement: Project Deletion
|
||||
|
||||
Reference in New Issue
Block a user