docs: sync tool-types-definition specs and mark tasks complete
- Mark manual testing tasks as complete in tool-types-definition - Sync tool-types-definition spec to main specs directory
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
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.jwt_service import mint_access_token
|
||||
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, git_repositories, ssh_keys, projects, 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
|
||||
|
||||
# Dispose old engine connections before reload to prevent pool exhaustion
|
||||
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 mint_access_token(
|
||||
settings=settings,
|
||||
subject=user_id,
|
||||
email="test@headquarter.local",
|
||||
name="Test User",
|
||||
expires_at=datetime.now(UTC) + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
|
||||
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,
|
||||
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,
|
||||
required_variables=["REPO_PATH", "TOOL_NAME"],
|
||||
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_list_tool_types_requires_authentication() -> None:
|
||||
_prepare_test_db()
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/tool-types")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_list_tool_types_returns_all_types() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
_insert_user(user_id)
|
||||
_insert_tool_type(
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"custom-tool",
|
||||
"Custom Tool",
|
||||
"version: '3.8'\nservices:\n app:\n image: custom",
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.get("/tool-types")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) >= 1
|
||||
custom_tool = next((t for t in data if t["name"] == "custom-tool"), None)
|
||||
assert custom_tool is not None
|
||||
assert custom_tool["display_name"] == "Custom Tool"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_get_tool_type_by_id() -> 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",
|
||||
"version: '3.8'\nservices:\n app:\n image: custom",
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.get(f"/tool-types/{tool_type_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == tool_type_id
|
||||
assert data["name"] == "custom-tool"
|
||||
assert data["display_name"] == "Custom Tool"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_get_tool_type_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("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.get("/tool-types/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_tool_type_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("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {
|
||||
"name": "my-custom-tool",
|
||||
"display_name": "My Custom Tool",
|
||||
"description": "A custom development tool",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: custom:latest",
|
||||
"required_variables": ["REPO_PATH"],
|
||||
}
|
||||
response = client.post("/tool-types", json=payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == "my-custom-tool"
|
||||
assert data["display_name"] == "My Custom Tool"
|
||||
assert data["description"] == "A custom development tool"
|
||||
assert data["is_builtin"] == False
|
||||
assert data["created_by_id"] == user_id
|
||||
assert "id" in data
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_tool_type_duplicate_name() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
_insert_user(user_id)
|
||||
_insert_tool_type(
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"existing-tool",
|
||||
"Existing Tool",
|
||||
"version: '3.8'\nservices:\n app:\n image: existing",
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {
|
||||
"name": "existing-tool",
|
||||
"display_name": "Existing Tool",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: custom",
|
||||
"required_variables": [],
|
||||
}
|
||||
response = client.post("/tool-types", json=payload)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_tool_type_invalid_yaml() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
_insert_user(user_id)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {
|
||||
"name": "bad-tool",
|
||||
"display_name": "Bad Tool",
|
||||
"compose_template": "this is not: valid: yaml: [",
|
||||
"required_variables": [],
|
||||
}
|
||||
response = client.post("/tool-types", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_tool_type_missing_services() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
_insert_user(user_id)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {
|
||||
"name": "bad-tool",
|
||||
"display_name": "Bad Tool",
|
||||
"compose_template": "version: '3.8'\ninvalid_key: value",
|
||||
"required_variables": [],
|
||||
}
|
||||
response = client.post("/tool-types", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_create_tool_type_missing_required_variable() -> None:
|
||||
_prepare_test_db()
|
||||
user_id = "11111111-1111-1111-1111-111111111111"
|
||||
_insert_user(user_id)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {
|
||||
"name": "bad-tool",
|
||||
"display_name": "Bad Tool",
|
||||
"compose_template": "version: '3.8'\nservices:\n app:\n image: custom",
|
||||
"required_variables": ["MISSING_VAR"],
|
||||
}
|
||||
response = client.post("/tool-types", json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_update_tool_type_successfully() -> 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",
|
||||
"version: '3.8'\nservices:\n app:\n image: custom",
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {
|
||||
"display_name": "Updated Custom Tool",
|
||||
"description": "Updated description",
|
||||
}
|
||||
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["display_name"] == "Updated Custom Tool"
|
||||
assert data["description"] == "Updated description"
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_update_tool_type_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("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {"display_name": "Updated"}
|
||||
response = client.put("/tool-types/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", json=payload)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_update_builtin_tool_type_fails() -> 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,
|
||||
"builtin-tool",
|
||||
"Built-in Tool",
|
||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
||||
is_builtin=True,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
payload = {"display_name": "Updated"}
|
||||
response = client.put(f"/tool-types/{tool_type_id}", json=payload)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_delete_tool_type_successfully() -> 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,
|
||||
"deletable-tool",
|
||||
"Deletable Tool",
|
||||
"version: '3.8'\nservices:\n app:\n image: custom",
|
||||
created_by_id=user_id,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.delete(f"/tool-types/{tool_type_id}")
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify it's gone
|
||||
get_response = client.get(f"/tool-types/{tool_type_id}")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_delete_tool_type_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("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.delete("/tool-types/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_delete_builtin_tool_type_fails() -> 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,
|
||||
"builtin-tool",
|
||||
"Built-in Tool",
|
||||
"version: '3.8'\nservices:\n app:\n image: builtin",
|
||||
is_builtin=True,
|
||||
)
|
||||
|
||||
app = _load_app()
|
||||
client = TestClient(app)
|
||||
client.cookies.set("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.delete(f"/tool-types/{tool_type_id}")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_builtin_tool_types_seeded_on_startup() -> 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("access_token", _mint_token(user_id))
|
||||
|
||||
response = client.get("/tool-types")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that built-in types exist
|
||||
builtin_names = [t["name"] for t in data if t["is_builtin"]]
|
||||
assert "code-server" in builtin_names
|
||||
assert "jupyter-notebook" in builtin_names
|
||||
|
||||
# Verify built-in types have correct attributes
|
||||
code_server = next((t for t in data if t["name"] == "code-server"), None)
|
||||
assert code_server is not None
|
||||
assert code_server["display_name"] == "VS Code Server"
|
||||
assert "services" in code_server["compose_template"]
|
||||
assert code_server["required_variables"] == ["REPO_PATH", "TOOL_NAME"]
|
||||
@@ -28,6 +28,6 @@
|
||||
|
||||
- [x] 4.1 Run backend quality gates (`pytest`, `ruff`, `mypy`)
|
||||
- [x] 4.2 Run frontend quality gates (`npm test`, `typecheck`, `lint`, `build`)
|
||||
- [ ] 4.3 Test CRUD operations manually
|
||||
- [ ] 4.4 Verify built-in types are seeded
|
||||
- [x] 4.3 Test CRUD operations manually
|
||||
- [x] 4.4 Verify built-in types are seeded
|
||||
- [x] 4.5 Update this tasks file with completed checkboxes
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tool Type Model
|
||||
|
||||
The system SHALL provide a `ToolType` model to store tool definitions.
|
||||
|
||||
#### Scenario: Model structure
|
||||
- GIVEN a tool type definition
|
||||
- THEN the model SHALL have:
|
||||
- `id`: UUID primary key
|
||||
- `name`: unique string (e.g., "code-server")
|
||||
- `display_name`: human-readable string (e.g., "VS Code Server")
|
||||
- `description`: optional text
|
||||
- `compose_template`: Docker Compose YAML string
|
||||
- `required_variables`: list of required template variables
|
||||
- `is_builtin`: boolean flag for system-defined types
|
||||
- `created_at`/`updated_at`: timestamps
|
||||
|
||||
### Requirement: CRUD API Endpoints
|
||||
|
||||
The system SHALL provide REST API endpoints for tool type management.
|
||||
|
||||
#### Scenario: List tool types
|
||||
- GIVEN an authenticated user
|
||||
- WHEN they GET /api/tool-types
|
||||
- THEN the system returns all tool types (built-in and custom)
|
||||
- AND returns 200 OK
|
||||
|
||||
#### Scenario: Create tool type
|
||||
- GIVEN an admin user
|
||||
- WHEN they POST /api/tool-types with valid data
|
||||
- THEN the system creates a new tool type
|
||||
- AND validates the compose template YAML
|
||||
- AND validates all required variables are present in template
|
||||
- AND returns 201 Created with the new tool type
|
||||
|
||||
#### Scenario: Get tool type
|
||||
- GIVEN an authenticated user
|
||||
- WHEN they GET /api/tool-types/{id}
|
||||
- THEN the system returns the tool type details
|
||||
- AND returns 200 OK
|
||||
|
||||
#### Scenario: Update tool type
|
||||
- GIVEN an admin user
|
||||
- WHEN they PUT /api/tool-types/{id} with valid data
|
||||
- THEN the system updates the tool type
|
||||
- AND re-validates the compose template
|
||||
- AND returns 200 OK with updated tool type
|
||||
|
||||
#### Scenario: Delete tool type
|
||||
- GIVEN an admin user
|
||||
- WHEN they DELETE /api/tool-types/{id}
|
||||
- THEN the system deletes the tool type
|
||||
- AND prevents deletion of built-in types
|
||||
- AND returns 204 No Content
|
||||
|
||||
### Requirement: Template Variable Substitution
|
||||
|
||||
The system SHALL support variable substitution in Docker Compose templates.
|
||||
|
||||
#### Scenario: Supported variables
|
||||
- GIVEN a compose template with variables
|
||||
- THEN the system SHALL support:
|
||||
- `{{REPO_PATH}}` - absolute path to repository
|
||||
- `{{PROJECT_NAME}}` - project name
|
||||
- `{{USER_ID}}` - user's UUID
|
||||
- `{{TOOL_NAME}}` - tool instance name
|
||||
|
||||
#### Scenario: Variable validation
|
||||
- GIVEN a new tool type with required variables
|
||||
- WHEN the template is created or updated
|
||||
- THEN the system validates all required variables exist in the template
|
||||
- AND returns 400 Bad Request if variables are missing
|
||||
|
||||
### Requirement: Built-in Tool Types
|
||||
|
||||
The system SHALL seed common tool types on first startup.
|
||||
|
||||
#### Scenario: Default tool types
|
||||
- GIVEN a fresh database
|
||||
- WHEN the application starts
|
||||
- THEN the system creates built-in tool types:
|
||||
- code-server (VS Code in browser)
|
||||
- jupyter-notebook (Jupyter Lab)
|
||||
|
||||
### Requirement: Compose Template Validation
|
||||
|
||||
The system SHALL validate Docker Compose templates.
|
||||
|
||||
#### Scenario: YAML validation
|
||||
- GIVEN a compose template string
|
||||
- WHEN creating or updating a tool type
|
||||
- THEN the system parses the YAML
|
||||
- AND returns 400 Bad Request if YAML is invalid
|
||||
|
||||
#### Scenario: Required structure
|
||||
- GIVEN a valid YAML compose template
|
||||
- THEN the system SHALL require:
|
||||
- `services` key present
|
||||
- At least one service defined
|
||||
Reference in New Issue
Block a user