""" Shared pytest fixtures for backend tests. """ import json import os from datetime import timedelta from unittest.mock import AsyncMock, Mock, patch import pytest from fastapi.testclient import TestClient # Set up environment variables before any imports during pytest collection os.environ.setdefault("SECRET_KEY", "test-secret-key-at-least-32-chars-long") os.environ.setdefault("ADMIN_USER", "testadmin") os.environ.setdefault("ADMIN_PASSWORD_HASH", "$pbkdf2-sha256$29000$N2YMIQQgBAAgxBgjhPD.vw$1234567890abcdef") os.environ.setdefault("VOLUME_ROOT", "/tmp/test-volume") os.environ.setdefault("DOCKER_HOST", "tcp://mock-docker:2375") os.environ.setdefault("GITEA_URL", "http://mock-gitea:3000") os.environ.setdefault("GITEA_TOKEN", "mock-token") @pytest.fixture def temp_volume_root(tmp_path): """Create temporary volume root for testing.""" volume_root = tmp_path / "volume1" volume_root.mkdir() (volume_root / "docker" / "nas-dashboard").mkdir(parents=True) return str(volume_root) @pytest.fixture def mock_config(temp_volume_root, monkeypatch): """Mock config module with test values.""" monkeypatch.setenv("SECRET_KEY", "test-secret-key-at-least-32-chars-long") monkeypatch.setenv("ADMIN_USER", "testadmin") monkeypatch.setenv("ADMIN_PASSWORD_HASH", "$pbkdf2-sha256$29000$N2YMIQQgBAAgxBgjhPD.vw$1234567890abcdef") monkeypatch.setenv("VOLUME_ROOT", temp_volume_root) monkeypatch.setenv("DOCKER_HOST", "tcp://mock-docker:2375") monkeypatch.setenv("GITEA_URL", "http://mock-gitea:3000") monkeypatch.setenv("GITEA_TOKEN", "mock-token") # Reload config module to pick up new env vars import importlib import config importlib.reload(config) return config @pytest.fixture def temp_auth_file(temp_volume_root): """Create temporary auth.json file.""" auth_file = os.path.join(temp_volume_root, "docker/nas-dashboard/auth.json") data = {"totp_secret": "", "passkey_credentials": [], "token_version": 0, "last_totp_ts": 0} with open(auth_file, "w") as f: json.dump(data, f) return auth_file @pytest.fixture def temp_rbac_file(temp_volume_root): """Create temporary rbac.json file.""" rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json") data = { "role_defaults": { "admin": {"pages": "*", "sidebar_links": "*"}, "member": {"pages": ["dashboard", "docker"], "sidebar_links": "*"}, "viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}, }, "user_overrides": {}, } with open(rbac_file, "w") as f: json.dump(data, f) return rbac_file @pytest.fixture def valid_access_token(mock_config): """Generate a valid access token for testing.""" from auth_service import create_access_token return create_access_token(data={"sub": "testuser", "role": "admin"}, expires_delta=timedelta(minutes=30)) @pytest.fixture def valid_refresh_token(mock_config, temp_auth_file): """Generate a valid refresh token for testing.""" from auth_service import create_refresh_token return create_refresh_token(data={"sub": "testuser", "role": "admin"}) @pytest.fixture def expired_access_token(mock_config): """Generate an expired access token for testing.""" from auth_service import create_access_token return create_access_token(data={"sub": "testuser", "role": "admin"}, expires_delta=timedelta(seconds=-1)) @pytest.fixture def mock_docker_client(): """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 mock_image = Mock() mock_image.tags = ["test-image:latest"] mock_image.id = "sha256:abc123def456" # Mock container container = Mock() container.id = "abc123" container.short_id = "abc123" container.name = "test-container" container.status = "running" container.image = mock_image container.ports = {"80/tcp": [{"HostPort": "8080"}]} container.attrs = {"State": {"Health": {"Status": "healthy"}}, "Config": {"Image": "test-image:latest"}} 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 @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.""" conn = AsyncMock() process = AsyncMock() process.stdout = AsyncMock() process.stdin = AsyncMock() process.stderr = AsyncMock() conn.create_process.return_value = process return conn @pytest.fixture def mock_httpx_client(): """Mock httpx AsyncClient for testing.""" client = AsyncMock() response = AsyncMock() response.status_code = 200 response.json.return_value = {"status": "ok"} client.get.return_value = response client.post.return_value = response return 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 importlib import routers.files 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 mock_limiter = Mock() mock_limiter.limit = lambda *args, **kwargs: lambda func: func with patch("slowapi.Limiter", return_value=mock_limiter): from main import app client = TestClient(app) yield client @pytest.fixture def admin_headers(valid_access_token): """Headers with admin access token.""" return {"Authorization": f"Bearer {valid_access_token}"} @pytest.fixture def mock_rbac_data(): """Sample RBAC configuration for testing.""" return { "role_defaults": { "admin": {"pages": "*", "sidebar_links": "*"}, "member": {"pages": ["dashboard", "docker", "files"], "sidebar_links": "*"}, "viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}, }, "user_overrides": { "customuser": {"pages": ["dashboard", "docker"], "sidebar_links": ["dashboard", "docker", "files"]} }, } @pytest.fixture def sample_totp_secret(): """Sample TOTP secret for testing.""" return "JBSWY3DPEHPK3PXP" # Base32 encoded secret @pytest.fixture def mock_request(): """Mock FastAPI Request object.""" request = Mock() request.client = Mock() request.client.host = "192.168.31.100" request.headers = {} return request @pytest.fixture def mock_conversation_db(tmp_path, monkeypatch): """Mock conversation tracker database""" import sqlite3 db_path = tmp_path / "conversations.db" # Create minimal SQLite schema conn = sqlite3.connect(db_path) conn.execute(""" CREATE TABLE conversations ( session_id TEXT PRIMARY KEY, project_path TEXT, started_at DATETIME, message_count INTEGER, total_input_tokens INTEGER DEFAULT 0, total_output_tokens INTEGER DEFAULT 0 ) """) conn.execute(""" CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, message_uuid TEXT UNIQUE NOT NULL, role TEXT NOT NULL, content_text TEXT, timestamp DATETIME NOT NULL ) """) conn.execute(""" CREATE TABLE tool_calls ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_uuid TEXT NOT NULL, tool_name TEXT NOT NULL, timestamp DATETIME NOT NULL ) """) conn.execute(""" CREATE TABLE daily_summaries ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT UNIQUE NOT NULL, summary_content TEXT NOT NULL ) """) # Insert test data conn.execute(""" INSERT INTO conversations VALUES ('test-session-id', '/test/project', '2026-04-19 10:00:00', 10, 1000, 500) """) conn.execute(""" INSERT INTO messages VALUES (1, 'test-session-id', 'msg-1', 'user', 'test message', '2026-04-19 10:00:00') """) conn.commit() conn.close() monkeypatch.setenv("CONVERSATION_TRACKER_DB", str(db_path)) return db_path