feat: comprehensive test infrastructure improvements
Phase 1: Fix immediate test failures - Fix path handling in files router (strip leading slashes) - Fix test assertions to match actual API behavior - Add proper error handling for missing files in download endpoint - Reload files router in test fixtures to pick up config changes - Fix upload endpoint test to use query params instead of form data Phase 2: Improve test reliability - Standardize error responses: docker_router now uses HTTPException - Add global exception handlers for validation, Docker errors, and unhandled exceptions - Fix flaky TOTP replay protection test - Fix flaky password hash loading test with proper module reload - Enhanced Docker mock fixtures with error scenarios and factory pattern Phase 3: Add coverage reporting - Add pytest-cov with 40% minimum coverage threshold - Add pyproject.toml with pytest and coverage configuration - Update test workflow to generate coverage and JUnit XML reports - Remove -x flag to see all test failures - Configure asyncio_default_fixture_loop_scope to fix deprecation warning Results: - All 144 tests passing (was 137 passed, 5 failed, 2 skipped) - Test execution time: ~3s locally - Coverage: 43% (above 40% threshold) - No skipped tests - Standardized error handling across all endpoints
This commit is contained in:
@@ -98,7 +98,11 @@ def expired_access_token(mock_config):
|
||||
|
||||
@pytest.fixture
|
||||
def mock_docker_client():
|
||||
"""Mock Docker client for testing."""
|
||||
"""Mock Docker client for testing.
|
||||
|
||||
Returns a mock client with a single running container.
|
||||
For error scenarios, use mock_docker_client_with_errors.
|
||||
"""
|
||||
client = Mock()
|
||||
|
||||
# Mock container image
|
||||
@@ -126,6 +130,77 @@ def mock_docker_client():
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_docker_client_with_errors():
|
||||
"""Mock Docker client that simulates error scenarios.
|
||||
|
||||
Use this fixture to test error handling:
|
||||
- Container not found (NotFound exception)
|
||||
- Connection errors (APIError)
|
||||
- Permission denied (APIError with 403)
|
||||
|
||||
Example:
|
||||
def test_container_not_found(test_app, admin_headers, mock_docker_client_with_errors):
|
||||
# Configure the mock to raise NotFound
|
||||
import docker.errors
|
||||
mock_docker_client_with_errors.containers.get.side_effect = docker.errors.NotFound("Container not found")
|
||||
"""
|
||||
import docker.errors
|
||||
|
||||
client = Mock()
|
||||
|
||||
# By default, raise NotFound for get operations
|
||||
client.containers.get.side_effect = docker.errors.NotFound("Container not found")
|
||||
client.containers.list.return_value = []
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_docker_client_factory():
|
||||
"""Factory for creating customizable Docker client mocks.
|
||||
|
||||
Returns a function that creates mock clients with specific configurations.
|
||||
|
||||
Example:
|
||||
def test_custom_container(mock_docker_client_factory):
|
||||
client = mock_docker_client_factory(
|
||||
container_name="my-container",
|
||||
container_status="stopped",
|
||||
image_tag="my-image:v1.0"
|
||||
)
|
||||
"""
|
||||
def _create_mock(container_name="test-container", container_status="running",
|
||||
image_tag="test-image:latest", container_id="abc123"):
|
||||
client = Mock()
|
||||
|
||||
# Mock container image
|
||||
mock_image = Mock()
|
||||
mock_image.tags = [image_tag]
|
||||
mock_image.id = f"sha256:{container_id}def456"
|
||||
|
||||
# Mock container
|
||||
container = Mock()
|
||||
container.id = container_id
|
||||
container.short_id = container_id[:12]
|
||||
container.name = container_name
|
||||
container.status = container_status
|
||||
container.image = mock_image
|
||||
container.ports = {"80/tcp": [{"HostPort": "8080"}]}
|
||||
container.attrs = {
|
||||
"State": {"Health": {"Status": "healthy"}},
|
||||
"Config": {"Image": image_tag}
|
||||
}
|
||||
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
||||
|
||||
client.containers.list.return_value = [container]
|
||||
client.containers.get.return_value = container
|
||||
|
||||
return client
|
||||
|
||||
return _create_mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_ssh_connection():
|
||||
"""Mock asyncssh connection for testing."""
|
||||
@@ -153,6 +228,11 @@ def mock_httpx_client():
|
||||
@pytest.fixture
|
||||
def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client):
|
||||
"""Create FastAPI test client with mocked dependencies."""
|
||||
# Reload routers to pick up new config values
|
||||
import routers.files
|
||||
import importlib
|
||||
importlib.reload(routers.files)
|
||||
|
||||
# Patch Docker client before importing main
|
||||
with patch("docker.DockerClient", return_value=mock_docker_client):
|
||||
# Mock the limiter to disable rate limiting during tests
|
||||
|
||||
@@ -79,9 +79,9 @@ class TestDockerContainerActions:
|
||||
headers=admin_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
assert "error" in data
|
||||
assert "detail" in data
|
||||
|
||||
|
||||
class TestDockerContainerLogs:
|
||||
|
||||
@@ -18,7 +18,7 @@ class TestFileBrowse:
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "files" in data or isinstance(data, list)
|
||||
assert "entries" in data or isinstance(data, list)
|
||||
|
||||
def test_browse_without_auth(self, test_app):
|
||||
"""Test browsing without authentication."""
|
||||
@@ -61,7 +61,7 @@ class TestFileUpload:
|
||||
"/api/files/upload",
|
||||
headers=admin_headers,
|
||||
files=files,
|
||||
data={"path": "/"}
|
||||
params={"path": "/"}
|
||||
)
|
||||
|
||||
assert response.status_code in [200, 201]
|
||||
@@ -73,7 +73,7 @@ class TestFileUpload:
|
||||
response = test_app.post(
|
||||
"/api/files/upload",
|
||||
files=files,
|
||||
data={"path": "/"}
|
||||
params={"path": "/"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -94,7 +94,7 @@ class TestFileUpload:
|
||||
"/api/files/upload",
|
||||
headers=headers,
|
||||
files=files,
|
||||
data={"path": "/"}
|
||||
params={"path": "/"}
|
||||
)
|
||||
|
||||
# Upload should be admin-only
|
||||
|
||||
@@ -242,7 +242,6 @@ class TestTOTP:
|
||||
# No secret saved, should return True (2FA disabled)
|
||||
assert verify_totp("any_code")
|
||||
|
||||
@pytest.mark.skip(reason="Flaky test - file I/O timing issues in CI")
|
||||
def test_verify_totp_replay_protection(self, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test that same TOTP code cannot be used twice."""
|
||||
save_totp_secret(sample_totp_secret)
|
||||
@@ -277,7 +276,6 @@ class TestPasswordPersistence:
|
||||
loaded = load_password_hash()
|
||||
assert loaded == hashed
|
||||
|
||||
@pytest.mark.skip(reason="Test isolation issue - previous test state persists")
|
||||
def test_load_password_hash_from_env(self, mock_config, temp_auth_file):
|
||||
"""Test loading password hash from environment variable."""
|
||||
# Clear any hash in the file first
|
||||
@@ -288,8 +286,13 @@ class TestPasswordPersistence:
|
||||
with open(temp_auth_file, 'w') as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# Reload auth_service to clear any cached data
|
||||
import auth_service
|
||||
import importlib
|
||||
importlib.reload(auth_service)
|
||||
|
||||
# When no hash in file, should return env var
|
||||
loaded = load_password_hash()
|
||||
loaded = auth_service.load_password_hash()
|
||||
assert loaded == mock_config.ADMIN_PASSWORD_HASH
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user