From b981c06d59b07c6c3e043768f113741805dcefa1 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Wed, 8 Apr 2026 00:21:32 +0800 Subject: [PATCH] feat: comprehensive test infrastructure improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix unit test imports: add env setup in conftest.py before module imports - Add 24 new auth router tests (RBAC, preferences, password validation) - Add 16 new tests for litellm and chat_summary routers - Apply black formatting and ruff linting across codebase - Add pre-commit hooks configuration (black, ruff, file checks) - Increase CI coverage threshold from 40% to 50% Test Results: - 206 tests passing (91 unit + 115 integration) - Coverage: 58.79% on core modules - auth.py: 57% → 85%, litellm.py: 23% → 87%, chat_summary.py: 41% → 100% - auth_service: 96.51%, config: 100%, rbac: 93.48% --- .gitea/workflows/test.yml | 2 +- dashboard/backend/.gitignore | 34 ++ dashboard/backend/.pre-commit-config.yaml | 24 ++ dashboard/backend/README-PRECOMMIT.md | 85 +++++ dashboard/backend/auth_service.py | 60 +++- dashboard/backend/config.py | 8 +- dashboard/backend/db/opc_db.py | 316 +++++++++++------- dashboard/backend/main.py | 179 ++++++---- dashboard/backend/pyproject.toml | 45 +++ dashboard/backend/rbac.py | 17 +- dashboard/backend/requirements-dev.txt | 3 + dashboard/backend/routers/auth.py | 27 +- dashboard/backend/routers/cc_connect.py | 1 + dashboard/backend/routers/chat_summary.py | 11 +- dashboard/backend/routers/docker_router.py | 4 +- dashboard/backend/routers/files.py | 6 +- dashboard/backend/routers/gitea.py | 6 +- dashboard/backend/routers/litellm.py | 4 +- dashboard/backend/routers/opc_agents.py | 41 +-- dashboard/backend/routers/opc_projects.py | 51 +-- dashboard/backend/routers/opc_tasks.py | 121 +++---- dashboard/backend/routers/opc_ws.py | 21 +- dashboard/backend/routers/passkey.py | 66 ++-- dashboard/backend/routers/security.py | 57 ++-- dashboard/backend/routers/system.py | 33 +- dashboard/backend/routers/terminal.py | 48 +-- dashboard/backend/routers/totp.py | 20 +- dashboard/backend/services/agent_executor.py | 118 ++++--- .../backend/services/agents/base_agent.py | 33 +- dashboard/backend/services/email_service.py | 9 +- dashboard/backend/services/pdf_service.py | 167 +++++---- dashboard/backend/tests/conftest.py | 78 +++-- .../tests/integration/test_auth_flow.py | 313 +++++++++++++---- .../tests/integration/test_chat_summary.py | 200 +++++++++++ .../backend/tests/integration/test_docker.py | 57 +--- .../backend/tests/integration/test_files.py | 86 ++--- .../backend/tests/integration/test_gitea.py | 32 +- .../backend/tests/integration/test_litellm.py | 78 +++++ .../backend/tests/integration/test_system.py | 41 +-- .../backend/tests/integration/test_totp.py | 20 +- dashboard/backend/tests/test_basic.py | 9 +- dashboard/backend/tests/unit/test_auth.py | 68 ++-- dashboard/backend/tests/unit/test_config.py | 25 +- dashboard/backend/tests/unit/test_rbac.py | 32 +- 44 files changed, 1732 insertions(+), 924 deletions(-) create mode 100644 dashboard/backend/.gitignore create mode 100644 dashboard/backend/.pre-commit-config.yaml create mode 100644 dashboard/backend/README-PRECOMMIT.md create mode 100644 dashboard/backend/tests/integration/test_chat_summary.py create mode 100644 dashboard/backend/tests/integration/test_litellm.py diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 2e60627..f7c1a13 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -70,7 +70,7 @@ jobs: pytest tests/ -v --timeout=30 \ --cov=. --cov-report=xml --cov-report=term \ --junit-xml=test-results.xml \ - --cov-fail-under=40 + --cov-fail-under=50 - name: Upload coverage report if: always() diff --git a/dashboard/backend/.gitignore b/dashboard/backend/.gitignore new file mode 100644 index 0000000..5aa8bea --- /dev/null +++ b/dashboard/backend/.gitignore @@ -0,0 +1,34 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +venv/ +env/ +ENV/ +.venv + +# Testing +.pytest_cache/ +.coverage +.coverage.* +htmlcov/ +.tox/ +*.cover +coverage.xml +test-results.xml + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Pre-commit +.pre-commit-config.yaml.bak + +# OS +.DS_Store +Thumbs.db diff --git a/dashboard/backend/.pre-commit-config.yaml b/dashboard/backend/.pre-commit-config.yaml new file mode 100644 index 0000000..59c5474 --- /dev/null +++ b/dashboard/backend/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3.12 + args: [--line-length=120] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + args: [--maxkb=1000] + - id: check-merge-conflict + - id: check-json diff --git a/dashboard/backend/README-PRECOMMIT.md b/dashboard/backend/README-PRECOMMIT.md new file mode 100644 index 0000000..bf4e6fa --- /dev/null +++ b/dashboard/backend/README-PRECOMMIT.md @@ -0,0 +1,85 @@ +# Pre-commit Hooks Setup + +This project uses pre-commit hooks to ensure code quality before commits. + +## Tools + +- **black**: Code formatter (line length: 120) +- **ruff**: Fast Python linter +- **pre-commit-hooks**: Basic file checks (trailing whitespace, YAML validation, etc.) + +## Installation + +### Option 1: Manual Installation (Recommended for this repo) + +Since this repo has a custom `core.hooksPath`, install pre-commit manually: + +```bash +cd dashboard/backend +source venv/bin/activate +pip install -r requirements-dev.txt + +# Run manually before committing +pre-commit run --all-files +``` + +### Option 2: Standard Installation + +If you want to use pre-commit's automatic hooks: + +```bash +cd /path/to/nas-tools +git config --unset-all core.hooksPath +cd dashboard/backend +source venv/bin/activate +pre-commit install +``` + +## Usage + +### Run on all files +```bash +cd dashboard/backend +source venv/bin/activate +pre-commit run --all-files +``` + +### Run on staged files only +```bash +pre-commit run +``` + +### Run specific hook +```bash +pre-commit run black --all-files +pre-commit run ruff --all-files +``` + +### Skip hooks (not recommended) +```bash +git commit --no-verify +``` + +## Configuration + +- `.pre-commit-config.yaml`: Hook configuration +- `pyproject.toml`: Black and Ruff settings + +## What the hooks check + +1. **black**: Formats Python code to consistent style +2. **ruff**: Checks for common Python errors and style issues +3. **trailing-whitespace**: Removes trailing whitespace +4. **end-of-file-fixer**: Ensures files end with newline +5. **check-yaml**: Validates YAML syntax +6. **check-json**: Validates JSON syntax +7. **check-merge-conflict**: Detects merge conflict markers +8. **check-added-large-files**: Prevents committing large files (>1MB) + +## CI Integration + +The test workflow runs these checks automatically: +- Tests must pass +- Coverage must be ≥50% + +Pre-commit hooks help catch issues locally before pushing to CI. diff --git a/dashboard/backend/auth_service.py b/dashboard/backend/auth_service.py index 11f7e0f..a321589 100644 --- a/dashboard/backend/auth_service.py +++ b/dashboard/backend/auth_service.py @@ -1,13 +1,14 @@ -from datetime import datetime, timedelta, timezone -from typing import Optional -import time -import threading import tempfile +import threading +import time +from datetime import UTC, datetime, timedelta + import jwt -from passlib.context import CryptContext import pyotp from fastapi import Depends, HTTPException, Response, status from fastapi.security import OAuth2PasswordBearer +from passlib.context import CryptContext + import config # Password handling @@ -20,22 +21,25 @@ COOKIE_SECURE = True COOKIE_SAMESITE = "lax" COOKIE_PATH = "/" + def verify_password(plain_password, hashed_password): return pwd_context.verify(plain_password, hashed_password) + def get_password_hash(password): return pwd_context.hash(password) -def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + +def create_access_token(data: dict, expires_delta: timedelta | None = None): to_encode = data.copy() - expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=15)) + expire = datetime.now(UTC) + (expires_delta or timedelta(minutes=15)) to_encode.update({"exp": expire, "type": "access"}) return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) def create_refresh_token(data: dict): to_encode = data.copy() - expire = datetime.now(timezone.utc) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(UTC) + timedelta(minutes=config.REFRESH_TOKEN_EXPIRE_MINUTES) to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()}) return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM) @@ -69,8 +73,10 @@ def clear_auth_cookies(response: Response): response.delete_cookie(ACCESS_COOKIE_NAME, path=COOKIE_PATH) response.delete_cookie(REFRESH_COOKIE_NAME, path=COOKIE_PATH) + def user_from_access_token(token: str, include_auth_header: bool = True): from rbac import User, get_pages, get_sidebar_links + credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -99,12 +105,17 @@ async def get_current_user(token: str = Depends(oauth2_scheme)): async def get_current_user_ws(token: str): return user_from_access_token(token, include_auth_header=False) + # TOTP Persistence def get_auth_file(): return config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json" -import json, os + + import base64 import hashlib +import json +import os + from cryptography.fernet import Fernet _auth_lock = threading.RLock() @@ -131,22 +142,27 @@ def _update_auth_data(mutator): _atomic_json_write(get_auth_file(), data) return result + def _get_fernet(): - from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=b"nas-dashboard-fernet", iterations=480000) key = base64.urlsafe_b64encode(kdf.derive(config.SECRET_KEY.encode())) return Fernet(key) + def _get_legacy_fernet(): key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest()) return Fernet(key) + def _encrypt(plaintext: str) -> str: if not plaintext: return "" return _get_fernet().encrypt(plaintext.encode()).decode() + def _decrypt(ciphertext: str) -> str: if not ciphertext: return "" @@ -160,44 +176,53 @@ def _decrypt(ciphertext: str) -> str: except Exception: return ciphertext # fallback for unencrypted legacy values + def _load_auth_data(): with _auth_lock: try: auth_path = get_auth_file() if os.path.exists(auth_path): - with open(auth_path, "r") as f: + with open(auth_path) as f: return json.load(f) except Exception: pass return {} + def _save_auth_data(data): _atomic_json_write(get_auth_file(), data) + def load_totp_secret(): encrypted = _load_auth_data().get("totp_secret", "") if not encrypted: return config.TOTP_SECRET return _decrypt(encrypted) + def save_totp_secret(secret: str): def mutator(data): data["totp_secret"] = _encrypt(secret) if secret else "" + _update_auth_data(mutator) + def load_password_hash(): return _load_auth_data().get("password_hash", "") or config.ADMIN_PASSWORD_HASH + def save_password_hash(hashed: str): def mutator(data): data["password_hash"] = hashed + _update_auth_data(mutator) - + + def verify_totp(token: str, secret: str = None): if secret is None: secret = load_totp_secret() if not secret: - return True # 2FA not enabled + return True # 2FA not enabled totp = pyotp.TOTP(secret) if not totp.verify(token): return False @@ -211,32 +236,41 @@ def verify_totp(token: str, secret: str = None): return _update_auth_data(mutator) + # Passkey credentials def load_passkey_credentials(): return _load_auth_data().get("passkey_credentials", []) + def save_passkey_credential(cred): def mutator(data): creds = data.get("passkey_credentials", []) creds.append(cred) data["passkey_credentials"] = creds + _update_auth_data(mutator) + def clear_passkey_credentials(): def mutator(data): data["passkey_credentials"] = [] + _update_auth_data(mutator) + # Token version for refresh token rotation def _load_token_version() -> int: return _load_auth_data().get("token_version", 0) + def increment_token_version() -> int: def mutator(data): v = data.get("token_version", 0) + 1 data["token_version"] = v return v + return _update_auth_data(mutator) + def verify_token_version(tv: int) -> bool: return tv == _load_token_version() diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index cbdf222..fda104c 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -1,6 +1,8 @@ -import os import ipaddress +import os + from fastapi import Request + # Dashboard v1.3 — Runner DNS fix applied GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000") @@ -42,7 +44,9 @@ TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "") WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "jimmygan.com") -WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",") +WEBAUTHN_ORIGINS = os.environ.get("WEBAUTHN_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split( + "," +) # CORS CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",") diff --git a/dashboard/backend/db/opc_db.py b/dashboard/backend/db/opc_db.py index 14a98ae..9bdfcfd 100644 --- a/dashboard/backend/db/opc_db.py +++ b/dashboard/backend/db/opc_db.py @@ -1,11 +1,13 @@ """ OPC Database Module - PostgreSQL connection and schema management """ -import asyncpg + import json -from typing import Optional, List, Dict, Any -from datetime import datetime import os +from datetime import datetime +from typing import Any + +import asyncpg # Database connection settings DB_HOST = os.getenv("OPC_DB_HOST", "gitea-db") @@ -15,7 +17,7 @@ DB_USER = os.getenv("OPC_DB_USER", "gitea") DB_PASSWORD = os.getenv("GITEA_DB_PASSWORD", "") # Connection pool -_pool: Optional[asyncpg.Pool] = None +_pool: asyncpg.Pool | None = None async def get_pool() -> asyncpg.Pool: @@ -24,6 +26,7 @@ async def get_pool() -> asyncpg.Pool: if _pool is None: import asyncio import logging + logger = logging.getLogger(__name__) max_retries = 3 @@ -67,7 +70,8 @@ async def init_db(): pool = await get_pool() async with pool.acquire() as conn: # Create tables - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS projects ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, @@ -77,9 +81,11 @@ async def init_db(): metadata JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """) + """ + ) - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS tasks ( id SERIAL PRIMARY KEY, title TEXT NOT NULL, @@ -96,9 +102,11 @@ async def init_db(): completed_at TIMESTAMP, due_date TIMESTAMP ) - """) + """ + ) - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS task_history ( id SERIAL PRIMARY KEY, task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, @@ -109,9 +117,11 @@ async def init_db(): new_value JSONB, timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """) + """ + ) - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS agents ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -123,9 +133,11 @@ async def init_db(): enabled BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """) + """ + ) - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS agent_executions ( id SERIAL PRIMARY KEY, task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, @@ -142,9 +154,11 @@ async def init_db(): approved_by TEXT, approved_at TIMESTAMP ) - """) + """ + ) - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS time_entries ( id SERIAL PRIMARY KEY, task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, @@ -157,9 +171,11 @@ async def init_db(): ended_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """) + """ + ) - await conn.execute(""" + await conn.execute( + """ CREATE TABLE IF NOT EXISTS clients ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, @@ -169,7 +185,8 @@ async def init_db(): metadata JSONB, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - """) + """ + ) # Create indexes await conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)") @@ -201,7 +218,7 @@ async def seed_agents(conn): - Provide status reports and risk assessments When assigned a task, analyze it and propose concrete actions.""", - "config": {"model": "cc-kiro", "temperature": 0.7, "approval_level": "auto"} + "config": {"model": "cc-kiro", "temperature": 0.7, "approval_level": "auto"}, }, { "id": "cto", @@ -216,7 +233,7 @@ When assigned a task, analyze it and propose concrete actions.""", - Ensure security best practices When assigned a task, provide technical leadership and propose improvements.""", - "config": {"model": "cc-kiro", "temperature": 0.5, "approval_level": "cxo"} + "config": {"model": "cc-kiro", "temperature": 0.5, "approval_level": "cxo"}, }, { "id": "coo", @@ -231,12 +248,13 @@ When assigned a task, provide technical leadership and propose improvements.""", - Optimize resource allocation When assigned a task, look for automation and optimization opportunities.""", - "config": {"model": "cc-kiro", "temperature": 0.6, "approval_level": "cxo"} - } + "config": {"model": "cc-kiro", "temperature": 0.6, "approval_level": "cxo"}, + }, ] for agent in agents: - await conn.execute(""" + await conn.execute( + """ INSERT INTO agents (id, name, role, description, capabilities, system_prompt, config, enabled) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (id) DO UPDATE SET @@ -246,34 +264,51 @@ When assigned a task, look for automation and optimization opportunities.""", capabilities = EXCLUDED.capabilities, system_prompt = EXCLUDED.system_prompt, config = EXCLUDED.config - """, agent["id"], agent["name"], agent["role"], agent["description"], - json.dumps(agent["capabilities"]), agent["system_prompt"], - json.dumps(agent["config"]), True) + """, + agent["id"], + agent["name"], + agent["role"], + agent["description"], + json.dumps(agent["capabilities"]), + agent["system_prompt"], + json.dumps(agent["config"]), + True, + ) # Task operations async def create_task( title: str, - description: Optional[str] = None, + description: str | None = None, status: str = "parking_lot", priority: str = "medium", - project_id: Optional[int] = None, - assigned_to: Optional[str] = None, + project_id: int | None = None, + assigned_to: str | None = None, assigned_type: str = "human", - tags: Optional[List[str]] = None, - due_date: Optional[datetime] = None, - actor: str = "system" -) -> Dict[str, Any]: + tags: list[str] | None = None, + due_date: datetime | None = None, + actor: str = "system", +) -> dict[str, Any]: """Create a new task""" pool = await get_pool() async with pool.acquire() as conn: - row = await conn.fetchrow(""" + row = await conn.fetchrow( + """ INSERT INTO tasks (title, description, status, priority, project_id, assigned_to, assigned_type, tags, due_date) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, title, description, status, priority, project_id, assigned_to, assigned_type, tags, created_at, updated_at, completed_at, due_date - """, title, description, status, priority, project_id, assigned_to, assigned_type, - json.dumps(tags or []), due_date) + """, + title, + description, + status, + priority, + project_id, + assigned_to, + assigned_type, + json.dumps(tags or []), + due_date, + ) task = dict(row) task["tags"] = json.loads(task["tags"]) if task["tags"] else [] @@ -285,23 +320,30 @@ async def create_task( task_for_history[key] = task_for_history[key].isoformat() # Log history - await conn.execute(""" + await conn.execute( + """ INSERT INTO task_history (task_id, action, actor, actor_type, new_value) VALUES ($1, $2, $3, $4, $5) - """, task["id"], "created", actor, "human", json.dumps(task_for_history)) + """, + task["id"], + "created", + actor, + "human", + json.dumps(task_for_history), + ) return task async def get_tasks( - status: Optional[str] = None, - assigned_to: Optional[str] = None, - project_id: Optional[int] = None, - priority: Optional[str] = None, - tags: Optional[List[str]] = None, + status: str | None = None, + assigned_to: str | None = None, + project_id: int | None = None, + priority: str | None = None, + tags: list[str] | None = None, limit: int = 50, - offset: int = 0 -) -> List[Dict[str, Any]]: + offset: int = 0, +) -> list[dict[str, Any]]: """Get tasks with filters""" pool = await get_pool() async with pool.acquire() as conn: @@ -346,11 +388,7 @@ async def get_tasks( return tasks -async def update_task( - task_id: int, - updates: Dict[str, Any], - actor: str = "system" -) -> Dict[str, Any]: +async def update_task(task_id: int, updates: dict[str, Any], actor: str = "system") -> dict[str, Any]: """Update a task""" pool = await get_pool() async with pool.acquire() as conn: @@ -367,7 +405,17 @@ async def update_task( param_count = 0 for key, value in updates.items(): - if key in ["title", "description", "status", "priority", "project_id", "assigned_to", "assigned_type", "due_date", "completed_at"]: + if key in [ + "title", + "description", + "status", + "priority", + "project_id", + "assigned_to", + "assigned_type", + "due_date", + "completed_at", + ]: param_count += 1 set_clauses.append(f"{key} = ${param_count}") params.append(value) @@ -395,10 +443,18 @@ async def update_task( updates_for_history[key] = updates_for_history[key].isoformat() # Log history - await conn.execute(""" + await conn.execute( + """ INSERT INTO task_history (task_id, action, actor, actor_type, old_value, new_value) VALUES ($1, $2, $3, $4, $5, $6) - """, task_id, "updated", actor, "human", json.dumps(old_values_for_history), json.dumps(updates_for_history)) + """, + task_id, + "updated", + actor, + "human", + json.dumps(old_values_for_history), + json.dumps(updates_for_history), + ) return task @@ -407,73 +463,85 @@ async def delete_task(task_id: int, actor: str = "system"): """Delete a task""" pool = await get_pool() async with pool.acquire() as conn: - await conn.execute(""" + await conn.execute( + """ INSERT INTO task_history (task_id, action, actor, actor_type) VALUES ($1, $2, $3, $4) - """, task_id, "deleted", actor, "human") + """, + task_id, + "deleted", + actor, + "human", + ) await conn.execute("DELETE FROM tasks WHERE id = $1", task_id) # Time tracking operations -async def start_time_entry( - task_id: int, - project_id: Optional[int] = None, - actor: str = "system" -) -> Dict[str, Any]: +async def start_time_entry(task_id: int, project_id: int | None = None, actor: str = "system") -> dict[str, Any]: """Start automatic time tracking for a task""" pool = await get_pool() async with pool.acquire() as conn: # Check if there's already an active entry - active = await conn.fetchrow(""" + active = await conn.fetchrow( + """ SELECT * FROM time_entries WHERE task_id = $1 AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1 - """, task_id) + """, + task_id, + ) if active: return dict(active) # Create new time entry - row = await conn.fetchrow(""" + row = await conn.fetchrow( + """ INSERT INTO time_entries (task_id, project_id, started_at, duration_minutes) VALUES ($1, $2, CURRENT_TIMESTAMP, 0) RETURNING * - """, task_id, project_id) + """, + task_id, + project_id, + ) return dict(row) -async def stop_time_entry(task_id: int) -> Optional[Dict[str, Any]]: +async def stop_time_entry(task_id: int) -> dict[str, Any] | None: """Stop automatic time tracking for a task""" pool = await get_pool() async with pool.acquire() as conn: # Find active entry - active = await conn.fetchrow(""" + active = await conn.fetchrow( + """ SELECT * FROM time_entries WHERE task_id = $1 AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1 - """, task_id) + """, + task_id, + ) if not active: return None # Calculate duration and update - row = await conn.fetchrow(""" + row = await conn.fetchrow( + """ UPDATE time_entries SET ended_at = CURRENT_TIMESTAMP, duration_minutes = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) / 60 WHERE id = $1 RETURNING * - """, active["id"]) + """, + active["id"], + ) return dict(row) -async def get_time_entries( - task_id: Optional[int] = None, - project_id: Optional[int] = None -) -> List[Dict[str, Any]]: +async def get_time_entries(task_id: int | None = None, project_id: int | None = None) -> list[dict[str, Any]]: """Get time entries""" pool = await get_pool() async with pool.acquire() as conn: @@ -494,20 +562,23 @@ async def get_time_entries( return [dict(row) for row in rows] -async def get_task_history(task_id: int) -> List[Dict[str, Any]]: +async def get_task_history(task_id: int) -> list[dict[str, Any]]: """Get task history""" pool = await get_pool() async with pool.acquire() as conn: - rows = await conn.fetch(""" + rows = await conn.fetch( + """ SELECT * FROM task_history WHERE task_id = $1 ORDER BY timestamp DESC - """, task_id) + """, + task_id, + ) return [dict(row) for row in rows] # Agent operations -async def get_agents(enabled_only: bool = True) -> List[Dict[str, Any]]: +async def get_agents(enabled_only: bool = True) -> list[dict[str, Any]]: """Get all agents""" pool = await get_pool() async with pool.acquire() as conn: @@ -526,7 +597,7 @@ async def get_agents(enabled_only: bool = True) -> List[Dict[str, Any]]: return agents -async def get_agent(agent_id: str) -> Optional[Dict[str, Any]]: +async def get_agent(agent_id: str) -> dict[str, Any] | None: """Get agent by ID""" pool = await get_pool() async with pool.acquire() as conn: @@ -541,11 +612,8 @@ async def get_agent(agent_id: str) -> Optional[Dict[str, Any]]: async def create_agent_execution( - task_id: int, - agent_id: str, - actor: str = "system", - requires_approval: bool = False -) -> Dict[str, Any]: + task_id: int, agent_id: str, actor: str = "system", requires_approval: bool = False +) -> dict[str, Any]: """Create agent execution record""" pool = await get_pool() async with pool.acquire() as conn: @@ -561,28 +629,43 @@ async def create_agent_execution( task_for_context = dict(task) for key in ["created_at", "updated_at", "completed_at", "due_date"]: if task_for_context.get(key): - task_for_context[key] = task_for_context[key].isoformat() if hasattr(task_for_context[key], 'isoformat') else task_for_context[key] + task_for_context[key] = ( + task_for_context[key].isoformat() + if hasattr(task_for_context[key], "isoformat") + else task_for_context[key] + ) agent_for_context = dict(agent) for key in ["created_at"]: if agent_for_context.get(key): - agent_for_context[key] = agent_for_context[key].isoformat() if hasattr(agent_for_context[key], 'isoformat') else agent_for_context[key] + agent_for_context[key] = ( + agent_for_context[key].isoformat() + if hasattr(agent_for_context[key], "isoformat") + else agent_for_context[key] + ) input_context = { "task": task_for_context, "agent": agent_for_context, - "timestamp": datetime.utcnow().isoformat() + "timestamp": datetime.utcnow().isoformat(), } # Check if agent requires approval (CxO level) agent_config = json.loads(agent["config"]) if agent["config"] else {} requires_approval = agent_config.get("approval_level") == "cxo" - row = await conn.fetchrow(""" + row = await conn.fetchrow( + """ INSERT INTO agent_executions (task_id, agent_id, status, input_context, requires_approval, started_at) VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP) RETURNING * - """, task_id, agent_id, "pending", json.dumps(input_context), requires_approval) + """, + task_id, + agent_id, + "pending", + json.dumps(input_context), + requires_approval, + ) execution = dict(row) execution["input_context"] = json.loads(execution["input_context"]) if execution["input_context"] else {} @@ -590,11 +673,8 @@ async def create_agent_execution( async def get_agent_executions( - task_id: Optional[int] = None, - agent_id: Optional[str] = None, - status: Optional[str] = None, - limit: int = 50 -) -> List[Dict[str, Any]]: + task_id: int | None = None, agent_id: str | None = None, status: str | None = None, limit: int = 50 +) -> list[dict[str, Any]]: """Get agent executions""" pool = await get_pool() async with pool.acquire() as conn: @@ -628,7 +708,7 @@ async def get_agent_executions( return executions -async def get_task_by_id(task_id: int) -> Optional[Dict[str, Any]]: +async def get_task_by_id(task_id: int) -> dict[str, Any] | None: """Get a single task by ID""" pool = await get_pool() async with pool.acquire() as conn: @@ -641,7 +721,7 @@ async def get_task_by_id(task_id: int) -> Optional[Dict[str, Any]]: return task -async def get_agent_execution(execution_id: int) -> Optional[Dict[str, Any]]: +async def get_agent_execution(execution_id: int) -> dict[str, Any] | None: """Get a single agent execution by ID""" pool = await get_pool() async with pool.acquire() as conn: @@ -657,23 +737,25 @@ async def get_agent_execution(execution_id: int) -> Optional[Dict[str, Any]]: async def approve_agent_execution( - execution_id: int, - approved: bool, - approved_by: str, - feedback: Optional[str] = None -) -> Dict[str, Any]: + execution_id: int, approved: bool, approved_by: str, feedback: str | None = None +) -> dict[str, Any]: """Approve or reject an agent execution""" pool = await get_pool() async with pool.acquire() as conn: # Update execution with approval status - row = await conn.fetchrow(""" + row = await conn.fetchrow( + """ UPDATE agent_executions SET approved_by = $1, approved_at = CURRENT_TIMESTAMP, status = CASE WHEN $2 THEN 'approved' ELSE 'rejected' END WHERE id = $3 RETURNING * - """, approved_by, approved, execution_id) + """, + approved_by, + approved, + execution_id, + ) if not row: raise ValueError(f"Execution {execution_id} not found") @@ -689,23 +771,27 @@ async def approve_agent_execution( execution["output_result"] = {} execution["output_result"]["approval_feedback"] = feedback - await conn.execute(""" + await conn.execute( + """ UPDATE agent_executions SET output_result = $1 WHERE id = $2 - """, json.dumps(execution["output_result"]), execution_id) + """, + json.dumps(execution["output_result"]), + execution_id, + ) return execution async def update_agent_config( agent_id: str, - name: Optional[str] = None, - description: Optional[str] = None, - system_prompt: Optional[str] = None, - config: Optional[Dict[str, Any]] = None, - enabled: Optional[bool] = None -) -> Dict[str, Any]: + name: str | None = None, + description: str | None = None, + system_prompt: str | None = None, + config: dict[str, Any] | None = None, + enabled: bool | None = None, +) -> dict[str, Any]: """Update agent configuration""" pool = await get_pool() async with pool.acquire() as conn: @@ -761,11 +847,11 @@ async def update_agent_config( async def update_execution_status( execution_id: int, status: str, - output_result: Optional[Dict[str, Any]] = None, - actions_proposed: Optional[List[Dict[str, Any]]] = None, - actions_executed: Optional[List[Dict[str, Any]]] = None, - error_message: Optional[str] = None -) -> Dict[str, Any]: + output_result: dict[str, Any] | None = None, + actions_proposed: list[dict[str, Any]] | None = None, + actions_executed: list[dict[str, Any]] | None = None, + error_message: str | None = None, +) -> dict[str, Any]: """Update agent execution status and results""" pool = await get_pool() async with pool.acquire() as conn: diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 04bcda4..ef22e5f 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -1,30 +1,49 @@ -from fastapi import FastAPI, Depends, Request -from fastapi.staticfiles import StaticFiles +import asyncio +import logging +import time +import traceback as tb +from contextlib import asynccontextmanager +from datetime import datetime, timedelta, timezone + +import docker.errors +import httpx +from fastapi import Depends, FastAPI, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse -from fastapi.exceptions import RequestValidationError +from fastapi.staticfiles import StaticFiles from slowapi import Limiter -from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded -from routers import docker_router, gitea, files, terminal, system, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents, opc_ws, opc_projects, auth as auth_router +from slowapi.util import get_remote_address + import auth_service as auth_module import config +from config import CORS_ORIGINS, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, get_client_ip from rbac import require_page -from contextlib import asynccontextmanager - -import asyncio -import httpx -import logging -import time -import jwt -import docker.errors -import traceback as tb -from datetime import datetime, timezone, timedelta -from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT, get_client_ip +from routers import auth as auth_router +from routers import ( + cc_connect, + chat_summary, + docker_router, + files, + gitea, + info_engine, + litellm, + opc_agents, + opc_projects, + opc_tasks, + opc_ws, + passkey, + security, + system, + terminal, + totp, +) _tz_cst = timezone(timedelta(hours=8)) + @asynccontextmanager async def lifespan(app: FastAPI): # Startup @@ -32,16 +51,19 @@ async def lifespan(app: FastAPI): # Initialize OPC database from db import opc_db + try: await opc_db.init_db() print("OPC database initialized") except Exception as e: print(f"Failed to initialize OPC database: {e}") import traceback + traceback.print_exc() # Start agent executor service from services import agent_executor + executor_task = None try: executor_task = asyncio.create_task(agent_executor.start_executor()) @@ -49,6 +71,7 @@ async def lifespan(app: FastAPI): except Exception as e: print(f"Failed to start agent executor: {e}") import traceback + traceback.print_exc() monitor_task = asyncio.create_task(monitor_containers()) @@ -61,6 +84,7 @@ async def lifespan(app: FastAPI): executor_task.cancel() monitor_task.cancel() + limiter = Limiter(key_func=get_remote_address) app = FastAPI(title="NAS Dashboard", lifespan=lifespan) app.state.limiter = limiter @@ -76,6 +100,7 @@ app.add_middleware( # Compress responses (static files and API responses) app.add_middleware(GZipMiddleware, minimum_size=1000) + @app.exception_handler(RateLimitExceeded) async def rate_limit_handler(request: Request, exc: RateLimitExceeded): return JSONResponse(status_code=429, content={"detail": "Too many requests, try again later"}) @@ -85,30 +110,21 @@ async def rate_limit_handler(request: Request, exc: RateLimitExceeded): async def validation_exception_handler(request: Request, exc: RequestValidationError): """Handle request validation errors with detailed information.""" logging.error(f"Validation error on {request.method} {request.url.path}: {exc.errors()}") - return JSONResponse( - status_code=422, - content={"detail": exc.errors()} - ) + return JSONResponse(status_code=422, content={"detail": exc.errors()}) @app.exception_handler(docker.errors.NotFound) async def docker_not_found_handler(request: Request, exc: docker.errors.NotFound): """Handle Docker container/image not found errors.""" logging.warning(f"Docker resource not found: {exc}") - return JSONResponse( - status_code=404, - content={"detail": "Docker resource not found"} - ) + return JSONResponse(status_code=404, content={"detail": "Docker resource not found"}) @app.exception_handler(docker.errors.APIError) async def docker_api_error_handler(request: Request, exc: docker.errors.APIError): """Handle Docker API errors.""" logging.error(f"Docker API error: {exc}") - return JSONResponse( - status_code=503, - content={"detail": "Docker service unavailable"} - ) + return JSONResponse(status_code=503, content={"detail": "Docker service unavailable"}) @app.exception_handler(Exception) @@ -116,10 +132,8 @@ async def generic_exception_handler(request: Request, exc: Exception): """Handle all unhandled exceptions.""" logging.error(f"Unhandled exception on {request.method} {request.url.path}: {exc}") logging.error(tb.format_exc()) - return JSONResponse( - status_code=500, - content={"detail": "Internal server error"} - ) + return JSONResponse(status_code=500, content={"detail": "Internal server error"}) + @app.middleware("http") async def security_headers(request: Request, call_next): @@ -128,7 +142,9 @@ async def security_headers(request: Request, call_next): response.headers["X-Frame-Options"] = "DENY" response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss: ws:; frame-ancestors 'none'" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss: ws:; frame-ancestors 'none'" + ) # Cache static assets with hashed filenames (Vite generates these) if request.url.path.startswith("/assets/"): @@ -136,8 +152,10 @@ async def security_headers(request: Request, call_next): return response + # Audit logger import os + _audit_log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log") os.makedirs(os.path.dirname(_audit_log_path), exist_ok=True) _audit_logger = logging.getLogger("audit") @@ -147,6 +165,7 @@ _audit_handler.setFormatter(logging.Formatter("%(message)s")) _audit_logger.addHandler(_audit_handler) _audit_logger.propagate = False + @app.middleware("http") async def audit_log(request: Request, call_next): start = time.time() @@ -156,19 +175,27 @@ async def audit_log(request: Request, call_next): username = user.username if user else "-" _audit_logger.info( "%s %s %s %s %s %d %dms", - datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), get_client_ip(request), username, - request.method, request.url.path, response.status_code, duration, + datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), + get_client_ip(request), + username, + request.method, + request.url.path, + response.status_code, + duration, ) return response + async def monitor_containers(): import docker + from config import DOCKER_HOST + client = docker.DockerClient(base_url=DOCKER_HOST) - + # Store initial states previous_states = {} - + while True: try: containers = await asyncio.to_thread(client.containers.list, all=True) @@ -190,13 +217,15 @@ async def monitor_containers(): previous_states[c.id] = current_status except Exception as e: logging.getLogger(__name__).error("Error in container monitor: %s", e) - + await asyncio.sleep(60) + @app.get("/api/health") async def health(): return {"status": "ok"} + @app.get("/api/client-ip") async def client_ip(request: Request): ip = get_client_ip(request) @@ -215,8 +244,10 @@ async def client_ip(request: Request): return {"lan": lan} + def _router_reachable(): import socket + try: s = socket.socket() s.settimeout(1) @@ -226,43 +257,63 @@ def _router_reachable(): except Exception: return False + # Dependency to store user in request.state for rbac dependencies -async def _inject_user(request: Request, user = Depends(auth_module.get_current_user)): +async def _inject_user(request: Request, user=Depends(auth_module.get_current_user)): request.state.user = user return user + # Public auth endpoints app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"]) app.include_router(passkey.router, prefix="/api/auth", tags=["passkey"]) app.include_router(totp.router, prefix="/api/auth", tags=["totp"]) # Protected endpoints with page-level RBAC -app.include_router(docker_router.router, prefix="/api/docker", - dependencies=[Depends(_inject_user), Depends(require_page("docker"))]) -app.include_router(gitea.router, prefix="/api/gitea", - dependencies=[Depends(_inject_user), Depends(require_page("gitea"))]) -app.include_router(files.router, prefix="/api/files", - dependencies=[Depends(_inject_user), Depends(require_page("files"))]) -app.include_router(system.router, prefix="/api/system", - dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) -app.include_router(litellm.router, prefix="/api/litellm", - dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) -app.include_router(cc_connect.router, prefix="/api/cc-connect", - dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) -app.include_router(chat_summary.router, prefix="/api/chat-summary", - dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))]) -app.include_router(info_engine.router, prefix="/api/info-engine", - dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]) -app.include_router(security.router, prefix="/api/security", - dependencies=[Depends(_inject_user), Depends(require_page("security"))]) +app.include_router( + docker_router.router, prefix="/api/docker", dependencies=[Depends(_inject_user), Depends(require_page("docker"))] +) +app.include_router( + gitea.router, prefix="/api/gitea", dependencies=[Depends(_inject_user), Depends(require_page("gitea"))] +) +app.include_router( + files.router, prefix="/api/files", dependencies=[Depends(_inject_user), Depends(require_page("files"))] +) +app.include_router( + system.router, prefix="/api/system", dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))] +) +app.include_router( + litellm.router, prefix="/api/litellm", dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))] +) +app.include_router( + cc_connect.router, + prefix="/api/cc-connect", + dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))], +) +app.include_router( + chat_summary.router, + prefix="/api/chat-summary", + dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))], +) +app.include_router( + info_engine.router, + prefix="/api/info-engine", + dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))], +) +app.include_router( + security.router, prefix="/api/security", dependencies=[Depends(_inject_user), Depends(require_page("security"))] +) # OPC endpoints -app.include_router(opc_tasks.router, prefix="/api/opc", - dependencies=[Depends(_inject_user), Depends(require_page("opc"))]) -app.include_router(opc_agents.router, prefix="/api/opc", - dependencies=[Depends(_inject_user), Depends(require_page("opc"))]) -app.include_router(opc_projects.router, prefix="/api/opc", - dependencies=[Depends(_inject_user), Depends(require_page("opc"))]) +app.include_router( + opc_tasks.router, prefix="/api/opc", dependencies=[Depends(_inject_user), Depends(require_page("opc"))] +) +app.include_router( + opc_agents.router, prefix="/api/opc", dependencies=[Depends(_inject_user), Depends(require_page("opc"))] +) +app.include_router( + opc_projects.router, prefix="/api/opc", dependencies=[Depends(_inject_user), Depends(require_page("opc"))] +) # WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary) app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint) @@ -270,11 +321,13 @@ app.add_api_websocket_route("/ws/opc", opc_ws.websocket_endpoint) # Mount static files (skip if directory doesn't exist, e.g., in test environments) import os + if os.path.isdir("/app/static"): app.mount("/", StaticFiles(directory="/app/static", html=True), name="static") else: # In test environment, create a minimal fallback import tempfile + _test_static = tempfile.mkdtemp() with open(os.path.join(_test_static, "index.html"), "w") as f: f.write("Test Environment") diff --git a/dashboard/backend/pyproject.toml b/dashboard/backend/pyproject.toml index 053abfd..2047e99 100644 --- a/dashboard/backend/pyproject.toml +++ b/dashboard/backend/pyproject.toml @@ -1,3 +1,48 @@ +[tool.black] +line-length = 120 +target-version = ['py312'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | venv + | _build + | buck-out + | build + | dist +)/ +''' + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by black) + "B008", # do not perform function calls in argument defaults + "C901", # too complex + "W191", # indentation contains tabs +] + +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401"] # unused imports in __init__.py + [tool.pytest.ini_options] minversion = "7.0" testpaths = ["tests"] diff --git a/dashboard/backend/rbac.py b/dashboard/backend/rbac.py index 7ddb417..0ca6df3 100644 --- a/dashboard/backend/rbac.py +++ b/dashboard/backend/rbac.py @@ -1,16 +1,18 @@ import json import os -import threading import tempfile -from typing import Optional +import threading + +from fastapi import HTTPException, Request from pydantic import BaseModel -from fastapi import HTTPException, Request, status, Depends import config + def get_rbac_file(): return os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json") + _rbac_lock = threading.RLock() ROLE_GROUP_MAP = { @@ -75,7 +77,7 @@ def save_rbac(data: dict): _atomic_json_write(get_rbac_file(), data) -def resolve_role(groups: list[str]) -> Optional[str]: +def resolve_role(groups: list[str]) -> str | None: for group in groups: if group in ROLE_GROUP_MAP: return ROLE_GROUP_MAP[group] @@ -107,6 +109,7 @@ def get_sidebar_order(username: str) -> dict | None: def save_sidebar_order(username: str, order: dict): def mutator(rbac): rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order + update_rbac(mutator) @@ -118,26 +121,32 @@ def has_page_access(user: User, page: str) -> bool: def require_page(page: str): """FastAPI dependency factory — 403 if user lacks page access.""" + async def checker(request: Request): user: User = request.state.user if not has_page_access(user, page): raise HTTPException(status_code=403, detail="Access denied") + return checker def require_admin(): """FastAPI dependency — 403 if user is not admin.""" + async def checker(request: Request): user: User = request.state.user if user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") + return checker def require_write(): """FastAPI dependency — 403 if viewer tries non-GET method.""" + async def checker(request: Request): user: User = request.state.user if user.role == "viewer" and request.method != "GET": raise HTTPException(status_code=403, detail="Read-only access") + return checker diff --git a/dashboard/backend/requirements-dev.txt b/dashboard/backend/requirements-dev.txt index 265fccc..957eab8 100644 --- a/dashboard/backend/requirements-dev.txt +++ b/dashboard/backend/requirements-dev.txt @@ -2,3 +2,6 @@ pytest==8.3.4 pytest-asyncio==0.24.0 pytest-cov==6.0.0 pytest-mock==3.14.0 +pre-commit==4.0.1 +black==24.10.0 +ruff==0.8.4 diff --git a/dashboard/backend/routers/auth.py b/dashboard/backend/routers/auth.py index 9963d47..2aa5f5d 100644 --- a/dashboard/backend/routers/auth.py +++ b/dashboard/backend/routers/auth.py @@ -1,16 +1,18 @@ """Core authentication endpoints: login, token refresh, user info, proxy auth.""" -from datetime import timedelta + import asyncio +import logging +from datetime import timedelta + import httpx -from typing import Optional -from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response +import jwt +from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status from pydantic import BaseModel from slowapi import Limiter from slowapi.util import get_remote_address -import jwt -import config + import auth_service as auth -import logging +import config logger = logging.getLogger(__name__) router = APIRouter() @@ -40,7 +42,7 @@ def _set_auth_response_tokens(response: Response, access_token: str, refresh_tok auth.set_auth_cookies(response, access_token, refresh_token) -def _extract_refresh_token(request: Request, req: Optional[RefreshRequest] = None) -> str: +def _extract_refresh_token(request: Request, req: RefreshRequest | None = None) -> str: cookie_token = request.cookies.get(auth.REFRESH_COOKIE_NAME, "") if cookie_token: return cookie_token @@ -87,7 +89,7 @@ async def send_failed_login_alert(ip_address: str, username: str, reason: str): res = await client.post( f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage", data={"chat_id": config.TELEGRAM_CHAT_ID, "text": msg, "parse_mode": "Markdown"}, - timeout=10.0 + timeout=10.0, ) logger.info("Telegram alert sent, status: %s", res.status_code) except Exception as e: @@ -147,6 +149,7 @@ async def proxy_auth(request: Request, response: Response): """Auto-login when Authelia has already authenticated the user via forward-auth. Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification.""" from rbac import resolve_role + if not config.is_trusted_proxy(request.client.host): logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {request.client.host}") raise HTTPException(status_code=403, detail="Unauthorized proxy source") @@ -196,7 +199,7 @@ async def read_users_me(current_user=Depends(auth.get_current_user)): @router.post("/refresh") @limiter.limit("10/minute") -async def refresh_token(request: Request, response: Response, req: Optional[RefreshRequest] = Body(default=None)): +async def refresh_token(request: Request, response: Response, req: RefreshRequest | None = Body(default=None)): refresh_token_value = _extract_refresh_token(request, req) access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value) _set_auth_response_tokens(response, access_token, refresh_token) @@ -232,6 +235,7 @@ async def rbac_config(current_user=Depends(auth.get_current_user)): if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") from rbac import load_rbac + return load_rbac() @@ -240,6 +244,7 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") from rbac import update_rbac + body = await request.json() pages = body.get("pages") if pages is None: @@ -257,6 +262,7 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend @router.get("/preferences") async def get_preferences(current_user=Depends(auth.get_current_user)): from rbac import get_sidebar_order + order = get_sidebar_order(current_user.username) return {"sidebar_order": order} @@ -264,7 +270,9 @@ async def get_preferences(current_user=Depends(auth.get_current_user)): @router.put("/preferences") async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)): import traceback + from rbac import save_sidebar_order + try: body = await request.json() order = body.get("sidebar_order") @@ -298,6 +306,7 @@ async def rbac_update_role(role: str, request: Request, current_user=Depends(aut if current_user.role != "admin": raise HTTPException(status_code=403, detail="Admin only") from rbac import update_rbac + body = await request.json() pages = body.get("pages") sidebar_links = body.get("sidebar_links") diff --git a/dashboard/backend/routers/cc_connect.py b/dashboard/backend/routers/cc_connect.py index c3d82be..9a2b694 100644 --- a/dashboard/backend/routers/cc_connect.py +++ b/dashboard/backend/routers/cc_connect.py @@ -10,6 +10,7 @@ router = APIRouter() _client = None + def get_docker_client(): """Get or create Docker client (lazy initialization).""" global _client diff --git a/dashboard/backend/routers/chat_summary.py b/dashboard/backend/routers/chat_summary.py index fe143dc..79f1f93 100644 --- a/dashboard/backend/routers/chat_summary.py +++ b/dashboard/backend/routers/chat_summary.py @@ -1,18 +1,22 @@ -from fastapi import APIRouter, HTTPException import aiosqlite +from fastapi import APIRouter, HTTPException + from config import CHAT_SUMMARY_DB router = APIRouter() + async def _db(): return aiosqlite.connect(CHAT_SUMMARY_DB) + @router.get("/dates") async def list_dates(): async with await _db() as db: cursor = await db.execute("SELECT date FROM summaries ORDER BY date DESC") return [r[0] for r in await cursor.fetchall()] + @router.get("/summary/{date}") async def get_summary(date: str): async with await _db() as db: @@ -22,18 +26,21 @@ async def get_summary(date: str): raise HTTPException(404, "No summary for this date") return {"date": date, "content": row[0], "created_at": row[1]} + @router.get("/messages/{date}") async def get_messages(date: str): async with await _db() as db: cursor = await db.execute( "SELECT group_name, sender_name, text, timestamp FROM messages WHERE date(timestamp)=? ORDER BY timestamp", - (date,) + (date,), ) return [{"group": r[0], "sender": r[1], "text": r[2], "time": r[3]} for r in await cursor.fetchall()] + @router.post("/trigger") async def trigger_summary(): import os + trigger_path = os.environ.get("CHAT_SUMMARY_TRIGGER", "/app/data/chat-summarizer/trigger") os.makedirs(os.path.dirname(trigger_path), exist_ok=True) with open(trigger_path, "w") as f: diff --git a/dashboard/backend/routers/docker_router.py b/dashboard/backend/routers/docker_router.py index ccebcde..76177a1 100644 --- a/dashboard/backend/routers/docker_router.py +++ b/dashboard/backend/routers/docker_router.py @@ -1,5 +1,6 @@ import docker -from fastapi import APIRouter, Depends, Query, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query + from config import DOCKER_HOST from rbac import require_admin @@ -7,6 +8,7 @@ router = APIRouter() _client = None + def get_docker_client(): """Get or create Docker client (lazy initialization).""" global _client diff --git a/dashboard/backend/routers/files.py b/dashboard/backend/routers/files.py index 30e705e..c9b76d1 100644 --- a/dashboard/backend/routers/files.py +++ b/dashboard/backend/routers/files.py @@ -2,8 +2,10 @@ import os import re import shutil from pathlib import Path -from fastapi import APIRouter, Depends, UploadFile, File, HTTPException + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse + from config import VOLUME_ROOT from rbac import require_admin @@ -49,7 +51,7 @@ def _is_safe_symlink(item: Path) -> bool: def _sanitize_filename(name: str) -> str: name = os.path.basename(name) name = name.replace("\x00", "").replace("/", "").replace("\\", "") - name = re.sub(r'\.\.+', '.', name) + name = re.sub(r"\.\.+", ".", name) name = name.strip(". ") if not name: raise ValueError("invalid filename") diff --git a/dashboard/backend/routers/gitea.py b/dashboard/backend/routers/gitea.py index 20528b4..405f61d 100644 --- a/dashboard/backend/routers/gitea.py +++ b/dashboard/backend/routers/gitea.py @@ -1,11 +1,13 @@ import re + import httpx from fastapi import APIRouter, HTTPException -from config import GITEA_URL, GITEA_TOKEN + +from config import GITEA_TOKEN, GITEA_URL router = APIRouter() HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {} -_SAFE_NAME = re.compile(r'^[a-zA-Z0-9._-]+$') +_SAFE_NAME = re.compile(r"^[a-zA-Z0-9._-]+$") @router.get("/repos") diff --git a/dashboard/backend/routers/litellm.py b/dashboard/backend/routers/litellm.py index 7bc9514..00471e5 100644 --- a/dashboard/backend/routers/litellm.py +++ b/dashboard/backend/routers/litellm.py @@ -1,7 +1,9 @@ import time + import httpx from fastapi import APIRouter -from config import LITELLM_URL, LITELLM_HEALTH_API_KEY + +from config import LITELLM_HEALTH_API_KEY, LITELLM_URL router = APIRouter() diff --git a/dashboard/backend/routers/opc_agents.py b/dashboard/backend/routers/opc_agents.py index 0ffe591..fb93309 100644 --- a/dashboard/backend/routers/opc_agents.py +++ b/dashboard/backend/routers/opc_agents.py @@ -1,25 +1,26 @@ """ OPC Agents Router """ + from fastapi import APIRouter, Depends, HTTPException, Request -from typing import Optional, List from pydantic import BaseModel + from db import opc_db -from rbac import require_page, User, require_admin +from rbac import User, require_admin router = APIRouter() class AgentUpdate(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - system_prompt: Optional[str] = None - enabled: Optional[bool] = None + name: str | None = None + description: str | None = None + system_prompt: str | None = None + enabled: bool | None = None class ExecutionApproval(BaseModel): approved: bool - feedback: Optional[str] = None + feedback: str | None = None @router.get("/agents") @@ -60,7 +61,7 @@ async def update_agent( name=updates.name, description=updates.description, system_prompt=updates.system_prompt, - enabled=updates.enabled + enabled=updates.enabled, ) return agent except ValueError as e: @@ -75,30 +76,21 @@ async def trigger_agent( ): """Manually trigger agent execution""" user: User = request.state.user - execution = await opc_db.create_agent_execution( - task_id=task_id, - agent_id=agent_id, - actor=user.username - ) + execution = await opc_db.create_agent_execution(task_id=task_id, agent_id=agent_id, actor=user.username) return execution @router.get("/executions") async def list_executions( request: Request, - task_id: Optional[int] = None, - agent_id: Optional[str] = None, - status: Optional[str] = None, + task_id: int | None = None, + agent_id: str | None = None, + status: str | None = None, limit: int = 50, ): """List agent executions""" user: User = request.state.user - executions = await opc_db.get_agent_executions( - task_id=task_id, - agent_id=agent_id, - status=status, - limit=limit - ) + executions = await opc_db.get_agent_executions(task_id=task_id, agent_id=agent_id, status=status, limit=limit) return {"items": executions} @@ -125,10 +117,7 @@ async def approve_execution( user: User = request.state.user try: execution = await opc_db.approve_agent_execution( - execution_id=execution_id, - approved=approval.approved, - approved_by=user.username, - feedback=approval.feedback + execution_id=execution_id, approved=approval.approved, approved_by=user.username, feedback=approval.feedback ) return execution except ValueError as e: diff --git a/dashboard/backend/routers/opc_projects.py b/dashboard/backend/routers/opc_projects.py index acabff8..ad56630 100644 --- a/dashboard/backend/routers/opc_projects.py +++ b/dashboard/backend/routers/opc_projects.py @@ -1,27 +1,29 @@ """ OPC Projects and Invoicing Router """ -from fastapi import APIRouter, Depends, HTTPException, Response, Request -from typing import Optional + +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Request, Response from pydantic import BaseModel + from db import opc_db from rbac import User from services import pdf_service -from datetime import datetime router = APIRouter() class ProjectCreate(BaseModel): name: str - description: Optional[str] = None - client_id: Optional[int] = None + description: str | None = None + client_id: int | None = None class InvoiceGenerate(BaseModel): project_id: int client_id: int - invoice_number: Optional[str] = None + invoice_number: str | None = None @router.get("/projects") @@ -45,11 +47,16 @@ async def create_project( user: User = request.state.user pool = await opc_db.get_pool() async with pool.acquire() as conn: - row = await conn.fetchrow(""" + row = await conn.fetchrow( + """ INSERT INTO projects (name, description, client_id) VALUES ($1, $2, $3) RETURNING * - """, project.name, project.description, project.client_id) + """, + project.name, + project.description, + project.client_id, + ) return dict(row) @@ -69,18 +76,23 @@ async def list_clients( async def create_client( request: Request, name: str, - email: Optional[str] = None, - company: Optional[str] = None, + email: str | None = None, + company: str | None = None, ): """Create a new client""" user: User = request.state.user pool = await opc_db.get_pool() async with pool.acquire() as conn: - row = await conn.fetchrow(""" + row = await conn.fetchrow( + """ INSERT INTO clients (name, email, company) VALUES ($1, $2, $3) RETURNING * - """, name, email, company) + """, + name, + email, + company, + ) return dict(row) @@ -100,11 +112,14 @@ async def generate_invoice( client = dict(client_row) # Get time entries for project - time_rows = await conn.fetch(""" + time_rows = await conn.fetch( + """ SELECT * FROM time_entries WHERE project_id = $1 AND billable = TRUE ORDER BY started_at - """, invoice.project_id) + """, + invoice.project_id, + ) if not time_rows: raise HTTPException(status_code=400, detail="No billable time entries found") @@ -116,7 +131,7 @@ async def generate_invoice( project_id=invoice.project_id, client=client, time_entries=time_entries, - invoice_number=invoice.invoice_number + invoice_number=invoice.invoice_number, ) # Return PDF @@ -124,9 +139,7 @@ async def generate_invoice( return Response( content=pdf_bytes, media_type="application/pdf", - headers={ - "Content-Disposition": f"attachment; filename={filename}" - } + headers={"Content-Disposition": f"attachment; filename={filename}"}, ) @@ -146,5 +159,5 @@ async def get_project_time( "total_minutes": total_minutes, "total_hours": round(total_minutes / 60, 2), "billable_minutes": billable_minutes, - "billable_hours": round(billable_minutes / 60, 2) + "billable_hours": round(billable_minutes / 60, 2), } diff --git a/dashboard/backend/routers/opc_tasks.py b/dashboard/backend/routers/opc_tasks.py index 7ff192b..24f1f8f 100644 --- a/dashboard/backend/routers/opc_tasks.py +++ b/dashboard/backend/routers/opc_tasks.py @@ -1,12 +1,14 @@ """ OPC Task Management Router """ -from fastapi import APIRouter, Depends, Query, HTTPException, Request -from typing import Optional, List -from pydantic import BaseModel + from datetime import datetime + +from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel + from db import opc_db -from rbac import require_page, User +from rbac import User from routers import opc_ws router = APIRouter() @@ -14,27 +16,27 @@ router = APIRouter() class TaskCreate(BaseModel): title: str - description: Optional[str] = None + description: str | None = None status: str = "parking_lot" priority: str = "medium" - project_id: Optional[int] = None - assigned_to: Optional[str] = None + project_id: int | None = None + assigned_to: str | None = None assigned_type: str = "human" - tags: Optional[List[str]] = None - due_date: Optional[datetime] = None + tags: list[str] | None = None + due_date: datetime | None = None class TaskUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - status: Optional[str] = None - priority: Optional[str] = None - project_id: Optional[int] = None - assigned_to: Optional[str] = None - assigned_type: Optional[str] = None - tags: Optional[List[str]] = None - due_date: Optional[datetime] = None - completed_at: Optional[datetime] = None + title: str | None = None + description: str | None = None + status: str | None = None + priority: str | None = None + project_id: int | None = None + assigned_to: str | None = None + assigned_type: str | None = None + tags: list[str] | None = None + due_date: datetime | None = None + completed_at: datetime | None = None class TaskMove(BaseModel): @@ -49,11 +51,11 @@ class TaskAssign(BaseModel): @router.get("/tasks") async def list_tasks( request: Request, - status: Optional[str] = Query(None), - assigned_to: Optional[str] = Query(None), - project_id: Optional[int] = Query(None), - priority: Optional[str] = Query(None), - tags: Optional[str] = Query(None), + status: str | None = Query(None), + assigned_to: str | None = Query(None), + project_id: int | None = Query(None), + priority: str | None = Query(None), + tags: str | None = Query(None), limit: int = Query(50, le=100), offset: int = Query(0, ge=0), ): @@ -67,7 +69,7 @@ async def list_tasks( priority=priority, tags=tag_list, limit=limit, - offset=offset + offset=offset, ) return {"items": tasks, "total": len(tasks)} @@ -91,16 +93,12 @@ async def create_task( assigned_type=task.assigned_type, tags=task.tags, due_date=due_date, - actor=user.username + actor=user.username, ) # Start automatic timer if status is in_progress if task.status == "in_progress": - await opc_db.start_time_entry( - task_id=created_task["id"], - project_id=task.project_id, - actor=user.username - ) + await opc_db.start_time_entry(task_id=created_task["id"], project_id=task.project_id, actor=user.username) # Broadcast WebSocket update await opc_ws.broadcast_task_update(created_task["id"], "created", created_task) @@ -144,11 +142,7 @@ async def update_task( # Start timer when moving to in_progress if old_status != "in_progress" and new_status == "in_progress": - await opc_db.start_time_entry( - task_id=task_id, - project_id=current_task.get("project_id"), - actor=user.username - ) + await opc_db.start_time_entry(task_id=task_id, project_id=current_task.get("project_id"), actor=user.username) # Stop timer when moving away from in_progress if old_status == "in_progress" and new_status != "in_progress": @@ -164,11 +158,7 @@ async def update_task( if "completed_at" in updates and updates["completed_at"] is not None: updates["completed_at"] = updates["completed_at"].replace(tzinfo=None) - updated_task = await opc_db.update_task( - task_id=task_id, - updates=updates, - actor=user.username - ) + updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username) # Broadcast WebSocket update await opc_ws.broadcast_task_update(task_id, "updated", updated_task) @@ -209,16 +199,14 @@ async def move_task( raise HTTPException(status_code=404, detail="Task not found") old_status = current_task["status"] - print(f"[Move] Task {task_id}: old_status={old_status}, new_status={move.status}, assigned_to={current_task.get('assigned_to')}") + print( + f"[Move] Task {task_id}: old_status={old_status}, new_status={move.status}, assigned_to={current_task.get('assigned_to')}" + ) # Handle automatic time tracking if old_status != "in_progress" and move.status == "in_progress": print(f"[Move] Task {task_id} moving to in_progress, old_status={old_status}") - await opc_db.start_time_entry( - task_id=task_id, - project_id=current_task.get("project_id"), - actor=user.username - ) + await opc_db.start_time_entry(task_id=task_id, project_id=current_task.get("project_id"), actor=user.username) # Auto-assign to default agent (PM) if not already assigned if not current_task.get("assigned_to"): @@ -232,18 +220,12 @@ async def move_task( print(f"[Move] Task {task_id} assigned to agent {agent_id}, checking for existing execution") # Check if there's already a pending execution - existing_executions = await opc_db.get_agent_executions( - task_id=task_id, - status="pending", - limit=1 - ) + existing_executions = await opc_db.get_agent_executions(task_id=task_id, status="pending", limit=1) if not existing_executions: print(f"[Move] No pending execution found, creating one for agent {agent_id}") execution_id = await opc_db.create_agent_execution( - task_id=task_id, - agent_id=agent_id, - actor=user.username + task_id=task_id, agent_id=agent_id, actor=user.username ) print(f"[Move] Created agent execution {execution_id} for task {task_id}") else: @@ -255,11 +237,7 @@ async def move_task( if move.status == "done": updates["completed_at"] = datetime.utcnow().replace(tzinfo=None) - updated_task = await opc_db.update_task( - task_id=task_id, - updates=updates, - actor=user.username - ) + updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username) return updated_task @@ -271,24 +249,13 @@ async def assign_task( ): """Assign task to human or agent""" user: User = request.state.user - updates = { - "assigned_to": assign.assigned_to, - "assigned_type": assign.assigned_type - } + updates = {"assigned_to": assign.assigned_to, "assigned_type": assign.assigned_type} - updated_task = await opc_db.update_task( - task_id=task_id, - updates=updates, - actor=user.username - ) + updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username) # If assigning to agent, create execution record if assign.assigned_type == "agent": - await opc_db.create_agent_execution( - task_id=task_id, - agent_id=assign.assigned_to, - actor=user.username - ) + await opc_db.create_agent_execution(task_id=task_id, agent_id=assign.assigned_to, actor=user.username) return updated_task @@ -313,8 +280,4 @@ async def get_task_time( user: User = request.state.user entries = await opc_db.get_time_entries(task_id=task_id) total_minutes = sum(e["duration_minutes"] for e in entries) - return { - "entries": entries, - "total_minutes": total_minutes, - "total_hours": round(total_minutes / 60, 2) - } + return {"entries": entries, "total_minutes": total_minutes, "total_hours": round(total_minutes / 60, 2)} diff --git a/dashboard/backend/routers/opc_ws.py b/dashboard/backend/routers/opc_ws.py index 93f8b9d..023f265 100644 --- a/dashboard/backend/routers/opc_ws.py +++ b/dashboard/backend/routers/opc_ws.py @@ -1,20 +1,19 @@ """ OPC WebSocket Router - Real-time updates for tasks and agent executions """ -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends -from typing import Set, Any, Dict -from datetime import datetime -import json -import asyncio + import logging -from auth_service import get_current_user_ws +from datetime import datetime +from typing import Any + +from fastapi import APIRouter, WebSocket, WebSocketDisconnect logger = logging.getLogger(__name__) router = APIRouter() # Active WebSocket connections -active_connections: Set[WebSocket] = set() +active_connections: set[WebSocket] = set() def serialize_datetime(obj: Any) -> Any: @@ -33,7 +32,7 @@ class ConnectionManager: """Manages WebSocket connections""" def __init__(self): - self.active_connections: Set[WebSocket] = set() + self.active_connections: set[WebSocket] = set() async def connect(self, websocket: WebSocket): """Accept and register a new connection""" @@ -95,7 +94,7 @@ async def broadcast_task_update(task_id: int, action: str, task_data: dict): "task_id": task_id, "action": action, # created, updated, moved, deleted "data": serialized_data, - "timestamp": datetime.utcnow().isoformat() + "timestamp": datetime.utcnow().isoformat(), } await manager.broadcast(message) @@ -110,7 +109,7 @@ async def broadcast_agent_execution(execution_id: int, status: str, execution_da "execution_id": execution_id, "status": status, # pending, running, completed, failed, pending_approval "data": serialized_data, - "timestamp": datetime.utcnow().isoformat() + "timestamp": datetime.utcnow().isoformat(), } await manager.broadcast(message) @@ -121,6 +120,6 @@ async def broadcast_agent_status(agent_id: str, status: str, message_text: str = "type": "agent_status", "agent_id": agent_id, "status": status, # idle, working, error - "message": message_text + "message": message_text, } await manager.broadcast(message) diff --git a/dashboard/backend/routers/passkey.py b/dashboard/backend/routers/passkey.py index 4db9356..a2f6db4 100644 --- a/dashboard/backend/routers/passkey.py +++ b/dashboard/backend/routers/passkey.py @@ -1,19 +1,22 @@ """WebAuthn Passkey authentication endpoints.""" + import json import time from datetime import timedelta + from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import JSONResponse from slowapi import Limiter from slowapi.util import get_remote_address from webauthn import ( - generate_registration_options, - verify_registration_response, generate_authentication_options, - verify_authentication_response + generate_registration_options, + verify_authentication_response, + verify_registration_response, ) +from webauthn.helpers import base64url_to_bytes, bytes_to_base64url, options_to_json from webauthn.helpers.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement -from webauthn.helpers import bytes_to_base64url, base64url_to_bytes, options_to_json + import auth_service as auth import config @@ -23,6 +26,7 @@ limiter = Limiter(key_func=get_remote_address) # In-memory challenge store with TTL _challenges = {} + def _store_challenge(challenge: bytes): """Store a WebAuthn challenge with timestamp for TTL tracking.""" _challenges[challenge] = time.time() @@ -32,12 +36,13 @@ def _store_challenge(challenge: bytes): for k in expired: _challenges.pop(k, None) + def _get_challenge(client_data_b64: str) -> bytes: """Extract and validate challenge from clientDataJSON.""" if not client_data_b64: raise HTTPException(status_code=400, detail="Missing clientDataJSON") try: - data = json.loads(base64url_to_bytes(client_data_b64).decode('utf-8')) + data = json.loads(base64url_to_bytes(client_data_b64).decode("utf-8")) chal_bytes = base64url_to_bytes(data.get("challenge", "")) except Exception: raise HTTPException(status_code=400, detail="Invalid clientDataJSON") @@ -47,14 +52,12 @@ def _get_challenge(client_data_b64: str) -> bytes: raise HTTPException(status_code=400, detail="Challenge expired or invalid") return chal_bytes + @router.post("/passkey/register/options") -async def passkey_register_options(current_user = Depends(auth.get_current_user)): +async def passkey_register_options(current_user=Depends(auth.get_current_user)): """Generate WebAuthn registration options for adding a new passkey.""" existing = auth.load_passkey_credentials() - exclude = [ - PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) - for c in existing - ] + exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing] options = generate_registration_options( rp_id=config.WEBAUTHN_RP_ID, rp_name="NAS Dashboard", @@ -66,8 +69,9 @@ async def passkey_register_options(current_user = Depends(auth.get_current_user) _store_challenge(options.challenge) return JSONResponse(content=json.loads(options_to_json(options))) + @router.post("/passkey/register/verify") -async def passkey_register_verify(request: Request, current_user = Depends(auth.get_current_user)): +async def passkey_register_verify(request: Request, current_user=Depends(auth.get_current_user)): """Verify and save a new passkey registration.""" body = await request.json() challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", "")) @@ -81,15 +85,18 @@ async def passkey_register_verify(request: Request, current_user = Depends(auth. except Exception as e: raise HTTPException(status_code=400, detail=str(e)) - auth.save_passkey_credential({ - "credential_id": bytes_to_base64url(verification.credential_id), - "public_key": bytes_to_base64url(verification.credential_public_key), - "sign_count": verification.sign_count, - "name": body.get("name", "Passkey"), - "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - }) + auth.save_passkey_credential( + { + "credential_id": bytes_to_base64url(verification.credential_id), + "public_key": bytes_to_base64url(verification.credential_public_key), + "sign_count": verification.sign_count, + "name": body.get("name", "Passkey"), + "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + } + ) return {"ok": True} + @router.post("/passkey/login/options") @limiter.limit("10/minute") async def passkey_login_options(request: Request): @@ -98,10 +105,7 @@ async def passkey_login_options(request: Request): if not creds: raise HTTPException(status_code=404, detail="No passkeys registered") - allow = [ - PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) - for c in creds - ] + allow = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in creds] options = generate_authentication_options( rp_id=config.WEBAUTHN_RP_ID, allow_credentials=allow, @@ -110,6 +114,7 @@ async def passkey_login_options(request: Request): _store_challenge(options.challenge) return JSONResponse(content=json.loads(options_to_json(options))) + @router.post("/passkey/login/verify") @limiter.limit("10/minute") async def passkey_login_verify(request: Request, response: Response): @@ -152,26 +157,24 @@ async def passkey_login_verify(request: Request, response: Response): "refresh_token": refresh_token, "token_type": "bearer", "user": username, - "role": "admin" + "role": "admin", } + @router.get("/passkey/list") -async def passkey_list(current_user = Depends(auth.get_current_user)): +async def passkey_list(current_user=Depends(auth.get_current_user)): """List all registered passkeys for the current user.""" creds = auth.load_passkey_credentials() return { "passkeys": [ - { - "id": c["credential_id"], - "name": c.get("name", "Passkey"), - "created_at": c.get("created_at", "") - } + {"id": c["credential_id"], "name": c.get("name", "Passkey"), "created_at": c.get("created_at", "")} for c in creds ] } + @router.post("/passkey/delete") -async def passkey_delete(request: Request, current_user = Depends(auth.get_current_user)): +async def passkey_delete(request: Request, current_user=Depends(auth.get_current_user)): """Delete a specific passkey by credential ID.""" body = await request.json() cred_id = body.get("id") @@ -181,8 +184,9 @@ async def passkey_delete(request: Request, current_user = Depends(auth.get_curre auth._save_auth_data(data) return {"ok": True} + @router.post("/passkey/clear") -async def passkey_clear(current_user = Depends(auth.get_current_user)): +async def passkey_clear(current_user=Depends(auth.get_current_user)): """Clear all passkeys for the current user.""" auth.clear_passkey_credentials() return {"ok": True} diff --git a/dashboard/backend/routers/security.py b/dashboard/backend/routers/security.py index 11b2c82..5e13a9e 100644 --- a/dashboard/backend/routers/security.py +++ b/dashboard/backend/routers/security.py @@ -1,15 +1,18 @@ -import docker -import re import json -from datetime import datetime, timezone, timedelta, time +import re from collections import Counter +from datetime import datetime, time, timedelta, timezone + +import docker from fastapi import APIRouter, Query + from config import DOCKER_HOST router = APIRouter() _client = None + def get_docker_client(): """Get or create Docker client (lazy initialization).""" global _client @@ -17,6 +20,7 @@ def get_docker_client(): _client = docker.DockerClient(base_url=DOCKER_HOST, timeout=10) return _client + _tz_cst = timezone(timedelta(hours=8)) # Suspicious path patterns (scanners, exploits, etc.) @@ -67,9 +71,8 @@ def _parse_authelia_logs(lines: str, limit: int) -> list[dict]: level = obj.get("level", "") msg = obj.get("msg", "") - is_failed = ( - level in ("error", "warning") - and any(kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied")) + is_failed = level in ("error", "warning") and any( + kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied") ) # Also catch "not authorized" for anonymous access attempts is_unauthorized = "is not authorized" in msg.lower() @@ -83,14 +86,16 @@ def _parse_authelia_logs(lines: str, limit: int) -> list[dict]: except Exception: ts = ts_raw[:19] if ts_raw else "" - entries.append({ - "type": "auth_failure" if is_failed else "unauthorized", - "timestamp": ts, - "message": msg, - "username": obj.get("username", obj.get("user", "")), - "ip": obj.get("remote_ip", obj.get("ip", "")), - "level": level, - }) + entries.append( + { + "type": "auth_failure" if is_failed else "unauthorized", + "timestamp": ts, + "message": msg, + "username": obj.get("username", obj.get("user", "")), + "ip": obj.get("remote_ip", obj.get("ip", "")), + "level": level, + } + ) return entries[-limit:] @@ -122,18 +127,24 @@ def _parse_caddy_logs(lines: str, limit: int) -> list[dict]: if isinstance(ts_raw, (int, float)): ts = datetime.fromtimestamp(ts_raw, tz=_tz_cst).strftime("%Y-%m-%d %H:%M:%S") else: - ts = datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")).astimezone(_tz_cst).strftime("%Y-%m-%d %H:%M:%S") + ts = ( + datetime.fromisoformat(str(ts_raw).replace("Z", "+00:00")) + .astimezone(_tz_cst) + .strftime("%Y-%m-%d %H:%M:%S") + ) except Exception: ts = str(ts_raw)[:19] - entries.append({ - "type": "suspicious_request", - "timestamp": ts, - "message": f"{status} {obj.get('request', {}).get('method', 'GET')} {uri}", - "ip": remote_ip, - "status": status, - "path": uri, - }) + entries.append( + { + "type": "suspicious_request", + "timestamp": ts, + "message": f"{status} {obj.get('request', {}).get('method', 'GET')} {uri}", + "ip": remote_ip, + "status": status, + "path": uri, + } + ) return entries[-limit:] diff --git a/dashboard/backend/routers/system.py b/dashboard/backend/routers/system.py index dc55482..4d079c7 100644 --- a/dashboard/backend/routers/system.py +++ b/dashboard/backend/routers/system.py @@ -1,12 +1,14 @@ import os -import shutil -import psutil import platform +import shutil import time + import httpx -from fastapi import APIRouter, Query, Request, HTTPException +import psutil +from fastapi import APIRouter, HTTPException, Query, Request + import config -from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, OPENCLAW_GATEWAY_TOKEN +from config import OPENCLAW_GATEWAY_TOKEN, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT router = APIRouter() @@ -36,7 +38,15 @@ def system_stats(): seen_devices.add(mount.device) try: u = shutil.disk_usage(mount.mountpoint) - volumes.append({"mount": mount.mountpoint, "total": u.total, "used": u.used, "free": u.free, "percent": round(u.used / u.total * 100, 1)}) + volumes.append( + { + "mount": mount.mountpoint, + "total": u.total, + "used": u.used, + "free": u.free, + "percent": round(u.used / u.total * 100, 1), + } + ) except Exception: pass mem = psutil.virtual_memory() @@ -90,7 +100,18 @@ def audit_log(lines: int = Query(100, le=500)): level = _classify(method, path, status, user) if level == "noise": continue - entries.append({"ts": ts, "ip": ip, "user": user, "method": method, "path": path, "status": status, "duration": dur, "level": level}) + entries.append( + { + "ts": ts, + "ip": ip, + "user": user, + "method": method, + "path": path, + "status": status, + "duration": dur, + "level": level, + } + ) return {"entries": entries} diff --git a/dashboard/backend/routers/terminal.py b/dashboard/backend/routers/terminal.py index c1d0ce1..52fa51e 100644 --- a/dashboard/backend/routers/terminal.py +++ b/dashboard/backend/routers/terminal.py @@ -1,13 +1,15 @@ import asyncio -import struct import logging import shlex -import jwt +import struct from collections import Counter -from fastapi import WebSocket, Query + import asyncssh -import config +import jwt +from fastapi import Query, WebSocket + import auth_service as auth +import config logger = logging.getLogger(__name__) @@ -61,16 +63,18 @@ def build_host_command(host_config: dict) -> str | None: f"bash -l -c 'cd /repos/nas-tools && tmux new-session -A -s {quoted_session}'" ) - script = " ".join([ - "if", - f"{docker_bin} exec {quoted_container} tmux has-session -t {quoted_session} >/dev/null 2>&1;", - "then", - f"printf '%sattached\\n' {quoted_marker};", - "else", - f"printf '%screated\\n' {quoted_marker};", - "fi;", - f"exec {attach_cmd}", - ]) + script = " ".join( + [ + "if", + f"{docker_bin} exec {quoted_container} tmux has-session -t {quoted_session} >/dev/null 2>&1;", + "then", + f"printf '%sattached\\n' {quoted_marker};", + "else", + f"printf '%screated\\n' {quoted_marker};", + "fi;", + f"exec {attach_cmd}", + ] + ) return f"bash -lc {shlex.quote(script)}" @@ -140,15 +144,17 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): try: conn = await asyncio.wait_for( asyncssh.connect( - h["host"], username=h["user"], - client_keys=[h["key"]], known_hosts=_known_hosts, + h["host"], + username=h["user"], + client_keys=[h["key"]], + known_hosts=_known_hosts, keepalive_interval=15, keepalive_count_max=8, ), - timeout=10.0 + timeout=10.0, ) logger.info("SSH connection established to %s", host) - except asyncio.TimeoutError: + except TimeoutError: logger.error("SSH connection to %s timed out after 10s", host) await _release_session(session_id) await websocket.close(code=1011, reason="SSH connection timeout") @@ -161,7 +167,9 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): try: process = await conn.create_process( - build_host_command(h), term_type="xterm-256color", term_size=(80, 24), + build_host_command(h), + term_type="xterm-256color", + term_size=(80, 24), encoding=None, ) @@ -212,7 +220,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")): read_task.cancel() try: await asyncio.wait_for(read_task, timeout=2.0) - except (asyncio.TimeoutError, asyncio.CancelledError): + except (TimeoutError, asyncio.CancelledError): pass process.close() finally: diff --git a/dashboard/backend/routers/totp.py b/dashboard/backend/routers/totp.py index bd864aa..4874138 100644 --- a/dashboard/backend/routers/totp.py +++ b/dashboard/backend/routers/totp.py @@ -1,31 +1,34 @@ """TOTP (Time-based One-Time Password) 2FA endpoints.""" + +import pyotp from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -import pyotp + import auth_service as auth router = APIRouter() + class Setup2FAResponse(BaseModel): secret: str uri: str + class Verify2FARequest(BaseModel): secret: str code: str + @router.post("/setup-2fa/generate", response_model=Setup2FAResponse) -async def generate_2fa(current_user = Depends(auth.get_current_user)): +async def generate_2fa(current_user=Depends(auth.get_current_user)): """Generate a new TOTP secret and provisioning URI for 2FA setup.""" secret = pyotp.random_base32() - uri = pyotp.totp.TOTP(secret).provisioning_uri( - name=current_user.username, - issuer_name="NAS Dashboard" - ) + uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user.username, issuer_name="NAS Dashboard") return {"secret": secret, "uri": uri} + @router.post("/setup-2fa/verify") -async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(auth.get_current_user)): +async def verify_2fa_setup(request: Verify2FARequest, current_user=Depends(auth.get_current_user)): """Verify TOTP code and enable 2FA for the user.""" totp = pyotp.TOTP(request.secret) if not totp.verify(request.code): @@ -34,8 +37,9 @@ async def verify_2fa_setup(request: Verify2FARequest, current_user = Depends(aut auth.save_totp_secret(request.secret) return {"message": "2FA enabled successfully"} + @router.post("/setup-2fa/disable") -async def disable_2fa(current_user = Depends(auth.get_current_user)): +async def disable_2fa(current_user=Depends(auth.get_current_user)): """Disable 2FA for the user.""" auth.save_totp_secret("") return {"message": "2FA disabled"} diff --git a/dashboard/backend/services/agent_executor.py b/dashboard/backend/services/agent_executor.py index c5e51e5..92a56b7 100644 --- a/dashboard/backend/services/agent_executor.py +++ b/dashboard/backend/services/agent_executor.py @@ -1,15 +1,18 @@ """ Agent Executor Service - Executes agent tasks and manages their lifecycle """ + import asyncio import logging -from typing import Dict, Any, Optional -from datetime import datetime -from db import opc_db -from services.agents.base_agent import BaseAgent -from services import email_service -import httpx import os +from datetime import datetime +from typing import Any + +import httpx + +from db import opc_db +from services import email_service +from services.agents.base_agent import BaseAgent logger = logging.getLogger(__name__) @@ -22,7 +25,7 @@ class AgentExecutor: """Manages agent execution lifecycle""" def __init__(self): - self.agents: Dict[str, BaseAgent] = {} + self.agents: dict[str, BaseAgent] = {} self.running = False async def initialize(self): @@ -35,7 +38,7 @@ class AgentExecutor: name=agent_data["name"], role=agent_data["role"], system_prompt=agent_data["system_prompt"], - config=agent_data["config"] + config=agent_data["config"], ) self.agents[agent_data["id"]] = agent logger.info(f"Loaded agent: {agent.name} ({agent.agent_id})") @@ -54,6 +57,7 @@ class AgentExecutor: print(f"[Executor] Error in executor loop: {e}") logger.error(f"Error in executor loop: {e}") import traceback + traceback.print_exc() await asyncio.sleep(5) # Check every 5 seconds @@ -74,9 +78,11 @@ class AgentExecutor: for execution in executions: try: - print(f"[Executor] Processing execution {execution['id']} for agent {execution['agent_id']} (status: {execution['status']})") + print( + f"[Executor] Processing execution {execution['id']} for agent {execution['agent_id']} (status: {execution['status']})" + ) - if execution['status'] == 'approved': + if execution["status"] == "approved": # Resume approved execution await self._resume_approved_execution(execution) else: @@ -87,7 +93,7 @@ class AgentExecutor: logger.error(f"Failed to execute agent {execution['agent_id']}: {e}") await self._mark_execution_failed(execution["id"], str(e)) - async def _execute_agent(self, execution: Dict[str, Any]): + async def _execute_agent(self, execution: dict[str, Any]): """Execute a single agent task""" agent_id = execution["agent_id"] task_id = execution["task_id"] @@ -127,7 +133,7 @@ class AgentExecutor: # Send completion notification await self._send_completion_notification(agent, task, result) - async def _resume_approved_execution(self, execution: Dict[str, Any]): + async def _resume_approved_execution(self, execution: dict[str, Any]): """Resume an approved execution and execute its actions""" agent_id = execution["agent_id"] task_id = execution["task_id"] @@ -160,12 +166,9 @@ class AgentExecutor: # Send completion notification await self._send_completion_notification(agent, task, result) - async def _build_context(self, task: Dict[str, Any]) -> Dict[str, Any]: + async def _build_context(self, task: dict[str, Any]) -> dict[str, Any]: """Build context for agent execution""" - context = { - "task": task, - "timestamp": datetime.utcnow().isoformat() - } + context = {"task": task, "timestamp": datetime.utcnow().isoformat()} # Get related tasks from same project if task.get("project_id"): @@ -178,7 +181,7 @@ class AgentExecutor: return context - async def _execute_actions(self, task: Dict[str, Any], actions: list): + async def _execute_actions(self, task: dict[str, Any], actions: list): """Execute proposed actions""" for action in actions: action_type = action.get("type") @@ -203,7 +206,7 @@ class AgentExecutor: logger.error(f"Failed to execute action {action_type}: {e}") raise - async def _action_create_subtask(self, parent_task: Dict[str, Any], action: Dict[str, Any]): + async def _action_create_subtask(self, parent_task: dict[str, Any], action: dict[str, Any]): """Create a subtask""" await opc_db.create_task( title=action.get("title", "Subtask"), @@ -212,48 +215,52 @@ class AgentExecutor: priority=parent_task.get("priority", "medium"), project_id=parent_task.get("project_id"), tags=parent_task.get("tags", []), - actor="agent" + actor="agent", ) logger.info(f"Created subtask: {action.get('title')}") - async def _action_update_status(self, task: Dict[str, Any], action: Dict[str, Any]): + async def _action_update_status(self, task: dict[str, Any], action: dict[str, Any]): """Update task status""" new_status = action.get("status") if new_status: - await opc_db.update_task( - task_id=task["id"], - updates={"status": new_status}, - actor="agent" - ) + await opc_db.update_task(task_id=task["id"], updates={"status": new_status}, actor="agent") logger.info(f"Updated task {task['id']} status to {new_status}") - async def _action_send_notification(self, action: Dict[str, Any]): + async def _action_send_notification(self, action: dict[str, Any]): """Send notification""" message = action.get("message", "") if message and TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID: await self._send_telegram(message) - async def _action_add_comment(self, task: Dict[str, Any], action: Dict[str, Any]): + async def _action_add_comment(self, task: dict[str, Any], action: dict[str, Any]): """Add comment to task (stored in history)""" comment = action.get("comment", "") if comment: # Store as task history pool = await opc_db.get_pool() async with pool.acquire() as conn: - await conn.execute(""" + await conn.execute( + """ INSERT INTO task_history (task_id, action, actor, actor_type, new_value) VALUES ($1, $2, $3, $4, $5) - """, task["id"], "comment", "agent", "agent", comment) + """, + task["id"], + "comment", + "agent", + "agent", + comment, + ) async def _update_execution_status(self, execution_id: int, status: str): """Update execution status""" await opc_db.update_execution_status(execution_id, status) - async def _mark_execution_completed(self, execution_id: int, result: Dict[str, Any]): + async def _mark_execution_completed(self, execution_id: int, result: dict[str, Any]): """Mark execution as completed""" pool = await opc_db.get_pool() async with pool.acquire() as conn: - await conn.execute(""" + await conn.execute( + """ UPDATE agent_executions SET status = $1, output_result = $2, @@ -261,36 +268,46 @@ class AgentExecutor: actions_executed = $3, completed_at = CURRENT_TIMESTAMP WHERE id = $4 - """, "completed", + """, + "completed", opc_db.json.dumps(result), opc_db.json.dumps(result.get("actions", [])), - execution_id) + execution_id, + ) async def _mark_execution_failed(self, execution_id: int, error: str): """Mark execution as failed""" pool = await opc_db.get_pool() async with pool.acquire() as conn: - await conn.execute(""" + await conn.execute( + """ UPDATE agent_executions SET status = $1, error_message = $2, completed_at = CURRENT_TIMESTAMP WHERE id = $3 - """, "failed", error, execution_id) + """, + "failed", + error, + execution_id, + ) - async def _mark_pending_approval(self, execution_id: int, result: Dict[str, Any]): + async def _mark_pending_approval(self, execution_id: int, result: dict[str, Any]): """Mark execution as pending approval""" pool = await opc_db.get_pool() async with pool.acquire() as conn: - await conn.execute(""" + await conn.execute( + """ UPDATE agent_executions SET status = $1, output_result = $2, actions_proposed = $3, requires_approval = TRUE WHERE id = $4 - """, "pending_approval", + """, + "pending_approval", opc_db.json.dumps(result), opc_db.json.dumps(result.get("actions", [])), - execution_id) + execution_id, + ) async def _send_telegram(self, message: str): """Send Telegram notification""" @@ -299,16 +316,12 @@ class AgentExecutor: async with httpx.AsyncClient(proxy=proxy_url, timeout=10.0) as client: await client.post( f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage", - data={ - "chat_id": TELEGRAM_CHAT_ID, - "text": message, - "parse_mode": "Markdown" - } + data={"chat_id": TELEGRAM_CHAT_ID, "text": message, "parse_mode": "Markdown"}, ) except Exception as e: logger.error(f"Failed to send Telegram notification: {e}") - async def _send_approval_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]): + async def _send_approval_notification(self, agent: BaseAgent, task: dict[str, Any], result: dict[str, Any]): """Send notification for approval request""" message = f"""🤖 *Agent Approval Required* @@ -331,11 +344,11 @@ Proposed Actions: await email_service.send_agent_approval_email( agent_name=agent.name, task=task, - reasoning=result.get('reasoning', 'No reasoning provided'), - actions=result.get('actions', []) + reasoning=result.get("reasoning", "No reasoning provided"), + actions=result.get("actions", []), ) - async def _send_completion_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]): + async def _send_completion_notification(self, agent: BaseAgent, task: dict[str, Any], result: dict[str, Any]): """Send notification for completed execution""" message = f"""✅ *Agent Task Completed* @@ -349,14 +362,12 @@ Actions Executed: {len(result.get('actions', []))} # Send Email await email_service.send_agent_completion_email( - agent_name=agent.name, - task=task, - actions_count=len(result.get('actions', [])) + agent_name=agent.name, task=task, actions_count=len(result.get("actions", [])) ) # Global executor instance -_executor: Optional[AgentExecutor] = None +_executor: AgentExecutor | None = None async def get_executor() -> AgentExecutor: @@ -382,5 +393,6 @@ async def start_executor(): except Exception as e: print(f"ERROR in start_executor: {e}") import traceback + traceback.print_exc() raise diff --git a/dashboard/backend/services/agents/base_agent.py b/dashboard/backend/services/agents/base_agent.py index 524aec5..b1cf337 100644 --- a/dashboard/backend/services/agents/base_agent.py +++ b/dashboard/backend/services/agents/base_agent.py @@ -1,17 +1,18 @@ """ Base Agent Class - Foundation for all OPC agents """ -from typing import Dict, Any, List, Optional + import json -import httpx import os -from datetime import datetime +from typing import Any + +import httpx class BaseAgent: """Base class for all OPC agents""" - def __init__(self, agent_id: str, name: str, role: str, system_prompt: str, config: Dict[str, Any]): + def __init__(self, agent_id: str, name: str, role: str, system_prompt: str, config: dict[str, Any]): self.agent_id = agent_id self.name = name self.role = role @@ -20,7 +21,7 @@ class BaseAgent: self.litellm_url = os.getenv("LITELLM_URL", "http://litellm:4005") self.litellm_api_key = os.getenv("LITELLM_API_KEY", "") - async def execute(self, task: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: + async def execute(self, task: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]: """ Execute agent on a task Returns: { @@ -40,7 +41,7 @@ class BaseAgent: return result - def _build_prompt(self, task: Dict[str, Any], context: Dict[str, Any]) -> str: + def _build_prompt(self, task: dict[str, Any], context: dict[str, Any]) -> str: """Build the prompt for the LLM""" prompt = f"""You are {self.name}, a {self.role} agent. @@ -77,6 +78,7 @@ Only propose actions you can actually execute. Be specific and actionable.""" async def _call_llm(self, prompt: str) -> str: """Call LiteLLM proxy""" import logging + logger = logging.getLogger(__name__) model = self.config.get("model", "claude-sonnet-4-6") @@ -96,11 +98,11 @@ Only propose actions you can actually execute. Be specific and actionable.""" "model": model, "messages": [ {"role": "system", "content": self.system_prompt}, - {"role": "user", "content": prompt} + {"role": "user", "content": prompt}, ], "temperature": temperature, - "max_tokens": 2000 - } + "max_tokens": 2000, + }, ) response.raise_for_status() data = response.json() @@ -118,7 +120,7 @@ Only propose actions you can actually execute. Be specific and actionable.""" logger.error(f"LiteLLM call failed: {e}") raise Exception(f"LLM call failed: {str(e)}") - def _parse_response(self, response: str) -> Dict[str, Any]: + def _parse_response(self, response: str) -> dict[str, Any]: """Parse LLM response and extract actions""" try: # Try to extract JSON from response @@ -148,16 +150,11 @@ Only propose actions you can actually execute. Be specific and actionable.""" # Fallback: return error action return { "reasoning": f"Failed to parse response: {str(e)}", - "actions": [ - { - "type": "error", - "message": f"Agent response parsing failed: {str(e)}" - } - ], - "requires_approval": False + "actions": [{"type": "error", "message": f"Agent response parsing failed: {str(e)}"}], + "requires_approval": False, } - def get_capabilities(self) -> List[str]: + def get_capabilities(self) -> list[str]: """Get agent capabilities""" return self.config.get("capabilities", []) diff --git a/dashboard/backend/services/email_service.py b/dashboard/backend/services/email_service.py index b0a3329..7a60f3c 100644 --- a/dashboard/backend/services/email_service.py +++ b/dashboard/backend/services/email_service.py @@ -1,11 +1,12 @@ """ Email notification service for OPC """ + +import logging import os import smtplib -from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart -import logging +from email.mime.text import MIMEText logger = logging.getLogger(__name__) @@ -90,7 +91,9 @@ async def send_agent_approval_email(agent_name: str, task: dict, reasoning: str, """Send email for agent approval request""" subject = f"Agent Approval Required: {agent_name}" - actions_text = "\n".join([f"- {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}" for action in actions]) + actions_text = "\n".join( + [f"- {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}" for action in actions] + ) body = f"""Agent approval required: diff --git a/dashboard/backend/services/pdf_service.py b/dashboard/backend/services/pdf_service.py index dc8de65..aee00dd 100644 --- a/dashboard/backend/services/pdf_service.py +++ b/dashboard/backend/services/pdf_service.py @@ -1,26 +1,27 @@ """ PDF Invoice Generation Service """ -from reportlab.lib.pagesizes import letter -from reportlab.lib import colors -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.lib.units import inch -from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer -from reportlab.lib.enums import TA_RIGHT, TA_CENTER -from datetime import datetime -from typing import List, Dict, Any + import io +from datetime import datetime +from typing import Any + +from reportlab.lib import colors +from reportlab.lib.pagesizes import letter +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle def generate_invoice_pdf( invoice_number: str, - client: Dict[str, Any], - time_entries: List[Dict[str, Any]], + client: dict[str, Any], + time_entries: list[dict[str, Any]], hourly_rate: float = 100.0, company_name: str = "Your Company", company_address: str = "", company_email: str = "", - company_phone: str = "" + company_phone: str = "", ) -> bytes: """ Generate PDF invoice from time entries @@ -34,18 +35,18 @@ def generate_invoice_pdf( # Custom styles title_style = ParagraphStyle( - 'CustomTitle', - parent=styles['Heading1'], + "CustomTitle", + parent=styles["Heading1"], fontSize=24, - textColor=colors.HexColor('#1F2937'), + textColor=colors.HexColor("#1F2937"), spaceAfter=30, ) header_style = ParagraphStyle( - 'Header', - parent=styles['Normal'], + "Header", + parent=styles["Normal"], fontSize=10, - textColor=colors.HexColor('#6B7280'), + textColor=colors.HexColor("#6B7280"), ) # Company Header @@ -61,10 +62,10 @@ def generate_invoice_pdf( # Invoice Title invoice_title = ParagraphStyle( - 'InvoiceTitle', - parent=styles['Heading2'], + "InvoiceTitle", + parent=styles["Heading2"], fontSize=18, - textColor=colors.HexColor('#4F46E5'), + textColor=colors.HexColor("#4F46E5"), ) elements.append(Paragraph("INVOICE", invoice_title)) elements.append(Spacer(1, 0.2 * inch)) @@ -74,96 +75,93 @@ def generate_invoice_pdf( invoice_data = [ ["Invoice Number:", invoice_number], ["Invoice Date:", invoice_date], - ["Client:", client.get('name', 'N/A')], + ["Client:", client.get("name", "N/A")], ] - if client.get('company'): - invoice_data.append(["Company:", client['company']]) - if client.get('email'): - invoice_data.append(["Email:", client['email']]) + if client.get("company"): + invoice_data.append(["Company:", client["company"]]) + if client.get("email"): + invoice_data.append(["Email:", client["email"]]) - invoice_table = Table(invoice_data, colWidths=[2*inch, 4*inch]) - invoice_table.setStyle(TableStyle([ - ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), - ('FONTNAME', (1, 0), (1, -1), 'Helvetica'), - ('FONTSIZE', (0, 0), (-1, -1), 10), - ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#374151')), - ('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#1F2937')), - ('VALIGN', (0, 0), (-1, -1), 'TOP'), - ('BOTTOMPADDING', (0, 0), (-1, -1), 8), - ])) + invoice_table = Table(invoice_data, colWidths=[2 * inch, 4 * inch]) + invoice_table.setStyle( + TableStyle( + [ + ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"), + ("FONTNAME", (1, 0), (1, -1), "Helvetica"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("TEXTCOLOR", (0, 0), (0, -1), colors.HexColor("#374151")), + ("TEXTCOLOR", (1, 0), (1, -1), colors.HexColor("#1F2937")), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("BOTTOMPADDING", (0, 0), (-1, -1), 8), + ] + ) + ) elements.append(invoice_table) elements.append(Spacer(1, 0.4 * inch)) # Time Entries Table - table_data = [ - ["Date", "Description", "Hours", "Rate", "Amount"] - ] + table_data = [["Date", "Description", "Hours", "Rate", "Amount"]] total_hours = 0 total_amount = 0 for entry in time_entries: - hours = entry['duration_minutes'] / 60 - rate = entry.get('hourly_rate', hourly_rate) + hours = entry["duration_minutes"] / 60 + rate = entry.get("hourly_rate", hourly_rate) amount = hours * rate total_hours += hours total_amount += amount - date_str = entry['started_at'].strftime("%Y-%m-%d") if entry.get('started_at') else "N/A" - desc = entry.get('description', 'Work performed') + date_str = entry["started_at"].strftime("%Y-%m-%d") if entry.get("started_at") else "N/A" + desc = entry.get("description", "Work performed") - table_data.append([ - date_str, - desc[:50] + "..." if len(desc) > 50 else desc, - f"{hours:.2f}", - f"${rate:.2f}", - f"${amount:.2f}" - ]) + table_data.append( + [date_str, desc[:50] + "..." if len(desc) > 50 else desc, f"{hours:.2f}", f"${rate:.2f}", f"${amount:.2f}"] + ) # Add totals row - table_data.append([ - "", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}" - ]) + table_data.append(["", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}"]) # Create table - time_table = Table(table_data, colWidths=[1.2*inch, 2.8*inch, 0.8*inch, 0.8*inch, 1*inch]) - time_table.setStyle(TableStyle([ - # Header row - ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4F46E5')), - ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), - ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), - ('FONTSIZE', (0, 0), (-1, 0), 11), - ('BOTTOMPADDING', (0, 0), (-1, 0), 12), - - # Data rows - ('FONTNAME', (0, 1), (-1, -2), 'Helvetica'), - ('FONTSIZE', (0, 1), (-1, -2), 9), - ('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#F9FAFB')]), - ('GRID', (0, 0), (-1, -2), 0.5, colors.HexColor('#E5E7EB')), - - # Total row - ('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#F3F4F6')), - ('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), - ('FONTSIZE', (0, -1), (-1, -1), 11), - ('TOPPADDING', (0, -1), (-1, -1), 12), - ('BOTTOMPADDING', (0, -1), (-1, -1), 12), - - # Alignment - ('ALIGN', (2, 0), (-1, -1), 'RIGHT'), - ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), - ])) + time_table = Table(table_data, colWidths=[1.2 * inch, 2.8 * inch, 0.8 * inch, 0.8 * inch, 1 * inch]) + time_table.setStyle( + TableStyle( + [ + # Header row + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4F46E5")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("FONTSIZE", (0, 0), (-1, 0), 11), + ("BOTTOMPADDING", (0, 0), (-1, 0), 12), + # Data rows + ("FONTNAME", (0, 1), (-1, -2), "Helvetica"), + ("FONTSIZE", (0, 1), (-1, -2), 9), + ("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, colors.HexColor("#F9FAFB")]), + ("GRID", (0, 0), (-1, -2), 0.5, colors.HexColor("#E5E7EB")), + # Total row + ("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#F3F4F6")), + ("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"), + ("FONTSIZE", (0, -1), (-1, -1), 11), + ("TOPPADDING", (0, -1), (-1, -1), 12), + ("BOTTOMPADDING", (0, -1), (-1, -1), 12), + # Alignment + ("ALIGN", (2, 0), (-1, -1), "RIGHT"), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ] + ) + ) elements.append(time_table) elements.append(Spacer(1, 0.5 * inch)) # Payment Terms terms_style = ParagraphStyle( - 'Terms', - parent=styles['Normal'], + "Terms", + parent=styles["Normal"], fontSize=9, - textColor=colors.HexColor('#6B7280'), + textColor=colors.HexColor("#6B7280"), ) elements.append(Paragraph("Payment Terms: Due within 30 days", terms_style)) elements.append(Spacer(1, 0.1 * inch)) @@ -179,10 +177,7 @@ def generate_invoice_pdf( def generate_invoice_from_project( - project_id: int, - client: Dict[str, Any], - time_entries: List[Dict[str, Any]], - invoice_number: str = None + project_id: int, client: dict[str, Any], time_entries: list[dict[str, Any]], invoice_number: str = None ) -> bytes: """Generate invoice for a project""" if not invoice_number: @@ -195,5 +190,5 @@ def generate_invoice_from_project( company_name="Your OPC", company_address="123 Business St, City, State 12345", company_email="billing@youropc.com", - company_phone="+1 (555) 123-4567" + company_phone="+1 (555) 123-4567", ) diff --git a/dashboard/backend/tests/conftest.py b/dashboard/backend/tests/conftest.py index 38dc5a6..de6d884 100644 --- a/dashboard/backend/tests/conftest.py +++ b/dashboard/backend/tests/conftest.py @@ -1,12 +1,23 @@ """ Shared pytest fixtures for backend tests. """ -import os + import json -import pytest +import os from datetime import timedelta +from unittest.mock import AsyncMock, Mock, patch + +import pytest from fastapi.testclient import TestClient -from unittest.mock import Mock, AsyncMock, patch + +# 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 @@ -30,8 +41,10 @@ def mock_config(temp_volume_root, monkeypatch): monkeypatch.setenv("GITEA_TOKEN", "mock-token") # Reload config module to pick up new env vars - import config import importlib + + import config + importlib.reload(config) return config @@ -41,12 +54,7 @@ def mock_config(temp_volume_root, monkeypatch): 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 - } + 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 @@ -60,9 +68,9 @@ def temp_rbac_file(temp_volume_root): "role_defaults": { "admin": {"pages": "*", "sidebar_links": "*"}, "member": {"pages": ["dashboard", "docker"], "sidebar_links": "*"}, - "viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]} + "viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}, }, - "user_overrides": {} + "user_overrides": {}, } with open(rbac_file, "w") as f: json.dump(data, f) @@ -73,16 +81,15 @@ def temp_rbac_file(temp_volume_root): 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) - ) + + 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"}) @@ -90,10 +97,8 @@ def valid_refresh_token(mock_config, temp_auth_file): 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) - ) + + return create_access_token(data={"sub": "testuser", "role": "admin"}, expires_delta=timedelta(seconds=-1)) @pytest.fixture @@ -118,10 +123,7 @@ def mock_docker_client(): 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.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] @@ -170,8 +172,13 @@ def mock_docker_client_factory(): image_tag="my-image:v1.0" ) """ - def _create_mock(container_name="test-container", container_status="running", - image_tag="test-image:latest", container_id="abc123"): + + def _create_mock( + container_name="test-container", + container_status="running", + image_tag="test-image:latest", + container_id="abc123", + ): client = Mock() # Mock container image @@ -187,10 +194,7 @@ def mock_docker_client_factory(): 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.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] @@ -229,8 +233,10 @@ def mock_httpx_client(): 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 + + import routers.files + importlib.reload(routers.files) # Patch Docker client before importing main @@ -241,6 +247,7 @@ def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client): with patch("slowapi.Limiter", return_value=mock_limiter): from main import app + client = TestClient(app) yield client @@ -258,14 +265,11 @@ def mock_rbac_data(): "role_defaults": { "admin": {"pages": "*", "sidebar_links": "*"}, "member": {"pages": ["dashboard", "docker", "files"], "sidebar_links": "*"}, - "viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]} + "viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}, }, "user_overrides": { - "customuser": { - "pages": ["dashboard", "docker"], - "sidebar_links": ["dashboard", "docker", "files"] - } - } + "customuser": {"pages": ["dashboard", "docker"], "sidebar_links": ["dashboard", "docker", "files"]} + }, } diff --git a/dashboard/backend/tests/integration/test_auth_flow.py b/dashboard/backend/tests/integration/test_auth_flow.py index bddc083..b77573c 100644 --- a/dashboard/backend/tests/integration/test_auth_flow.py +++ b/dashboard/backend/tests/integration/test_auth_flow.py @@ -1,10 +1,8 @@ """ Integration tests for authentication flow. """ -import pytest + import pyotp -from fastapi import status -from auth_service import get_password_hash, save_totp_secret, save_password_hash class TestLoginFlow: @@ -12,14 +10,15 @@ class TestLoginFlow: def test_login_success_no_2fa(self, test_app, mock_config, temp_auth_file): """Test successful login without 2FA.""" + from auth_service import get_password_hash, save_password_hash + # Set up password password = "test_password" hashed = get_password_hash(password) save_password_hash(hashed) response = test_app.post( - "/api/auth/login", - json={"username": "testadmin", "password": password, "totp_code": ""} + "/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""} ) assert response.status_code == 200 @@ -33,6 +32,8 @@ class TestLoginFlow: def test_login_success_with_2fa(self, test_app, mock_config, temp_auth_file, sample_totp_secret): """Test successful login with 2FA.""" + from auth_service import get_password_hash, save_password_hash, save_totp_secret + password = "test_password" hashed = get_password_hash(password) save_password_hash(hashed) @@ -42,8 +43,7 @@ class TestLoginFlow: valid_code = totp.now() response = test_app.post( - "/api/auth/login", - json={"username": "testadmin", "password": password, "totp_code": valid_code} + "/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": valid_code} ) assert response.status_code == 200 @@ -53,13 +53,14 @@ class TestLoginFlow: def test_login_wrong_password(self, test_app, mock_config, temp_auth_file): """Test login with wrong password.""" + from auth_service import get_password_hash, save_password_hash + password = "correct_password" hashed = get_password_hash(password) save_password_hash(hashed) response = test_app.post( - "/api/auth/login", - json={"username": "testadmin", "password": "wrong_password", "totp_code": ""} + "/api/auth/login", json={"username": "testadmin", "password": "wrong_password", "totp_code": ""} ) assert response.status_code == 401 @@ -67,27 +68,29 @@ class TestLoginFlow: def test_login_wrong_username(self, test_app, mock_config, temp_auth_file): """Test login with wrong username.""" + from auth_service import get_password_hash, save_password_hash + password = "test_password" hashed = get_password_hash(password) save_password_hash(hashed) response = test_app.post( - "/api/auth/login", - json={"username": "wronguser", "password": password, "totp_code": ""} + "/api/auth/login", json={"username": "wronguser", "password": password, "totp_code": ""} ) assert response.status_code == 401 def test_login_missing_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret): """Test login with 2FA enabled but no code provided.""" + from auth_service import get_password_hash, save_password_hash, save_totp_secret + password = "test_password" hashed = get_password_hash(password) save_password_hash(hashed) save_totp_secret(sample_totp_secret) response = test_app.post( - "/api/auth/login", - json={"username": "testadmin", "password": password, "totp_code": ""} + "/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""} ) assert response.status_code == 403 @@ -95,27 +98,29 @@ class TestLoginFlow: def test_login_invalid_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret): """Test login with invalid 2FA code.""" + from auth_service import get_password_hash, save_password_hash, save_totp_secret + password = "test_password" hashed = get_password_hash(password) save_password_hash(hashed) save_totp_secret(sample_totp_secret) response = test_app.post( - "/api/auth/login", - json={"username": "testadmin", "password": password, "totp_code": "000000"} + "/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": "000000"} ) assert response.status_code == 401 def test_login_sets_cookies(self, test_app, mock_config, temp_auth_file): """Test that login sets auth cookies.""" + from auth_service import get_password_hash, save_password_hash + password = "test_password" hashed = get_password_hash(password) save_password_hash(hashed) response = test_app.post( - "/api/auth/login", - json={"username": "testadmin", "password": password, "totp_code": ""} + "/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""} ) assert response.status_code == 200 @@ -129,10 +134,7 @@ class TestRefreshFlow: def test_refresh_with_valid_token(self, test_app, mock_config, temp_auth_file, valid_refresh_token): """Test refreshing with valid refresh token.""" - response = test_app.post( - "/api/auth/refresh", - json={"refresh_token": valid_refresh_token} - ) + response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_refresh_token}) assert response.status_code == 200 data = response.json() @@ -152,19 +154,13 @@ class TestRefreshFlow: def test_refresh_with_invalid_token(self, test_app, mock_config, temp_auth_file): """Test refreshing with invalid token.""" - response = test_app.post( - "/api/auth/refresh", - json={"refresh_token": "invalid.token.here"} - ) + response = test_app.post("/api/auth/refresh", json={"refresh_token": "invalid.token.here"}) assert response.status_code == 401 def test_refresh_with_expired_token(self, test_app, mock_config, temp_auth_file, expired_access_token): """Test refreshing with expired token.""" - response = test_app.post( - "/api/auth/refresh", - json={"refresh_token": expired_access_token} - ) + response = test_app.post("/api/auth/refresh", json={"refresh_token": expired_access_token}) assert response.status_code == 401 @@ -177,10 +173,7 @@ class TestRefreshFlow: def test_refresh_with_access_token_fails(self, test_app, mock_config, temp_auth_file, valid_access_token): """Test that access token cannot be used for refresh.""" - response = test_app.post( - "/api/auth/refresh", - json={"refresh_token": valid_access_token} - ) + response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_access_token}) assert response.status_code == 401 assert "Invalid token type" in response.json()["detail"] @@ -241,11 +234,7 @@ class TestProxyAuth: def test_proxy_auth_from_trusted_source(self, test_app, mock_config, temp_auth_file, temp_rbac_file): """Test proxy auth from trusted proxy.""" response = test_app.get( - "/api/auth/proxy", - headers={ - "Remote-User": "proxyuser", - "Remote-Groups": "dashboard_admin" - } + "/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"} ) # Note: This test may need adjustment based on actual proxy auth implementation @@ -256,11 +245,7 @@ class TestProxyAuth: """Test proxy auth from untrusted source.""" # Mock request from untrusted IP response = test_app.get( - "/api/auth/proxy", - headers={ - "Remote-User": "proxyuser", - "Remote-Groups": "dashboard_admin" - } + "/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"} ) # Should reject untrusted proxy @@ -272,6 +257,8 @@ class TestPasswordChange: def test_change_password_success(self, test_app, mock_config, temp_auth_file, admin_headers): """Test successful password change.""" + from auth_service import get_password_hash, save_password_hash + old_password = "old_password" new_password = "new_password_123" @@ -282,16 +269,15 @@ class TestPasswordChange: response = test_app.post( "/api/auth/change-password", headers=admin_headers, - json={ - "current_password": old_password, - "new_password": new_password - } + json={"current_password": old_password, "new_password": new_password}, ) assert response.status_code == 200 def test_change_password_wrong_old_password(self, test_app, mock_config, temp_auth_file, admin_headers): """Test password change with wrong old password.""" + from auth_service import get_password_hash, save_password_hash + old_password = "old_password" hashed = get_password_hash(old_password) save_password_hash(hashed) @@ -299,22 +285,233 @@ class TestPasswordChange: response = test_app.post( "/api/auth/change-password", headers=admin_headers, - json={ - "current_password": "wrong_password", - "new_password": "new_password_123" - } + json={"current_password": "wrong_password", "new_password": "new_password_123"}, ) assert response.status_code == 400 def test_change_password_without_auth(self, test_app, mock_config, temp_auth_file): """Test password change without authentication.""" - response = test_app.post( - "/api/auth/change-password", - json={ - "current_password": "old", - "new_password": "new" - } - ) + response = test_app.post("/api/auth/change-password", json={"current_password": "old", "new_password": "new"}) + + assert response.status_code == 401 + + def test_change_password_too_short(self, test_app, mock_config, temp_auth_file, admin_headers): + """Test password change with password too short.""" + from auth_service import get_password_hash, save_password_hash + + old_password = "old_password" + hashed = get_password_hash(old_password) + save_password_hash(hashed) + + response = test_app.post( + "/api/auth/change-password", + headers=admin_headers, + json={"current_password": old_password, "new_password": "short"}, + ) + + assert response.status_code == 400 + assert "at least 8 characters" in response.json()["detail"] + + +class TestRBACConfig: + """Test RBAC configuration endpoints.""" + + def test_get_rbac_config_as_admin(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers): + """Test getting RBAC config as admin.""" + response = test_app.get("/api/auth/rbac/config", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert "role_defaults" in data or "user_overrides" in data + + def test_get_rbac_config_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file): + """Test that non-admin cannot get RBAC config.""" + from datetime import timedelta + + from auth_service import create_access_token + + member_token = create_access_token( + data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30) + ) + headers = {"Authorization": f"Bearer {member_token}"} + + response = test_app.get("/api/auth/rbac/config", headers=headers) + + assert response.status_code == 403 + + def test_get_rbac_config_without_auth(self, test_app, mock_config, temp_auth_file): + """Test getting RBAC config without authentication.""" + response = test_app.get("/api/auth/rbac/config") + + assert response.status_code == 401 + + def test_set_user_override(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers): + """Test setting user page override.""" + response = test_app.put( + "/api/auth/rbac/overrides/testuser", headers=admin_headers, json={"pages": ["overview", "docker"]} + ) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + def test_set_user_override_missing_pages( + self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers + ): + """Test setting user override without pages field.""" + response = test_app.put("/api/auth/rbac/overrides/testuser", headers=admin_headers, json={}) + + assert response.status_code == 400 + assert "Missing pages field" in response.json()["detail"] + + def test_set_user_override_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file): + """Test that non-admin cannot set user override.""" + from datetime import timedelta + + from auth_service import create_access_token + + member_token = create_access_token( + data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30) + ) + headers = {"Authorization": f"Bearer {member_token}"} + + response = test_app.put("/api/auth/rbac/overrides/testuser", headers=headers, json={"pages": ["overview"]}) + + assert response.status_code == 403 + + def test_delete_user_override(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers): + """Test deleting user override.""" + # First set an override + test_app.put("/api/auth/rbac/overrides/testuser", headers=admin_headers, json={"pages": ["overview"]}) + + # Then delete it + response = test_app.delete("/api/auth/rbac/overrides/testuser", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + def test_delete_user_override_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file): + """Test that non-admin cannot delete user override.""" + from datetime import timedelta + + from auth_service import create_access_token + + member_token = create_access_token( + data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30) + ) + headers = {"Authorization": f"Bearer {member_token}"} + + response = test_app.delete("/api/auth/rbac/overrides/testuser", headers=headers) + + assert response.status_code == 403 + + def test_update_role_config(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers): + """Test updating role configuration.""" + response = test_app.put( + "/api/auth/rbac/roles/member", + headers=admin_headers, + json={"pages": ["overview", "docker"], "sidebar_links": ["navidrome", "jellyfin"]}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + def test_update_role_config_with_wildcard( + self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers + ): + """Test updating role with wildcard pages.""" + response = test_app.put( + "/api/auth/rbac/roles/admin", headers=admin_headers, json={"pages": "*", "sidebar_links": "*"} + ) + + assert response.status_code == 200 + + def test_update_role_config_missing_pages( + self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers + ): + """Test updating role without pages field.""" + response = test_app.put( + "/api/auth/rbac/roles/member", headers=admin_headers, json={"sidebar_links": ["navidrome"]} + ) + + assert response.status_code == 400 + assert "Missing pages field" in response.json()["detail"] + + def test_update_role_config_invalid_pages_type( + self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers + ): + """Test updating role with invalid pages type.""" + response = test_app.put("/api/auth/rbac/roles/member", headers=admin_headers, json={"pages": "invalid"}) + + assert response.status_code == 400 + assert "pages must be" in response.json()["detail"] + + def test_update_role_config_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file): + """Test that non-admin cannot update role config.""" + from datetime import timedelta + + from auth_service import create_access_token + + member_token = create_access_token( + data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30) + ) + headers = {"Authorization": f"Bearer {member_token}"} + + response = test_app.put("/api/auth/rbac/roles/member", headers=headers, json={"pages": ["overview"]}) + + assert response.status_code == 403 + + +class TestPreferences: + """Test user preferences endpoints.""" + + def test_get_preferences(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers): + """Test getting user preferences.""" + response = test_app.get("/api/auth/preferences", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert "sidebar_order" in data + + def test_get_preferences_without_auth(self, test_app, mock_config, temp_auth_file): + """Test getting preferences without authentication.""" + response = test_app.get("/api/auth/preferences") + + assert response.status_code == 401 + + def test_save_preferences(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers): + """Test saving user preferences.""" + response = test_app.put( + "/api/auth/preferences", + headers=admin_headers, + json={"sidebar_order": {"main": ["overview", "docker", "files"], "media": ["navidrome", "jellyfin"]}}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + def test_save_preferences_missing_sidebar_order( + self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers + ): + """Test saving preferences without sidebar_order.""" + response = test_app.put("/api/auth/preferences", headers=admin_headers, json={}) + + assert response.status_code == 400 + assert "Missing sidebar_order" in response.json()["detail"] + + def test_save_preferences_invalid_type(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers): + """Test saving preferences with invalid type.""" + response = test_app.put("/api/auth/preferences", headers=admin_headers, json={"sidebar_order": "invalid"}) + + assert response.status_code == 400 + assert "Missing sidebar_order dict" in response.json()["detail"] + + def test_save_preferences_without_auth(self, test_app, mock_config, temp_auth_file): + """Test saving preferences without authentication.""" + response = test_app.put("/api/auth/preferences", json={"sidebar_order": {}}) assert response.status_code == 401 diff --git a/dashboard/backend/tests/integration/test_chat_summary.py b/dashboard/backend/tests/integration/test_chat_summary.py new file mode 100644 index 0000000..0487e66 --- /dev/null +++ b/dashboard/backend/tests/integration/test_chat_summary.py @@ -0,0 +1,200 @@ +""" +Integration tests for chat summary operations. +""" +import pytest +import aiosqlite +import os +from unittest.mock import patch, AsyncMock, MagicMock + + +class TestChatSummaryDates: + """Test chat summary dates endpoint.""" + + @pytest.mark.asyncio + async def test_list_dates_empty(self, test_app, mock_config, admin_headers, tmp_path): + """Test listing dates when database is empty.""" + db_path = tmp_path / "test_chat.db" + + # Create empty database with schema + async with aiosqlite.connect(str(db_path)) as db: + await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)") + await db.commit() + + with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)): + response = test_app.get("/api/chat-summary/dates", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 0 + + @pytest.mark.asyncio + async def test_list_dates_with_data(self, test_app, mock_config, admin_headers, tmp_path): + """Test listing dates with summaries.""" + db_path = tmp_path / "test_chat.db" + + # Create database with test data + async with aiosqlite.connect(str(db_path)) as db: + await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)") + await db.execute( + "INSERT INTO summaries VALUES (?, ?, ?)", ("2024-01-01", "Summary 1", "2024-01-01T10:00:00") + ) + await db.execute( + "INSERT INTO summaries VALUES (?, ?, ?)", ("2024-01-02", "Summary 2", "2024-01-02T10:00:00") + ) + await db.commit() + + with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)): + response = test_app.get("/api/chat-summary/dates", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert "2024-01-02" in data + assert "2024-01-01" in data + + def test_list_dates_without_auth(self, test_app, mock_config): + """Test listing dates without authentication.""" + response = test_app.get("/api/chat-summary/dates") + + assert response.status_code == 401 + + +class TestChatSummaryGet: + """Test get summary endpoint.""" + + @pytest.mark.asyncio + async def test_get_summary_success(self, test_app, mock_config, admin_headers, tmp_path): + """Test getting a summary for a specific date.""" + db_path = tmp_path / "test_chat.db" + + # Create database with test data + async with aiosqlite.connect(str(db_path)) as db: + await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)") + await db.execute( + "INSERT INTO summaries VALUES (?, ?, ?)", + ("2024-01-01", "Test summary content", "2024-01-01T10:00:00"), + ) + await db.commit() + + with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)): + response = test_app.get("/api/chat-summary/summary/2024-01-01", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert data["date"] == "2024-01-01" + assert data["content"] == "Test summary content" + assert data["created_at"] == "2024-01-01T10:00:00" + + @pytest.mark.asyncio + async def test_get_summary_not_found(self, test_app, mock_config, admin_headers, tmp_path): + """Test getting a summary for a date that doesn't exist.""" + db_path = tmp_path / "test_chat.db" + + # Create empty database + async with aiosqlite.connect(str(db_path)) as db: + await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)") + await db.commit() + + with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)): + response = test_app.get("/api/chat-summary/summary/2024-01-01", headers=admin_headers) + + assert response.status_code == 404 + data = response.json() + assert "No summary for this date" in data["detail"] + + def test_get_summary_without_auth(self, test_app, mock_config): + """Test getting summary without authentication.""" + response = test_app.get("/api/chat-summary/summary/2024-01-01") + + assert response.status_code == 401 + + +class TestChatSummaryMessages: + """Test get messages endpoint.""" + + @pytest.mark.asyncio + async def test_get_messages_success(self, test_app, mock_config, admin_headers, tmp_path): + """Test getting messages for a specific date.""" + db_path = tmp_path / "test_chat.db" + + # Create database with test data + async with aiosqlite.connect(str(db_path)) as db: + await db.execute("CREATE TABLE messages (group_name TEXT, sender_name TEXT, text TEXT, timestamp TEXT)") + await db.execute( + "INSERT INTO messages VALUES (?, ?, ?, ?)", + ("Group1", "User1", "Hello", "2024-01-01 10:00:00"), + ) + await db.execute( + "INSERT INTO messages VALUES (?, ?, ?, ?)", + ("Group1", "User2", "Hi there", "2024-01-01 10:01:00"), + ) + await db.commit() + + with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)): + response = test_app.get("/api/chat-summary/messages/2024-01-01", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert len(data) == 2 + assert data[0]["group"] == "Group1" + assert data[0]["sender"] == "User1" + assert data[0]["text"] == "Hello" + + @pytest.mark.asyncio + async def test_get_messages_empty(self, test_app, mock_config, admin_headers, tmp_path): + """Test getting messages when none exist for date.""" + db_path = tmp_path / "test_chat.db" + + # Create empty database + async with aiosqlite.connect(str(db_path)) as db: + await db.execute("CREATE TABLE messages (group_name TEXT, sender_name TEXT, text TEXT, timestamp TEXT)") + await db.commit() + + with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)): + response = test_app.get("/api/chat-summary/messages/2024-01-01", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert isinstance(data, list) + assert len(data) == 0 + + def test_get_messages_without_auth(self, test_app, mock_config): + """Test getting messages without authentication.""" + response = test_app.get("/api/chat-summary/messages/2024-01-01") + + assert response.status_code == 401 + + +class TestChatSummaryTrigger: + """Test trigger summary endpoint.""" + + def test_trigger_summary_success(self, test_app, mock_config, admin_headers, tmp_path): + """Test triggering a summary generation.""" + trigger_path = tmp_path / "trigger" + + with patch.dict(os.environ, {"CHAT_SUMMARY_TRIGGER": str(trigger_path)}): + response = test_app.post("/api/chat-summary/trigger", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "triggered" + assert trigger_path.exists() + assert trigger_path.read_text() == "1" + + def test_trigger_summary_creates_directory(self, test_app, mock_config, admin_headers, tmp_path): + """Test that trigger creates parent directory if needed.""" + trigger_path = tmp_path / "subdir" / "trigger" + + with patch.dict(os.environ, {"CHAT_SUMMARY_TRIGGER": str(trigger_path)}): + response = test_app.post("/api/chat-summary/trigger", headers=admin_headers) + + assert response.status_code == 200 + assert trigger_path.parent.exists() + assert trigger_path.exists() + + def test_trigger_summary_without_auth(self, test_app, mock_config): + """Test triggering summary without authentication.""" + response = test_app.post("/api/chat-summary/trigger") + + assert response.status_code == 401 diff --git a/dashboard/backend/tests/integration/test_docker.py b/dashboard/backend/tests/integration/test_docker.py index ce053eb..69fcb8e 100644 --- a/dashboard/backend/tests/integration/test_docker.py +++ b/dashboard/backend/tests/integration/test_docker.py @@ -1,8 +1,8 @@ """ Integration tests for Docker operations. """ + import pytest -from unittest.mock import Mock, patch class TestDockerContainerList: @@ -35,10 +35,7 @@ class TestDockerContainerActions: """Test starting a container.""" container_id = "abc123" - response = test_app.post( - f"/api/docker/containers/{container_id}/start", - headers=admin_headers - ) + response = test_app.post(f"/api/docker/containers/{container_id}/start", headers=admin_headers) assert response.status_code == 200 data = response.json() @@ -48,10 +45,7 @@ class TestDockerContainerActions: """Test stopping a container.""" container_id = "abc123" - response = test_app.post( - f"/api/docker/containers/{container_id}/stop", - headers=admin_headers - ) + response = test_app.post(f"/api/docker/containers/{container_id}/stop", headers=admin_headers) assert response.status_code == 200 @@ -59,10 +53,7 @@ class TestDockerContainerActions: """Test restarting a container.""" container_id = "abc123" - response = test_app.post( - f"/api/docker/containers/{container_id}/restart", - headers=admin_headers - ) + response = test_app.post(f"/api/docker/containers/{container_id}/restart", headers=admin_headers) assert response.status_code == 200 @@ -74,10 +65,7 @@ class TestDockerContainerActions: def test_container_action_invalid_action(self, test_app, admin_headers): """Test container action with invalid action.""" - response = test_app.post( - "/api/docker/containers/abc123/invalid_action", - headers=admin_headers - ) + response = test_app.post("/api/docker/containers/abc123/invalid_action", headers=admin_headers) assert response.status_code == 400 data = response.json() @@ -91,10 +79,7 @@ class TestDockerContainerLogs: """Test getting container logs.""" container_id = "abc123" - response = test_app.get( - f"/api/docker/containers/{container_id}/logs", - headers=admin_headers - ) + response = test_app.get(f"/api/docker/containers/{container_id}/logs", headers=admin_headers) assert response.status_code == 200 data = response.json() @@ -104,10 +89,7 @@ class TestDockerContainerLogs: """Test getting container logs with tail limit.""" container_id = "abc123" - response = test_app.get( - f"/api/docker/containers/{container_id}/logs?tail=100", - headers=admin_headers - ) + response = test_app.get(f"/api/docker/containers/{container_id}/logs?tail=100", headers=admin_headers) assert response.status_code == 200 @@ -122,10 +104,7 @@ class TestDockerContainerLogs: # Note: In this test setup, the mock always returns a container # Testing actual error handling would require a different approach # For now, verify the endpoint works with the mock - response = test_app.get( - "/api/docker/containers/nonexistent/logs", - headers=admin_headers - ) + response = test_app.get("/api/docker/containers/nonexistent/logs", headers=admin_headers) # With the current mock setup, this will succeed assert response.status_code == 200 @@ -137,12 +116,11 @@ class TestDockerPermissions: @pytest.fixture def member_token(self, mock_config, temp_auth_file, temp_rbac_file): """Create token for member role.""" - from auth_service import create_access_token from datetime import timedelta - return create_access_token( - data={"sub": "memberuser", "role": "member"}, - expires_delta=timedelta(minutes=30) - ) + + from auth_service import create_access_token + + return create_access_token(data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)) @pytest.fixture def member_headers(self, member_token): @@ -158,22 +136,19 @@ class TestDockerPermissions: def test_member_cannot_start_container(self, test_app, member_headers): """Test that member cannot start containers (admin only).""" - response = test_app.post( - "/api/docker/containers/abc123/start", - headers=member_headers - ) + response = test_app.post("/api/docker/containers/abc123/start", headers=member_headers) # Should be forbidden for non-admin assert response.status_code == 403 def test_viewer_can_list_containers(self, test_app, mock_config, temp_auth_file, temp_rbac_file): """Test that viewer can list containers if they have docker page access.""" - from auth_service import create_access_token from datetime import timedelta + from auth_service import create_access_token + viewer_token = create_access_token( - data={"sub": "vieweruser", "role": "viewer"}, - expires_delta=timedelta(minutes=30) + data={"sub": "vieweruser", "role": "viewer"}, expires_delta=timedelta(minutes=30) ) headers = {"Authorization": f"Bearer {viewer_token}"} diff --git a/dashboard/backend/tests/integration/test_files.py b/dashboard/backend/tests/integration/test_files.py index b2cbbc3..e93df94 100644 --- a/dashboard/backend/tests/integration/test_files.py +++ b/dashboard/backend/tests/integration/test_files.py @@ -1,7 +1,7 @@ """ Integration tests for file operations. """ -import pytest + import os @@ -10,11 +10,7 @@ class TestFileBrowse: def test_browse_root(self, test_app, admin_headers, temp_volume_root): """Test browsing root directory.""" - response = test_app.get( - "/api/files/browse", - headers=admin_headers, - params={"path": "/"} - ) + response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/"}) assert response.status_code == 200 data = response.json() @@ -28,22 +24,14 @@ class TestFileBrowse: def test_browse_blocked_directory(self, test_app, admin_headers): """Test browsing blocked directory.""" - response = test_app.get( - "/api/files/browse", - headers=admin_headers, - params={"path": "/@appstore"} - ) + response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/@appstore"}) # Should be forbidden assert response.status_code == 403 def test_browse_path_traversal_attempt(self, test_app, admin_headers): """Test path traversal protection.""" - response = test_app.get( - "/api/files/browse", - headers=admin_headers, - params={"path": "/../../../etc"} - ) + response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/../../../etc"}) # Should be forbidden assert response.status_code == 403 @@ -57,12 +45,7 @@ class TestFileUpload: test_content = b"test file content" files = {"file": ("test.txt", test_content, "text/plain")} - response = test_app.post( - "/api/files/upload", - headers=admin_headers, - files=files, - params={"path": "/"} - ) + response = test_app.post("/api/files/upload", headers=admin_headers, files=files, params={"path": "/"}) assert response.status_code in [200, 201] @@ -70,32 +53,23 @@ class TestFileUpload: """Test upload without authentication.""" files = {"file": ("test.txt", b"content", "text/plain")} - response = test_app.post( - "/api/files/upload", - files=files, - params={"path": "/"} - ) + response = test_app.post("/api/files/upload", files=files, params={"path": "/"}) assert response.status_code == 401 def test_upload_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file): """Test that non-admin cannot upload.""" - from auth_service import create_access_token from datetime import timedelta + from auth_service import create_access_token + member_token = create_access_token( - data={"sub": "memberuser", "role": "member"}, - expires_delta=timedelta(minutes=30) + data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30) ) headers = {"Authorization": f"Bearer {member_token}"} files = {"file": ("test.txt", b"content", "text/plain")} - response = test_app.post( - "/api/files/upload", - headers=headers, - files=files, - params={"path": "/"} - ) + response = test_app.post("/api/files/upload", headers=headers, files=files, params={"path": "/"}) # Upload should be admin-only assert response.status_code == 403 @@ -111,39 +85,28 @@ class TestFileDelete: with open(test_file, "w") as f: f.write("test content") - response = test_app.delete( - "/api/files/delete", - headers=admin_headers, - params={"path": "/test_delete.txt"} - ) + response = test_app.delete("/api/files/delete", headers=admin_headers, params={"path": "/test_delete.txt"}) assert response.status_code in [200, 204] def test_delete_without_auth(self, test_app): """Test delete without authentication.""" - response = test_app.delete( - "/api/files/delete", - params={"path": "/test.txt"} - ) + response = test_app.delete("/api/files/delete", params={"path": "/test.txt"}) assert response.status_code == 401 def test_delete_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file): """Test that non-admin cannot delete.""" - from auth_service import create_access_token from datetime import timedelta + from auth_service import create_access_token + member_token = create_access_token( - data={"sub": "memberuser", "role": "member"}, - expires_delta=timedelta(minutes=30) + data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30) ) headers = {"Authorization": f"Bearer {member_token}"} - response = test_app.delete( - "/api/files/delete", - headers=headers, - params={"path": "/test.txt"} - ) + response = test_app.delete("/api/files/delete", headers=headers, params={"path": "/test.txt"}) # Delete should be admin-only assert response.status_code == 403 @@ -160,30 +123,19 @@ class TestFileDownload: with open(test_file, "w") as f: f.write(test_content) - response = test_app.get( - "/api/files/download", - headers=admin_headers, - params={"path": "/test_download.txt"} - ) + response = test_app.get("/api/files/download", headers=admin_headers, params={"path": "/test_download.txt"}) assert response.status_code == 200 assert test_content in response.text or response.content == test_content.encode() def test_download_without_auth(self, test_app): """Test download without authentication.""" - response = test_app.get( - "/api/files/download", - params={"path": "/test.txt"} - ) + response = test_app.get("/api/files/download", params={"path": "/test.txt"}) assert response.status_code == 401 def test_download_nonexistent_file(self, test_app, admin_headers): """Test downloading nonexistent file.""" - response = test_app.get( - "/api/files/download", - headers=admin_headers, - params={"path": "/nonexistent.txt"} - ) + response = test_app.get("/api/files/download", headers=admin_headers, params={"path": "/nonexistent.txt"}) assert response.status_code == 404 diff --git a/dashboard/backend/tests/integration/test_gitea.py b/dashboard/backend/tests/integration/test_gitea.py index 88f46d8..aa51c28 100644 --- a/dashboard/backend/tests/integration/test_gitea.py +++ b/dashboard/backend/tests/integration/test_gitea.py @@ -1,6 +1,7 @@ """ Integration tests for Gitea operations. """ + import pytest @@ -27,19 +28,13 @@ class TestGiteaCommits: @pytest.mark.skip(reason="Requires external Gitea API - tested manually") def test_list_commits(self, test_app, admin_headers): """Test listing commits for a repository.""" - response = test_app.get( - "/api/gitea/repos/owner/repo/commits", - headers=admin_headers - ) + response = test_app.get("/api/gitea/repos/owner/repo/commits", headers=admin_headers) # This would require a real Gitea instance assert response.status_code in [200, 404, 500, 503] def test_list_commits_invalid_owner(self, test_app, admin_headers): """Test listing commits with invalid owner name.""" - response = test_app.get( - "/api/gitea/repos/invalid@owner/repo/commits", - headers=admin_headers - ) + response = test_app.get("/api/gitea/repos/invalid@owner/repo/commits", headers=admin_headers) assert response.status_code == 400 data = response.json() @@ -48,10 +43,7 @@ class TestGiteaCommits: def test_list_commits_invalid_repo(self, test_app, admin_headers): """Test listing commits with invalid repo name.""" - response = test_app.get( - "/api/gitea/repos/owner/invalid@repo/commits", - headers=admin_headers - ) + response = test_app.get("/api/gitea/repos/owner/invalid@repo/commits", headers=admin_headers) assert response.status_code == 400 data = response.json() @@ -60,19 +52,13 @@ class TestGiteaCommits: def test_list_commits_special_chars_in_owner(self, test_app, admin_headers): """Test listing commits with special characters in owner.""" - response = test_app.get( - "/api/gitea/repos/owner$/repo/commits", - headers=admin_headers - ) + response = test_app.get("/api/gitea/repos/owner$/repo/commits", headers=admin_headers) assert response.status_code == 400 def test_list_commits_special_chars_in_repo(self, test_app, admin_headers): """Test listing commits with special characters in repo.""" - response = test_app.get( - "/api/gitea/repos/owner/repo!/commits", - headers=admin_headers - ) + response = test_app.get("/api/gitea/repos/owner/repo!/commits", headers=admin_headers) assert response.status_code == 400 @@ -81,10 +67,7 @@ class TestGiteaCommits: """Test that valid names with allowed characters pass validation.""" # Valid characters: a-zA-Z0-9._- # This will fail at the HTTP call level, but should pass validation - response = test_app.get( - "/api/gitea/repos/valid-owner_123/repo.name-456/commits", - headers=admin_headers - ) + response = test_app.get("/api/gitea/repos/valid-owner_123/repo.name-456/commits", headers=admin_headers) # Should not be 400 (validation error), but may be 500/503 (HTTP error) assert response.status_code != 400 @@ -94,4 +77,3 @@ class TestGiteaCommits: response = test_app.get("/api/gitea/repos/owner/repo/commits") assert response.status_code == 401 - diff --git a/dashboard/backend/tests/integration/test_litellm.py b/dashboard/backend/tests/integration/test_litellm.py new file mode 100644 index 0000000..4e3cae2 --- /dev/null +++ b/dashboard/backend/tests/integration/test_litellm.py @@ -0,0 +1,78 @@ +""" +Integration tests for LiteLLM health check operations. +""" +import pytest +from unittest.mock import AsyncMock, patch + + +class TestLiteLLMHealth: + """Test LiteLLM health check endpoint.""" + + @pytest.mark.asyncio + async def test_health_check_success(self, test_app, mock_config, admin_headers, monkeypatch): + """Test successful health check.""" + monkeypatch.setenv("LITELLM_URL", "http://mock-litellm:4000") + monkeypatch.setenv("LITELLM_HEALTH_API_KEY", "") + + # Mock successful response + mock_response = AsyncMock() + mock_response.is_success = True + mock_response.status_code = 200 + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get.return_value = mock_response + + response = test_app.get("/api/litellm/health", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + assert data["status"] == "up" + assert "latency_ms" in data + assert data["http_status"] == 200 + + @pytest.mark.asyncio + async def test_health_check_auth_required(self, test_app, mock_config, admin_headers, monkeypatch): + """Test health check when auth is required but not provided.""" + monkeypatch.setenv("LITELLM_URL", "http://mock-litellm:4000") + monkeypatch.setenv("LITELLM_HEALTH_API_KEY", "") + + # Mock 401 response without API key + mock_response = AsyncMock() + mock_response.is_success = False + mock_response.status_code = 401 + + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get.return_value = mock_response + + response = test_app.get("/api/litellm/health", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + assert data["status"] == "auth_required" + assert data["http_status"] == 401 + + @pytest.mark.asyncio + async def test_health_check_connection_error(self, test_app, mock_config, admin_headers, monkeypatch): + """Test health check when connection fails.""" + monkeypatch.setenv("LITELLM_URL", "http://mock-litellm:4000") + monkeypatch.setenv("LITELLM_HEALTH_API_KEY", "") + + # Mock connection error + with patch("httpx.AsyncClient") as mock_client: + mock_client.return_value.__aenter__.return_value.get.side_effect = Exception("Connection refused") + + response = test_app.get("/api/litellm/health", headers=admin_headers) + + assert response.status_code == 200 + data = response.json() + assert data["ok"] is False + assert data["status"] == "down" + assert "Connection refused" in data["error"] + + def test_health_check_without_auth(self, test_app, mock_config): + """Test health check without authentication.""" + response = test_app.get("/api/litellm/health") + + assert response.status_code == 401 diff --git a/dashboard/backend/tests/integration/test_system.py b/dashboard/backend/tests/integration/test_system.py index 2a515b4..220157a 100644 --- a/dashboard/backend/tests/integration/test_system.py +++ b/dashboard/backend/tests/integration/test_system.py @@ -1,9 +1,11 @@ """ Integration tests for system operations. """ -import pytest + import os -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch + +import pytest class TestSystemStats: @@ -17,7 +19,7 @@ class TestSystemStats: mock_disk.used = 500000000000 mock_disk.free = 500000000000 - with patch('shutil.disk_usage', return_value=mock_disk): + with patch("shutil.disk_usage", return_value=mock_disk): response = test_app.get("/api/system/stats", headers=admin_headers) assert response.status_code == 200 @@ -81,11 +83,7 @@ class TestAuditLog: def test_get_audit_log_with_limit(self, test_app, admin_headers, temp_volume_root): """Test getting audit log with line limit.""" - response = test_app.get( - "/api/system/audit-log", - headers=admin_headers, - params={"lines": 50} - ) + response = test_app.get("/api/system/audit-log", headers=admin_headers, params={"lines": 50}) assert response.status_code == 200 data = response.json() @@ -104,22 +102,14 @@ class TestNotification: @pytest.mark.skip(reason="Requires Telegram bot token and makes external API call") def test_send_notification_success(self, test_app, admin_headers): """Test sending a notification.""" - response = test_app.post( - "/api/system/notify", - headers=admin_headers, - json={"message": "Test notification"} - ) + response = test_app.post("/api/system/notify", headers=admin_headers, json={"message": "Test notification"}) # This will fail without proper Telegram config assert response.status_code in [200, 400] def test_send_notification_without_message(self, test_app, admin_headers): """Test sending notification without message.""" - response = test_app.post( - "/api/system/notify", - headers=admin_headers, - json={} - ) + response = test_app.post("/api/system/notify", headers=admin_headers, json={}) assert response.status_code == 200 data = response.json() @@ -127,10 +117,7 @@ class TestNotification: def test_send_notification_without_auth(self, test_app): """Test sending notification without authentication.""" - response = test_app.post( - "/api/system/notify", - json={"message": "Test"} - ) + response = test_app.post("/api/system/notify", json={"message": "Test"}) assert response.status_code == 401 @@ -140,6 +127,7 @@ class TestOpenClawToken: def test_get_openclaw_token_as_admin(self, test_app, admin_headers, mock_config, monkeypatch): """Test getting OpenClaw token as admin from LAN.""" + # Mock the IP check to return a LAN IP def mock_get_client_ip(request): return "192.168.1.100" @@ -148,6 +136,7 @@ class TestOpenClawToken: return True import config + monkeypatch.setattr(config, "get_client_ip", mock_get_client_ip) monkeypatch.setattr(config, "is_lan_ip", mock_is_lan_ip) @@ -159,6 +148,7 @@ class TestOpenClawToken: def test_get_openclaw_token_non_lan(self, test_app, admin_headers, monkeypatch): """Test getting OpenClaw token from non-LAN IP.""" + # Mock the IP check to return a non-LAN IP def mock_get_client_ip(request): return "1.2.3.4" @@ -167,6 +157,7 @@ class TestOpenClawToken: return False import config + monkeypatch.setattr(config, "get_client_ip", mock_get_client_ip) monkeypatch.setattr(config, "is_lan_ip", mock_is_lan_ip) @@ -176,12 +167,12 @@ class TestOpenClawToken: def test_get_openclaw_token_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file): """Test that non-admin cannot get OpenClaw token.""" - from auth_service import create_access_token from datetime import timedelta + from auth_service import create_access_token + member_token = create_access_token( - data={"sub": "memberuser", "role": "member"}, - expires_delta=timedelta(minutes=30) + data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30) ) headers = {"Authorization": f"Bearer {member_token}"} diff --git a/dashboard/backend/tests/integration/test_totp.py b/dashboard/backend/tests/integration/test_totp.py index 2104905..2cb69a9 100644 --- a/dashboard/backend/tests/integration/test_totp.py +++ b/dashboard/backend/tests/integration/test_totp.py @@ -1,7 +1,7 @@ """ Integration tests for TOTP 2FA operations. """ -import pytest + import pyotp @@ -38,9 +38,7 @@ class TestTOTPSetup: # Verify the code response = test_app.post( - "/api/auth/setup-2fa/verify", - headers=admin_headers, - json={"secret": secret, "code": valid_code} + "/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": secret, "code": valid_code} ) assert response.status_code == 200 @@ -51,9 +49,7 @@ class TestTOTPSetup: def test_verify_2fa_setup_invalid_code(self, test_app, admin_headers): """Test verifying 2FA with invalid code.""" response = test_app.post( - "/api/auth/setup-2fa/verify", - headers=admin_headers, - json={"secret": "JBSWY3DPEHPK3PXP", "code": "000000"} + "/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": "JBSWY3DPEHPK3PXP", "code": "000000"} ) assert response.status_code == 400 @@ -63,10 +59,7 @@ class TestTOTPSetup: def test_verify_2fa_without_auth(self, test_app): """Test verifying 2FA without authentication.""" - response = test_app.post( - "/api/auth/setup-2fa/verify", - json={"secret": "JBSWY3DPEHPK3PXP", "code": "123456"} - ) + response = test_app.post("/api/auth/setup-2fa/verify", json={"secret": "JBSWY3DPEHPK3PXP", "code": "123456"}) assert response.status_code == 401 @@ -100,14 +93,13 @@ class TestTOTPWorkflow: totp = pyotp.TOTP(secret) valid_code = totp.now() verify_response = test_app.post( - "/api/auth/setup-2fa/verify", - headers=admin_headers, - json={"secret": secret, "code": valid_code} + "/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": secret, "code": valid_code} ) assert verify_response.status_code == 200 # Step 3: Verify 2FA is enabled by checking auth_service from auth_service import load_totp_secret + saved_secret = load_totp_secret() assert saved_secret == secret diff --git a/dashboard/backend/tests/test_basic.py b/dashboard/backend/tests/test_basic.py index 93da286..4cad862 100644 --- a/dashboard/backend/tests/test_basic.py +++ b/dashboard/backend/tests/test_basic.py @@ -1,8 +1,8 @@ """ Basic diagnostic tests to validate test environment. """ + import sys -import pytest def test_python_version(): @@ -13,18 +13,19 @@ def test_python_version(): def test_imports(): """Verify all required modules can be imported.""" try: - import fastapi - import uvicorn + import asyncssh import docker + import fastapi import httpx import jwt import passlib import pyotp - import asyncssh import pytest import pytest_asyncio import pytest_cov import pytest_mock + import uvicorn + assert True except ImportError as e: pytest.fail(f"Import failed: {e}") diff --git a/dashboard/backend/tests/unit/test_auth.py b/dashboard/backend/tests/unit/test_auth.py index 1f05f4a..57ffaab 100644 --- a/dashboard/backend/tests/unit/test_auth.py +++ b/dashboard/backend/tests/unit/test_auth.py @@ -1,30 +1,33 @@ """ Unit tests for auth.py - JWT, TOTP, password hashing, encryption. """ -import pytest + +from datetime import UTC, datetime, timedelta + import jwt import pyotp -from datetime import datetime, timedelta, timezone +import pytest +from fastapi import HTTPException + from auth_service import ( - verify_password, - get_password_hash, + _decrypt, + _encrypt, + clear_passkey_credentials, create_access_token, create_refresh_token, - user_from_access_token, - verify_totp, - load_totp_secret, - save_totp_secret, - load_password_hash, - save_password_hash, + get_password_hash, increment_token_version, - verify_token_version, - _encrypt, - _decrypt, load_passkey_credentials, + load_password_hash, + load_totp_secret, save_passkey_credential, - clear_passkey_credentials, + save_password_hash, + save_totp_secret, + user_from_access_token, + verify_password, + verify_token_version, + verify_totp, ) -from fastapi import HTTPException class TestPasswordHashing: @@ -79,8 +82,8 @@ class TestJWTTokens: token = create_access_token(data, expires_delta) payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM]) - exp_time = datetime.fromtimestamp(payload["exp"], tz=timezone.utc) - now = datetime.now(timezone.utc) + exp_time = datetime.fromtimestamp(payload["exp"], tz=UTC) + now = datetime.now(UTC) # Should expire in approximately 60 minutes time_diff = (exp_time - now).total_seconds() @@ -111,10 +114,7 @@ class TestJWTTokens: def test_user_from_expired_token(self, mock_config, temp_rbac_file): """Test that expired token raises HTTPException.""" - token = create_access_token( - {"sub": "testuser", "role": "admin"}, - expires_delta=timedelta(seconds=-1) - ) + token = create_access_token({"sub": "testuser", "role": "admin"}, expires_delta=timedelta(seconds=-1)) with pytest.raises(HTTPException) as exc_info: user_from_access_token(token) @@ -141,9 +141,9 @@ class TestJWTTokens: """Test that token without username fails.""" # Create token without 'sub' field token = jwt.encode( - {"role": "admin", "type": "access", "exp": datetime.now(timezone.utc) + timedelta(minutes=30)}, + {"role": "admin", "type": "access", "exp": datetime.now(UTC) + timedelta(minutes=30)}, mock_config.SECRET_KEY, - algorithm=mock_config.ALGORITHM + algorithm=mock_config.ALGORITHM, ) with pytest.raises(HTTPException) as exc_info: @@ -209,7 +209,9 @@ class TestTOTP: monkeypatch.setenv("TOTP_SECRET", "ENV_SECRET_KEY") import importlib + import config + importlib.reload(config) # When no secret in file, should return env var @@ -237,7 +239,9 @@ class TestTOTP: # Also clear environment variable fallback monkeypatch.setenv("TOTP_SECRET", "") import importlib + import config + importlib.reload(config) # No secret saved, should return True (2FA disabled) assert verify_totp("any_code") @@ -248,10 +252,11 @@ class TestTOTP: # Ensure auth file is properly initialized with last_totp_ts import json - with open(temp_auth_file, 'r') as f: + + with open(temp_auth_file) as f: data = json.load(f) - data['last_totp_ts'] = 0 - with open(temp_auth_file, 'w') as f: + data["last_totp_ts"] = 0 + with open(temp_auth_file, "w") as f: json.dump(data, f) totp = pyotp.TOTP(sample_totp_secret) @@ -280,15 +285,18 @@ class TestPasswordPersistence: """Test loading password hash from environment variable.""" # Clear any hash in the file first import json - with open(temp_auth_file, 'r') as f: + + with open(temp_auth_file) as f: data = json.load(f) - data['password_hash'] = "" - with open(temp_auth_file, 'w') as f: + data["password_hash"] = "" + 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 + + import auth_service + importlib.reload(auth_service) # When no hash in file, should return env var diff --git a/dashboard/backend/tests/unit/test_config.py b/dashboard/backend/tests/unit/test_config.py index a36e04c..527a79b 100644 --- a/dashboard/backend/tests/unit/test_config.py +++ b/dashboard/backend/tests/unit/test_config.py @@ -1,16 +1,19 @@ """ Unit tests for config.py - IP parsing, environment validation. """ -import pytest + from unittest.mock import Mock + +import pytest + from config import ( - _parse_ip, _ip_matches_ranges, + _parse_ip, + get_client_ip, + get_forwarded_client_ip, is_lan_ip, is_tailscale_ip, is_trusted_proxy, - get_forwarded_client_ip, - get_client_ip, ) @@ -146,15 +149,15 @@ class TestIsTailscaleIP: def test_is_tailscale_ip_invalid(self): """Test non-Tailscale IPs.""" assert not is_tailscale_ip("100.63.255.255") # Just below range - assert not is_tailscale_ip("100.128.0.0") # Just above range + assert not is_tailscale_ip("100.128.0.0") # Just above range assert not is_tailscale_ip("192.168.1.1") assert not is_tailscale_ip("10.0.0.1") def test_is_tailscale_ip_boundary(self): """Test Tailscale IP range boundaries.""" # 100.64.0.0/10 means 100.64.0.0 to 100.127.255.255 - assert is_tailscale_ip("100.64.0.0") # Start of range - assert is_tailscale_ip("100.127.255.255") # End of range + assert is_tailscale_ip("100.64.0.0") # Start of range + assert is_tailscale_ip("100.127.255.255") # End of range class TestIsTrustedProxy: @@ -283,8 +286,10 @@ class TestConfigValidation: monkeypatch.setenv("SECRET_KEY", "short") with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"): - import config import importlib + + import config + importlib.reload(config) def test_secret_key_empty(self, monkeypatch): @@ -292,6 +297,8 @@ class TestConfigValidation: monkeypatch.delenv("SECRET_KEY", raising=False) with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"): - import config import importlib + + import config + importlib.reload(config) diff --git a/dashboard/backend/tests/unit/test_rbac.py b/dashboard/backend/tests/unit/test_rbac.py index e643732..5935620 100644 --- a/dashboard/backend/tests/unit/test_rbac.py +++ b/dashboard/backend/tests/unit/test_rbac.py @@ -1,19 +1,20 @@ """ Unit tests for rbac.py - Role-based access control. """ -import pytest + import json import os + from rbac import ( - load_rbac, - save_rbac, - update_rbac, - resolve_role, + DEFAULT_RBAC, + ROLE_GROUP_MAP, get_pages, get_sidebar_links, get_sidebar_order, - DEFAULT_RBAC, - ROLE_GROUP_MAP, + load_rbac, + resolve_role, + save_rbac, + update_rbac, ) @@ -58,7 +59,7 @@ class TestSaveRBAC: rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json") test_data = { "role_defaults": {"admin": {"pages": "*"}}, - "user_overrides": {"testuser": {"pages": ["dashboard"]}} + "user_overrides": {"testuser": {"pages": ["dashboard"]}}, } save_rbac(test_data) @@ -74,6 +75,7 @@ class TestSaveRBAC: # Remove directory import shutil + if os.path.exists(os.path.dirname(rbac_file)): shutil.rmtree(os.path.dirname(rbac_file)) @@ -88,6 +90,7 @@ class TestUpdateRBAC: def test_update_rbac_with_mutator(self, mock_config, temp_rbac_file): """Test updating RBAC with mutator function.""" + def add_user_override(data): data["user_overrides"]["newuser"] = {"pages": ["dashboard", "docker"]} return "success" @@ -188,6 +191,7 @@ class TestGetPages: def test_get_pages_with_user_override(self, mock_config, temp_rbac_file): """Test getting pages with user-specific override.""" + # Add user override def add_override(data): data["user_overrides"]["customuser"] = {"pages": ["dashboard", "files"]} @@ -223,11 +227,9 @@ class TestGetSidebarLinks: def test_get_sidebar_links_with_user_override(self, mock_config, temp_rbac_file): """Test getting sidebar links with user override.""" + def add_override(data): - data["user_overrides"]["customuser"] = { - "pages": "*", - "sidebar_links": ["dashboard", "docker", "files"] - } + data["user_overrides"]["customuser"] = {"pages": "*", "sidebar_links": ["dashboard", "docker", "files"]} update_rbac(add_override) @@ -236,6 +238,7 @@ class TestGetSidebarLinks: def test_get_sidebar_links_no_override(self, mock_config, temp_rbac_file): """Test that user without sidebar_links override gets role default.""" + def add_override(data): data["user_overrides"]["customuser"] = {"pages": ["dashboard"]} # No sidebar_links specified @@ -256,10 +259,7 @@ class TestGetSidebarOrder: def test_get_sidebar_order_with_override(self, mock_config, temp_rbac_file): """Test getting sidebar order with user override.""" - custom_order = { - "Main": ["dashboard", "docker"], - "Media": ["navidrome", "immich"] - } + custom_order = {"Main": ["dashboard", "docker"], "Media": ["navidrome", "immich"]} def add_override(data): data["user_overrides"]["customuser"] = {"sidebar_order": custom_order}