feat: implement docker infrastructure (US-001)
- Add docker-compose.yml with postgres, redis, api, and web services - Add multi-stage Dockerfile for API (Python 3.11) - Add multi-stage Dockerfile for web (Node.js 20 + nginx) - Add Makefile with common development commands - Add .env.example with all required environment variables - Add placeholder pyproject.toml and package.json for builds - Configure health checks for all services - Setup persistent volumes for postgres, redis, and repos - Run services as non-root users
This commit is contained in:
@@ -1,90 +0,0 @@
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_db_session
|
||||
from app.main import app
|
||||
from app.models import Base
|
||||
|
||||
TEST_DATABASE_URL = settings.database_url
|
||||
if "/headquarter_test" not in TEST_DATABASE_URL:
|
||||
TEST_DATABASE_URL = TEST_DATABASE_URL.replace("/headquarter", "/headquarter_test")
|
||||
if TEST_DATABASE_URL.startswith("postgresql://"):
|
||||
TEST_DATABASE_URL = TEST_DATABASE_URL.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop() -> Generator[Any, None, None]:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
async def db_engine() -> AsyncGenerator[AsyncEngine, None]:
|
||||
engine = create_async_engine(TEST_DATABASE_URL, echo=False, poolclass=NullPool)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
except Exception as exc:
|
||||
await engine.dispose()
|
||||
pytest.skip(f"PostgreSQL unavailable for tests: {exc}")
|
||||
yield engine
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(
|
||||
db_engine: AsyncEngine,
|
||||
) -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
|
||||
async with db_engine.connect() as connection:
|
||||
trans = await connection.begin_nested()
|
||||
testing_session_local = async_sessionmaker(
|
||||
connection, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
|
||||
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with testing_session_local() as session:
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_db_session] = override_get_db
|
||||
original_db_url = settings.database_url
|
||||
settings.database_url = TEST_DATABASE_URL
|
||||
yield testing_session_local
|
||||
settings.database_url = original_db_url
|
||||
app.dependency_overrides.pop(get_db_session, None)
|
||||
await trans.rollback()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
db_session: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_client(
|
||||
client: AsyncClient,
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
original_debug = settings.debug
|
||||
original_bypass = settings.auth_dev_bypass
|
||||
settings.debug = True
|
||||
settings.auth_dev_bypass = True
|
||||
yield client
|
||||
settings.debug = original_debug
|
||||
settings.auth_dev_bypass = original_bypass
|
||||
@@ -1,87 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dev_bypass_creates_user(auth_client: AsyncClient) -> None:
|
||||
response = await auth_client.get("/api/v1/users/me")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["authentik_sub"] == "dev-user"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_token_raises_401_when_bypass_disabled(client: AsyncClient) -> None:
|
||||
original_debug = settings.debug
|
||||
original_bypass = settings.auth_dev_bypass
|
||||
settings.debug = False
|
||||
settings.auth_dev_bypass = False
|
||||
|
||||
response = await client.get("/api/v1/users/me")
|
||||
|
||||
settings.debug = original_debug
|
||||
settings.auth_dev_bypass = original_bypass
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inactive_user_raises_403(
|
||||
auth_client: AsyncClient, db_session: async_sessionmaker[Any]
|
||||
) -> None:
|
||||
async with db_session() as session:
|
||||
result = await session.execute(select(User).where(User.authentik_sub == "dev-user"))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
user = User(
|
||||
authentik_sub="dev-user",
|
||||
email="dev@localhost",
|
||||
display_name="Dev User",
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
async with db_session() as session:
|
||||
result = await session.execute(select(User).where(User.authentik_sub == "dev-user"))
|
||||
user = result.scalar_one()
|
||||
user.is_active = False
|
||||
await session.commit()
|
||||
|
||||
response = await auth_client.get("/api/v1/users/me")
|
||||
|
||||
async with db_session() as session:
|
||||
result = await session.execute(select(User).where(User.authentik_sub == "dev-user"))
|
||||
user = result.scalar_one()
|
||||
user.is_active = True
|
||||
await session.commit()
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dev_bypass_email_uniqueness(
|
||||
auth_client: AsyncClient, db_session: async_sessionmaker[Any]
|
||||
) -> None:
|
||||
async with db_session() as session:
|
||||
result = await session.execute(select(User).where(User.email == "dev@localhost"))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
await session.delete(existing)
|
||||
await session.commit()
|
||||
|
||||
response = await auth_client.get("/api/v1/users/me")
|
||||
assert response.status_code == 200
|
||||
|
||||
async with db_session() as session:
|
||||
result = await session.execute(select(User).where(User.email == "dev@localhost"))
|
||||
user = result.scalar_one_or_none()
|
||||
assert user is not None
|
||||
assert user.authentik_sub == "dev-user"
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Tests for credential models and storage interface."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential
|
||||
from app.git.types import CredentialKind
|
||||
|
||||
|
||||
class MinimalCredentialStorage(CredentialStorage):
|
||||
"""Concrete subclass for testing."""
|
||||
|
||||
def create(self, credential: GitCredential) -> uuid.UUID:
|
||||
return credential.id
|
||||
|
||||
def get(self, credential_id: uuid.UUID) -> GitCredential | None:
|
||||
return None
|
||||
|
||||
def delete(self, credential_id: uuid.UUID) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_git_credential_can_be_instantiated() -> None:
|
||||
cred = GitCredential(
|
||||
kind=CredentialKind.ssh_key,
|
||||
encrypted_payload="encrypted-data",
|
||||
)
|
||||
assert cred.kind == CredentialKind.ssh_key
|
||||
assert cred.encrypted_payload == "encrypted-data"
|
||||
assert isinstance(cred.id, uuid.UUID)
|
||||
|
||||
|
||||
def test_access_token_credential_can_be_instantiated() -> None:
|
||||
cred = AccessTokenCredential(encrypted_payload="encrypted-data")
|
||||
assert cred.kind == CredentialKind.access_token
|
||||
assert cred.encrypted_payload == "encrypted-data"
|
||||
|
||||
|
||||
def test_credential_storage_cannot_be_instantiated_directly() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
CredentialStorage() # type: ignore[abstract]
|
||||
|
||||
|
||||
def test_no_plaintext_secret_fields() -> None:
|
||||
fields = set(GitCredential.model_fields.keys())
|
||||
assert "token" not in fields
|
||||
assert "private_key" not in fields
|
||||
|
||||
|
||||
def test_extra_forbidden() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
GitCredential(
|
||||
kind=CredentialKind.access_token,
|
||||
encrypted_payload="encrypted-data",
|
||||
secret_plaintext="should-fail", # type: ignore[call-arg]
|
||||
)
|
||||
|
||||
|
||||
def test_minimal_credential_storage_implements_all_methods() -> None:
|
||||
storage = MinimalCredentialStorage()
|
||||
cred = GitCredential(
|
||||
kind=CredentialKind.access_token,
|
||||
encrypted_payload="encrypted-data",
|
||||
)
|
||||
assert storage.create(cred) == cred.id
|
||||
assert storage.get(cred.id) is None
|
||||
storage.delete(cred.id)
|
||||
|
||||
|
||||
def test_encrypted_payload_not_in_repr() -> None:
|
||||
cred = GitCredential(
|
||||
kind=CredentialKind.ssh_key,
|
||||
encrypted_payload="secret-value",
|
||||
)
|
||||
repr_str = repr(cred)
|
||||
assert "secret-value" not in repr_str
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Tests for local Git operations."""
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.git.operations import LocalGitOperations
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_git() -> LocalGitOperations:
|
||||
return LocalGitOperations()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_repo() -> Any:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
repo_path = Path(tmpdir) / "repo"
|
||||
repo_path.mkdir()
|
||||
subprocess.run(
|
||||
["git", "init", "--initial-branch=main"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.email", "test@example.com"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
yield repo_path
|
||||
|
||||
|
||||
def test_clone_invalid_repo_raises(local_git: LocalGitOperations) -> None:
|
||||
with pytest.raises(RuntimeError, match="Git clone failed"):
|
||||
local_git.clone("https://example.com/repo.git", Path("/tmp/dest"), "cred-id")
|
||||
|
||||
|
||||
def test_fetch_no_remote_succeeds(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||
local_git.fetch(temp_repo, "cred-id")
|
||||
|
||||
|
||||
def test_push_no_remote_raises(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||
with pytest.raises(RuntimeError, match="Git push failed"):
|
||||
local_git.push(temp_repo, "cred-id")
|
||||
|
||||
|
||||
def test_get_status_on_non_git_directory_raises(local_git: LocalGitOperations) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
with pytest.raises(RuntimeError, match="Not a git repository"):
|
||||
local_git.get_status(Path(tmpdir))
|
||||
|
||||
|
||||
def test_get_status_clean_repo(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||
status = local_git.get_status(temp_repo)
|
||||
assert status["branch"] == "main"
|
||||
assert status["clean"] is True
|
||||
assert status["untracked"] == []
|
||||
assert status["modified"] == []
|
||||
assert status["staged"] == []
|
||||
assert status["deleted"] == []
|
||||
|
||||
|
||||
def test_get_status_untracked_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||
(temp_repo / "newfile.txt").write_text("hello")
|
||||
status = local_git.get_status(temp_repo)
|
||||
assert "newfile.txt" in status["untracked"]
|
||||
assert status["clean"] is False
|
||||
|
||||
|
||||
def test_get_status_staged_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||
file_path = temp_repo / "newfile.txt"
|
||||
file_path.write_text("hello")
|
||||
subprocess.run(
|
||||
["git", "add", "newfile.txt"],
|
||||
cwd=temp_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
status = local_git.get_status(temp_repo)
|
||||
assert "newfile.txt" in status["staged"]
|
||||
assert status["clean"] is False
|
||||
|
||||
|
||||
def test_get_status_modified_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||
file_path = temp_repo / "newfile.txt"
|
||||
file_path.write_text("hello")
|
||||
subprocess.run(
|
||||
["git", "add", "newfile.txt"],
|
||||
cwd=temp_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
file_path.write_text("world")
|
||||
status = local_git.get_status(temp_repo)
|
||||
assert "newfile.txt" in status["modified"]
|
||||
assert status["clean"] is False
|
||||
|
||||
|
||||
def test_get_status_deleted_file(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||
file_path = temp_repo / "newfile.txt"
|
||||
file_path.write_text("hello")
|
||||
subprocess.run(
|
||||
["git", "add", "newfile.txt"],
|
||||
cwd=temp_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "add file"],
|
||||
cwd=temp_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
file_path.unlink()
|
||||
status = local_git.get_status(temp_repo)
|
||||
assert "newfile.txt" in status["deleted"]
|
||||
assert status["clean"] is False
|
||||
|
||||
|
||||
def test_get_status_branch_name(local_git: LocalGitOperations, temp_repo: Path) -> None:
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "feature-branch"],
|
||||
cwd=temp_repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
status = local_git.get_status(temp_repo)
|
||||
assert status["branch"] == "feature-branch"
|
||||
@@ -1,66 +0,0 @@
|
||||
"""Tests for Git provider abstraction and types."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.git.provider import GitProvider
|
||||
from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus
|
||||
|
||||
|
||||
class MinimalGitProvider(GitProvider):
|
||||
"""Concrete subclass for testing."""
|
||||
|
||||
def get_kind(self) -> ProviderKind:
|
||||
return ProviderKind.generic
|
||||
|
||||
def validate_connection(self, git_url: str, credential_id: str) -> ConnectionStatus:
|
||||
return ConnectionStatus.connected
|
||||
|
||||
def list_repositories(self, credential_id: str) -> list[dict[str, Any]]:
|
||||
return []
|
||||
|
||||
def create_deploy_key(self, git_url: str, public_key: str) -> str:
|
||||
return "key-id"
|
||||
|
||||
def delete_deploy_key(self, git_url: str, deploy_key_id: str) -> None:
|
||||
return None
|
||||
|
||||
def get_default_branch(self, git_url: str, credential_id: str) -> str:
|
||||
return "main"
|
||||
|
||||
|
||||
def test_git_provider_cannot_be_instantiated_directly() -> None:
|
||||
with pytest.raises(TypeError):
|
||||
GitProvider() # type: ignore[abstract]
|
||||
|
||||
|
||||
def test_minimal_git_provider_implements_all_methods() -> None:
|
||||
provider = MinimalGitProvider()
|
||||
assert provider.get_kind() == ProviderKind.generic
|
||||
status = provider.validate_connection("https://example.com/repo.git", "cred-id")
|
||||
assert status == ConnectionStatus.connected
|
||||
assert provider.list_repositories("cred-id") == []
|
||||
assert provider.create_deploy_key("https://example.com/repo.git", "ssh-rsa AAAA") == "key-id"
|
||||
provider.delete_deploy_key("https://example.com/repo.git", "key-id")
|
||||
assert provider.get_default_branch("https://example.com/repo.git", "cred-id") == "main"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member", ["github", "gitlab", "gitea", "forgejo", "generic"])
|
||||
def test_provider_kind_membership(member: str) -> None:
|
||||
assert member in ProviderKind
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member", ["ssh_key", "access_token"])
|
||||
def test_credential_kind_membership(member: str) -> None:
|
||||
assert member in CredentialKind
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member", ["pending", "connected", "disconnected", "error"])
|
||||
def test_connection_status_membership(member: str) -> None:
|
||||
assert member in ConnectionStatus
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member", ["generated", "registered", "rotating", "revoked"])
|
||||
def test_ssh_key_status_membership(member: str) -> None:
|
||||
assert member in SshKeyStatus
|
||||
@@ -1,14 +0,0 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_returns_ok(client: AsyncClient) -> None:
|
||||
response = await client.get("/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["service"] == settings.app_name
|
||||
assert data["database"] == "connected"
|
||||
@@ -1,35 +0,0 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_project_crud(auth_client: AsyncClient) -> None:
|
||||
# Create
|
||||
resp = await auth_client.post("/api/v1/projects", json={"name": "Test", "slug": "test"})
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "Test"
|
||||
project_id = data["id"]
|
||||
|
||||
# List
|
||||
resp = await auth_client.get("/api/v1/projects")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
# Get
|
||||
resp = await auth_client.get(f"/api/v1/projects/{project_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["slug"] == "test"
|
||||
|
||||
# Update
|
||||
resp = await auth_client.put(f"/api/v1/projects/{project_id}", json={"name": "Updated"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "Updated"
|
||||
|
||||
# Delete
|
||||
resp = await auth_client.delete(f"/api/v1/projects/{project_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
# Verify deletion
|
||||
resp = await auth_client.get(f"/api/v1/projects/{project_id}")
|
||||
assert resp.status_code == 404
|
||||
@@ -1,40 +0,0 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_crud(auth_client: AsyncClient) -> None:
|
||||
# Create project first
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/projects", json={"name": "RepoTest", "slug": "repo-test"}
|
||||
)
|
||||
project_id = resp.json()["id"]
|
||||
|
||||
# Create repo
|
||||
resp = await auth_client.post(
|
||||
f"/api/v1/projects/{project_id}/repositories",
|
||||
json={"name": "repo1", "git_url": "https://git.example.com/repo1.git"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
repo_id = resp.json()["id"]
|
||||
|
||||
# List
|
||||
resp = await auth_client.get(f"/api/v1/projects/{project_id}/repositories")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) == 1
|
||||
|
||||
# Get
|
||||
resp = await auth_client.get(f"/api/v1/projects/{project_id}/repositories/{repo_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Update
|
||||
resp = await auth_client.put(
|
||||
f"/api/v1/projects/{project_id}/repositories/{repo_id}",
|
||||
json={"name": "repo1-updated"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "repo1-updated"
|
||||
|
||||
# Delete
|
||||
resp = await auth_client.delete(f"/api/v1/projects/{project_id}/repositories/{repo_id}")
|
||||
assert resp.status_code == 204
|
||||
@@ -1,72 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from app.models.secret import Secret
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secret_encrypt_decrypt(
|
||||
auth_client: AsyncClient, db_session: async_sessionmaker[Any]
|
||||
) -> None:
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/projects",
|
||||
json={"name": "SecretTest", "slug": "secret-test"},
|
||||
)
|
||||
project_id = resp.json()["id"]
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/secrets",
|
||||
json={
|
||||
"scope_type": "project",
|
||||
"scope_id": str(project_id),
|
||||
"key": "api_key",
|
||||
"value": "super-secret",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["value"] == "super-secret"
|
||||
secret_id = data["id"]
|
||||
|
||||
resp = await auth_client.get(f"/api/v1/secrets/{secret_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "super-secret"
|
||||
|
||||
async with db_session() as session:
|
||||
result = await session.execute(select(Secret).where(Secret.id == secret_id))
|
||||
secret = result.scalar_one()
|
||||
assert secret.encrypted_value != "super-secret"
|
||||
|
||||
resp = await auth_client.put(
|
||||
f"/api/v1/secrets/{secret_id}",
|
||||
json={"value": "new-secret"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["value"] == "new-secret"
|
||||
|
||||
resp = await auth_client.delete(f"/api/v1/secrets/{secret_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_secret_ownership_enforced(auth_client: AsyncClient) -> None:
|
||||
resp = await auth_client.post("/api/v1/projects", json={"name": "P1", "slug": "p1"})
|
||||
p1 = resp.json()["id"]
|
||||
|
||||
resp = await auth_client.post("/api/v1/projects", json={"name": "P2", "slug": "p2"})
|
||||
p2 = resp.json()["id"]
|
||||
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/secrets",
|
||||
json={"scope_type": "project", "scope_id": str(p1), "key": "k1", "value": "v1"},
|
||||
)
|
||||
|
||||
resp = await auth_client.get("/api/v1/secrets", params={"scope_id": str(p2)})
|
||||
assert resp.status_code == 200
|
||||
secrets = resp.json()
|
||||
for s in secrets:
|
||||
assert s["scope_id"] != str(p1) or s["scope_type"] != "project"
|
||||
@@ -1,30 +0,0 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_definition_crud(auth_client: AsyncClient) -> None:
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/tool-definitions",
|
||||
json={
|
||||
"key": "opencode",
|
||||
"name": "OpenCode",
|
||||
"image": "ghcr.io/opencode-ai/opencode:latest",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
td_id = resp.json()["id"]
|
||||
|
||||
resp = await auth_client.get("/api/v1/tool-definitions")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
resp = await auth_client.get(f"/api/v1/tool-definitions/{td_id}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
resp = await auth_client.put(f"/api/v1/tool-definitions/{td_id}", json={"name": "OpenCodeV2"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "OpenCodeV2"
|
||||
|
||||
resp = await auth_client.delete(f"/api/v1/tool-definitions/{td_id}")
|
||||
assert resp.status_code == 204
|
||||
@@ -1,110 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.runtime_injection import RuntimeInjectionError, RuntimeInjectionService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_configs_empty(db_session):
|
||||
result = await RuntimeInjectionService.resolve_configs(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_configs_global_only(db_session, sample_config):
|
||||
result = await RuntimeInjectionService.resolve_configs(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result == {"test_key": "test_value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_configs_scope_override(db_session):
|
||||
from app.models.config import Config
|
||||
|
||||
global_config = Config(
|
||||
scope_type="global",
|
||||
scope_id=UUID(int=0),
|
||||
key="shared_key",
|
||||
value="global_value",
|
||||
)
|
||||
project_config = Config(
|
||||
scope_type="project",
|
||||
scope_id=UUID(int=1),
|
||||
key="shared_key",
|
||||
value="project_value",
|
||||
)
|
||||
db_session.add_all([global_config, project_config])
|
||||
await db_session.commit()
|
||||
|
||||
result = await RuntimeInjectionService.resolve_configs(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result["shared_key"] == "project_value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_secrets_empty(db_session):
|
||||
result = await RuntimeInjectionService.resolve_secrets(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_secrets_decrypts(db_session, sample_secret):
|
||||
result = await RuntimeInjectionService.resolve_secrets(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result == {"secret_key": "secret_value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_secrets_exist_missing(db_session):
|
||||
with pytest.raises(RuntimeInjectionError, match="Missing required secrets"):
|
||||
await RuntimeInjectionService.validate_secrets_exist(
|
||||
db_session,
|
||||
required_secret_keys=["missing_secret"],
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_secrets_exist_found(db_session, sample_secret):
|
||||
await RuntimeInjectionService.validate_secrets_exist(
|
||||
db_session,
|
||||
required_secret_keys=["secret_key"],
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
|
||||
|
||||
def test_generate_config_files(tmp_path):
|
||||
configs = {"app": {"port": 8080}, "debug": True}
|
||||
mounts = RuntimeInjectionService.generate_config_files(configs, tmp_path)
|
||||
|
||||
assert len(mounts) == 2
|
||||
assert (tmp_path / "app.json").exists()
|
||||
assert (tmp_path / "debug.json").exists()
|
||||
assert (tmp_path / "app.json").stat().st_mode & 0o777 == 0o400
|
||||
|
||||
|
||||
def test_generate_secret_env_vars():
|
||||
secrets = {"api_key": "abc123", "db_pass": "secret"}
|
||||
env_vars = RuntimeInjectionService.generate_secret_env_vars(secrets)
|
||||
|
||||
assert env_vars == {"API_KEY": "abc123", "DB_PASS": "secret"}
|
||||
@@ -1,197 +0,0 @@
|
||||
|
||||
from app.services.traefik import TraefikLabelGenerator
|
||||
|
||||
|
||||
class TestTraefikLabelGenerator:
|
||||
def test_generate_subdomain(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com")
|
||||
subdomain = gen.generate_subdomain(
|
||||
tool_key="code-server",
|
||||
project_slug="my-project",
|
||||
user_slug="alice",
|
||||
)
|
||||
assert subdomain == "code-server-my-project-alice.hq.example.com"
|
||||
|
||||
def test_generate_subdomain_with_different_domain(self):
|
||||
gen = TraefikLabelGenerator(domain="tools.localhost")
|
||||
subdomain = gen.generate_subdomain(
|
||||
tool_key="opencode",
|
||||
project_slug="test",
|
||||
user_slug="bob",
|
||||
)
|
||||
assert subdomain == "opencode-test-bob.tools.localhost"
|
||||
|
||||
def test_generate_labels_basic(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com")
|
||||
labels = gen.generate_labels(
|
||||
instance_id="abc12345-6789-1234-5678-123456789abc",
|
||||
tool_key="code-server",
|
||||
project_slug="my-project",
|
||||
user_slug="alice",
|
||||
container_port=8443,
|
||||
)
|
||||
|
||||
assert labels["traefik.enable"] == "true"
|
||||
assert "Host(`code-server-my-project-alice.hq.example.com`)" in labels[
|
||||
"traefik.http.routers.tool-abc12345.rule"
|
||||
]
|
||||
assert labels["traefik.http.routers.tool-abc12345.entrypoints"] == "websecure"
|
||||
assert labels["traefik.http.routers.tool-abc12345.service"] == "tool-abc12345"
|
||||
|
||||
def test_generate_labels_tls(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com", entrypoint="websecure")
|
||||
labels = gen.generate_labels(
|
||||
instance_id="abc12345-6789-1234-5678-123456789abc",
|
||||
tool_key="code-server",
|
||||
project_slug="my-project",
|
||||
user_slug="alice",
|
||||
container_port=8443,
|
||||
)
|
||||
|
||||
assert labels["traefik.http.routers.tool-abc12345.tls"] == "true"
|
||||
assert (
|
||||
labels["traefik.http.routers.tool-abc12345.tls.certresolver"]
|
||||
== "letsencrypt"
|
||||
)
|
||||
|
||||
def test_generate_labels_no_tls_for_http(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com", entrypoint="web")
|
||||
labels = gen.generate_labels(
|
||||
instance_id="abc12345-6789-1234-5678-123456789abc",
|
||||
tool_key="code-server",
|
||||
project_slug="my-project",
|
||||
user_slug="alice",
|
||||
container_port=8443,
|
||||
)
|
||||
|
||||
assert "traefik.http.routers.tool-abc12345.tls" not in labels
|
||||
assert "traefik.http.routers.tool-abc12345.tls.certresolver" not in labels
|
||||
|
||||
def test_generate_labels_service_config(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com")
|
||||
labels = gen.generate_labels(
|
||||
instance_id="abc12345-6789-1234-5678-123456789abc",
|
||||
tool_key="code-server",
|
||||
project_slug="my-project",
|
||||
user_slug="alice",
|
||||
container_port=8443,
|
||||
)
|
||||
|
||||
assert (
|
||||
labels["traefik.http.services.tool-abc12345.loadbalancer.server.port"]
|
||||
== "8443"
|
||||
)
|
||||
assert (
|
||||
labels["traefik.http.services.tool-abc12345.loadbalancer.server.scheme"]
|
||||
== "http"
|
||||
)
|
||||
|
||||
def test_generate_labels_security_headers(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com")
|
||||
labels = gen.generate_labels(
|
||||
instance_id="abc12345-6789-1234-5678-123456789abc",
|
||||
tool_key="code-server",
|
||||
project_slug="my-project",
|
||||
user_slug="alice",
|
||||
container_port=8443,
|
||||
)
|
||||
|
||||
middleware_name = "tool-abc12345-sec"
|
||||
assert (
|
||||
labels[f"traefik.http.middlewares.{middleware_name}.headers.stsSeconds"]
|
||||
== "31536000"
|
||||
)
|
||||
assert (
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.stsIncludeSubdomains"
|
||||
]
|
||||
== "true"
|
||||
)
|
||||
assert (
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.forceStsHeader"
|
||||
]
|
||||
== "true"
|
||||
)
|
||||
assert (
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.contentTypeNosniff"
|
||||
]
|
||||
== "true"
|
||||
)
|
||||
assert (
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.browserXssFilter"
|
||||
]
|
||||
== "true"
|
||||
)
|
||||
assert (
|
||||
labels[
|
||||
f"traefik.http.middlewares.{middleware_name}.headers.customFrameOptionsValue"
|
||||
]
|
||||
== "SAMEORIGIN"
|
||||
)
|
||||
|
||||
def test_generate_labels_middleware_attached(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com")
|
||||
labels = gen.generate_labels(
|
||||
instance_id="abc12345-6789-1234-5678-123456789abc",
|
||||
tool_key="code-server",
|
||||
project_slug="my-project",
|
||||
user_slug="alice",
|
||||
container_port=8443,
|
||||
)
|
||||
|
||||
assert labels["traefik.http.routers.tool-abc12345.middlewares"] == "tool-abc12345-sec"
|
||||
|
||||
def test_generate_labels_network(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com")
|
||||
labels = gen.generate_labels(
|
||||
instance_id="abc12345-6789-1234-5678-123456789abc",
|
||||
tool_key="code-server",
|
||||
project_slug="my-project",
|
||||
user_slug="alice",
|
||||
container_port=8443,
|
||||
network_name="custom-network",
|
||||
)
|
||||
|
||||
assert labels["traefik.docker.network"] == "custom-network"
|
||||
|
||||
def test_generate_labels_default_network(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com")
|
||||
labels = gen.generate_labels(
|
||||
instance_id="abc12345-6789-1234-5678-123456789abc",
|
||||
tool_key="code-server",
|
||||
project_slug="my-project",
|
||||
user_slug="alice",
|
||||
container_port=8443,
|
||||
)
|
||||
|
||||
assert labels["traefik.docker.network"] == "tools"
|
||||
|
||||
def test_generate_removal_labels(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com")
|
||||
labels = gen.generate_removal_labels(
|
||||
instance_id="abc12345-6789-1234-5678-123456789abc",
|
||||
)
|
||||
|
||||
assert labels["traefik.enable"] == "false"
|
||||
assert labels["traefik.http.routers.tool-abc12345.rule"] == ""
|
||||
|
||||
def test_generate_labels_with_opencode(self):
|
||||
gen = TraefikLabelGenerator(domain="hq.example.com")
|
||||
labels = gen.generate_labels(
|
||||
instance_id="xyz78901-2345-6789-0123-456789012345",
|
||||
tool_key="opencode",
|
||||
project_slug="demo",
|
||||
user_slug="charlie",
|
||||
container_port=3000,
|
||||
)
|
||||
|
||||
assert "Host(`opencode-demo-charlie.hq.example.com`)" in labels[
|
||||
"traefik.http.routers.tool-xyz78901.rule"
|
||||
]
|
||||
assert (
|
||||
labels["traefik.http.services.tool-xyz78901.loadbalancer.server.port"]
|
||||
== "3000"
|
||||
)
|
||||
@@ -1,241 +0,0 @@
|
||||
"""Tests for the tool manifest Pydantic models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.tools.models import (
|
||||
HealthCheckConfig,
|
||||
MountConfig,
|
||||
PortConfig,
|
||||
ResourceLimits,
|
||||
SecretRef,
|
||||
ToolManifest,
|
||||
TraefikConfig,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _minimal_manifest(**overrides: object) -> ToolManifest:
|
||||
defaults: dict[str, object] = {
|
||||
"id": "test-tool",
|
||||
"name": "Test Tool",
|
||||
"image": "test:latest",
|
||||
"ports": [PortConfig(container_port=8080, primary=True)],
|
||||
"traefik": TraefikConfig(enabled=False),
|
||||
}
|
||||
defaults.update(overrides)
|
||||
return ToolManifest.model_validate(defaults)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valid construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_valid_opencode_shape() -> None:
|
||||
manifest = ToolManifest(
|
||||
id="opencode",
|
||||
name="OpenCode",
|
||||
description="AI-powered terminal-based development environment.",
|
||||
image="ghcr.io/opencode-ai/opencode:latest",
|
||||
runtime_working_dir="/workspace",
|
||||
ports=[PortConfig(container_port=3000, name="http", primary=True)],
|
||||
workspace_mounts=[
|
||||
MountConfig(source_pattern="{project_repo}", target="/workspace")
|
||||
],
|
||||
config_mounts=[
|
||||
MountConfig(
|
||||
source_pattern="{user_config}/opencode", target="/root/.config/opencode"
|
||||
)
|
||||
],
|
||||
env={"TERM": "xterm-256color", "FORCE_COLOR": "1"},
|
||||
health_check=HealthCheckConfig(
|
||||
type="http", path="/", port=3000, start_period_seconds=15
|
||||
),
|
||||
resource_limits=ResourceLimits(cpus=2.0, memory_mb=4096),
|
||||
traefik=TraefikConfig(
|
||||
enabled=True, subdomain_prefix="opencode", port=3000
|
||||
),
|
||||
)
|
||||
assert manifest.id == "opencode"
|
||||
assert manifest.ports[0].primary is True
|
||||
assert manifest.traefik is not None
|
||||
assert manifest.traefik.enabled is True
|
||||
|
||||
|
||||
def test_valid_code_server_shape() -> None:
|
||||
manifest = ToolManifest(
|
||||
id="code-server",
|
||||
name="code-server",
|
||||
description="VS Code in the browser.",
|
||||
image="codercom/code-server:latest",
|
||||
runtime_working_dir="/workspace",
|
||||
ports=[PortConfig(container_port=8080, name="http", primary=True)],
|
||||
workspace_mounts=[
|
||||
MountConfig(source_pattern="{project_repo}", target="/workspace")
|
||||
],
|
||||
config_mounts=[
|
||||
MountConfig(
|
||||
source_pattern="{user_config}/code-server",
|
||||
target="/home/coder/.config/code-server",
|
||||
)
|
||||
],
|
||||
health_check=HealthCheckConfig(type="http", path="/healthz", port=8080),
|
||||
resource_limits=ResourceLimits(cpus=2.0, memory_mb=4096),
|
||||
traefik=TraefikConfig(enabled=True, subdomain_prefix="code", port=8080),
|
||||
secrets=[
|
||||
SecretRef(name="code-server-password", env_var="PASSWORD", required=False)
|
||||
],
|
||||
)
|
||||
assert manifest.id == "code-server"
|
||||
assert manifest.secrets[0].env_var == "PASSWORD"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Invalid id values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_invalid_id_uppercase() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
_minimal_manifest(id="OpenCode")
|
||||
assert "id" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_invalid_id_spaces() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
_minimal_manifest(id="open code")
|
||||
assert "id" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_invalid_id_empty_string() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
_minimal_manifest(id="")
|
||||
assert "id" in str(exc_info.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Port validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_invalid_container_port_zero() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
PortConfig(container_port=0)
|
||||
assert "container_port" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_invalid_container_port_too_high() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
PortConfig(container_port=70000)
|
||||
assert "container_port" in str(exc_info.value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mount target validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_mount_target_not_absolute() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
MountConfig(source_pattern="{project_repo}", target="workspace")
|
||||
assert "absolute" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HealthCheck validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_health_check_http_missing_path() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
HealthCheckConfig(type="http")
|
||||
assert "path" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_health_check_command_missing_command() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
HealthCheckConfig(type="command")
|
||||
assert "command" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_health_check_tcp_allows_missing_path() -> None:
|
||||
hc = HealthCheckConfig(type="tcp")
|
||||
assert hc.type == "tcp"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Traefik + primary port validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_missing_primary_port_when_traefik_enabled() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ToolManifest(
|
||||
id="bad-tool",
|
||||
name="Bad Tool",
|
||||
image="test:latest",
|
||||
ports=[PortConfig(container_port=8080, primary=False)],
|
||||
traefik=TraefikConfig(enabled=True),
|
||||
)
|
||||
assert "primary" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
def test_traefik_disabled_allows_no_primary_port() -> None:
|
||||
manifest = ToolManifest(
|
||||
id="no-route",
|
||||
name="No Route",
|
||||
image="test:latest",
|
||||
ports=[PortConfig(container_port=8080, primary=False)],
|
||||
traefik=TraefikConfig(enabled=False),
|
||||
)
|
||||
assert manifest.traefik is not None
|
||||
assert manifest.traefik.enabled is False
|
||||
|
||||
|
||||
def test_no_traefik_allows_no_primary_port() -> None:
|
||||
manifest = ToolManifest(
|
||||
id="no-route",
|
||||
name="No Route",
|
||||
image="test:latest",
|
||||
ports=[PortConfig(container_port=8080, primary=False)],
|
||||
)
|
||||
assert manifest.traefik is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resource limits validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_resource_limits_cpus_too_low() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ResourceLimits(cpus=0.001)
|
||||
assert "cpus" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_resource_limits_memory_mb_too_low() -> None:
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
ResourceLimits(memory_mb=8)
|
||||
assert "memory_mb" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_resource_limits_memory_swap_negative_one_ok() -> None:
|
||||
rl = ResourceLimits(memory_swap_mb=-1)
|
||||
assert rl.memory_swap_mb == -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serialization round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_serialization_roundtrip() -> None:
|
||||
original = _minimal_manifest(
|
||||
id="roundtrip",
|
||||
name="Roundtrip Tool",
|
||||
ports=[PortConfig(container_port=3000, name="http", primary=True)],
|
||||
traefik=TraefikConfig(enabled=True, subdomain_prefix="rt"),
|
||||
)
|
||||
dumped = original.model_dump(mode="json")
|
||||
restored = ToolManifest.model_validate(dumped)
|
||||
assert restored.id == original.id
|
||||
assert restored.ports[0].container_port == original.ports[0].container_port
|
||||
assert restored.traefik is not None
|
||||
assert restored.traefik.subdomain_prefix == "rt"
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Tests for the in-memory tool manifest registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.tools.models import PortConfig, ToolManifest, TraefikConfig
|
||||
from app.tools.registry import ToolRegistry
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def registry() -> ToolRegistry:
|
||||
return ToolRegistry()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_manifest() -> ToolManifest:
|
||||
return ToolManifest(
|
||||
id="test-tool",
|
||||
name="Test Tool",
|
||||
image="test:latest",
|
||||
ports=[PortConfig(container_port=8080, primary=True)],
|
||||
traefik=TraefikConfig(enabled=False),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Register / get round-trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_register_and_get(registry: ToolRegistry, sample_manifest: ToolManifest) -> None:
|
||||
registry.register(sample_manifest)
|
||||
retrieved = registry.get("test-tool")
|
||||
assert retrieved is not None
|
||||
assert retrieved.id == "test-tool"
|
||||
|
||||
|
||||
def test_get_missing_returns_none(registry: ToolRegistry) -> None:
|
||||
assert registry.get("missing") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# List
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_returns_all(registry: ToolRegistry) -> None:
|
||||
m1 = ToolManifest(
|
||||
id="tool-a",
|
||||
name="Tool A",
|
||||
image="a:latest",
|
||||
ports=[PortConfig(container_port=8080, primary=True)],
|
||||
traefik=TraefikConfig(enabled=False),
|
||||
)
|
||||
m2 = ToolManifest(
|
||||
id="tool-b",
|
||||
name="Tool B",
|
||||
image="b:latest",
|
||||
ports=[PortConfig(container_port=3000, primary=True)],
|
||||
traefik=TraefikConfig(enabled=False),
|
||||
)
|
||||
registry.register(m1)
|
||||
registry.register(m2)
|
||||
assert len(registry.list()) == 2
|
||||
ids = {m.id for m in registry.list()}
|
||||
assert ids == {"tool-a", "tool-b"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Overwrite behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_register_overwrites_existing(
|
||||
registry: ToolRegistry,
|
||||
sample_manifest: ToolManifest,
|
||||
) -> None:
|
||||
registry.register(sample_manifest)
|
||||
updated = ToolManifest(
|
||||
id="test-tool",
|
||||
name="Updated Tool",
|
||||
image="updated:latest",
|
||||
ports=[PortConfig(container_port=8080, primary=True)],
|
||||
traefik=TraefikConfig(enabled=False),
|
||||
)
|
||||
registry.register(updated)
|
||||
retrieved = registry.get("test-tool")
|
||||
assert retrieved is not None
|
||||
assert retrieved.name == "Updated Tool"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remove
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_remove_returns_manifest(registry: ToolRegistry, sample_manifest: ToolManifest) -> None:
|
||||
registry.register(sample_manifest)
|
||||
removed = registry.remove("test-tool")
|
||||
assert removed is not None
|
||||
assert removed.id == "test-tool"
|
||||
assert registry.get("test-tool") is None
|
||||
|
||||
|
||||
def test_remove_missing_returns_none(registry: ToolRegistry) -> None:
|
||||
assert registry.remove("missing") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_load_valid_yaml_file(registry: ToolRegistry, tmp_path: Path) -> None:
|
||||
yaml_path = tmp_path / "my-tool.yml"
|
||||
yaml_path.write_text(
|
||||
"""
|
||||
id: my-tool
|
||||
name: My Tool
|
||||
image: my-tool:latest
|
||||
ports:
|
||||
- container_port: 8080
|
||||
primary: true
|
||||
traefik:
|
||||
enabled: false
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
manifest = registry.load_file(yaml_path)
|
||||
assert manifest.id == "my-tool"
|
||||
assert manifest.name == "My Tool"
|
||||
assert manifest.ports[0].container_port == 8080
|
||||
|
||||
|
||||
def test_load_invalid_yaml_raises(registry: ToolRegistry, tmp_path: Path) -> None:
|
||||
yaml_path = tmp_path / "bad-tool.yml"
|
||||
yaml_path.write_text(
|
||||
"""
|
||||
id: BAD ID
|
||||
name: Bad Tool
|
||||
image: bad:latest
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
registry.load_file(yaml_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Load builtin manifests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_load_builtin_manifests(registry: ToolRegistry) -> None:
|
||||
registry.load_builtin_manifests()
|
||||
# Built-in manifests from Step 4 may not exist yet in isolation,
|
||||
# but the method should not raise regardless of directory contents.
|
||||
assert isinstance(registry.list(), list)
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Tests for the FastAPI tool manifest router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.tools.registry import registry
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry() -> None:
|
||||
registry._manifests.clear()
|
||||
registry.load_builtin_manifests()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_tools_includes_builtins() -> None:
|
||||
response = client.get("/api/v1/tools")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
ids = {item["id"] for item in data}
|
||||
assert "opencode" in ids
|
||||
assert "code-server" in ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/tools/{tool_id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_tool_opencode() -> None:
|
||||
response = client.get("/api/v1/tools/opencode")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == "opencode"
|
||||
assert data["name"] == "OpenCode"
|
||||
|
||||
|
||||
def test_get_tool_not_found() -> None:
|
||||
response = client.get("/api/v1/tools/nonexistent")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/v1/tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_tool_success() -> None:
|
||||
payload = {
|
||||
"id": "new-tool",
|
||||
"name": "New Tool",
|
||||
"image": "new-tool:latest",
|
||||
"ports": [{"container_port": 3000, "primary": True}],
|
||||
"traefik": {"enabled": False},
|
||||
}
|
||||
response = client.post("/api/v1/tools", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["id"] == "new-tool"
|
||||
assert data["name"] == "New Tool"
|
||||
|
||||
|
||||
def test_create_tool_duplicate() -> None:
|
||||
payload = {
|
||||
"id": "opencode",
|
||||
"name": "Duplicate",
|
||||
"image": "dup:latest",
|
||||
"ports": [{"container_port": 3000, "primary": True}],
|
||||
"traefik": {"enabled": False},
|
||||
}
|
||||
response = client.post("/api/v1/tools", json=payload)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
def test_create_tool_invalid_id() -> None:
|
||||
payload = {
|
||||
"id": "Bad ID",
|
||||
"name": "Bad Tool",
|
||||
"image": "bad:latest",
|
||||
"ports": [{"container_port": 3000, "primary": True}],
|
||||
"traefik": {"enabled": False},
|
||||
}
|
||||
response = client.post("/api/v1/tools", json=payload)
|
||||
assert response.status_code == 422
|
||||
Reference in New Issue
Block a user