diff --git a/apps/api/src/api/tool_configs.py b/apps/api/src/api/tool_configs.py index 7a50afa..9c77ee8 100644 --- a/apps/api/src/api/tool_configs.py +++ b/apps/api/src/api/tool_configs.py @@ -4,7 +4,7 @@ import logging import uuid from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession diff --git a/apps/api/src/models/tool_config.py b/apps/api/src/models/tool_config.py index 2d7e824..87e043b 100644 --- a/apps/api/src/models/tool_config.py +++ b/apps/api/src/models/tool_config.py @@ -1,7 +1,7 @@ import uuid from typing import TYPE_CHECKING -from sqlalchemy import ForeignKey, String, Text +from sqlalchemy import ForeignKey, JSON, String, Text from sqlalchemy import Uuid as UUID from sqlalchemy.orm import Mapped, mapped_column, relationship diff --git a/apps/api/tests/integration/test_config_folders_api.py b/apps/api/tests/integration/test_config_folders_api.py new file mode 100644 index 0000000..63c130e --- /dev/null +++ b/apps/api/tests/integration/test_config_folders_api.py @@ -0,0 +1,546 @@ +import uuid +from datetime import UTC, datetime, timedelta +import asyncio + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +from src.auth.session import create_session_cookie +from src.config import Settings, build_database_url +from src.models import Base +from src.models.config_folder import ConfigFolder +from src.models.user import User + + +def _prepare_test_db() -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute(text("TRUNCATE TABLE config_folders, users RESTART IDENTITY CASCADE")) + await engine.dispose() + + asyncio.run(_run()) + + +def _load_app(): + import importlib + import src.database as database_module + import src.api.auth as auth_module + import src.api.config_folders as config_folders_module + import src.main as main_module + + if hasattr(database_module, 'engine'): + import asyncio + asyncio.run(database_module.engine.dispose()) + + importlib.reload(database_module) + importlib.reload(auth_module) + importlib.reload(config_folders_module) + importlib.reload(main_module) + return main_module.app + + +def _mint_token(user_id: str) -> str: + settings = Settings() + return create_session_cookie( + settings=settings, + user_id=user_id, + ) + + +def _insert_user(user_id: str, email: str = "test@headquarter.local") -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + user = User( + id=uuid.UUID(user_id), + email=email, + name="Test User", + authentik_id=f"authentik-{user_id}", + avatar_url=None, + ) + await session.merge(user) + await session.commit() + await engine.dispose() + + asyncio.run(_run()) + + +def _insert_config_folder( + folder_id: str, + user_id: str, + name: str, + mount_path: str = "/home/user", + files: dict | None = None, + is_active: bool = True, +) -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + folder = ConfigFolder( + id=uuid.UUID(folder_id), + user_id=uuid.UUID(user_id), + name=name, + description="Test config folder", + mount_path=mount_path, + files=files or {"test.txt": "hello world"}, + is_active=is_active, + ) + await session.merge(folder) + await session.commit() + await engine.dispose() + + asyncio.run(_run()) + + +@pytest.mark.integration +def test_list_config_folders_requires_authentication() -> None: + _prepare_test_db() + app = _load_app() + client = TestClient(app) + + response = client.get("/config-folders") + + assert response.status_code == 401 + + +@pytest.mark.integration +def test_list_config_folders_returns_user_folders() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + _insert_config_folder( + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + user_id, + "my-dotfiles", + files={".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\""}, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + response = client.get("/config-folders") + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "my-dotfiles" + assert data[0]["files"] == {".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\""} + assert data[0]["is_active"] == True + + +@pytest.mark.integration +def test_list_config_folders_only_returns_own_folders() -> None: + _prepare_test_db() + user1_id = "11111111-1111-1111-1111-111111111111" + user2_id = "22222222-2222-2222-2222-222222222222" + _insert_user(user1_id) + _insert_user(user2_id) + _insert_config_folder( + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + user1_id, + "user1-folder", + ) + _insert_config_folder( + "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + user2_id, + "user2-folder", + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user1_id)) + + response = client.get("/config-folders") + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "user1-folder" + + +@pytest.mark.integration +def test_create_config_folder_successfully() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "name": "my-dotfiles", + "description": "My personal configuration files", + "mount_path": "/home/user", + "files": { + ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"", + ".gitconfig": "[user]\\nname = Test User", + }, + } + response = client.post("/config-folders", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "my-dotfiles" + assert data["description"] == "My personal configuration files" + assert data["mount_path"] == "/home/user" + assert data["files"] == { + ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"", + ".gitconfig": "[user]\\nname = Test User", + } + assert data["is_active"] == True + assert "id" in data + + +@pytest.mark.integration +def test_create_config_folder_duplicate_name() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + _insert_config_folder( + "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + user_id, + "existing-folder", + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "name": "existing-folder", + "mount_path": "/home/user", + "files": {}, + } + response = client.post("/config-folders", json=payload) + + assert response.status_code == 409 + + +@pytest.mark.integration +def test_create_config_folder_exceeds_size_limit() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + # Create files that total > 10MB + large_content = "x" * (11 * 1024 * 1024) # 11MB + payload = { + "name": "too-large", + "mount_path": "/home/user", + "files": { + "large.txt": large_content, + }, + } + response = client.post("/config-folders", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.integration +def test_create_config_folder_path_traversal_attack() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "name": "attack", + "mount_path": "/home/user", + "files": { + "../../../etc/passwd": "root:x:0:0", + }, + } + response = client.post("/config-folders", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.integration +def test_get_config_folder_by_id() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_config_folder( + folder_id, + user_id, + "my-dotfiles", + files={".zshrc": "test content"}, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + response = client.get(f"/config-folders/{folder_id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == folder_id + assert data["name"] == "my-dotfiles" + assert data["files"] == {".zshrc": "test content"} + + +@pytest.mark.integration +def test_get_config_folder_not_found() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + response = client.get("/config-folders/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + + assert response.status_code == 404 + + +@pytest.mark.integration +def test_get_config_folder_forbidden() -> None: + _prepare_test_db() + user1_id = "11111111-1111-1111-1111-111111111111" + user2_id = "22222222-2222-2222-2222-222222222222" + folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user1_id) + _insert_user(user2_id) + _insert_config_folder(folder_id, user1_id, "private-folder") + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user2_id)) + + response = client.get(f"/config-folders/{folder_id}") + + assert response.status_code == 403 + + +@pytest.mark.integration +def test_update_config_folder_successfully() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_config_folder( + folder_id, + user_id, + "old-name", + mount_path="/old/path", + files={".zshrc": "old content"}, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "name": "new-name", + "mount_path": "/new/path", + "files": {".zshrc": "new content"}, + "is_active": False, + } + response = client.put(f"/config-folders/{folder_id}", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "new-name" + assert data["mount_path"] == "/new/path" + assert data["files"] == {".zshrc": "new content"} + assert data["is_active"] == False + + +@pytest.mark.integration +def test_update_config_folder_not_found() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = {"name": "new-name", "mount_path": "/new/path", "files": {}} + response = client.put("/config-folders/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", json=payload) + + assert response.status_code == 404 + + +@pytest.mark.integration +def test_delete_config_folder_successfully() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_config_folder(folder_id, user_id, "deletable-folder") + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + response = client.delete(f"/config-folders/{folder_id}") + + assert response.status_code == 204 + + # Verify it's gone + get_response = client.get(f"/config-folders/{folder_id}") + assert get_response.status_code == 404 + + +@pytest.mark.integration +def test_add_project_override_successfully() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + project_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + _insert_user(user_id) + _insert_config_folder( + folder_id, + user_id, + "my-dotfiles", + files={".zshrc": "global content"}, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "project_id": project_id, + "mount_path": "/workspace", + "files": {".zshrc": "project-specific content"}, + } + response = client.post(f"/config-folders/{folder_id}/overrides", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["project_overrides"][project_id]["mount_path"] == "/workspace" + assert data["project_overrides"][project_id]["files"] == {".zshrc": "project-specific content"} + + +@pytest.mark.integration +def test_add_project_override_folder_not_found() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "project_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + "mount_path": "/workspace", + "files": {}, + } + response = client.post("/config-folders/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/overrides", json=payload) + + assert response.status_code == 404 + + +@pytest.mark.integration +def test_update_project_override_successfully() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + project_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + _insert_user(user_id) + _insert_config_folder( + folder_id, + user_id, + "my-dotfiles", + files={".zshrc": "global"}, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + # First add an override + client.post( + f"/config-folders/{folder_id}/overrides", + json={"project_id": project_id, "mount_path": "/old", "files": {".zshrc": "old"}}, + ) + + # Then update it + payload = { + "mount_path": "/new", + "files": {".zshrc": "new"}, + } + response = client.put(f"/config-folders/{folder_id}/overrides/{project_id}", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["project_overrides"][project_id]["mount_path"] == "/new" + assert data["project_overrides"][project_id]["files"] == {".zshrc": "new"} + + +@pytest.mark.integration +def test_delete_project_override_successfully() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + folder_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + project_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + _insert_user(user_id) + _insert_config_folder(folder_id, user_id, "my-dotfiles") + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + # Add an override first + client.post( + f"/config-folders/{folder_id}/overrides", + json={"project_id": project_id, "mount_path": "/workspace", "files": {}}, + ) + + # Delete it + response = client.delete(f"/config-folders/{folder_id}/overrides/{project_id}") + + assert response.status_code == 204 + + # Verify it's gone + get_response = client.get(f"/config-folders/{folder_id}") + data = get_response.json() + assert project_id not in data.get("project_overrides", {}) diff --git a/apps/api/tests/integration/test_projects_api.py b/apps/api/tests/integration/test_projects_api.py index 1a884e7..3d126c8 100644 --- a/apps/api/tests/integration/test_projects_api.py +++ b/apps/api/tests/integration/test_projects_api.py @@ -6,7 +6,7 @@ import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker -from src.auth.jwt_service import mint_access_token +from src.auth.session import mint_access_token from src.config import Settings, build_database_url from src.models import Base from src.models.project import Project diff --git a/apps/api/tests/integration/test_tool_configs_api_extended.py b/apps/api/tests/integration/test_tool_configs_api_extended.py new file mode 100644 index 0000000..84fbbc4 --- /dev/null +++ b/apps/api/tests/integration/test_tool_configs_api_extended.py @@ -0,0 +1,381 @@ +import uuid +from datetime import UTC, datetime, timedelta +import asyncio + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +from src.auth.session import create_session_cookie +from src.config import Settings, build_database_url +from src.models import Base +from src.models.tool_config import ToolConfig +from src.models.tool_type import ToolType +from src.models.user import User + + +def _prepare_test_db() -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute(text("TRUNCATE TABLE tool_configs, tool_types, users RESTART IDENTITY CASCADE")) + await engine.dispose() + + asyncio.run(_run()) + + +def _load_app(): + import importlib + import src.database as database_module + import src.api.auth as auth_module + import src.api.tool_configs as tool_configs_module + import src.main as main_module + + if hasattr(database_module, 'engine'): + import asyncio + asyncio.run(database_module.engine.dispose()) + + importlib.reload(database_module) + importlib.reload(auth_module) + importlib.reload(tool_configs_module) + importlib.reload(main_module) + return main_module.app + + +def _mint_token(user_id: str) -> str: + settings = Settings() + return create_session_cookie( + settings=settings, + user_id=user_id, + ) + + +def _insert_user(user_id: str, email: str = "test@headquarter.local") -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + user = User( + id=uuid.UUID(user_id), + email=email, + name="Test User", + authentik_id=f"authentik-{user_id}", + avatar_url=None, + ) + await session.merge(user) + await session.commit() + await engine.dispose() + + asyncio.run(_run()) + + +def _insert_tool_type( + tool_type_id: str, + name: str, + display_name: str, + created_by_id: str | None = None, +) -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + tool_type = ToolType( + id=uuid.UUID(tool_type_id), + name=name, + display_name=display_name, + description="A test tool type", + compose_template="version: '3.8'\\nservices:\\n app:\\n image: test", + required_variables=["REPO_PATH"], + is_builtin=False, + created_by_id=uuid.UUID(created_by_id) if created_by_id else None, + ) + await session.merge(tool_type) + await session.commit() + await engine.dispose() + + asyncio.run(_run()) + + +def _insert_tool_config( + config_id: str, + user_id: str, + tool_type_id: str, + key: str, + value: str, + config_type: str = "env", + **kwargs, +) -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + config = ToolConfig( + id=uuid.UUID(config_id), + user_id=uuid.UUID(user_id), + tool_type_id=uuid.UUID(tool_type_id), + key=key, + value=value, + config_type=config_type, + **kwargs, + ) + await session.merge(config) + await session.commit() + await engine.dispose() + + asyncio.run(_run()) + + +@pytest.mark.integration +def test_create_tool_config_with_new_fields() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "tool_type_id": tool_type_id, + "key": "advanced-config", + "value": "test-value", + "config_type": "env", + "port_override": 9090, + "start_command": "python app.py --port 9090", + "working_directory": "/app/src", + "environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"}, + "volumes": [ + {"source": "dotfiles", "target": "/home/user/.config", "type": "config_folder"}, + ], + } + response = client.post("/tool-configs", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["key"] == "advanced-config" + assert data["port_override"] == 9090 + assert data["start_command"] == "python app.py --port 9090" + assert data["working_directory"] == "/app/src" + assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"} + assert len(data["volumes"]) == 1 + assert data["volumes"][0]["source"] == "dotfiles" + + +@pytest.mark.integration +def test_create_tool_config_invalid_port() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "tool_type_id": tool_type_id, + "key": "bad-config", + "value": "test", + "port_override": 99999, # Invalid port + } + response = client.post("/tool-configs", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.integration +def test_create_tool_config_invalid_volume_structure() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "tool_type_id": tool_type_id, + "key": "bad-config", + "value": "test", + "volumes": [ + {"invalid_key": "value"}, # Missing required fields + ], + } + response = client.post("/tool-configs", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.integration +def test_update_tool_config_with_new_fields() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + config_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + _insert_user(user_id) + _insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id) + _insert_tool_config(config_id, user_id, tool_type_id, "my-config", "old-value") + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "value": "new-value", + "port_override": 8080, + "start_command": "npm start", + "working_directory": "/app", + "environment_variables": {"NODE_ENV": "production"}, + "volumes": [ + {"source": "config", "target": "/app/config", "type": "bind"}, + ], + } + response = client.put(f"/tool-configs/{config_id}", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["value"] == "new-value" + assert data["port_override"] == 8080 + assert data["start_command"] == "npm start" + assert data["working_directory"] == "/app" + assert data["environment_variables"] == {"NODE_ENV": "production"} + assert len(data["volumes"]) == 1 + + +@pytest.mark.integration +def test_list_tool_configs_returns_new_fields() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + config_id = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + _insert_user(user_id) + _insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id) + _insert_tool_config( + config_id, + user_id, + tool_type_id, + "advanced-config", + "test-value", + port_override=9090, + start_command="python app.py", + working_directory="/app", + environment_variables={"DEBUG": "true"}, + volumes=[{"source": "data", "target": "/data", "type": "bind"}], + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + response = client.get("/tool-configs") + + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + config = data[0] + assert config["port_override"] == 9090 + assert config["start_command"] == "python app.py" + assert config["working_directory"] == "/app" + assert config["environment_variables"] == {"DEBUG": "true"} + assert len(config["volumes"]) == 1 + + +@pytest.mark.integration +def test_get_tool_config_defaults() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_tool_type( + tool_type_id, + "test-tool", + "Test Tool", + created_by_id=user_id, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + response = client.get(f"/tool-configs/defaults/{tool_type_id}") + + assert response.status_code == 200 + data = response.json() + assert "tool_type_id" in data + assert data["tool_type_id"] == tool_type_id + assert "suggested_configs" in data + assert "port_override" in data + + +@pytest.mark.integration +def test_tool_config_backward_compatibility() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_tool_type(tool_type_id, "test-tool", "Test Tool", created_by_id=user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + # Create config without new fields (old API usage) + payload = { + "tool_type_id": tool_type_id, + "key": "simple-config", + "value": "simple-value", + "config_type": "env", + } + response = client.post("/tool-configs", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["key"] == "simple-config" + # New fields should have default values + assert data["port_override"] is None + assert data["start_command"] is None + assert data["working_directory"] is None + assert data["environment_variables"] == {} + assert data["volumes"] == [] diff --git a/apps/api/tests/integration/test_tool_types_api.py b/apps/api/tests/integration/test_tool_types_api.py index fdd47ad..95ae5ae 100644 --- a/apps/api/tests/integration/test_tool_types_api.py +++ b/apps/api/tests/integration/test_tool_types_api.py @@ -7,7 +7,7 @@ from fastapi.testclient import TestClient from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker -from src.auth.jwt_service import mint_access_token +from src.auth.session import mint_access_token from src.config import Settings, build_database_url from src.models import Base from src.models.tool_type import ToolType diff --git a/apps/api/tests/integration/test_tool_types_api_extended.py b/apps/api/tests/integration/test_tool_types_api_extended.py new file mode 100644 index 0000000..9bce87f --- /dev/null +++ b/apps/api/tests/integration/test_tool_types_api_extended.py @@ -0,0 +1,415 @@ +import uuid +from datetime import UTC, datetime, timedelta +import asyncio + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker + +from src.auth.session import create_session_cookie +from src.config import Settings, build_database_url +from src.models import Base +from src.models.tool_type import ToolType +from src.models.user import User + + +def _prepare_test_db() -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + await connection.execute(text("TRUNCATE TABLE tool_types, users RESTART IDENTITY CASCADE")) + await engine.dispose() + + asyncio.run(_run()) + + +def _load_app(): + import importlib + import src.database as database_module + import src.api.auth as auth_module + import src.api.tool_types as tool_types_module + import src.main as main_module + + if hasattr(database_module, 'engine'): + import asyncio + asyncio.run(database_module.engine.dispose()) + + importlib.reload(database_module) + importlib.reload(auth_module) + importlib.reload(tool_types_module) + importlib.reload(main_module) + return main_module.app + + +def _mint_token(user_id: str) -> str: + settings = Settings() + return create_session_cookie( + settings=settings, + user_id=user_id, + ) + + +def _insert_user(user_id: str, email: str = "test@headquarter.local") -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + user = User( + id=uuid.UUID(user_id), + email=email, + name="Test User", + authentik_id=f"authentik-{user_id}", + avatar_url=None, + ) + await session.merge(user) + await session.commit() + await engine.dispose() + + asyncio.run(_run()) + + +def _insert_tool_type( + tool_type_id: str, + name: str, + display_name: str, + compose_template: str | None = None, + dockerfile_template: str | None = None, + definition_type: str = "compose", + readiness_probe: dict | None = None, + is_builtin: bool = False, + created_by_id: str | None = None, +) -> None: + async def _run() -> None: + engine = create_async_engine( + build_database_url( + user="headquarter", + password="headquarter", + host="localhost", + port=5432, + database="headquarter", + ) + ) + session_factory = async_sessionmaker(engine, expire_on_commit=False) + async with session_factory() as session: + tool_type = ToolType( + id=uuid.UUID(tool_type_id), + name=name, + display_name=display_name, + description="A test tool type", + compose_template=compose_template, + dockerfile_template=dockerfile_template, + definition_type=definition_type, + readiness_probe=readiness_probe, + required_variables=["REPO_PATH"], + is_builtin=is_builtin, + created_by_id=uuid.UUID(created_by_id) if created_by_id else None, + ) + await session.merge(tool_type) + await session.commit() + await engine.dispose() + + asyncio.run(_run()) + + +@pytest.mark.integration +def test_create_tool_type_with_dockerfile() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "name": "custom-docker-tool", + "display_name": "Custom Docker Tool", + "description": "A custom tool with Dockerfile", + "definition_type": "dockerfile", + "dockerfile_template": "FROM python:3.11\\nRUN pip install flask\\nCMD ['python', 'app.py']", + "default_port": 5000, + "required_variables": ["REPO_PATH"], + } + response = client.post("/tool-types", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "custom-docker-tool" + assert data["definition_type"] == "dockerfile" + assert data["dockerfile_template"] == "FROM python:3.11\\nRUN pip install flask\\nCMD ['python', 'app.py']" + assert data["compose_template"] is None + + +@pytest.mark.integration +def test_create_tool_type_with_readiness_probe() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "name": "probed-tool", + "display_name": "Probed Tool", + "compose_template": "version: '3.8'\\nservices:\\n app:\\n image: nginx", + "default_port": 8080, + "required_variables": [], + "readiness_probe": { + "command": "curl -f http://localhost:8080/health", + "timeout": 60, + "interval": 3, + }, + } + response = client.post("/tool-types", json=payload) + + assert response.status_code == 201 + data = response.json() + assert data["readiness_probe"] == { + "command": "curl -f http://localhost:8080/health", + "timeout": 60, + "interval": 3, + } + + +@pytest.mark.integration +def test_create_tool_type_invalid_definition_type() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "name": "bad-tool", + "display_name": "Bad Tool", + "definition_type": "invalid", + "compose_template": "version: '3.8'\\nservices:\\n app:\\n image: nginx", + "required_variables": [], + } + response = client.post("/tool-types", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.integration +def test_create_tool_type_dockerfile_without_template() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "name": "bad-docker-tool", + "display_name": "Bad Docker Tool", + "definition_type": "dockerfile", + "dockerfile_template": "", + "required_variables": [], + } + response = client.post("/tool-types", json=payload) + + assert response.status_code == 422 + + +@pytest.mark.integration +def test_update_tool_type_with_new_fields() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_tool_type( + tool_type_id, + "custom-tool", + "Custom Tool", + compose_template="version: '3.8'\\nservices:\\n app:\\n image: old", + created_by_id=user_id, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "definition_type": "dockerfile", + "dockerfile_template": "FROM python:3.11", + "readiness_probe": { + "command": "python --version", + "timeout": 30, + "interval": 2, + }, + } + response = client.put(f"/tool-types/{tool_type_id}", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["definition_type"] == "dockerfile" + assert data["dockerfile_template"] == "FROM python:3.11" + assert data["readiness_probe"]["command"] == "python --version" + + +@pytest.mark.integration +def test_validate_tool_type_compose() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "definition_type": "compose", + "compose_template": "version: '3.8'\\nservices:\\n app:\\n image: nginx", + } + response = client.post("/tool-types/validate", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["valid"] == True + assert "errors" not in data or len(data["errors"]) == 0 + + +@pytest.mark.integration +def test_validate_tool_type_invalid_compose() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "definition_type": "compose", + "compose_template": "this is not: valid: yaml: [", + } + response = client.post("/tool-types/validate", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["valid"] == False + assert len(data["errors"]) > 0 + + +@pytest.mark.integration +def test_validate_tool_type_dockerfile() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "definition_type": "dockerfile", + "dockerfile_template": "FROM python:3.11\\nRUN pip install flask", + } + response = client.post("/tool-types/validate", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["valid"] == True + + +@pytest.mark.integration +def test_validate_tool_type_empty_dockerfile() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + payload = { + "definition_type": "dockerfile", + "dockerfile_template": "", + } + response = client.post("/tool-types/validate", json=payload) + + assert response.status_code == 200 + data = response.json() + assert data["valid"] == False + + +@pytest.mark.integration +def test_get_tool_type_returns_new_fields() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + tool_type_id = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + _insert_user(user_id) + _insert_tool_type( + tool_type_id, + "probed-tool", + "Probed Tool", + definition_type="dockerfile", + dockerfile_template="FROM python:3.11", + readiness_probe={"command": "python --version", "timeout": 30, "interval": 2}, + created_by_id=user_id, + ) + + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + response = client.get(f"/tool-types/{tool_type_id}") + + assert response.status_code == 200 + data = response.json() + assert data["definition_type"] == "dockerfile" + assert data["dockerfile_template"] == "FROM python:3.11" + assert data["readiness_probe"]["command"] == "python --version" + assert data["readiness_probe"]["timeout"] == 30 + + +@pytest.mark.integration +def test_builtin_tool_types_have_definition_type_compose() -> None: + _prepare_test_db() + user_id = "11111111-1111-1111-1111-111111111111" + _insert_user(user_id) + + # Load app triggers startup event which seeds built-in types + app = _load_app() + client = TestClient(app) + client.cookies.set("session", _mint_token(user_id)) + + response = client.get("/tool-types") + + assert response.status_code == 200 + data = response.json() + + builtin_types = [t for t in data if t["is_builtin"]] + assert len(builtin_types) > 0 + + for tool_type in builtin_types: + assert tool_type["definition_type"] == "compose" + assert tool_type["compose_template"] is not None + assert tool_type["dockerfile_template"] is None diff --git a/apps/api/tests/integration/test_users_api.py b/apps/api/tests/integration/test_users_api.py index 2244aa2..2a0ecde 100644 --- a/apps/api/tests/integration/test_users_api.py +++ b/apps/api/tests/integration/test_users_api.py @@ -8,7 +8,7 @@ import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine -from src.auth.jwt_service import mint_access_token +from src.auth.session import mint_access_token from src.config import Settings, build_database_url from src.models import Base from src.models.user import User diff --git a/apps/api/tests/unit/test_docker_build.py b/apps/api/tests/unit/test_docker_build.py new file mode 100644 index 0000000..18a403d --- /dev/null +++ b/apps/api/tests/unit/test_docker_build.py @@ -0,0 +1,151 @@ +"""Unit tests for docker build service.""" + +import subprocess +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from src.services.docker_build import build_image + + +class TestBuildImage: + """Tests for build_image function.""" + + @patch("subprocess.run") + def test_builds_image_successfully(self, mock_run) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout="Successfully built abc123", + stderr="", + ) + + with tempfile.TemporaryDirectory() as tmpdir: + result = build_image(tmpdir, "FROM python:3.11", "test-image:latest") + + assert result[0] == 0 + assert "Successfully built" in result[1] + mock_run.assert_called_once() + call_args = mock_run.call_args + assert "test-image:latest" in call_args[0][0] + assert "build" in call_args[0][0] + + @patch("subprocess.run") + def test_build_fails(self, mock_run) -> None: + mock_run.return_value = MagicMock( + returncode=1, + stdout="", + stderr="Error: failed to build", + ) + + with tempfile.TemporaryDirectory() as tmpdir: + result = build_image(tmpdir, "FROM invalid:image", "test-image:latest") + + assert result[0] == 1 + assert "failed to build" in result[2] + + @patch("subprocess.run") + def test_build_with_tag(self, mock_run) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout="", + stderr="", + ) + + with tempfile.TemporaryDirectory() as tmpdir: + build_image(tmpdir, "FROM python:3.11", "my-registry/tool:v1.0") + + call_args = mock_run.call_args[0][0] + assert "my-registry/tool:v1.0" in call_args + + @patch("subprocess.run") + def test_build_command_structure(self, mock_run) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with tempfile.TemporaryDirectory() as tmpdir: + build_image(tmpdir, "FROM python:3.11", "test:latest") + + cmd = mock_run.call_args[0][0] + assert cmd[0] == "docker" + assert cmd[1] == "build" + assert "-t" in cmd + assert "test:latest" in cmd + assert tmpdir in cmd + + @patch("subprocess.run") + def test_build_writes_dockerfile(self, mock_run) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with tempfile.TemporaryDirectory() as tmpdir: + dockerfile_content = "FROM python:3.11\\nRUN pip install flask" + build_image(tmpdir, dockerfile_content, "test:latest") + + dockerfile_path = Path(tmpdir) / "Dockerfile" + assert dockerfile_path.exists() + assert dockerfile_path.read_text() == dockerfile_content + + @patch("subprocess.run") + def test_build_writes_context_files(self, mock_run) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with tempfile.TemporaryDirectory() as tmpdir: + build_context = { + "requirements.txt": "flask==2.0\\nnumpy==1.21", + "app.py": "from flask import Flask\\napp = Flask(__name__)", + } + build_image(tmpdir, "FROM python:3.11", "test:latest", build_context) + + req_path = Path(tmpdir) / "requirements.txt" + app_path = Path(tmpdir) / "app.py" + assert req_path.exists() + assert req_path.read_text() == "flask==2.0\\nnumpy==1.21" + assert app_path.exists() + assert app_path.read_text() == "from flask import Flask\\napp = Flask(__name__)" + + @patch("subprocess.run") + def test_build_creates_nested_directories(self, mock_run) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with tempfile.TemporaryDirectory() as tmpdir: + build_context = { + "src/app.py": "print('hello')", + } + build_image(tmpdir, "FROM python:3.11", "test:latest", build_context) + + app_path = Path(tmpdir) / "src" / "app.py" + assert app_path.exists() + + @patch("subprocess.run") + def test_build_prevents_path_traversal(self, mock_run) -> None: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + with tempfile.TemporaryDirectory() as tmpdir: + build_context = { + "../../../etc/passwd": "root:x:0:0", + } + + with pytest.raises(ValueError, match="escapes instance directory"): + build_image(tmpdir, "FROM python:3.11", "test:latest", build_context) + + mock_run.assert_not_called() + + @patch("subprocess.run") + def test_build_timeout(self, mock_run) -> None: + mock_run.side_effect = subprocess.TimeoutExpired(cmd=["docker", "build"], timeout=300) + + with tempfile.TemporaryDirectory() as tmpdir: + result = build_image(tmpdir, "FROM python:3.11", "test:latest") + + assert result[0] == 1 + assert "timed out" in result[2].lower() + + @patch("subprocess.run") + def test_build_exception(self, mock_run) -> None: + mock_run.side_effect = OSError("Docker not available") + + with tempfile.TemporaryDirectory() as tmpdir: + result = build_image(tmpdir, "FROM python:3.11", "test:latest") + + assert result[0] == 1 + assert "Docker not available" in result[2] diff --git a/apps/api/tests/unit/test_readiness_probe.py b/apps/api/tests/unit/test_readiness_probe.py new file mode 100644 index 0000000..0e3e75e --- /dev/null +++ b/apps/api/tests/unit/test_readiness_probe.py @@ -0,0 +1,217 @@ +"""Unit tests for readiness probe service.""" + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + +from src.services.readiness_probe import execute_probe + + +class TestExecuteProbe: + """Tests for execute_probe function.""" + + @patch("subprocess.run") + async def test_probe_succeeds_first_attempt(self, mock_run) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout="healthy", + stderr="", + ) + + result, logs = await execute_probe("container-123", "curl -f http://localhost:8080") + + assert result is True + assert any("Success" in log for log in logs) + mock_run.assert_called_once_with( + ["docker", "exec", "container-123", "sh", "-c", "curl -f http://localhost:8080"], + capture_output=True, + text=True, + timeout=2, + ) + + @patch("subprocess.run") + async def test_probe_fails_then_succeeds(self, mock_run) -> None: + mock_run.side_effect = [ + MagicMock(returncode=1, stdout="", stderr="Connection refused"), + MagicMock(returncode=1, stdout="", stderr="Connection refused"), + MagicMock(returncode=0, stdout="healthy", stderr=""), + ] + + result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=10, interval=0.1) + + assert result is True + assert mock_run.call_count == 3 + assert any("Attempt 1: Failed" in log for log in logs) + assert any("Attempt 3: Success" in log for log in logs) + + @patch("subprocess.run") + async def test_probe_times_out(self, mock_run) -> None: + mock_run.return_value = MagicMock( + returncode=1, + stdout="", + stderr="Connection refused", + ) + + result, logs = await execute_probe("container-123", "curl -f http://localhost:8080", timeout=0.5, interval=0.2) + + assert result is False + assert any("timed out" in log.lower() for log in logs) + + @patch("subprocess.run") + async def test_probe_command_not_found(self, mock_run) -> None: + mock_run.return_value = MagicMock( + returncode=127, + stdout="", + stderr="command not found", + ) + + result, logs = await execute_probe("container-123", "nonexistent-command", timeout=1, interval=0.3) + + assert result is False + assert any("exit code 127" in log for log in logs) + + @patch("subprocess.run") + async def test_probe_exception(self, mock_run) -> None: + mock_run.side_effect = OSError("Docker not available") + + result, logs = await execute_probe("container-123", "curl http://localhost", timeout=1, interval=0.3) + + assert result is False + assert any("Error" in log for log in logs) + + @patch("subprocess.run") + async def test_probe_with_special_characters(self, mock_run) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout="", + stderr="", + ) + + cmd = "bash -c 'echo \"hello world\" && exit 0'" + await execute_probe("container-123", cmd) + + call_args = mock_run.call_args + assert cmd in call_args[0][0] + + @patch("subprocess.run") + async def test_probe_captures_stdout(self, mock_run) -> None: + mock_run.return_value = MagicMock( + returncode=0, + stdout="Server is ready\\nVersion: 1.0", + stderr="", + ) + + result, logs = await execute_probe("container-123", "cat /app/status") + + assert result is True + assert any("Server is ready" in log for log in logs) + + +class TestIntegrationScenarios: + """Integration-style tests with realistic scenarios.""" + + @patch("subprocess.run") + async def test_web_server_probe(self, mock_run) -> None: + """Test typical web server health check.""" + mock_run.side_effect = [ + MagicMock(returncode=1, stdout="", stderr=""), + MagicMock(returncode=1, stdout="", stderr=""), + MagicMock(returncode=1, stdout="", stderr=""), + MagicMock(returncode=0, stdout="OK", stderr=""), + ] + + result, logs = await execute_probe( + "web-container", + "curl -f http://localhost:8080/health", + timeout=10, + interval=0.2, + ) + + assert result is True + assert mock_run.call_count == 4 + + @patch("subprocess.run") + async def test_command_probe(self, mock_run) -> None: + """Test command availability check.""" + mock_run.return_value = MagicMock( + returncode=0, + stdout="opencode 1.0.0", + stderr="", + ) + + result, logs = await execute_probe( + "tool-container", + "which opencode && opencode --version", + timeout=30, + interval=2, + ) + + assert result is True + assert any("opencode 1.0.0" in log for log in logs) + + @patch("subprocess.run") + async def test_database_probe(self, mock_run) -> None: + """Test database readiness check.""" + mock_run.side_effect = [ + MagicMock(returncode=1, stdout="", stderr=""), + MagicMock(returncode=1, stdout="", stderr=""), + MagicMock(returncode=0, stdout="/var/run/postgresql:5432 - accepting connections", stderr=""), + ] + + result, logs = await execute_probe( + "db-container", + "pg_isready -U postgres", + timeout=10, + interval=0.3, + ) + + assert result is True + assert mock_run.call_count == 3 + + @patch("subprocess.run") + async def test_file_probe(self, mock_run) -> None: + """Test file existence check.""" + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") + + result, logs = await execute_probe( + "app-container", + "[ -f /app/ready ]", + timeout=10, + interval=1, + ) + + assert result is True + + @patch("subprocess.run") + async def test_slow_starting_service(self, mock_run) -> None: + """Test service that takes time to start.""" + # Simulate 5 failures before success + side_effects = [MagicMock(returncode=1, stdout="", stderr="")] * 5 + side_effects.append(MagicMock(returncode=0, stdout="Ready", stderr="")) + mock_run.side_effect = side_effects + + result, logs = await execute_probe( + "slow-container", + "curl -f http://localhost:8080", + timeout=10, + interval=0.2, + ) + + assert result is True + assert mock_run.call_count == 6 + assert any("Attempt 6: Success" in log for log in logs) + + @patch("subprocess.run") + async def test_zero_timeout_immediate_return(self, mock_run) -> None: + mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="") + + result, logs = await execute_probe( + "container", + "test", + timeout=0, + interval=1, + ) + + assert result is False + assert any("timed out" in log.lower() for log in logs) diff --git a/apps/web/src/api/config_folders.test.ts b/apps/web/src/api/config_folders.test.ts new file mode 100644 index 0000000..ac6828e --- /dev/null +++ b/apps/web/src/api/config_folders.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; + +import axios from "axios"; +import { + createConfigFolder, + deleteConfigFolder, + listConfigFolders, + updateConfigFolder, +} from "../api/config_folders"; + +vi.mock("axios"); +const mockedAxios = vi.mocked(axios); + +describe("config_folders API", () => { + describe("listConfigFolders", () => { + it("returns folders with files and overrides", async () => { + const mockResponse = { + data: [ + { + id: "folder-1", + name: "my-dotfiles", + mount_path: "/home/user", + files: { + ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"", + }, + project_overrides: { + "proj-1": { + mount_path: "/workspace", + files: { ".zshrc": "different content" }, + }, + }, + is_active: true, + }, + ], + }; + mockedAxios.get.mockResolvedValue(mockResponse); + + const result = await listConfigFolders(); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe("my-dotfiles"); + expect(result[0].files).toEqual({ + ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"", + }); + expect(result[0].project_overrides).toEqual({ + "proj-1": { + mount_path: "/workspace", + files: { ".zshrc": "different content" }, + }, + }); + }); + + it("returns empty array when no folders", async () => { + mockedAxios.get.mockResolvedValue({ data: [] }); + + const result = await listConfigFolders(); + + expect(result).toEqual([]); + }); + }); + + describe("createConfigFolder", () => { + it("creates folder with files", async () => { + const mockResponse = { + data: { + id: "new-folder", + name: "my-configs", + mount_path: "/home/user", + files: { "test.txt": "hello" }, + is_active: true, + }, + }; + mockedAxios.post.mockResolvedValue(mockResponse); + + const result = await createConfigFolder({ + name: "my-configs", + mount_path: "/home/user", + files: { "test.txt": "hello" }, + }); + + expect(result.name).toBe("my-configs"); + expect(result.files).toEqual({ "test.txt": "hello" }); + expect(mockedAxios.post).toHaveBeenCalledWith( + "/config-folders", + expect.objectContaining({ + name: "my-configs", + mount_path: "/home/user", + files: { "test.txt": "hello" }, + }) + ); + }); + }); + + describe("updateConfigFolder", () => { + it("updates folder files", async () => { + const mockResponse = { + data: { + id: "folder-1", + name: "updated-name", + files: { "new.txt": "content" }, + }, + }; + mockedAxios.put.mockResolvedValue(mockResponse); + + const result = await updateConfigFolder("folder-1", { + name: "updated-name", + files: { "new.txt": "content" }, + }); + + expect(result.name).toBe("updated-name"); + expect(mockedAxios.put).toHaveBeenCalledWith( + "/config-folders/folder-1", + expect.objectContaining({ + name: "updated-name", + files: { "new.txt": "content" }, + }) + ); + }); + + it("updates folder activation status", async () => { + const mockResponse = { + data: { + id: "folder-1", + name: "my-configs", + is_active: false, + }, + }; + mockedAxios.put.mockResolvedValue(mockResponse); + + const result = await updateConfigFolder("folder-1", { + is_active: false, + }); + + expect(result.is_active).toBe(false); + }); + }); + + describe("deleteConfigFolder", () => { + it("deletes folder", async () => { + mockedAxios.delete.mockResolvedValue({ data: undefined }); + + await deleteConfigFolder("folder-1"); + + expect(mockedAxios.delete).toHaveBeenCalledWith("/config-folders/folder-1"); + }); + }); +}); diff --git a/apps/web/src/api/tool_types.test.ts b/apps/web/src/api/tool_types.test.ts new file mode 100644 index 0000000..99a0681 --- /dev/null +++ b/apps/web/src/api/tool_types.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from "vitest"; + +import axios from "axios"; +import { + createToolType, + deleteToolType, + listToolTypes, + updateToolType, + validateToolType, +} from "../api/tool_types"; + +vi.mock("axios"); +const mockedAxios = vi.mocked(axios); + +describe("tool_types API", () => { + describe("listToolTypes", () => { + it("returns tool types with new fields", async () => { + const mockResponse = { + data: [ + { + id: "type-1", + name: "custom-tool", + display_name: "Custom Tool", + definition_type: "dockerfile", + dockerfile_template: "FROM python:3.11", + readiness_probe: { + command: "python --version", + timeout: 30, + interval: 2, + }, + }, + ], + }; + mockedAxios.get.mockResolvedValue(mockResponse); + + const result = await listToolTypes(); + + expect(result[0].definition_type).toBe("dockerfile"); + expect(result[0].dockerfile_template).toBe("FROM python:3.11"); + expect(result[0].readiness_probe).toEqual({ + command: "python --version", + timeout: 30, + interval: 2, + }); + }); + + it("returns compose tool types", async () => { + const mockResponse = { + data: [ + { + id: "type-1", + name: "code-server", + definition_type: "compose", + compose_template: "version: '3.8'", + dockerfile_template: null, + }, + ], + }; + mockedAxios.get.mockResolvedValue(mockResponse); + + const result = await listToolTypes(); + + expect(result[0].definition_type).toBe("compose"); + expect(result[0].dockerfile_template).toBeNull(); + }); + }); + + describe("createToolType", () => { + it("creates tool type with dockerfile", async () => { + const mockResponse = { + data: { + id: "new-type", + name: "docker-tool", + definition_type: "dockerfile", + dockerfile_template: "FROM node:18", + }, + }; + mockedAxios.post.mockResolvedValue(mockResponse); + + const result = await createToolType({ + name: "docker-tool", + display_name: "Docker Tool", + definition_type: "dockerfile", + dockerfile_template: "FROM node:18", + default_port: 3000, + required_variables: [], + }); + + expect(result.definition_type).toBe("dockerfile"); + expect(mockedAxios.post).toHaveBeenCalledWith( + "/tool-types", + expect.objectContaining({ + definition_type: "dockerfile", + dockerfile_template: "FROM node:18", + }) + ); + }); + + it("creates tool type with readiness probe", async () => { + const mockResponse = { + data: { + id: "new-type", + name: "probed-tool", + readiness_probe: { + command: "curl -f http://localhost:8080", + timeout: 60, + interval: 3, + }, + }, + }; + mockedAxios.post.mockResolvedValue(mockResponse); + + const result = await createToolType({ + name: "probed-tool", + display_name: "Probed Tool", + compose_template: "version: '3.8'", + default_port: 8080, + required_variables: [], + readiness_probe: { + command: "curl -f http://localhost:8080", + timeout: 60, + interval: 3, + }, + }); + + expect(result.readiness_probe).toEqual({ + command: "curl -f http://localhost:8080", + timeout: 60, + interval: 3, + }); + }); + }); + + describe("validateToolType", () => { + it("validates compose template", async () => { + const mockResponse = { + data: { valid: true, errors: [] }, + }; + mockedAxios.post.mockResolvedValue(mockResponse); + + const result = await validateToolType({ + definition_type: "compose", + compose_template: "version: '3.8'", + }); + + expect(result.valid).toBe(true); + }); + + it("returns validation errors", async () => { + const mockResponse = { + data: { valid: false, errors: ["Invalid YAML"] }, + }; + mockedAxios.post.mockResolvedValue(mockResponse); + + const result = await validateToolType({ + definition_type: "compose", + compose_template: "invalid: yaml: [", + }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain("Invalid YAML"); + }); + }); + + describe("updateToolType", () => { + it("updates tool type with new fields", async () => { + const mockResponse = { + data: { + id: "type-1", + name: "updated-tool", + definition_type: "dockerfile", + dockerfile_template: "FROM python:3.11", + }, + }; + mockedAxios.put.mockResolvedValue(mockResponse); + + const result = await updateToolType("type-1", { + definition_type: "dockerfile", + dockerfile_template: "FROM python:3.11", + }); + + expect(result.definition_type).toBe("dockerfile"); + expect(mockedAxios.put).toHaveBeenCalledWith( + "/tool-types/type-1", + expect.objectContaining({ + definition_type: "dockerfile", + }) + ); + }); + }); + + describe("deleteToolType", () => { + it("deletes tool type", async () => { + mockedAxios.delete.mockResolvedValue({ data: undefined }); + + await deleteToolType("type-1"); + + expect(mockedAxios.delete).toHaveBeenCalledWith("/tool-types/type-1"); + }); + }); +}); diff --git a/apps/web/src/pages/tool-workshop.test.tsx b/apps/web/src/pages/tool-workshop.test.tsx new file mode 100644 index 0000000..63967d7 --- /dev/null +++ b/apps/web/src/pages/tool-workshop.test.tsx @@ -0,0 +1,558 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ToolWorkshopPage } from "./tool-workshop"; +import * as toolTypesApi from "../api/tool_types"; +import * as toolConfigsApi from "../api/tool_configs"; +import * as configFoldersApi from "../api/config_folders"; + +const mockToolTypes = [ + { + id: "type-1", + name: "code-server", + display_name: "VS Code Server", + description: "VS Code in browser", + category: "editor", + interfaces: ["web"], + default_port: 8443, + definition_type: "compose", + compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server", + dockerfile_template: null, + readiness_probe: null, + required_variables: ["REPO_PATH"], + is_builtin: true, + created_by_id: null, + }, + { + id: "type-2", + name: "custom-tool", + display_name: "Custom Tool", + description: "My custom tool", + category: "utility", + interfaces: ["terminal"], + default_port: 8080, + definition_type: "dockerfile", + compose_template: null, + dockerfile_template: "FROM python:3.11", + readiness_probe: { + command: "python --version", + timeout: 30, + interval: 2, + }, + required_variables: [], + is_builtin: false, + created_by_id: "user-1", + }, +]; + +const mockConfigs = [ + { + id: "config-1", + tool_type_id: "type-1", + key: "OPENAI_API_KEY", + value: "sk-test123", + config_type: "env", + file_path: null, + port_override: null, + start_command: null, + working_directory: null, + environment_variables: {}, + volumes: [], + }, + { + id: "config-2", + tool_type_id: "type-2", + key: "advanced-config", + value: "test-value", + config_type: "env", + port_override: 9090, + start_command: "python app.py", + working_directory: "/app", + environment_variables: { DEBUG: "true" }, + volumes: [{ source: "data", target: "/data", type: "bind" }], + }, +]; + +const mockFolders = [ + { + id: "folder-1", + name: "my-dotfiles", + description: "My personal config files", + mount_path: "/home/user", + files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" }, + project_overrides: {}, + is_active: true, + }, + { + id: "folder-2", + name: "project-configs", + description: "Project specific configs", + mount_path: "/workspace", + files: { ".env": "API_URL=http://localhost:8080" }, + project_overrides: { + "proj-1": { + mount_path: "/app", + files: { ".env": "API_URL=http://prod.api" }, + }, + }, + is_active: false, + }, +]; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("ToolWorkshopPage", () => { + it("renders loading state initially", () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {})); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {})); + vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {})); + + render(); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it("renders tool types tab by default", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + expect(screen.getByText("Custom Tool")).toBeInTheDocument(); + }); + + it("switches to configs tab", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /configs/i })); + + await waitFor(() => { + expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); + }); + expect(screen.getByText("advanced-config")).toBeInTheDocument(); + }); + + it("switches to folders tab", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /folders/i })); + + await waitFor(() => { + expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); + }); + expect(screen.getByText("project-configs")).toBeInTheDocument(); + }); + + it("opens tool type creation form", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); + + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/display name/i)).toBeInTheDocument(); + }); + + it("creates tool type with compose definition", async () => { + const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); + + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: "new-tool" }, + }); + fireEvent.change(screen.getByLabelText(/display name/i), { + target: { value: "New Tool" }, + }); + fireEvent.change(screen.getByLabelText(/compose template/i), { + target: { value: "version: '3.8'\\nservices:\\n app:\\n image: nginx" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /create$/i })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "new-tool", + display_name: "New Tool", + definition_type: "compose", + compose_template: "version: '3.8'\\nservices:\\n app:\\n image: nginx", + }) + ); + }); + expect(listMock).toHaveBeenCalledTimes(2); + }); + + it("creates tool type with dockerfile definition", async () => { + const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + const createMock = vi.spyOn(toolTypesApi, "createToolType").mockResolvedValue(mockToolTypes[1]); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); + + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: "docker-tool" }, + }); + fireEvent.change(screen.getByLabelText(/display name/i), { + target: { value: "Docker Tool" }, + }); + + // Switch to dockerfile + fireEvent.click(screen.getByLabelText(/dockerfile/i)); + + fireEvent.change(screen.getByLabelText(/dockerfile template/i), { + target: { value: "FROM python:3.11\\nRUN pip install flask" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /create$/i })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "docker-tool", + definition_type: "dockerfile", + dockerfile_template: "FROM python:3.11\\nRUN pip install flask", + }) + ); + }); + }); + + it("shows readiness probe fields", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); + + expect(screen.getByLabelText(/readiness command/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/timeout/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/interval/i)).toBeInTheDocument(); + }); + + it("opens config creation form", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({ + tool_type_id: "type-1", + suggested_configs: [], + port_override: null, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /configs/i })); + + await waitFor(() => { + expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /add config/i })); + + expect(screen.getByLabelText(/key/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/value/i)).toBeInTheDocument(); + }); + + it("creates config with advanced fields", async () => { + const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1]); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + vi.spyOn(toolConfigsApi, "getToolConfigDefaults").mockResolvedValue({ + tool_type_id: "type-1", + suggested_configs: [], + port_override: null, + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /configs/i })); + + await waitFor(() => { + expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /add config/i })); + + fireEvent.change(screen.getByLabelText(/key/i), { + target: { value: "MY_CONFIG" }, + }); + fireEvent.change(screen.getByLabelText(/value/i), { + target: { value: "my-value" }, + }); + fireEvent.change(screen.getByLabelText(/port override/i), { + target: { value: "9090" }, + }); + fireEvent.change(screen.getByLabelText(/start command/i), { + target: { value: "python app.py" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /create$/i })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + key: "MY_CONFIG", + value: "my-value", + port_override: 9090, + start_command: "python app.py", + }) + ); + }); + expect(configsListMock).toHaveBeenCalledTimes(2); + }); + + it("opens folder creation form", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /folders/i })); + + await waitFor(() => { + expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /create folder/i })); + + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/mount path/i)).toBeInTheDocument(); + }); + + it("creates config folder successfully", async () => { + const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0]); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /folders/i })); + + await waitFor(() => { + expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /create folder/i })); + + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: "new-folder" }, + }); + fireEvent.change(screen.getByLabelText(/mount path/i), { + target: { value: "/home/dev" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /create$/i })); + + await waitFor(() => { + expect(createMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: "new-folder", + mount_path: "/home/dev", + }) + ); + }); + expect(foldersListMock).toHaveBeenCalledTimes(2); + }); + + it("shows folder active/inactive status", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /folders/i })); + + await waitFor(() => { + expect(screen.getByText("my-dotfiles")).toBeInTheDocument(); + }); + + // Check that active folder is marked + const activeFolder = screen.getByText("my-dotfiles").closest("[data-testid='folder-item']") || + screen.getByText("my-dotfiles").parentElement; + expect(activeFolder?.textContent).toContain("active"); + }); + + it("validates tool type before creation", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + const validateMock = vi.spyOn(toolTypesApi, "validateToolType").mockResolvedValue({ + valid: false, + errors: ["Invalid YAML syntax"], + }); + + render(); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /create tool type/i })); + + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: "bad-tool" }, + }); + fireEvent.change(screen.getByLabelText(/display name/i), { + target: { value: "Bad Tool" }, + }); + fireEvent.change(screen.getByLabelText(/compose template/i), { + target: { value: "invalid: yaml: [" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /validate/i })); + + await waitFor(() => { + expect(validateMock).toHaveBeenCalled(); + }); + + expect(screen.getByText(/invalid yaml syntax/i)).toBeInTheDocument(); + }); + + it("handles error state gracefully", async () => { + vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error")); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error")); + vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error")); + + render(); + + await waitFor(() => { + expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); + }); + + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + it("retries loading after error", async () => { + const listMock = vi.spyOn(toolTypesApi, "listToolTypes") + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce(mockToolTypes); + vi.spyOn(toolConfigsApi, "listToolConfigs") + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders") + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText(/failed to load/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + + await waitFor(() => { + expect(screen.getByText("VS Code Server")).toBeInTheDocument(); + }); + expect(listMock).toHaveBeenCalledTimes(2); + }); + + it("deletes tool type successfully", async () => { + const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes); + const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined); + vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs); + vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders); + + render(); + + await waitFor(() => { + expect(screen.getByText("Custom Tool")).toBeInTheDocument(); + }); + + // Find and click delete button for custom tool (not built-in) + const customToolCard = screen.getByText("Custom Tool").closest("[data-testid='tool-type-item']") || + screen.getByText("Custom Tool").parentElement; + if (customToolCard) { + const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i }); + if (deleteButton) { + fireEvent.click(deleteButton); + + // Confirm deletion + const confirmButton = screen.queryByRole("button", { name: /confirm/i }); + if (confirmButton) { + fireEvent.click(confirmButton); + } + + await waitFor(() => { + expect(deleteMock).toHaveBeenCalledWith("type-2"); + }); + expect(listMock).toHaveBeenCalledTimes(2); + } + } + }); +});