diff --git a/dashboard/backend/routers/files.py b/dashboard/backend/routers/files.py index 2b255df..8b6aafd 100644 --- a/dashboard/backend/routers/files.py +++ b/dashboard/backend/routers/files.py @@ -8,12 +8,15 @@ from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Query from fastapi.responses import StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import auth_service as auth from config import VOLUME_ROOT from rbac import require_admin router = APIRouter() public_router = APIRouter() # For endpoints that don't require auth +_bearer = HTTPBearer(auto_error=False) logger = logging.getLogger(__name__) BASE = Path(VOLUME_ROOT) @@ -119,7 +122,7 @@ def create_download_token(path: str): @public_router.get("/download") -def download(path: str = None, token: str = Query(None)): +def download(path: str = None, token: str = Query(None), credentials: HTTPAuthorizationCredentials | None = Depends(_bearer)): """Download a file. Supports both authenticated access (path param) and token-based access (token param).""" try: if token: @@ -136,10 +139,22 @@ def download(path: str = None, token: str = Query(None)): del _download_tokens[token] target = Path(file_path) else: - # Authenticated download + # Authenticated download — check auth before revealing file existence if not path: raise HTTPException(status_code=400, detail="Path or token required") - target = _safe_path(path) + + if not credentials: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + auth.user_from_access_token(credentials.credentials) + except HTTPException: + raise HTTPException(status_code=401, detail="Not authenticated") + + try: + target = _safe_path(path) + except ValueError: + raise HTTPException(status_code=403, detail="Access denied") if not target.exists(): raise HTTPException(status_code=404, detail="File not found") diff --git a/dashboard/backend/tests/conftest.py b/dashboard/backend/tests/conftest.py index 55b72ed..56f5dca 100644 --- a/dashboard/backend/tests/conftest.py +++ b/dashboard/backend/tests/conftest.py @@ -4,7 +4,7 @@ Shared pytest fixtures for backend tests. import json import os -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import AsyncMock, Mock, patch import pytest @@ -233,6 +233,182 @@ def mock_httpx_client(): 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.""" @@ -241,9 +417,16 @@ def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client, mo 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): @@ -252,10 +435,12 @@ def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client, mo mock_limiter.limit = lambda *args, **kwargs: lambda func: func with patch("slowapi.Limiter", return_value=mock_limiter): - from main import app + # Mock OPC database to avoid requiring PostgreSQL in tests + with _patch_opc_db(): + from main import app - client = TestClient(app) - yield client + client = TestClient(app) + yield client @pytest.fixture @@ -308,8 +493,10 @@ def mock_conversation_db(tmp_path, monkeypatch): 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, @@ -323,7 +510,12 @@ def mock_conversation_db(tmp_path, monkeypatch): message_uuid TEXT UNIQUE NOT NULL, role TEXT NOT NULL, content_text TEXT, - timestamp DATETIME NOT NULL + 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(""" @@ -331,6 +523,9 @@ def mock_conversation_db(tmp_path, monkeypatch): 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 ) """) @@ -350,11 +545,11 @@ def mock_conversation_db(tmp_path, monkeypatch): # Insert test data conn.execute(""" INSERT INTO conversations VALUES - ('test-session-id', '/test/project', 'test-slug', '2026-04-19 10:00:00', 10, 1000, 500, 'claude-sonnet-4') + ('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') + (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() diff --git a/dashboard/backend/tests/integration/test_websocket_security.py b/dashboard/backend/tests/integration/test_websocket_security.py index 3ce84a9..b8b7611 100644 --- a/dashboard/backend/tests/integration/test_websocket_security.py +++ b/dashboard/backend/tests/integration/test_websocket_security.py @@ -6,10 +6,11 @@ from unittest.mock import AsyncMock, MagicMock, patch def test_terminal_ws_requires_authentication(test_app): """Test terminal WebSocket requires authentication""" - # Try to connect without auth cookie - with test_app.websocket_connect("/api/terminal/ws?host=nas") as websocket: - # Should be rejected - pass + # Connection without auth should be rejected — either the upgrade fails + # (403) or the server immediately closes the connection after upgrade. + with pytest.raises(Exception): + with test_app.websocket_connect("/api/terminal/ws?host=nas") as websocket: + websocket.receive_text() def test_terminal_ws_rejects_invalid_token(test_app):