Files
headquarter/apps/api/tests/integration/test_config_profiles_api.py
Alex Blank 33d08faf70 feat: allow relative target paths for git mounts
- Remove absolute path requirement from target_path validation
- Resolve relative paths against working_directory at instance startup
- Fall back to /home/user if no working_directory is configured
- Update frontend to allow relative target paths
- Update spec to document relative path support
- Update tests to allow relative paths and test path traversal rejection
2026-05-27 15:01:16 +02:00

454 lines
16 KiB
Python

import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestConfigProfilesAPI:
"""Integration tests for config profiles API."""
def test_list_config_profiles_requires_authentication(self, test_client: TestClient) -> None:
"""Test that listing config profiles requires authentication."""
response = test_client.get("/config-profiles")
assert response.status_code == 401
def test_list_config_profiles_returns_user_profiles(self, authenticated_client: TestClient) -> None:
"""Test that authenticated users can list their profiles."""
response = authenticated_client.get("/config-profiles")
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
def test_create_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config profile."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "test-profile",
"description": "Test profile",
"env_vars": {"VAR": "value"},
"runtime_hints": {"start_command": "npm start"},
"mounts": [{"target": "/app", "mode": "rw", "files": {}}],
"files": {"test.txt": "hello"},
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "test-profile"
assert data["env_vars"] == {"VAR": "value"}
assert data["files"] == {"test.txt": "hello"}
assert data["mounts"][0]["target"] == "/app"
def test_create_config_profile_duplicate_name(self, authenticated_client: TestClient) -> None:
"""Test that duplicate profile names are rejected."""
# Create first profile
response = authenticated_client.post(
"/config-profiles",
json={
"name": "duplicate-profile",
"env_vars": {},
"files": {},
},
)
assert response.status_code == 201
# Try to create second with same name
response = authenticated_client.post(
"/config-profiles",
json={
"name": "duplicate-profile",
"env_vars": {},
"files": {},
},
)
assert response.status_code == 409
def test_create_config_profile_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
"""Test that profiles exceeding 10MB are rejected."""
large_content = "x" * (11 * 1024 * 1024) # 11MB
response = authenticated_client.post(
"/config-profiles",
json={
"name": "large-profile",
"env_vars": {},
"files": {"large.txt": large_content},
},
)
assert response.status_code == 413
def test_create_config_profile_invalid_file_path(self, authenticated_client: TestClient) -> None:
"""Test that invalid file paths are rejected."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-profile",
"env_vars": {},
"files": {"../../../etc/passwd": "malicious"},
},
)
assert response.status_code == 422
def test_create_config_profile_invalid_mount_target(self, authenticated_client: TestClient) -> None:
"""Test that invalid mount targets are rejected."""
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-mount-profile",
"env_vars": {},
"files": {},
"mounts": [{"target": "relative/path", "mode": "rw", "files": {}}],
},
)
assert response.status_code == 422
def test_get_config_profile_by_id(self, authenticated_client: TestClient) -> None:
"""Test getting a config profile by ID."""
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "get-test",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Get it back
response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-test"
def test_get_config_profile_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent profile."""
response = authenticated_client.get(f"/config-profiles/{uuid.uuid4()}")
assert response.status_code == 404
def test_update_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a config profile."""
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "update-test",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Update it
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={
"name": "updated-name",
"env_vars": {"NEW_VAR": "new_value"},
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["env_vars"] == {"NEW_VAR": "new_value"}
def test_delete_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a config profile."""
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "delete-test",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Delete it
response = authenticated_client.delete(f"/config-profiles/{profile_id}")
assert response.status_code == 204
# Verify it's gone
get_response = authenticated_client.get(f"/config-profiles/{profile_id}")
assert get_response.status_code == 404
def test_update_profile_includes_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating profile includes."""
# Create base profile
base_response = authenticated_client.post(
"/config-profiles",
json={
"name": "base-profile",
"env_vars": {"BASE_VAR": "base_value"},
"files": {},
},
)
base_id = base_response.json()["id"]
# Create child profile
child_response = authenticated_client.post(
"/config-profiles",
json={
"name": "child-profile",
"env_vars": {},
"files": {},
},
)
child_id = child_response.json()["id"]
# Update includes
response = authenticated_client.put(
f"/config-profiles/{child_id}/includes",
json={"includes": [base_id]},
)
assert response.status_code == 200
data = response.json()
print(f"Response data: {data}")
print(f"Includes: {data.get('includes', 'NO INCLUDES KEY')}")
assert len(data["includes"]) == 1, f"Expected 1 include, got {len(data.get('includes', []))}: {data.get('includes', [])}"
assert data["includes"][0]["included_profile_id"] == base_id
def test_update_profile_includes_cycle_detection(self, authenticated_client: TestClient) -> None:
"""Test that include cycles are detected."""
# Create profile A
a_response = authenticated_client.post(
"/config-profiles",
json={
"name": "profile-a",
"env_vars": {},
"files": {},
},
)
a_id = a_response.json()["id"]
# Create profile B
b_response = authenticated_client.post(
"/config-profiles",
json={
"name": "profile-b",
"env_vars": {},
"files": {},
},
)
b_id = b_response.json()["id"]
# Make B include A
authenticated_client.put(
f"/config-profiles/{b_id}/includes",
json={"includes": [a_id]},
)
# Try to make A include B (would create cycle)
response = authenticated_client.put(
f"/config-profiles/{a_id}/includes",
json={"includes": [b_id]},
)
assert response.status_code == 400
def test_preview_config_profile_successfully(self, authenticated_client: TestClient) -> None:
"""Test previewing a resolved config profile."""
# Create base profile
base_response = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-base",
"env_vars": {"BASE_VAR": "base"},
"files": {},
},
)
base_id = base_response.json()["id"]
# Create child profile
child_response = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-child",
"env_vars": {"CHILD_VAR": "child"},
"files": {},
},
)
child_id = child_response.json()["id"]
# Make child include base
authenticated_client.put(
f"/config-profiles/{child_id}/includes",
json={"includes": [base_id]},
)
# Preview child
response = authenticated_client.get(f"/config-profiles/{child_id}/preview")
assert response.status_code == 200
data = response.json()
assert data["profile_name"] == "preview-child"
assert data["env_vars"]["BASE_VAR"] == "base"
assert data["env_vars"]["CHILD_VAR"] == "child"
assert len(data["included_profiles"]) == 1
def test_resolve_default_profile(self, authenticated_client: TestClient) -> None:
"""Test resolving default profile for project/tool."""
# Create a global default profile (no project/tool scoping)
authenticated_client.post(
"/config-profiles",
json={
"name": "default-profile",
"env_vars": {},
"files": {},
"is_default": True,
},
)
# Resolve default with random project/tool (should fall back to global)
project_id = str(uuid.uuid4())
tool_type_id = str(uuid.uuid4())
response = authenticated_client.get(
"/config-profiles/defaults/resolve",
params={"project_id": project_id, "tool_type_id": tool_type_id},
)
assert response.status_code == 200
data = response.json()
assert data["profile_name"] == "default-profile"
def test_resolve_default_profile_no_match(self, authenticated_client: TestClient) -> None:
"""Test resolving default profile when no profiles exist."""
project_id = str(uuid.uuid4())
tool_type_id = str(uuid.uuid4())
response = authenticated_client.get(
"/config-profiles/defaults/resolve",
params={"project_id": project_id, "tool_type_id": tool_type_id},
)
assert response.status_code == 200
data = response.json()
assert data["profile_id"] is None
def test_create_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test creating a config profile with git mounts."""
_project_id, repo_id = test_project_and_repo
response = authenticated_client.post(
"/config-profiles",
json={
"name": "git-mount-profile",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
"branch": "main",
}
],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "git-mount-profile"
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["target_path"] == "/app"
assert data["git_mounts"][0]["branch"] == "main"
def test_update_config_profile_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test updating git mounts on a config profile."""
_project_id, repo_id = test_project_and_repo
# Create profile first
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "update-git-mounts",
"env_vars": {},
"files": {},
},
)
profile_id = create_response.json()["id"]
# Update with git mounts
response = authenticated_client.put(
f"/config-profiles/{profile_id}",
json={
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": "config",
"target_path": "/config",
}
],
},
)
assert response.status_code == 200
data = response.json()
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["source_path"] == "config"
def test_create_config_profile_invalid_git_mount_source_path(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that invalid git mount source paths are rejected."""
_project_id, repo_id = test_project_and_repo
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-git-mount",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": "/absolute/path",
"target_path": "/app",
}
],
},
)
assert response.status_code == 422
def test_create_config_profile_invalid_git_mount_target_path_traversal(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test that git mount target paths with traversal are rejected."""
_project_id, repo_id = test_project_and_repo
response = authenticated_client.post(
"/config-profiles",
json={
"name": "bad-git-mount-target",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "../../../etc/passwd",
}
],
},
)
assert response.status_code == 422
def test_preview_config_profile_with_git_mounts(self, authenticated_client: TestClient, test_project_and_repo) -> None:
"""Test previewing a profile with git mounts."""
_project_id, repo_id = test_project_and_repo
# Create profile with git mounts
create_response = authenticated_client.post(
"/config-profiles",
json={
"name": "preview-git-mounts",
"env_vars": {},
"files": {},
"git_mounts": [
{
"remote_url": "https://github.com/user/repo.git",
"source_path": ".",
"target_path": "/app",
}
],
},
)
profile_id = create_response.json()["id"]
# Preview
response = authenticated_client.get(f"/config-profiles/{profile_id}/preview")
assert response.status_code == 200
data = response.json()
assert len(data["git_mounts"]) == 1
assert data["git_mounts"][0]["remote_url"] == "https://github.com/user/repo.git"