feat: comprehensive test infrastructure improvements
- 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%
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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,39 +176,48 @@ 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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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(",")
|
||||
|
||||
+201
-115
@@ -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:
|
||||
|
||||
+113
-60
@@ -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,14 +175,22 @@ 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
|
||||
@@ -193,10 +220,12 @@ async def monitor_containers():
|
||||
|
||||
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("<html><body>Test Environment</body></html>")
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -10,6 +10,7 @@ router = APIRouter()
|
||||
|
||||
_client = None
|
||||
|
||||
|
||||
def get_docker_client():
|
||||
"""Get or create Docker client (lazy initialization)."""
|
||||
global _client
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
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}
|
||||
|
||||
@@ -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({
|
||||
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({
|
||||
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:]
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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,7 +63,8 @@ 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([
|
||||
script = " ".join(
|
||||
[
|
||||
"if",
|
||||
f"{docker_bin} exec {quoted_container} tmux has-session -t {quoted_session} >/dev/null 2>&1;",
|
||||
"then",
|
||||
@@ -70,7 +73,8 @@ def build_host_command(host_config: dict) -> str | None:
|
||||
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:
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", [])
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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([
|
||||
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),
|
||||
|
||||
("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')),
|
||||
|
||||
("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),
|
||||
|
||||
("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'),
|
||||
]))
|
||||
("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("<b>Payment Terms:</b> 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",
|
||||
)
|
||||
|
||||
@@ -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"]}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}"}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}"}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user