""" Shared pytest fixtures for backend tests. """ import json import os from datetime import datetime, 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") os.environ.setdefault("TRANSMISSION_USER", "test-tx-user") os.environ.setdefault("TRANSMISSION_PASS", "test-tx-pass") @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") monkeypatch.setenv("TRANSMISSION_USER", "test-tx-user") monkeypatch.setenv("TRANSMISSION_PASS", "test-tx-pass") # 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 class _DictRow: """Mock for asyncpg Record that supports both dict() conversion and positional access.""" def __init__(self, data: dict): self._data = data def __getitem__(self, key): if isinstance(key, int): return list(self._data.values())[key] return self._data[key] def keys(self): return self._data.keys() def values(self): return self._data.values() def __iter__(self): return iter(self._data) def get(self, key, default=None): return self._data.get(key, default) def _patch_opc_db(): """Mock OPC database to avoid requiring PostgreSQL in tests. Returns a context manager that patches db.opc_db.get_pool to return an in-memory mock pool. Tasks/executions are stored in lists keyed by ID. """ import db.opc_db as _opc_db _task_counter = [0] _exec_counter = [0] _tasks: dict[int, dict] = {} _executions: dict[int, dict] = {} _history: list[dict] = [] def _next_id(counter): counter[0] += 1 return counter[0] def _make_task(title, description, status, priority, project_id, assigned_to, assigned_type, tags, due_date): tid = _next_id(_task_counter) now = datetime.utcnow().isoformat() task = { "id": tid, "title": title, "description": description, "status": status, "priority": priority, "project_id": project_id, "assigned_to": assigned_to, "assigned_type": assigned_type, "tags": json.dumps(tags or []), "metadata": json.dumps({"created_by": "admin"}), "created_at": now, "updated_at": now, "completed_at": None, "due_date": due_date.isoformat() if due_date else None, } _tasks[tid] = task return task class _MockConnection: async def fetchrow(self, query, *params): query_lower = query.strip().lower() # INSERT ... RETURNING if query_lower.startswith("insert into tasks"): title = params[0] if len(params) > 0 else "" desc = params[1] if len(params) > 1 else None status = params[2] if len(params) > 2 else "parking_lot" priority = params[3] if len(params) > 3 else "medium" pid = params[4] if len(params) > 4 else None assigned_to = params[5] if len(params) > 5 else None assigned_type = params[6] if len(params) > 6 else "human" tags = params[7] if len(params) > 7 else None due_date = params[8] if len(params) > 8 else None task = _make_task(title, desc, status, priority, pid, assigned_to, assigned_type, tags, due_date) return _DictRow(task) elif query_lower.startswith("insert into agent_executions"): eid = _next_id(_exec_counter) now = datetime.utcnow().isoformat() exec_data = { "id": eid, "task_id": params[0] if len(params) > 0 else None, "agent_id": params[1] if len(params) > 1 else None, "status": params[2] if len(params) > 2 else "pending", "input_context": params[3] if len(params) > 3 else "{}", "requires_approval": params[4] if len(params) > 4 else False, "started_at": now, "completed_at": None, "output_result": None, "actions_proposed": None, "actions_executed": None, "error_message": None, "approved_by": None, "approved_at": None, } _executions[eid] = exec_data return _DictRow(exec_data) elif query_lower.startswith("insert into task_history"): _history.append({"task_id": params[0], "action": params[1]}) return None elif query_lower.startswith("select * from tasks where id ="): tid = params[0] if params else None if tid in _tasks: return _DictRow(_tasks[tid]) return None elif query_lower.startswith("select * from agent_executions where id ="): eid = params[0] if params else None if eid in _executions: return _DictRow(_executions[eid]) return None elif "update tasks set" in query_lower: tid = params[-1] if params else None if tid in _tasks: return _DictRow(_tasks[tid]) return None elif "update agent_executions" in query_lower: eid = params[-1] if params else None if eid in _executions: if "approved_by" in query_lower: _executions[eid]["approved_by"] = params[0] if len(params) > 0 else None _executions[eid]["approved_at"] = datetime.utcnow().isoformat() _executions[eid]["status"] = "approved" if (len(params) > 1 and params[1]) else "rejected" return _DictRow(_executions[eid]) return None return None async def fetch(self, query, *params): query_lower = query.strip().lower() if "from agent_executions" in query_lower: return [_DictRow(e) for e in _executions.values()] if "from tasks" in query_lower or "select * from tasks" in query_lower: return [_DictRow(t) for t in _tasks.values()] if "from task_history" in query_lower: return [_DictRow(h) for h in _history] if "from agents" in query_lower: return [_DictRow({ "id": "pm", "name": "Project Manager", "role": "PM", "description": "test", "capabilities": "[]", "system_prompt": "test", "config": '{"approval_level":"auto"}', "enabled": True, "created_at": datetime.utcnow().isoformat(), }), _DictRow({ "id": "cto", "name": "CTO", "role": "CTO", "description": "test", "capabilities": "[]", "system_prompt": "test", "config": '{"approval_level":"cxo"}', "enabled": True, "created_at": datetime.utcnow().isoformat(), })] if "from time_entries" in query_lower: return [] return [] async def execute(self, query, *params): # no-op for DDL/DML return "OK" class _MockAcquireContext: async def __aenter__(self): return _MockConnection() async def __aexit__(self, *args): pass class _MockPool: def acquire(self): return _MockAcquireContext() async def _mock_get_pool(): return _MockPool() return patch.object(_opc_db, "get_pool", side_effect=_mock_get_pool) @pytest.fixture def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client, mock_conversation_db): """Create FastAPI test client with mocked dependencies.""" # Reload routers to pick up new config values import importlib import routers.files import routers.conversation_tracker import routers.security import routers.system importlib.reload(routers.files) importlib.reload(routers.conversation_tracker) importlib.reload(routers.security) importlib.reload(routers.system) # Reset cached Docker client in security router routers.security._client = None # 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): # Mock OPC database to avoid requiring PostgreSQL in tests with _patch_opc_db(): 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, git_branch TEXT, slug TEXT, started_at DATETIME, last_updated_at DATETIME, message_count INTEGER, total_input_tokens INTEGER DEFAULT 0, total_output_tokens INTEGER DEFAULT 0, model TEXT ) """) 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, model TEXT, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, has_tool_use INTEGER DEFAULT 0, has_thinking INTEGER DEFAULT 0 ) """) conn.execute(""" CREATE TABLE tool_calls ( id INTEGER PRIMARY KEY AUTOINCREMENT, message_uuid TEXT NOT NULL, tool_name TEXT NOT NULL, tool_input TEXT, tool_result TEXT, is_error INTEGER DEFAULT 0, 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, conversation_count INTEGER DEFAULT 0, total_messages INTEGER DEFAULT 0, total_tokens INTEGER DEFAULT 0, created_at DATETIME, project_path TEXT ) """) # Insert test data conn.execute(""" INSERT INTO conversations VALUES ('test-session-id', '/test/project', 'main', 'test-slug', '2026-04-19 10:00:00', '2026-04-19 11:00:00', 10, 1000, 500, 'claude-sonnet-4') """) conn.execute(""" INSERT INTO messages VALUES (1, 'test-session-id', 'msg-1', 'user', 'test message', '2026-04-19 10:00:00', 'claude-sonnet-4', 100, 50, 0, 0) """) conn.commit() conn.close() monkeypatch.setenv("CONVERSATION_TRACKER_DB", str(db_path)) return db_path