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 \
|
pytest tests/ -v --timeout=30 \
|
||||||
--cov=. --cov-report=xml --cov-report=term \
|
--cov=. --cov-report=xml --cov-report=term \
|
||||||
--junit-xml=test-results.xml \
|
--junit-xml=test-results.xml \
|
||||||
--cov-fail-under=40
|
--cov-fail-under=50
|
||||||
|
|
||||||
- name: Upload coverage report
|
- name: Upload coverage report
|
||||||
if: always()
|
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 tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from passlib.context import CryptContext
|
|
||||||
import pyotp
|
import pyotp
|
||||||
from fastapi import Depends, HTTPException, Response, status
|
from fastapi import Depends, HTTPException, Response, status
|
||||||
from fastapi.security import OAuth2PasswordBearer
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
# Password handling
|
# Password handling
|
||||||
@@ -20,22 +21,25 @@ COOKIE_SECURE = True
|
|||||||
COOKIE_SAMESITE = "lax"
|
COOKIE_SAMESITE = "lax"
|
||||||
COOKIE_PATH = "/"
|
COOKIE_PATH = "/"
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password, hashed_password):
|
def verify_password(plain_password, hashed_password):
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
|
||||||
def get_password_hash(password):
|
def get_password_hash(password):
|
||||||
return pwd_context.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()
|
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"})
|
to_encode.update({"exp": expire, "type": "access"})
|
||||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(data: dict):
|
def create_refresh_token(data: dict):
|
||||||
to_encode = data.copy()
|
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()})
|
to_encode.update({"exp": expire, "type": "refresh", "tv": _load_token_version()})
|
||||||
return jwt.encode(to_encode, config.SECRET_KEY, algorithm=config.ALGORITHM)
|
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(ACCESS_COOKIE_NAME, path=COOKIE_PATH)
|
||||||
response.delete_cookie(REFRESH_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):
|
def user_from_access_token(token: str, include_auth_header: bool = True):
|
||||||
from rbac import User, get_pages, get_sidebar_links
|
from rbac import User, get_pages, get_sidebar_links
|
||||||
|
|
||||||
credentials_exception = HTTPException(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials",
|
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):
|
async def get_current_user_ws(token: str):
|
||||||
return user_from_access_token(token, include_auth_header=False)
|
return user_from_access_token(token, include_auth_header=False)
|
||||||
|
|
||||||
|
|
||||||
# TOTP Persistence
|
# TOTP Persistence
|
||||||
def get_auth_file():
|
def get_auth_file():
|
||||||
return config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
return config.VOLUME_ROOT + "/docker/nas-dashboard/auth.json"
|
||||||
import json, os
|
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
_auth_lock = threading.RLock()
|
_auth_lock = threading.RLock()
|
||||||
@@ -131,22 +142,27 @@ def _update_auth_data(mutator):
|
|||||||
_atomic_json_write(get_auth_file(), data)
|
_atomic_json_write(get_auth_file(), data)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _get_fernet():
|
def _get_fernet():
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
||||||
from cryptography.hazmat.primitives import hashes
|
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)
|
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=b"nas-dashboard-fernet", iterations=480000)
|
||||||
key = base64.urlsafe_b64encode(kdf.derive(config.SECRET_KEY.encode()))
|
key = base64.urlsafe_b64encode(kdf.derive(config.SECRET_KEY.encode()))
|
||||||
return Fernet(key)
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
def _get_legacy_fernet():
|
def _get_legacy_fernet():
|
||||||
key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest())
|
key = base64.urlsafe_b64encode(hashlib.sha256(config.SECRET_KEY.encode()).digest())
|
||||||
return Fernet(key)
|
return Fernet(key)
|
||||||
|
|
||||||
|
|
||||||
def _encrypt(plaintext: str) -> str:
|
def _encrypt(plaintext: str) -> str:
|
||||||
if not plaintext:
|
if not plaintext:
|
||||||
return ""
|
return ""
|
||||||
return _get_fernet().encrypt(plaintext.encode()).decode()
|
return _get_fernet().encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
def _decrypt(ciphertext: str) -> str:
|
def _decrypt(ciphertext: str) -> str:
|
||||||
if not ciphertext:
|
if not ciphertext:
|
||||||
return ""
|
return ""
|
||||||
@@ -160,44 +176,53 @@ def _decrypt(ciphertext: str) -> str:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return ciphertext # fallback for unencrypted legacy values
|
return ciphertext # fallback for unencrypted legacy values
|
||||||
|
|
||||||
|
|
||||||
def _load_auth_data():
|
def _load_auth_data():
|
||||||
with _auth_lock:
|
with _auth_lock:
|
||||||
try:
|
try:
|
||||||
auth_path = get_auth_file()
|
auth_path = get_auth_file()
|
||||||
if os.path.exists(auth_path):
|
if os.path.exists(auth_path):
|
||||||
with open(auth_path, "r") as f:
|
with open(auth_path) as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _save_auth_data(data):
|
def _save_auth_data(data):
|
||||||
_atomic_json_write(get_auth_file(), data)
|
_atomic_json_write(get_auth_file(), data)
|
||||||
|
|
||||||
|
|
||||||
def load_totp_secret():
|
def load_totp_secret():
|
||||||
encrypted = _load_auth_data().get("totp_secret", "")
|
encrypted = _load_auth_data().get("totp_secret", "")
|
||||||
if not encrypted:
|
if not encrypted:
|
||||||
return config.TOTP_SECRET
|
return config.TOTP_SECRET
|
||||||
return _decrypt(encrypted)
|
return _decrypt(encrypted)
|
||||||
|
|
||||||
|
|
||||||
def save_totp_secret(secret: str):
|
def save_totp_secret(secret: str):
|
||||||
def mutator(data):
|
def mutator(data):
|
||||||
data["totp_secret"] = _encrypt(secret) if secret else ""
|
data["totp_secret"] = _encrypt(secret) if secret else ""
|
||||||
|
|
||||||
_update_auth_data(mutator)
|
_update_auth_data(mutator)
|
||||||
|
|
||||||
|
|
||||||
def load_password_hash():
|
def load_password_hash():
|
||||||
return _load_auth_data().get("password_hash", "") or config.ADMIN_PASSWORD_HASH
|
return _load_auth_data().get("password_hash", "") or config.ADMIN_PASSWORD_HASH
|
||||||
|
|
||||||
|
|
||||||
def save_password_hash(hashed: str):
|
def save_password_hash(hashed: str):
|
||||||
def mutator(data):
|
def mutator(data):
|
||||||
data["password_hash"] = hashed
|
data["password_hash"] = hashed
|
||||||
|
|
||||||
_update_auth_data(mutator)
|
_update_auth_data(mutator)
|
||||||
|
|
||||||
|
|
||||||
def verify_totp(token: str, secret: str = None):
|
def verify_totp(token: str, secret: str = None):
|
||||||
if secret is None:
|
if secret is None:
|
||||||
secret = load_totp_secret()
|
secret = load_totp_secret()
|
||||||
if not secret:
|
if not secret:
|
||||||
return True # 2FA not enabled
|
return True # 2FA not enabled
|
||||||
totp = pyotp.TOTP(secret)
|
totp = pyotp.TOTP(secret)
|
||||||
if not totp.verify(token):
|
if not totp.verify(token):
|
||||||
return False
|
return False
|
||||||
@@ -211,32 +236,41 @@ def verify_totp(token: str, secret: str = None):
|
|||||||
|
|
||||||
return _update_auth_data(mutator)
|
return _update_auth_data(mutator)
|
||||||
|
|
||||||
|
|
||||||
# Passkey credentials
|
# Passkey credentials
|
||||||
def load_passkey_credentials():
|
def load_passkey_credentials():
|
||||||
return _load_auth_data().get("passkey_credentials", [])
|
return _load_auth_data().get("passkey_credentials", [])
|
||||||
|
|
||||||
|
|
||||||
def save_passkey_credential(cred):
|
def save_passkey_credential(cred):
|
||||||
def mutator(data):
|
def mutator(data):
|
||||||
creds = data.get("passkey_credentials", [])
|
creds = data.get("passkey_credentials", [])
|
||||||
creds.append(cred)
|
creds.append(cred)
|
||||||
data["passkey_credentials"] = creds
|
data["passkey_credentials"] = creds
|
||||||
|
|
||||||
_update_auth_data(mutator)
|
_update_auth_data(mutator)
|
||||||
|
|
||||||
|
|
||||||
def clear_passkey_credentials():
|
def clear_passkey_credentials():
|
||||||
def mutator(data):
|
def mutator(data):
|
||||||
data["passkey_credentials"] = []
|
data["passkey_credentials"] = []
|
||||||
|
|
||||||
_update_auth_data(mutator)
|
_update_auth_data(mutator)
|
||||||
|
|
||||||
|
|
||||||
# Token version for refresh token rotation
|
# Token version for refresh token rotation
|
||||||
def _load_token_version() -> int:
|
def _load_token_version() -> int:
|
||||||
return _load_auth_data().get("token_version", 0)
|
return _load_auth_data().get("token_version", 0)
|
||||||
|
|
||||||
|
|
||||||
def increment_token_version() -> int:
|
def increment_token_version() -> int:
|
||||||
def mutator(data):
|
def mutator(data):
|
||||||
v = data.get("token_version", 0) + 1
|
v = data.get("token_version", 0) + 1
|
||||||
data["token_version"] = v
|
data["token_version"] = v
|
||||||
return v
|
return v
|
||||||
|
|
||||||
return _update_auth_data(mutator)
|
return _update_auth_data(mutator)
|
||||||
|
|
||||||
|
|
||||||
def verify_token_version(tv: int) -> bool:
|
def verify_token_version(tv: int) -> bool:
|
||||||
return tv == _load_token_version()
|
return tv == _load_token_version()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import os
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import os
|
||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
|
|
||||||
# Dashboard v1.3 — Runner DNS fix applied
|
# Dashboard v1.3 — Runner DNS fix applied
|
||||||
|
|
||||||
GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000")
|
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", "")
|
TELEGRAM_PROXY = os.environ.get("TELEGRAM_PROXY", "")
|
||||||
|
|
||||||
WEBAUTHN_RP_ID = os.environ.get("WEBAUTHN_RP_ID", "jimmygan.com")
|
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
|
||||||
CORS_ORIGINS = os.environ.get("CORS_ORIGINS", "https://nas.jimmygan.com,https://nas.jimmygan.com:8443").split(",")
|
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
|
OPC Database Module - PostgreSQL connection and schema management
|
||||||
"""
|
"""
|
||||||
import asyncpg
|
|
||||||
import json
|
import json
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
from datetime import datetime
|
|
||||||
import os
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
|
|
||||||
# Database connection settings
|
# Database connection settings
|
||||||
DB_HOST = os.getenv("OPC_DB_HOST", "gitea-db")
|
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", "")
|
DB_PASSWORD = os.getenv("GITEA_DB_PASSWORD", "")
|
||||||
|
|
||||||
# Connection pool
|
# Connection pool
|
||||||
_pool: Optional[asyncpg.Pool] = None
|
_pool: asyncpg.Pool | None = None
|
||||||
|
|
||||||
|
|
||||||
async def get_pool() -> asyncpg.Pool:
|
async def get_pool() -> asyncpg.Pool:
|
||||||
@@ -24,6 +26,7 @@ async def get_pool() -> asyncpg.Pool:
|
|||||||
if _pool is None:
|
if _pool is None:
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
@@ -67,7 +70,8 @@ async def init_db():
|
|||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# Create tables
|
# Create tables
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -77,9 +81,11 @@ async def init_db():
|
|||||||
metadata JSONB,
|
metadata JSONB,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS tasks (
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
@@ -96,9 +102,11 @@ async def init_db():
|
|||||||
completed_at TIMESTAMP,
|
completed_at TIMESTAMP,
|
||||||
due_date TIMESTAMP
|
due_date TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS task_history (
|
CREATE TABLE IF NOT EXISTS task_history (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
@@ -109,9 +117,11 @@ async def init_db():
|
|||||||
new_value JSONB,
|
new_value JSONB,
|
||||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS agents (
|
CREATE TABLE IF NOT EXISTS agents (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -123,9 +133,11 @@ async def init_db():
|
|||||||
enabled BOOLEAN DEFAULT TRUE,
|
enabled BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS agent_executions (
|
CREATE TABLE IF NOT EXISTS agent_executions (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
@@ -142,9 +154,11 @@ async def init_db():
|
|||||||
approved_by TEXT,
|
approved_by TEXT,
|
||||||
approved_at TIMESTAMP
|
approved_at TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS time_entries (
|
CREATE TABLE IF NOT EXISTS time_entries (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||||
@@ -157,9 +171,11 @@ async def init_db():
|
|||||||
ended_at TIMESTAMP,
|
ended_at TIMESTAMP,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS clients (
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -169,7 +185,8 @@ async def init_db():
|
|||||||
metadata JSONB,
|
metadata JSONB,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
# Create indexes
|
# Create indexes
|
||||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
|
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
|
- Provide status reports and risk assessments
|
||||||
|
|
||||||
When assigned a task, analyze it and propose concrete actions.""",
|
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",
|
"id": "cto",
|
||||||
@@ -216,7 +233,7 @@ When assigned a task, analyze it and propose concrete actions.""",
|
|||||||
- Ensure security best practices
|
- Ensure security best practices
|
||||||
|
|
||||||
When assigned a task, provide technical leadership and propose improvements.""",
|
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",
|
"id": "coo",
|
||||||
@@ -231,12 +248,13 @@ When assigned a task, provide technical leadership and propose improvements.""",
|
|||||||
- Optimize resource allocation
|
- Optimize resource allocation
|
||||||
|
|
||||||
When assigned a task, look for automation and optimization opportunities.""",
|
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:
|
for agent in agents:
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
INSERT INTO agents (id, name, role, description, capabilities, system_prompt, config, enabled)
|
INSERT INTO agents (id, name, role, description, capabilities, system_prompt, config, enabled)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
@@ -246,34 +264,51 @@ When assigned a task, look for automation and optimization opportunities.""",
|
|||||||
capabilities = EXCLUDED.capabilities,
|
capabilities = EXCLUDED.capabilities,
|
||||||
system_prompt = EXCLUDED.system_prompt,
|
system_prompt = EXCLUDED.system_prompt,
|
||||||
config = EXCLUDED.config
|
config = EXCLUDED.config
|
||||||
""", agent["id"], agent["name"], agent["role"], agent["description"],
|
""",
|
||||||
json.dumps(agent["capabilities"]), agent["system_prompt"],
|
agent["id"],
|
||||||
json.dumps(agent["config"]), True)
|
agent["name"],
|
||||||
|
agent["role"],
|
||||||
|
agent["description"],
|
||||||
|
json.dumps(agent["capabilities"]),
|
||||||
|
agent["system_prompt"],
|
||||||
|
json.dumps(agent["config"]),
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Task operations
|
# Task operations
|
||||||
async def create_task(
|
async def create_task(
|
||||||
title: str,
|
title: str,
|
||||||
description: Optional[str] = None,
|
description: str | None = None,
|
||||||
status: str = "parking_lot",
|
status: str = "parking_lot",
|
||||||
priority: str = "medium",
|
priority: str = "medium",
|
||||||
project_id: Optional[int] = None,
|
project_id: int | None = None,
|
||||||
assigned_to: Optional[str] = None,
|
assigned_to: str | None = None,
|
||||||
assigned_type: str = "human",
|
assigned_type: str = "human",
|
||||||
tags: Optional[List[str]] = None,
|
tags: list[str] | None = None,
|
||||||
due_date: Optional[datetime] = None,
|
due_date: datetime | None = None,
|
||||||
actor: str = "system"
|
actor: str = "system",
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a new task"""
|
"""Create a new task"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
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)
|
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)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
RETURNING id, title, description, status, priority, project_id, assigned_to, assigned_type, tags,
|
RETURNING id, title, description, status, priority, project_id, assigned_to, assigned_type, tags,
|
||||||
created_at, updated_at, completed_at, due_date
|
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 = dict(row)
|
||||||
task["tags"] = json.loads(task["tags"]) if task["tags"] else []
|
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()
|
task_for_history[key] = task_for_history[key].isoformat()
|
||||||
|
|
||||||
# Log history
|
# Log history
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
INSERT INTO task_history (task_id, action, actor, actor_type, new_value)
|
INSERT INTO task_history (task_id, action, actor, actor_type, new_value)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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
|
return task
|
||||||
|
|
||||||
|
|
||||||
async def get_tasks(
|
async def get_tasks(
|
||||||
status: Optional[str] = None,
|
status: str | None = None,
|
||||||
assigned_to: Optional[str] = None,
|
assigned_to: str | None = None,
|
||||||
project_id: Optional[int] = None,
|
project_id: int | None = None,
|
||||||
priority: Optional[str] = None,
|
priority: str | None = None,
|
||||||
tags: Optional[List[str]] = None,
|
tags: list[str] | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0
|
offset: int = 0,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get tasks with filters"""
|
"""Get tasks with filters"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -346,11 +388,7 @@ async def get_tasks(
|
|||||||
return tasks
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
async def update_task(
|
async def update_task(task_id: int, updates: dict[str, Any], actor: str = "system") -> dict[str, Any]:
|
||||||
task_id: int,
|
|
||||||
updates: Dict[str, Any],
|
|
||||||
actor: str = "system"
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Update a task"""
|
"""Update a task"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -367,7 +405,17 @@ async def update_task(
|
|||||||
param_count = 0
|
param_count = 0
|
||||||
|
|
||||||
for key, value in updates.items():
|
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
|
param_count += 1
|
||||||
set_clauses.append(f"{key} = ${param_count}")
|
set_clauses.append(f"{key} = ${param_count}")
|
||||||
params.append(value)
|
params.append(value)
|
||||||
@@ -395,10 +443,18 @@ async def update_task(
|
|||||||
updates_for_history[key] = updates_for_history[key].isoformat()
|
updates_for_history[key] = updates_for_history[key].isoformat()
|
||||||
|
|
||||||
# Log history
|
# Log history
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
INSERT INTO task_history (task_id, action, actor, actor_type, old_value, new_value)
|
INSERT INTO task_history (task_id, action, actor, actor_type, old_value, new_value)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
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
|
return task
|
||||||
|
|
||||||
@@ -407,73 +463,85 @@ async def delete_task(task_id: int, actor: str = "system"):
|
|||||||
"""Delete a task"""
|
"""Delete a task"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
INSERT INTO task_history (task_id, action, actor, actor_type)
|
INSERT INTO task_history (task_id, action, actor, actor_type)
|
||||||
VALUES ($1, $2, $3, $4)
|
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)
|
await conn.execute("DELETE FROM tasks WHERE id = $1", task_id)
|
||||||
|
|
||||||
|
|
||||||
# Time tracking operations
|
# Time tracking operations
|
||||||
async def start_time_entry(
|
async def start_time_entry(task_id: int, project_id: int | None = None, actor: str = "system") -> dict[str, Any]:
|
||||||
task_id: int,
|
|
||||||
project_id: Optional[int] = None,
|
|
||||||
actor: str = "system"
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Start automatic time tracking for a task"""
|
"""Start automatic time tracking for a task"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# Check if there's already an active entry
|
# Check if there's already an active entry
|
||||||
active = await conn.fetchrow("""
|
active = await conn.fetchrow(
|
||||||
|
"""
|
||||||
SELECT * FROM time_entries
|
SELECT * FROM time_entries
|
||||||
WHERE task_id = $1 AND ended_at IS NULL
|
WHERE task_id = $1 AND ended_at IS NULL
|
||||||
ORDER BY started_at DESC LIMIT 1
|
ORDER BY started_at DESC LIMIT 1
|
||||||
""", task_id)
|
""",
|
||||||
|
task_id,
|
||||||
|
)
|
||||||
|
|
||||||
if active:
|
if active:
|
||||||
return dict(active)
|
return dict(active)
|
||||||
|
|
||||||
# Create new time entry
|
# Create new time entry
|
||||||
row = await conn.fetchrow("""
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
INSERT INTO time_entries (task_id, project_id, started_at, duration_minutes)
|
INSERT INTO time_entries (task_id, project_id, started_at, duration_minutes)
|
||||||
VALUES ($1, $2, CURRENT_TIMESTAMP, 0)
|
VALUES ($1, $2, CURRENT_TIMESTAMP, 0)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""", task_id, project_id)
|
""",
|
||||||
|
task_id,
|
||||||
|
project_id,
|
||||||
|
)
|
||||||
|
|
||||||
return dict(row)
|
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"""
|
"""Stop automatic time tracking for a task"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# Find active entry
|
# Find active entry
|
||||||
active = await conn.fetchrow("""
|
active = await conn.fetchrow(
|
||||||
|
"""
|
||||||
SELECT * FROM time_entries
|
SELECT * FROM time_entries
|
||||||
WHERE task_id = $1 AND ended_at IS NULL
|
WHERE task_id = $1 AND ended_at IS NULL
|
||||||
ORDER BY started_at DESC LIMIT 1
|
ORDER BY started_at DESC LIMIT 1
|
||||||
""", task_id)
|
""",
|
||||||
|
task_id,
|
||||||
|
)
|
||||||
|
|
||||||
if not active:
|
if not active:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Calculate duration and update
|
# Calculate duration and update
|
||||||
row = await conn.fetchrow("""
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
UPDATE time_entries
|
UPDATE time_entries
|
||||||
SET ended_at = CURRENT_TIMESTAMP,
|
SET ended_at = CURRENT_TIMESTAMP,
|
||||||
duration_minutes = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) / 60
|
duration_minutes = EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - started_at)) / 60
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""", active["id"])
|
""",
|
||||||
|
active["id"],
|
||||||
|
)
|
||||||
|
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
async def get_time_entries(
|
async def get_time_entries(task_id: int | None = None, project_id: int | None = None) -> list[dict[str, Any]]:
|
||||||
task_id: Optional[int] = None,
|
|
||||||
project_id: Optional[int] = None
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Get time entries"""
|
"""Get time entries"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -494,20 +562,23 @@ async def get_time_entries(
|
|||||||
return [dict(row) for row in rows]
|
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"""
|
"""Get task history"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
rows = await conn.fetch("""
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
SELECT * FROM task_history
|
SELECT * FROM task_history
|
||||||
WHERE task_id = $1
|
WHERE task_id = $1
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC
|
||||||
""", task_id)
|
""",
|
||||||
|
task_id,
|
||||||
|
)
|
||||||
return [dict(row) for row in rows]
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
# Agent operations
|
# 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"""
|
"""Get all agents"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -526,7 +597,7 @@ async def get_agents(enabled_only: bool = True) -> List[Dict[str, Any]]:
|
|||||||
return agents
|
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"""
|
"""Get agent by ID"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
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(
|
async def create_agent_execution(
|
||||||
task_id: int,
|
task_id: int, agent_id: str, actor: str = "system", requires_approval: bool = False
|
||||||
agent_id: str,
|
) -> dict[str, Any]:
|
||||||
actor: str = "system",
|
|
||||||
requires_approval: bool = False
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Create agent execution record"""
|
"""Create agent execution record"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -561,28 +629,43 @@ async def create_agent_execution(
|
|||||||
task_for_context = dict(task)
|
task_for_context = dict(task)
|
||||||
for key in ["created_at", "updated_at", "completed_at", "due_date"]:
|
for key in ["created_at", "updated_at", "completed_at", "due_date"]:
|
||||||
if task_for_context.get(key):
|
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)
|
agent_for_context = dict(agent)
|
||||||
for key in ["created_at"]:
|
for key in ["created_at"]:
|
||||||
if agent_for_context.get(key):
|
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 = {
|
input_context = {
|
||||||
"task": task_for_context,
|
"task": task_for_context,
|
||||||
"agent": agent_for_context,
|
"agent": agent_for_context,
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if agent requires approval (CxO level)
|
# Check if agent requires approval (CxO level)
|
||||||
agent_config = json.loads(agent["config"]) if agent["config"] else {}
|
agent_config = json.loads(agent["config"]) if agent["config"] else {}
|
||||||
requires_approval = agent_config.get("approval_level") == "cxo"
|
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)
|
INSERT INTO agent_executions (task_id, agent_id, status, input_context, requires_approval, started_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||||
RETURNING *
|
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 = dict(row)
|
||||||
execution["input_context"] = json.loads(execution["input_context"]) if execution["input_context"] else {}
|
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(
|
async def get_agent_executions(
|
||||||
task_id: Optional[int] = None,
|
task_id: int | None = None, agent_id: str | None = None, status: str | None = None, limit: int = 50
|
||||||
agent_id: Optional[str] = None,
|
) -> list[dict[str, Any]]:
|
||||||
status: Optional[str] = None,
|
|
||||||
limit: int = 50
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Get agent executions"""
|
"""Get agent executions"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -628,7 +708,7 @@ async def get_agent_executions(
|
|||||||
return 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"""
|
"""Get a single task by ID"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
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
|
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"""
|
"""Get a single agent execution by ID"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
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(
|
async def approve_agent_execution(
|
||||||
execution_id: int,
|
execution_id: int, approved: bool, approved_by: str, feedback: str | None = None
|
||||||
approved: bool,
|
) -> dict[str, Any]:
|
||||||
approved_by: str,
|
|
||||||
feedback: Optional[str] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Approve or reject an agent execution"""
|
"""Approve or reject an agent execution"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
# Update execution with approval status
|
# Update execution with approval status
|
||||||
row = await conn.fetchrow("""
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
UPDATE agent_executions
|
UPDATE agent_executions
|
||||||
SET approved_by = $1,
|
SET approved_by = $1,
|
||||||
approved_at = CURRENT_TIMESTAMP,
|
approved_at = CURRENT_TIMESTAMP,
|
||||||
status = CASE WHEN $2 THEN 'approved' ELSE 'rejected' END
|
status = CASE WHEN $2 THEN 'approved' ELSE 'rejected' END
|
||||||
WHERE id = $3
|
WHERE id = $3
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""", approved_by, approved, execution_id)
|
""",
|
||||||
|
approved_by,
|
||||||
|
approved,
|
||||||
|
execution_id,
|
||||||
|
)
|
||||||
|
|
||||||
if not row:
|
if not row:
|
||||||
raise ValueError(f"Execution {execution_id} not found")
|
raise ValueError(f"Execution {execution_id} not found")
|
||||||
@@ -689,23 +771,27 @@ async def approve_agent_execution(
|
|||||||
execution["output_result"] = {}
|
execution["output_result"] = {}
|
||||||
execution["output_result"]["approval_feedback"] = feedback
|
execution["output_result"]["approval_feedback"] = feedback
|
||||||
|
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
UPDATE agent_executions
|
UPDATE agent_executions
|
||||||
SET output_result = $1
|
SET output_result = $1
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
""", json.dumps(execution["output_result"]), execution_id)
|
""",
|
||||||
|
json.dumps(execution["output_result"]),
|
||||||
|
execution_id,
|
||||||
|
)
|
||||||
|
|
||||||
return execution
|
return execution
|
||||||
|
|
||||||
|
|
||||||
async def update_agent_config(
|
async def update_agent_config(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
name: Optional[str] = None,
|
name: str | None = None,
|
||||||
description: Optional[str] = None,
|
description: str | None = None,
|
||||||
system_prompt: Optional[str] = None,
|
system_prompt: str | None = None,
|
||||||
config: Optional[Dict[str, Any]] = None,
|
config: dict[str, Any] | None = None,
|
||||||
enabled: Optional[bool] = None
|
enabled: bool | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Update agent configuration"""
|
"""Update agent configuration"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
@@ -761,11 +847,11 @@ async def update_agent_config(
|
|||||||
async def update_execution_status(
|
async def update_execution_status(
|
||||||
execution_id: int,
|
execution_id: int,
|
||||||
status: str,
|
status: str,
|
||||||
output_result: Optional[Dict[str, Any]] = None,
|
output_result: dict[str, Any] | None = None,
|
||||||
actions_proposed: Optional[List[Dict[str, Any]]] = None,
|
actions_proposed: list[dict[str, Any]] | None = None,
|
||||||
actions_executed: Optional[List[Dict[str, Any]]] = None,
|
actions_executed: list[dict[str, Any]] | None = None,
|
||||||
error_message: Optional[str] = None
|
error_message: str | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Update agent execution status and results"""
|
"""Update agent execution status and results"""
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
|
|||||||
+116
-63
@@ -1,30 +1,49 @@
|
|||||||
from fastapi import FastAPI, Depends, Request
|
import asyncio
|
||||||
from fastapi.staticfiles import StaticFiles
|
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.cors import CORSMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.staticfiles import StaticFiles
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
|
||||||
from slowapi.errors import RateLimitExceeded
|
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 auth_service as auth_module
|
||||||
import config
|
import config
|
||||||
|
from config import CORS_ORIGINS, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, get_client_ip
|
||||||
from rbac import require_page
|
from rbac import require_page
|
||||||
from contextlib import asynccontextmanager
|
from routers import auth as auth_router
|
||||||
|
from routers import (
|
||||||
import asyncio
|
cc_connect,
|
||||||
import httpx
|
chat_summary,
|
||||||
import logging
|
docker_router,
|
||||||
import time
|
files,
|
||||||
import jwt
|
gitea,
|
||||||
import docker.errors
|
info_engine,
|
||||||
import traceback as tb
|
litellm,
|
||||||
from datetime import datetime, timezone, timedelta
|
opc_agents,
|
||||||
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT, get_client_ip
|
opc_projects,
|
||||||
|
opc_tasks,
|
||||||
|
opc_ws,
|
||||||
|
passkey,
|
||||||
|
security,
|
||||||
|
system,
|
||||||
|
terminal,
|
||||||
|
totp,
|
||||||
|
)
|
||||||
|
|
||||||
_tz_cst = timezone(timedelta(hours=8))
|
_tz_cst = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# Startup
|
# Startup
|
||||||
@@ -32,16 +51,19 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
# Initialize OPC database
|
# Initialize OPC database
|
||||||
from db import opc_db
|
from db import opc_db
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await opc_db.init_db()
|
await opc_db.init_db()
|
||||||
print("OPC database initialized")
|
print("OPC database initialized")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to initialize OPC database: {e}")
|
print(f"Failed to initialize OPC database: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
# Start agent executor service
|
# Start agent executor service
|
||||||
from services import agent_executor
|
from services import agent_executor
|
||||||
|
|
||||||
executor_task = None
|
executor_task = None
|
||||||
try:
|
try:
|
||||||
executor_task = asyncio.create_task(agent_executor.start_executor())
|
executor_task = asyncio.create_task(agent_executor.start_executor())
|
||||||
@@ -49,6 +71,7 @@ async def lifespan(app: FastAPI):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to start agent executor: {e}")
|
print(f"Failed to start agent executor: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
monitor_task = asyncio.create_task(monitor_containers())
|
monitor_task = asyncio.create_task(monitor_containers())
|
||||||
@@ -61,6 +84,7 @@ async def lifespan(app: FastAPI):
|
|||||||
executor_task.cancel()
|
executor_task.cancel()
|
||||||
monitor_task.cancel()
|
monitor_task.cancel()
|
||||||
|
|
||||||
|
|
||||||
limiter = Limiter(key_func=get_remote_address)
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
app = FastAPI(title="NAS Dashboard", lifespan=lifespan)
|
app = FastAPI(title="NAS Dashboard", lifespan=lifespan)
|
||||||
app.state.limiter = limiter
|
app.state.limiter = limiter
|
||||||
@@ -76,6 +100,7 @@ app.add_middleware(
|
|||||||
# Compress responses (static files and API responses)
|
# Compress responses (static files and API responses)
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
app.add_middleware(GZipMiddleware, minimum_size=1000)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(RateLimitExceeded)
|
@app.exception_handler(RateLimitExceeded)
|
||||||
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
|
||||||
return JSONResponse(status_code=429, content={"detail": "Too many requests, try again later"})
|
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):
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
"""Handle request validation errors with detailed information."""
|
"""Handle request validation errors with detailed information."""
|
||||||
logging.error(f"Validation error on {request.method} {request.url.path}: {exc.errors()}")
|
logging.error(f"Validation error on {request.method} {request.url.path}: {exc.errors()}")
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=422, content={"detail": exc.errors()})
|
||||||
status_code=422,
|
|
||||||
content={"detail": exc.errors()}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(docker.errors.NotFound)
|
@app.exception_handler(docker.errors.NotFound)
|
||||||
async def docker_not_found_handler(request: Request, exc: docker.errors.NotFound):
|
async def docker_not_found_handler(request: Request, exc: docker.errors.NotFound):
|
||||||
"""Handle Docker container/image not found errors."""
|
"""Handle Docker container/image not found errors."""
|
||||||
logging.warning(f"Docker resource not found: {exc}")
|
logging.warning(f"Docker resource not found: {exc}")
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=404, content={"detail": "Docker resource not found"})
|
||||||
status_code=404,
|
|
||||||
content={"detail": "Docker resource not found"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(docker.errors.APIError)
|
@app.exception_handler(docker.errors.APIError)
|
||||||
async def docker_api_error_handler(request: Request, exc: docker.errors.APIError):
|
async def docker_api_error_handler(request: Request, exc: docker.errors.APIError):
|
||||||
"""Handle Docker API errors."""
|
"""Handle Docker API errors."""
|
||||||
logging.error(f"Docker API error: {exc}")
|
logging.error(f"Docker API error: {exc}")
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=503, content={"detail": "Docker service unavailable"})
|
||||||
status_code=503,
|
|
||||||
content={"detail": "Docker service unavailable"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
@@ -116,10 +132,8 @@ async def generic_exception_handler(request: Request, exc: Exception):
|
|||||||
"""Handle all unhandled exceptions."""
|
"""Handle all unhandled exceptions."""
|
||||||
logging.error(f"Unhandled exception on {request.method} {request.url.path}: {exc}")
|
logging.error(f"Unhandled exception on {request.method} {request.url.path}: {exc}")
|
||||||
logging.error(tb.format_exc())
|
logging.error(tb.format_exc())
|
||||||
return JSONResponse(
|
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||||
status_code=500,
|
|
||||||
content={"detail": "Internal server error"}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def security_headers(request: Request, call_next):
|
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-Frame-Options"] = "DENY"
|
||||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
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)
|
# Cache static assets with hashed filenames (Vite generates these)
|
||||||
if request.url.path.startswith("/assets/"):
|
if request.url.path.startswith("/assets/"):
|
||||||
@@ -136,8 +152,10 @@ async def security_headers(request: Request, call_next):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
# Audit logger
|
# Audit logger
|
||||||
import os
|
import os
|
||||||
|
|
||||||
_audit_log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
|
_audit_log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
|
||||||
os.makedirs(os.path.dirname(_audit_log_path), exist_ok=True)
|
os.makedirs(os.path.dirname(_audit_log_path), exist_ok=True)
|
||||||
_audit_logger = logging.getLogger("audit")
|
_audit_logger = logging.getLogger("audit")
|
||||||
@@ -147,6 +165,7 @@ _audit_handler.setFormatter(logging.Formatter("%(message)s"))
|
|||||||
_audit_logger.addHandler(_audit_handler)
|
_audit_logger.addHandler(_audit_handler)
|
||||||
_audit_logger.propagate = False
|
_audit_logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def audit_log(request: Request, call_next):
|
async def audit_log(request: Request, call_next):
|
||||||
start = time.time()
|
start = time.time()
|
||||||
@@ -156,19 +175,27 @@ async def audit_log(request: Request, call_next):
|
|||||||
username = user.username if user else "-"
|
username = user.username if user else "-"
|
||||||
_audit_logger.info(
|
_audit_logger.info(
|
||||||
"%s %s %s %s %s %d %dms",
|
"%s %s %s %s %s %d %dms",
|
||||||
datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), get_client_ip(request), username,
|
datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"),
|
||||||
request.method, request.url.path, response.status_code, duration,
|
get_client_ip(request),
|
||||||
|
username,
|
||||||
|
request.method,
|
||||||
|
request.url.path,
|
||||||
|
response.status_code,
|
||||||
|
duration,
|
||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
async def monitor_containers():
|
async def monitor_containers():
|
||||||
import docker
|
import docker
|
||||||
|
|
||||||
from config import DOCKER_HOST
|
from config import DOCKER_HOST
|
||||||
|
|
||||||
client = docker.DockerClient(base_url=DOCKER_HOST)
|
client = docker.DockerClient(base_url=DOCKER_HOST)
|
||||||
|
|
||||||
# Store initial states
|
# Store initial states
|
||||||
previous_states = {}
|
previous_states = {}
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
containers = await asyncio.to_thread(client.containers.list, all=True)
|
containers = await asyncio.to_thread(client.containers.list, all=True)
|
||||||
@@ -190,13 +217,15 @@ async def monitor_containers():
|
|||||||
previous_states[c.id] = current_status
|
previous_states[c.id] = current_status
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).error("Error in container monitor: %s", e)
|
logging.getLogger(__name__).error("Error in container monitor: %s", e)
|
||||||
|
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/client-ip")
|
@app.get("/api/client-ip")
|
||||||
async def client_ip(request: Request):
|
async def client_ip(request: Request):
|
||||||
ip = get_client_ip(request)
|
ip = get_client_ip(request)
|
||||||
@@ -215,8 +244,10 @@ async def client_ip(request: Request):
|
|||||||
|
|
||||||
return {"lan": lan}
|
return {"lan": lan}
|
||||||
|
|
||||||
|
|
||||||
def _router_reachable():
|
def _router_reachable():
|
||||||
import socket
|
import socket
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s = socket.socket()
|
s = socket.socket()
|
||||||
s.settimeout(1)
|
s.settimeout(1)
|
||||||
@@ -226,43 +257,63 @@ def _router_reachable():
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Dependency to store user in request.state for rbac dependencies
|
# 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
|
request.state.user = user
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
# Public auth endpoints
|
# Public auth endpoints
|
||||||
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
app.include_router(auth_router.router, prefix="/api/auth", tags=["auth"])
|
||||||
app.include_router(passkey.router, prefix="/api/auth", tags=["passkey"])
|
app.include_router(passkey.router, prefix="/api/auth", tags=["passkey"])
|
||||||
app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
|
app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
|
||||||
|
|
||||||
# Protected endpoints with page-level RBAC
|
# Protected endpoints with page-level RBAC
|
||||||
app.include_router(docker_router.router, prefix="/api/docker",
|
app.include_router(
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("docker"))])
|
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(
|
||||||
app.include_router(files.router, prefix="/api/files",
|
gitea.router, prefix="/api/gitea", dependencies=[Depends(_inject_user), Depends(require_page("gitea"))]
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("files"))])
|
)
|
||||||
app.include_router(system.router, prefix="/api/system",
|
app.include_router(
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
files.router, prefix="/api/files", dependencies=[Depends(_inject_user), Depends(require_page("files"))]
|
||||||
app.include_router(litellm.router, prefix="/api/litellm",
|
)
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
app.include_router(
|
||||||
app.include_router(cc_connect.router, prefix="/api/cc-connect",
|
system.router, prefix="/api/system", dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
)
|
||||||
app.include_router(chat_summary.router, prefix="/api/chat-summary",
|
app.include_router(
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))])
|
litellm.router, prefix="/api/litellm", dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))]
|
||||||
app.include_router(info_engine.router, prefix="/api/info-engine",
|
)
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
|
app.include_router(
|
||||||
app.include_router(security.router, prefix="/api/security",
|
cc_connect.router,
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
|
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
|
# OPC endpoints
|
||||||
app.include_router(opc_tasks.router, prefix="/api/opc",
|
app.include_router(
|
||||||
dependencies=[Depends(_inject_user), Depends(require_page("opc"))])
|
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(
|
||||||
app.include_router(opc_projects.router, prefix="/api/opc",
|
opc_agents.router, prefix="/api/opc", dependencies=[Depends(_inject_user), Depends(require_page("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)
|
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
|
||||||
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
|
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)
|
# Mount static files (skip if directory doesn't exist, e.g., in test environments)
|
||||||
import os
|
import os
|
||||||
|
|
||||||
if os.path.isdir("/app/static"):
|
if os.path.isdir("/app/static"):
|
||||||
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
|
||||||
else:
|
else:
|
||||||
# In test environment, create a minimal fallback
|
# In test environment, create a minimal fallback
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
_test_static = tempfile.mkdtemp()
|
_test_static = tempfile.mkdtemp()
|
||||||
with open(os.path.join(_test_static, "index.html"), "w") as f:
|
with open(os.path.join(_test_static, "index.html"), "w") as f:
|
||||||
f.write("<html><body>Test Environment</body></html>")
|
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]
|
[tool.pytest.ini_options]
|
||||||
minversion = "7.0"
|
minversion = "7.0"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Optional
|
import threading
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from fastapi import HTTPException, Request, status, Depends
|
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
|
|
||||||
def get_rbac_file():
|
def get_rbac_file():
|
||||||
return os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json")
|
return os.path.join(config.VOLUME_ROOT, "docker/nas-dashboard/rbac.json")
|
||||||
|
|
||||||
|
|
||||||
_rbac_lock = threading.RLock()
|
_rbac_lock = threading.RLock()
|
||||||
|
|
||||||
ROLE_GROUP_MAP = {
|
ROLE_GROUP_MAP = {
|
||||||
@@ -75,7 +77,7 @@ def save_rbac(data: dict):
|
|||||||
_atomic_json_write(get_rbac_file(), data)
|
_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:
|
for group in groups:
|
||||||
if group in ROLE_GROUP_MAP:
|
if group in ROLE_GROUP_MAP:
|
||||||
return ROLE_GROUP_MAP[group]
|
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 save_sidebar_order(username: str, order: dict):
|
||||||
def mutator(rbac):
|
def mutator(rbac):
|
||||||
rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order
|
rbac.setdefault("user_overrides", {}).setdefault(username, {})["sidebar_order"] = order
|
||||||
|
|
||||||
update_rbac(mutator)
|
update_rbac(mutator)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,26 +121,32 @@ def has_page_access(user: User, page: str) -> bool:
|
|||||||
|
|
||||||
def require_page(page: str):
|
def require_page(page: str):
|
||||||
"""FastAPI dependency factory — 403 if user lacks page access."""
|
"""FastAPI dependency factory — 403 if user lacks page access."""
|
||||||
|
|
||||||
async def checker(request: Request):
|
async def checker(request: Request):
|
||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
if not has_page_access(user, page):
|
if not has_page_access(user, page):
|
||||||
raise HTTPException(status_code=403, detail="Access denied")
|
raise HTTPException(status_code=403, detail="Access denied")
|
||||||
|
|
||||||
return checker
|
return checker
|
||||||
|
|
||||||
|
|
||||||
def require_admin():
|
def require_admin():
|
||||||
"""FastAPI dependency — 403 if user is not admin."""
|
"""FastAPI dependency — 403 if user is not admin."""
|
||||||
|
|
||||||
async def checker(request: Request):
|
async def checker(request: Request):
|
||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
if user.role != "admin":
|
if user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
|
|
||||||
return checker
|
return checker
|
||||||
|
|
||||||
|
|
||||||
def require_write():
|
def require_write():
|
||||||
"""FastAPI dependency — 403 if viewer tries non-GET method."""
|
"""FastAPI dependency — 403 if viewer tries non-GET method."""
|
||||||
|
|
||||||
async def checker(request: Request):
|
async def checker(request: Request):
|
||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
if user.role == "viewer" and request.method != "GET":
|
if user.role == "viewer" and request.method != "GET":
|
||||||
raise HTTPException(status_code=403, detail="Read-only access")
|
raise HTTPException(status_code=403, detail="Read-only access")
|
||||||
|
|
||||||
return checker
|
return checker
|
||||||
|
|||||||
@@ -2,3 +2,6 @@ pytest==8.3.4
|
|||||||
pytest-asyncio==0.24.0
|
pytest-asyncio==0.24.0
|
||||||
pytest-cov==6.0.0
|
pytest-cov==6.0.0
|
||||||
pytest-mock==3.14.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."""
|
"""Core authentication endpoints: login, token refresh, user info, proxy auth."""
|
||||||
from datetime import timedelta
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from typing import Optional
|
import jwt
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, status, Request, Response
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
import jwt
|
|
||||||
import config
|
|
||||||
import auth_service as auth
|
import auth_service as auth
|
||||||
import logging
|
import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
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)
|
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, "")
|
cookie_token = request.cookies.get(auth.REFRESH_COOKIE_NAME, "")
|
||||||
if cookie_token:
|
if cookie_token:
|
||||||
return 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(
|
res = await client.post(
|
||||||
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage",
|
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
data={"chat_id": config.TELEGRAM_CHAT_ID, "text": msg, "parse_mode": "Markdown"},
|
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)
|
logger.info("Telegram alert sent, status: %s", res.status_code)
|
||||||
except Exception as e:
|
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.
|
"""Auto-login when Authelia has already authenticated the user via forward-auth.
|
||||||
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
Caddy sets Remote-User and Remote-Groups headers after successful Authelia verification."""
|
||||||
from rbac import resolve_role
|
from rbac import resolve_role
|
||||||
|
|
||||||
if not config.is_trusted_proxy(request.client.host):
|
if not config.is_trusted_proxy(request.client.host):
|
||||||
logger.warning(f"Rejected proxy auth attempt from unauthorized IP: {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")
|
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")
|
@router.post("/refresh")
|
||||||
@limiter.limit("10/minute")
|
@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)
|
refresh_token_value = _extract_refresh_token(request, req)
|
||||||
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
|
access_token, refresh_token = await _refresh_tokens_from_value(refresh_token_value)
|
||||||
_set_auth_response_tokens(response, access_token, refresh_token)
|
_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":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import load_rbac
|
from rbac import load_rbac
|
||||||
|
|
||||||
return 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":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import update_rbac
|
from rbac import update_rbac
|
||||||
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
pages = body.get("pages")
|
pages = body.get("pages")
|
||||||
if pages is None:
|
if pages is None:
|
||||||
@@ -257,6 +262,7 @@ async def rbac_set_override(username: str, request: Request, current_user=Depend
|
|||||||
@router.get("/preferences")
|
@router.get("/preferences")
|
||||||
async def get_preferences(current_user=Depends(auth.get_current_user)):
|
async def get_preferences(current_user=Depends(auth.get_current_user)):
|
||||||
from rbac import get_sidebar_order
|
from rbac import get_sidebar_order
|
||||||
|
|
||||||
order = get_sidebar_order(current_user.username)
|
order = get_sidebar_order(current_user.username)
|
||||||
return {"sidebar_order": order}
|
return {"sidebar_order": order}
|
||||||
|
|
||||||
@@ -264,7 +270,9 @@ async def get_preferences(current_user=Depends(auth.get_current_user)):
|
|||||||
@router.put("/preferences")
|
@router.put("/preferences")
|
||||||
async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)):
|
async def save_preferences(request: Request, current_user=Depends(auth.get_current_user)):
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
from rbac import save_sidebar_order
|
from rbac import save_sidebar_order
|
||||||
|
|
||||||
try:
|
try:
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
order = body.get("sidebar_order")
|
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":
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="Admin only")
|
raise HTTPException(status_code=403, detail="Admin only")
|
||||||
from rbac import update_rbac
|
from rbac import update_rbac
|
||||||
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
pages = body.get("pages")
|
pages = body.get("pages")
|
||||||
sidebar_links = body.get("sidebar_links")
|
sidebar_links = body.get("sidebar_links")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
def get_docker_client():
|
def get_docker_client():
|
||||||
"""Get or create Docker client (lazy initialization)."""
|
"""Get or create Docker client (lazy initialization)."""
|
||||||
global _client
|
global _client
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
from config import CHAT_SUMMARY_DB
|
from config import CHAT_SUMMARY_DB
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
async def _db():
|
async def _db():
|
||||||
return aiosqlite.connect(CHAT_SUMMARY_DB)
|
return aiosqlite.connect(CHAT_SUMMARY_DB)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dates")
|
@router.get("/dates")
|
||||||
async def list_dates():
|
async def list_dates():
|
||||||
async with await _db() as db:
|
async with await _db() as db:
|
||||||
cursor = await db.execute("SELECT date FROM summaries ORDER BY date DESC")
|
cursor = await db.execute("SELECT date FROM summaries ORDER BY date DESC")
|
||||||
return [r[0] for r in await cursor.fetchall()]
|
return [r[0] for r in await cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/summary/{date}")
|
@router.get("/summary/{date}")
|
||||||
async def get_summary(date: str):
|
async def get_summary(date: str):
|
||||||
async with await _db() as db:
|
async with await _db() as db:
|
||||||
@@ -22,18 +26,21 @@ async def get_summary(date: str):
|
|||||||
raise HTTPException(404, "No summary for this date")
|
raise HTTPException(404, "No summary for this date")
|
||||||
return {"date": date, "content": row[0], "created_at": row[1]}
|
return {"date": date, "content": row[0], "created_at": row[1]}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/messages/{date}")
|
@router.get("/messages/{date}")
|
||||||
async def get_messages(date: str):
|
async def get_messages(date: str):
|
||||||
async with await _db() as db:
|
async with await _db() as db:
|
||||||
cursor = await db.execute(
|
cursor = await db.execute(
|
||||||
"SELECT group_name, sender_name, text, timestamp FROM messages WHERE date(timestamp)=? ORDER BY timestamp",
|
"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()]
|
return [{"group": r[0], "sender": r[1], "text": r[2], "time": r[3]} for r in await cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/trigger")
|
@router.post("/trigger")
|
||||||
async def trigger_summary():
|
async def trigger_summary():
|
||||||
import os
|
import os
|
||||||
|
|
||||||
trigger_path = os.environ.get("CHAT_SUMMARY_TRIGGER", "/app/data/chat-summarizer/trigger")
|
trigger_path = os.environ.get("CHAT_SUMMARY_TRIGGER", "/app/data/chat-summarizer/trigger")
|
||||||
os.makedirs(os.path.dirname(trigger_path), exist_ok=True)
|
os.makedirs(os.path.dirname(trigger_path), exist_ok=True)
|
||||||
with open(trigger_path, "w") as f:
|
with open(trigger_path, "w") as f:
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import docker
|
import docker
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
from config import DOCKER_HOST
|
from config import DOCKER_HOST
|
||||||
from rbac import require_admin
|
from rbac import require_admin
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
def get_docker_client():
|
def get_docker_client():
|
||||||
"""Get or create Docker client (lazy initialization)."""
|
"""Get or create Docker client (lazy initialization)."""
|
||||||
global _client
|
global _client
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
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 fastapi.responses import FileResponse
|
||||||
|
|
||||||
from config import VOLUME_ROOT
|
from config import VOLUME_ROOT
|
||||||
from rbac import require_admin
|
from rbac import require_admin
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ def _is_safe_symlink(item: Path) -> bool:
|
|||||||
def _sanitize_filename(name: str) -> str:
|
def _sanitize_filename(name: str) -> str:
|
||||||
name = os.path.basename(name)
|
name = os.path.basename(name)
|
||||||
name = name.replace("\x00", "").replace("/", "").replace("\\", "")
|
name = name.replace("\x00", "").replace("/", "").replace("\\", "")
|
||||||
name = re.sub(r'\.\.+', '.', name)
|
name = re.sub(r"\.\.+", ".", name)
|
||||||
name = name.strip(". ")
|
name = name.strip(". ")
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("invalid filename")
|
raise ValueError("invalid filename")
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from config import GITEA_URL, GITEA_TOKEN
|
|
||||||
|
from config import GITEA_TOKEN, GITEA_URL
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
HEADERS = {"Authorization": f"token {GITEA_TOKEN}"} if GITEA_TOKEN else {}
|
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")
|
@router.get("/repos")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from config import LITELLM_URL, LITELLM_HEALTH_API_KEY
|
|
||||||
|
from config import LITELLM_HEALTH_API_KEY, LITELLM_URL
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
"""
|
"""
|
||||||
OPC Agents Router
|
OPC Agents Router
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from typing import Optional, List
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from db import opc_db
|
from db import opc_db
|
||||||
from rbac import require_page, User, require_admin
|
from rbac import User, require_admin
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class AgentUpdate(BaseModel):
|
class AgentUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: str | None = None
|
||||||
description: Optional[str] = None
|
description: str | None = None
|
||||||
system_prompt: Optional[str] = None
|
system_prompt: str | None = None
|
||||||
enabled: Optional[bool] = None
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class ExecutionApproval(BaseModel):
|
class ExecutionApproval(BaseModel):
|
||||||
approved: bool
|
approved: bool
|
||||||
feedback: Optional[str] = None
|
feedback: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/agents")
|
@router.get("/agents")
|
||||||
@@ -60,7 +61,7 @@ async def update_agent(
|
|||||||
name=updates.name,
|
name=updates.name,
|
||||||
description=updates.description,
|
description=updates.description,
|
||||||
system_prompt=updates.system_prompt,
|
system_prompt=updates.system_prompt,
|
||||||
enabled=updates.enabled
|
enabled=updates.enabled,
|
||||||
)
|
)
|
||||||
return agent
|
return agent
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -75,30 +76,21 @@ async def trigger_agent(
|
|||||||
):
|
):
|
||||||
"""Manually trigger agent execution"""
|
"""Manually trigger agent execution"""
|
||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
execution = await opc_db.create_agent_execution(
|
execution = 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
|
|
||||||
)
|
|
||||||
return execution
|
return execution
|
||||||
|
|
||||||
|
|
||||||
@router.get("/executions")
|
@router.get("/executions")
|
||||||
async def list_executions(
|
async def list_executions(
|
||||||
request: Request,
|
request: Request,
|
||||||
task_id: Optional[int] = None,
|
task_id: int | None = None,
|
||||||
agent_id: Optional[str] = None,
|
agent_id: str | None = None,
|
||||||
status: Optional[str] = None,
|
status: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
):
|
):
|
||||||
"""List agent executions"""
|
"""List agent executions"""
|
||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
executions = await opc_db.get_agent_executions(
|
executions = await opc_db.get_agent_executions(task_id=task_id, agent_id=agent_id, status=status, limit=limit)
|
||||||
task_id=task_id,
|
|
||||||
agent_id=agent_id,
|
|
||||||
status=status,
|
|
||||||
limit=limit
|
|
||||||
)
|
|
||||||
return {"items": executions}
|
return {"items": executions}
|
||||||
|
|
||||||
|
|
||||||
@@ -125,10 +117,7 @@ async def approve_execution(
|
|||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
try:
|
try:
|
||||||
execution = await opc_db.approve_agent_execution(
|
execution = await opc_db.approve_agent_execution(
|
||||||
execution_id=execution_id,
|
execution_id=execution_id, approved=approval.approved, approved_by=user.username, feedback=approval.feedback
|
||||||
approved=approval.approved,
|
|
||||||
approved_by=user.username,
|
|
||||||
feedback=approval.feedback
|
|
||||||
)
|
)
|
||||||
return execution
|
return execution
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
"""
|
"""
|
||||||
OPC Projects and Invoicing Router
|
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 pydantic import BaseModel
|
||||||
|
|
||||||
from db import opc_db
|
from db import opc_db
|
||||||
from rbac import User
|
from rbac import User
|
||||||
from services import pdf_service
|
from services import pdf_service
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class ProjectCreate(BaseModel):
|
class ProjectCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: str | None = None
|
||||||
client_id: Optional[int] = None
|
client_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class InvoiceGenerate(BaseModel):
|
class InvoiceGenerate(BaseModel):
|
||||||
project_id: int
|
project_id: int
|
||||||
client_id: int
|
client_id: int
|
||||||
invoice_number: Optional[str] = None
|
invoice_number: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/projects")
|
@router.get("/projects")
|
||||||
@@ -45,11 +47,16 @@ async def create_project(
|
|||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
pool = await opc_db.get_pool()
|
pool = await opc_db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
row = await conn.fetchrow("""
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
INSERT INTO projects (name, description, client_id)
|
INSERT INTO projects (name, description, client_id)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""", project.name, project.description, project.client_id)
|
""",
|
||||||
|
project.name,
|
||||||
|
project.description,
|
||||||
|
project.client_id,
|
||||||
|
)
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,18 +76,23 @@ async def list_clients(
|
|||||||
async def create_client(
|
async def create_client(
|
||||||
request: Request,
|
request: Request,
|
||||||
name: str,
|
name: str,
|
||||||
email: Optional[str] = None,
|
email: str | None = None,
|
||||||
company: Optional[str] = None,
|
company: str | None = None,
|
||||||
):
|
):
|
||||||
"""Create a new client"""
|
"""Create a new client"""
|
||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
pool = await opc_db.get_pool()
|
pool = await opc_db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
row = await conn.fetchrow("""
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
INSERT INTO clients (name, email, company)
|
INSERT INTO clients (name, email, company)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
""", name, email, company)
|
""",
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
company,
|
||||||
|
)
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
@@ -100,11 +112,14 @@ async def generate_invoice(
|
|||||||
client = dict(client_row)
|
client = dict(client_row)
|
||||||
|
|
||||||
# Get time entries for project
|
# Get time entries for project
|
||||||
time_rows = await conn.fetch("""
|
time_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
SELECT * FROM time_entries
|
SELECT * FROM time_entries
|
||||||
WHERE project_id = $1 AND billable = TRUE
|
WHERE project_id = $1 AND billable = TRUE
|
||||||
ORDER BY started_at
|
ORDER BY started_at
|
||||||
""", invoice.project_id)
|
""",
|
||||||
|
invoice.project_id,
|
||||||
|
)
|
||||||
|
|
||||||
if not time_rows:
|
if not time_rows:
|
||||||
raise HTTPException(status_code=400, detail="No billable time entries found")
|
raise HTTPException(status_code=400, detail="No billable time entries found")
|
||||||
@@ -116,7 +131,7 @@ async def generate_invoice(
|
|||||||
project_id=invoice.project_id,
|
project_id=invoice.project_id,
|
||||||
client=client,
|
client=client,
|
||||||
time_entries=time_entries,
|
time_entries=time_entries,
|
||||||
invoice_number=invoice.invoice_number
|
invoice_number=invoice.invoice_number,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return PDF
|
# Return PDF
|
||||||
@@ -124,9 +139,7 @@ async def generate_invoice(
|
|||||||
return Response(
|
return Response(
|
||||||
content=pdf_bytes,
|
content=pdf_bytes,
|
||||||
media_type="application/pdf",
|
media_type="application/pdf",
|
||||||
headers={
|
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||||
"Content-Disposition": f"attachment; filename={filename}"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,5 +159,5 @@ async def get_project_time(
|
|||||||
"total_minutes": total_minutes,
|
"total_minutes": total_minutes,
|
||||||
"total_hours": round(total_minutes / 60, 2),
|
"total_hours": round(total_minutes / 60, 2),
|
||||||
"billable_minutes": billable_minutes,
|
"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
|
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 datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from db import opc_db
|
from db import opc_db
|
||||||
from rbac import require_page, User
|
from rbac import User
|
||||||
from routers import opc_ws
|
from routers import opc_ws
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -14,27 +16,27 @@ router = APIRouter()
|
|||||||
|
|
||||||
class TaskCreate(BaseModel):
|
class TaskCreate(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
description: Optional[str] = None
|
description: str | None = None
|
||||||
status: str = "parking_lot"
|
status: str = "parking_lot"
|
||||||
priority: str = "medium"
|
priority: str = "medium"
|
||||||
project_id: Optional[int] = None
|
project_id: int | None = None
|
||||||
assigned_to: Optional[str] = None
|
assigned_to: str | None = None
|
||||||
assigned_type: str = "human"
|
assigned_type: str = "human"
|
||||||
tags: Optional[List[str]] = None
|
tags: list[str] | None = None
|
||||||
due_date: Optional[datetime] = None
|
due_date: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskUpdate(BaseModel):
|
class TaskUpdate(BaseModel):
|
||||||
title: Optional[str] = None
|
title: str | None = None
|
||||||
description: Optional[str] = None
|
description: str | None = None
|
||||||
status: Optional[str] = None
|
status: str | None = None
|
||||||
priority: Optional[str] = None
|
priority: str | None = None
|
||||||
project_id: Optional[int] = None
|
project_id: int | None = None
|
||||||
assigned_to: Optional[str] = None
|
assigned_to: str | None = None
|
||||||
assigned_type: Optional[str] = None
|
assigned_type: str | None = None
|
||||||
tags: Optional[List[str]] = None
|
tags: list[str] | None = None
|
||||||
due_date: Optional[datetime] = None
|
due_date: datetime | None = None
|
||||||
completed_at: Optional[datetime] = None
|
completed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
class TaskMove(BaseModel):
|
class TaskMove(BaseModel):
|
||||||
@@ -49,11 +51,11 @@ class TaskAssign(BaseModel):
|
|||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
async def list_tasks(
|
async def list_tasks(
|
||||||
request: Request,
|
request: Request,
|
||||||
status: Optional[str] = Query(None),
|
status: str | None = Query(None),
|
||||||
assigned_to: Optional[str] = Query(None),
|
assigned_to: str | None = Query(None),
|
||||||
project_id: Optional[int] = Query(None),
|
project_id: int | None = Query(None),
|
||||||
priority: Optional[str] = Query(None),
|
priority: str | None = Query(None),
|
||||||
tags: Optional[str] = Query(None),
|
tags: str | None = Query(None),
|
||||||
limit: int = Query(50, le=100),
|
limit: int = Query(50, le=100),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
):
|
):
|
||||||
@@ -67,7 +69,7 @@ async def list_tasks(
|
|||||||
priority=priority,
|
priority=priority,
|
||||||
tags=tag_list,
|
tags=tag_list,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset
|
offset=offset,
|
||||||
)
|
)
|
||||||
return {"items": tasks, "total": len(tasks)}
|
return {"items": tasks, "total": len(tasks)}
|
||||||
|
|
||||||
@@ -91,16 +93,12 @@ async def create_task(
|
|||||||
assigned_type=task.assigned_type,
|
assigned_type=task.assigned_type,
|
||||||
tags=task.tags,
|
tags=task.tags,
|
||||||
due_date=due_date,
|
due_date=due_date,
|
||||||
actor=user.username
|
actor=user.username,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start automatic timer if status is in_progress
|
# Start automatic timer if status is in_progress
|
||||||
if task.status == "in_progress":
|
if task.status == "in_progress":
|
||||||
await opc_db.start_time_entry(
|
await opc_db.start_time_entry(task_id=created_task["id"], project_id=task.project_id, actor=user.username)
|
||||||
task_id=created_task["id"],
|
|
||||||
project_id=task.project_id,
|
|
||||||
actor=user.username
|
|
||||||
)
|
|
||||||
|
|
||||||
# Broadcast WebSocket update
|
# Broadcast WebSocket update
|
||||||
await opc_ws.broadcast_task_update(created_task["id"], "created", created_task)
|
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
|
# Start timer when moving to in_progress
|
||||||
if old_status != "in_progress" and new_status == "in_progress":
|
if old_status != "in_progress" and new_status == "in_progress":
|
||||||
await opc_db.start_time_entry(
|
await opc_db.start_time_entry(task_id=task_id, project_id=current_task.get("project_id"), actor=user.username)
|
||||||
task_id=task_id,
|
|
||||||
project_id=current_task.get("project_id"),
|
|
||||||
actor=user.username
|
|
||||||
)
|
|
||||||
|
|
||||||
# Stop timer when moving away from in_progress
|
# Stop timer when moving away from in_progress
|
||||||
if old_status == "in_progress" and new_status != "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:
|
if "completed_at" in updates and updates["completed_at"] is not None:
|
||||||
updates["completed_at"] = updates["completed_at"].replace(tzinfo=None)
|
updates["completed_at"] = updates["completed_at"].replace(tzinfo=None)
|
||||||
|
|
||||||
updated_task = await opc_db.update_task(
|
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||||
task_id=task_id,
|
|
||||||
updates=updates,
|
|
||||||
actor=user.username
|
|
||||||
)
|
|
||||||
|
|
||||||
# Broadcast WebSocket update
|
# Broadcast WebSocket update
|
||||||
await opc_ws.broadcast_task_update(task_id, "updated", updated_task)
|
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")
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
old_status = current_task["status"]
|
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
|
# Handle automatic time tracking
|
||||||
if old_status != "in_progress" and move.status == "in_progress":
|
if old_status != "in_progress" and move.status == "in_progress":
|
||||||
print(f"[Move] Task {task_id} moving to in_progress, old_status={old_status}")
|
print(f"[Move] Task {task_id} moving to in_progress, old_status={old_status}")
|
||||||
await opc_db.start_time_entry(
|
await opc_db.start_time_entry(task_id=task_id, project_id=current_task.get("project_id"), actor=user.username)
|
||||||
task_id=task_id,
|
|
||||||
project_id=current_task.get("project_id"),
|
|
||||||
actor=user.username
|
|
||||||
)
|
|
||||||
|
|
||||||
# Auto-assign to default agent (PM) if not already assigned
|
# Auto-assign to default agent (PM) if not already assigned
|
||||||
if not current_task.get("assigned_to"):
|
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")
|
print(f"[Move] Task {task_id} assigned to agent {agent_id}, checking for existing execution")
|
||||||
|
|
||||||
# Check if there's already a pending execution
|
# Check if there's already a pending execution
|
||||||
existing_executions = await opc_db.get_agent_executions(
|
existing_executions = await opc_db.get_agent_executions(task_id=task_id, status="pending", limit=1)
|
||||||
task_id=task_id,
|
|
||||||
status="pending",
|
|
||||||
limit=1
|
|
||||||
)
|
|
||||||
|
|
||||||
if not existing_executions:
|
if not existing_executions:
|
||||||
print(f"[Move] No pending execution found, creating one for agent {agent_id}")
|
print(f"[Move] No pending execution found, creating one for agent {agent_id}")
|
||||||
execution_id = await opc_db.create_agent_execution(
|
execution_id = await opc_db.create_agent_execution(
|
||||||
task_id=task_id,
|
task_id=task_id, agent_id=agent_id, actor=user.username
|
||||||
agent_id=agent_id,
|
|
||||||
actor=user.username
|
|
||||||
)
|
)
|
||||||
print(f"[Move] Created agent execution {execution_id} for task {task_id}")
|
print(f"[Move] Created agent execution {execution_id} for task {task_id}")
|
||||||
else:
|
else:
|
||||||
@@ -255,11 +237,7 @@ async def move_task(
|
|||||||
if move.status == "done":
|
if move.status == "done":
|
||||||
updates["completed_at"] = datetime.utcnow().replace(tzinfo=None)
|
updates["completed_at"] = datetime.utcnow().replace(tzinfo=None)
|
||||||
|
|
||||||
updated_task = await opc_db.update_task(
|
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||||
task_id=task_id,
|
|
||||||
updates=updates,
|
|
||||||
actor=user.username
|
|
||||||
)
|
|
||||||
return updated_task
|
return updated_task
|
||||||
|
|
||||||
|
|
||||||
@@ -271,24 +249,13 @@ async def assign_task(
|
|||||||
):
|
):
|
||||||
"""Assign task to human or agent"""
|
"""Assign task to human or agent"""
|
||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
updates = {
|
updates = {"assigned_to": assign.assigned_to, "assigned_type": assign.assigned_type}
|
||||||
"assigned_to": assign.assigned_to,
|
|
||||||
"assigned_type": assign.assigned_type
|
|
||||||
}
|
|
||||||
|
|
||||||
updated_task = await opc_db.update_task(
|
updated_task = await opc_db.update_task(task_id=task_id, updates=updates, actor=user.username)
|
||||||
task_id=task_id,
|
|
||||||
updates=updates,
|
|
||||||
actor=user.username
|
|
||||||
)
|
|
||||||
|
|
||||||
# If assigning to agent, create execution record
|
# If assigning to agent, create execution record
|
||||||
if assign.assigned_type == "agent":
|
if assign.assigned_type == "agent":
|
||||||
await opc_db.create_agent_execution(
|
await opc_db.create_agent_execution(task_id=task_id, agent_id=assign.assigned_to, actor=user.username)
|
||||||
task_id=task_id,
|
|
||||||
agent_id=assign.assigned_to,
|
|
||||||
actor=user.username
|
|
||||||
)
|
|
||||||
|
|
||||||
return updated_task
|
return updated_task
|
||||||
|
|
||||||
@@ -313,8 +280,4 @@ async def get_task_time(
|
|||||||
user: User = request.state.user
|
user: User = request.state.user
|
||||||
entries = await opc_db.get_time_entries(task_id=task_id)
|
entries = await opc_db.get_time_entries(task_id=task_id)
|
||||||
total_minutes = sum(e["duration_minutes"] for e in entries)
|
total_minutes = sum(e["duration_minutes"] for e in entries)
|
||||||
return {
|
return {"entries": entries, "total_minutes": total_minutes, "total_hours": round(total_minutes / 60, 2)}
|
||||||
"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
|
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
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
# Active WebSocket connections
|
# Active WebSocket connections
|
||||||
active_connections: Set[WebSocket] = set()
|
active_connections: set[WebSocket] = set()
|
||||||
|
|
||||||
|
|
||||||
def serialize_datetime(obj: Any) -> Any:
|
def serialize_datetime(obj: Any) -> Any:
|
||||||
@@ -33,7 +32,7 @@ class ConnectionManager:
|
|||||||
"""Manages WebSocket connections"""
|
"""Manages WebSocket connections"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.active_connections: Set[WebSocket] = set()
|
self.active_connections: set[WebSocket] = set()
|
||||||
|
|
||||||
async def connect(self, websocket: WebSocket):
|
async def connect(self, websocket: WebSocket):
|
||||||
"""Accept and register a new connection"""
|
"""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,
|
"task_id": task_id,
|
||||||
"action": action, # created, updated, moved, deleted
|
"action": action, # created, updated, moved, deleted
|
||||||
"data": serialized_data,
|
"data": serialized_data,
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
await manager.broadcast(message)
|
await manager.broadcast(message)
|
||||||
|
|
||||||
@@ -110,7 +109,7 @@ async def broadcast_agent_execution(execution_id: int, status: str, execution_da
|
|||||||
"execution_id": execution_id,
|
"execution_id": execution_id,
|
||||||
"status": status, # pending, running, completed, failed, pending_approval
|
"status": status, # pending, running, completed, failed, pending_approval
|
||||||
"data": serialized_data,
|
"data": serialized_data,
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
}
|
}
|
||||||
await manager.broadcast(message)
|
await manager.broadcast(message)
|
||||||
|
|
||||||
@@ -121,6 +120,6 @@ async def broadcast_agent_status(agent_id: str, status: str, message_text: str =
|
|||||||
"type": "agent_status",
|
"type": "agent_status",
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"status": status, # idle, working, error
|
"status": status, # idle, working, error
|
||||||
"message": message_text
|
"message": message_text,
|
||||||
}
|
}
|
||||||
await manager.broadcast(message)
|
await manager.broadcast(message)
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
"""WebAuthn Passkey authentication endpoints."""
|
"""WebAuthn Passkey authentication endpoints."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
from webauthn import (
|
from webauthn import (
|
||||||
generate_registration_options,
|
|
||||||
verify_registration_response,
|
|
||||||
generate_authentication_options,
|
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.structs import PublicKeyCredentialDescriptor, UserVerificationRequirement
|
||||||
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes, options_to_json
|
|
||||||
import auth_service as auth
|
import auth_service as auth
|
||||||
import config
|
import config
|
||||||
|
|
||||||
@@ -23,6 +26,7 @@ limiter = Limiter(key_func=get_remote_address)
|
|||||||
# In-memory challenge store with TTL
|
# In-memory challenge store with TTL
|
||||||
_challenges = {}
|
_challenges = {}
|
||||||
|
|
||||||
|
|
||||||
def _store_challenge(challenge: bytes):
|
def _store_challenge(challenge: bytes):
|
||||||
"""Store a WebAuthn challenge with timestamp for TTL tracking."""
|
"""Store a WebAuthn challenge with timestamp for TTL tracking."""
|
||||||
_challenges[challenge] = time.time()
|
_challenges[challenge] = time.time()
|
||||||
@@ -32,12 +36,13 @@ def _store_challenge(challenge: bytes):
|
|||||||
for k in expired:
|
for k in expired:
|
||||||
_challenges.pop(k, None)
|
_challenges.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
def _get_challenge(client_data_b64: str) -> bytes:
|
def _get_challenge(client_data_b64: str) -> bytes:
|
||||||
"""Extract and validate challenge from clientDataJSON."""
|
"""Extract and validate challenge from clientDataJSON."""
|
||||||
if not client_data_b64:
|
if not client_data_b64:
|
||||||
raise HTTPException(status_code=400, detail="Missing clientDataJSON")
|
raise HTTPException(status_code=400, detail="Missing clientDataJSON")
|
||||||
try:
|
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", ""))
|
chal_bytes = base64url_to_bytes(data.get("challenge", ""))
|
||||||
except Exception:
|
except Exception:
|
||||||
raise HTTPException(status_code=400, detail="Invalid clientDataJSON")
|
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")
|
raise HTTPException(status_code=400, detail="Challenge expired or invalid")
|
||||||
return chal_bytes
|
return chal_bytes
|
||||||
|
|
||||||
|
|
||||||
@router.post("/passkey/register/options")
|
@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."""
|
"""Generate WebAuthn registration options for adding a new passkey."""
|
||||||
existing = auth.load_passkey_credentials()
|
existing = auth.load_passkey_credentials()
|
||||||
exclude = [
|
exclude = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in existing]
|
||||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
|
|
||||||
for c in existing
|
|
||||||
]
|
|
||||||
options = generate_registration_options(
|
options = generate_registration_options(
|
||||||
rp_id=config.WEBAUTHN_RP_ID,
|
rp_id=config.WEBAUTHN_RP_ID,
|
||||||
rp_name="NAS Dashboard",
|
rp_name="NAS Dashboard",
|
||||||
@@ -66,8 +69,9 @@ async def passkey_register_options(current_user = Depends(auth.get_current_user)
|
|||||||
_store_challenge(options.challenge)
|
_store_challenge(options.challenge)
|
||||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/passkey/register/verify")
|
@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."""
|
"""Verify and save a new passkey registration."""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
challenge = _get_challenge(body.get("response", {}).get("clientDataJSON", ""))
|
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:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=400, detail=str(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),
|
"credential_id": bytes_to_base64url(verification.credential_id),
|
||||||
"sign_count": verification.sign_count,
|
"public_key": bytes_to_base64url(verification.credential_public_key),
|
||||||
"name": body.get("name", "Passkey"),
|
"sign_count": verification.sign_count,
|
||||||
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
"name": body.get("name", "Passkey"),
|
||||||
})
|
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||||
|
}
|
||||||
|
)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/passkey/login/options")
|
@router.post("/passkey/login/options")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def passkey_login_options(request: Request):
|
async def passkey_login_options(request: Request):
|
||||||
@@ -98,10 +105,7 @@ async def passkey_login_options(request: Request):
|
|||||||
if not creds:
|
if not creds:
|
||||||
raise HTTPException(status_code=404, detail="No passkeys registered")
|
raise HTTPException(status_code=404, detail="No passkeys registered")
|
||||||
|
|
||||||
allow = [
|
allow = [PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"])) for c in creds]
|
||||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
|
|
||||||
for c in creds
|
|
||||||
]
|
|
||||||
options = generate_authentication_options(
|
options = generate_authentication_options(
|
||||||
rp_id=config.WEBAUTHN_RP_ID,
|
rp_id=config.WEBAUTHN_RP_ID,
|
||||||
allow_credentials=allow,
|
allow_credentials=allow,
|
||||||
@@ -110,6 +114,7 @@ async def passkey_login_options(request: Request):
|
|||||||
_store_challenge(options.challenge)
|
_store_challenge(options.challenge)
|
||||||
return JSONResponse(content=json.loads(options_to_json(options)))
|
return JSONResponse(content=json.loads(options_to_json(options)))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/passkey/login/verify")
|
@router.post("/passkey/login/verify")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def passkey_login_verify(request: Request, response: Response):
|
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,
|
"refresh_token": refresh_token,
|
||||||
"token_type": "bearer",
|
"token_type": "bearer",
|
||||||
"user": username,
|
"user": username,
|
||||||
"role": "admin"
|
"role": "admin",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/passkey/list")
|
@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."""
|
"""List all registered passkeys for the current user."""
|
||||||
creds = auth.load_passkey_credentials()
|
creds = auth.load_passkey_credentials()
|
||||||
return {
|
return {
|
||||||
"passkeys": [
|
"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
|
for c in creds
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/passkey/delete")
|
@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."""
|
"""Delete a specific passkey by credential ID."""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
cred_id = body.get("id")
|
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)
|
auth._save_auth_data(data)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/passkey/clear")
|
@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."""
|
"""Clear all passkeys for the current user."""
|
||||||
auth.clear_passkey_credentials()
|
auth.clear_passkey_credentials()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import docker
|
|
||||||
import re
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone, timedelta, time
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from datetime import datetime, time, timedelta, timezone
|
||||||
|
|
||||||
|
import docker
|
||||||
from fastapi import APIRouter, Query
|
from fastapi import APIRouter, Query
|
||||||
|
|
||||||
from config import DOCKER_HOST
|
from config import DOCKER_HOST
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
def get_docker_client():
|
def get_docker_client():
|
||||||
"""Get or create Docker client (lazy initialization)."""
|
"""Get or create Docker client (lazy initialization)."""
|
||||||
global _client
|
global _client
|
||||||
@@ -17,6 +20,7 @@ def get_docker_client():
|
|||||||
_client = docker.DockerClient(base_url=DOCKER_HOST, timeout=10)
|
_client = docker.DockerClient(base_url=DOCKER_HOST, timeout=10)
|
||||||
return _client
|
return _client
|
||||||
|
|
||||||
|
|
||||||
_tz_cst = timezone(timedelta(hours=8))
|
_tz_cst = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
# Suspicious path patterns (scanners, exploits, etc.)
|
# Suspicious path patterns (scanners, exploits, etc.)
|
||||||
@@ -67,9 +71,8 @@ def _parse_authelia_logs(lines: str, limit: int) -> list[dict]:
|
|||||||
level = obj.get("level", "")
|
level = obj.get("level", "")
|
||||||
msg = obj.get("msg", "")
|
msg = obj.get("msg", "")
|
||||||
|
|
||||||
is_failed = (
|
is_failed = level in ("error", "warning") and any(
|
||||||
level in ("error", "warning")
|
kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied")
|
||||||
and any(kw in msg.lower() for kw in ("unsuccessful", "banned", "failed", "invalid", "denied"))
|
|
||||||
)
|
)
|
||||||
# Also catch "not authorized" for anonymous access attempts
|
# Also catch "not authorized" for anonymous access attempts
|
||||||
is_unauthorized = "is not authorized" in msg.lower()
|
is_unauthorized = "is not authorized" in msg.lower()
|
||||||
@@ -83,14 +86,16 @@ def _parse_authelia_logs(lines: str, limit: int) -> list[dict]:
|
|||||||
except Exception:
|
except Exception:
|
||||||
ts = ts_raw[:19] if ts_raw else ""
|
ts = ts_raw[:19] if ts_raw else ""
|
||||||
|
|
||||||
entries.append({
|
entries.append(
|
||||||
"type": "auth_failure" if is_failed else "unauthorized",
|
{
|
||||||
"timestamp": ts,
|
"type": "auth_failure" if is_failed else "unauthorized",
|
||||||
"message": msg,
|
"timestamp": ts,
|
||||||
"username": obj.get("username", obj.get("user", "")),
|
"message": msg,
|
||||||
"ip": obj.get("remote_ip", obj.get("ip", "")),
|
"username": obj.get("username", obj.get("user", "")),
|
||||||
"level": level,
|
"ip": obj.get("remote_ip", obj.get("ip", "")),
|
||||||
})
|
"level": level,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return entries[-limit:]
|
return entries[-limit:]
|
||||||
|
|
||||||
@@ -122,18 +127,24 @@ def _parse_caddy_logs(lines: str, limit: int) -> list[dict]:
|
|||||||
if isinstance(ts_raw, (int, float)):
|
if isinstance(ts_raw, (int, float)):
|
||||||
ts = datetime.fromtimestamp(ts_raw, tz=_tz_cst).strftime("%Y-%m-%d %H:%M:%S")
|
ts = datetime.fromtimestamp(ts_raw, tz=_tz_cst).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
else:
|
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:
|
except Exception:
|
||||||
ts = str(ts_raw)[:19]
|
ts = str(ts_raw)[:19]
|
||||||
|
|
||||||
entries.append({
|
entries.append(
|
||||||
"type": "suspicious_request",
|
{
|
||||||
"timestamp": ts,
|
"type": "suspicious_request",
|
||||||
"message": f"{status} {obj.get('request', {}).get('method', 'GET')} {uri}",
|
"timestamp": ts,
|
||||||
"ip": remote_ip,
|
"message": f"{status} {obj.get('request', {}).get('method', 'GET')} {uri}",
|
||||||
"status": status,
|
"ip": remote_ip,
|
||||||
"path": uri,
|
"status": status,
|
||||||
})
|
"path": uri,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return entries[-limit:]
|
return entries[-limit:]
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import psutil
|
|
||||||
import platform
|
import platform
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Query, Request, HTTPException
|
import psutil
|
||||||
|
from fastapi import APIRouter, HTTPException, Query, Request
|
||||||
|
|
||||||
import config
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -36,7 +38,15 @@ def system_stats():
|
|||||||
seen_devices.add(mount.device)
|
seen_devices.add(mount.device)
|
||||||
try:
|
try:
|
||||||
u = shutil.disk_usage(mount.mountpoint)
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
mem = psutil.virtual_memory()
|
mem = psutil.virtual_memory()
|
||||||
@@ -90,7 +100,18 @@ def audit_log(lines: int = Query(100, le=500)):
|
|||||||
level = _classify(method, path, status, user)
|
level = _classify(method, path, status, user)
|
||||||
if level == "noise":
|
if level == "noise":
|
||||||
continue
|
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}
|
return {"entries": entries}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import struct
|
|
||||||
import logging
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
import jwt
|
import struct
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from fastapi import WebSocket, Query
|
|
||||||
import asyncssh
|
import asyncssh
|
||||||
import config
|
import jwt
|
||||||
|
from fastapi import Query, WebSocket
|
||||||
|
|
||||||
import auth_service as auth
|
import auth_service as auth
|
||||||
|
import config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -61,16 +63,18 @@ def build_host_command(host_config: dict) -> str | None:
|
|||||||
f"bash -l -c 'cd /repos/nas-tools && tmux new-session -A -s {quoted_session}'"
|
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;",
|
"if",
|
||||||
"then",
|
f"{docker_bin} exec {quoted_container} tmux has-session -t {quoted_session} >/dev/null 2>&1;",
|
||||||
f"printf '%sattached\\n' {quoted_marker};",
|
"then",
|
||||||
"else",
|
f"printf '%sattached\\n' {quoted_marker};",
|
||||||
f"printf '%screated\\n' {quoted_marker};",
|
"else",
|
||||||
"fi;",
|
f"printf '%screated\\n' {quoted_marker};",
|
||||||
f"exec {attach_cmd}",
|
"fi;",
|
||||||
])
|
f"exec {attach_cmd}",
|
||||||
|
]
|
||||||
|
)
|
||||||
return f"bash -lc {shlex.quote(script)}"
|
return f"bash -lc {shlex.quote(script)}"
|
||||||
|
|
||||||
|
|
||||||
@@ -140,15 +144,17 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
|||||||
try:
|
try:
|
||||||
conn = await asyncio.wait_for(
|
conn = await asyncio.wait_for(
|
||||||
asyncssh.connect(
|
asyncssh.connect(
|
||||||
h["host"], username=h["user"],
|
h["host"],
|
||||||
client_keys=[h["key"]], known_hosts=_known_hosts,
|
username=h["user"],
|
||||||
|
client_keys=[h["key"]],
|
||||||
|
known_hosts=_known_hosts,
|
||||||
keepalive_interval=15,
|
keepalive_interval=15,
|
||||||
keepalive_count_max=8,
|
keepalive_count_max=8,
|
||||||
),
|
),
|
||||||
timeout=10.0
|
timeout=10.0,
|
||||||
)
|
)
|
||||||
logger.info("SSH connection established to %s", host)
|
logger.info("SSH connection established to %s", host)
|
||||||
except asyncio.TimeoutError:
|
except TimeoutError:
|
||||||
logger.error("SSH connection to %s timed out after 10s", host)
|
logger.error("SSH connection to %s timed out after 10s", host)
|
||||||
await _release_session(session_id)
|
await _release_session(session_id)
|
||||||
await websocket.close(code=1011, reason="SSH connection timeout")
|
await websocket.close(code=1011, reason="SSH connection timeout")
|
||||||
@@ -161,7 +167,9 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
process = await conn.create_process(
|
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,
|
encoding=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -212,7 +220,7 @@ async def ws_endpoint(websocket: WebSocket, host: str = Query("nas")):
|
|||||||
read_task.cancel()
|
read_task.cancel()
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(read_task, timeout=2.0)
|
await asyncio.wait_for(read_task, timeout=2.0)
|
||||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
except (TimeoutError, asyncio.CancelledError):
|
||||||
pass
|
pass
|
||||||
process.close()
|
process.close()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1,31 +1,34 @@
|
|||||||
"""TOTP (Time-based One-Time Password) 2FA endpoints."""
|
"""TOTP (Time-based One-Time Password) 2FA endpoints."""
|
||||||
|
|
||||||
|
import pyotp
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import pyotp
|
|
||||||
import auth_service as auth
|
import auth_service as auth
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class Setup2FAResponse(BaseModel):
|
class Setup2FAResponse(BaseModel):
|
||||||
secret: str
|
secret: str
|
||||||
uri: str
|
uri: str
|
||||||
|
|
||||||
|
|
||||||
class Verify2FARequest(BaseModel):
|
class Verify2FARequest(BaseModel):
|
||||||
secret: str
|
secret: str
|
||||||
code: str
|
code: str
|
||||||
|
|
||||||
|
|
||||||
@router.post("/setup-2fa/generate", response_model=Setup2FAResponse)
|
@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."""
|
"""Generate a new TOTP secret and provisioning URI for 2FA setup."""
|
||||||
secret = pyotp.random_base32()
|
secret = pyotp.random_base32()
|
||||||
uri = pyotp.totp.TOTP(secret).provisioning_uri(
|
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=current_user.username, issuer_name="NAS Dashboard")
|
||||||
name=current_user.username,
|
|
||||||
issuer_name="NAS Dashboard"
|
|
||||||
)
|
|
||||||
return {"secret": secret, "uri": uri}
|
return {"secret": secret, "uri": uri}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/setup-2fa/verify")
|
@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."""
|
"""Verify TOTP code and enable 2FA for the user."""
|
||||||
totp = pyotp.TOTP(request.secret)
|
totp = pyotp.TOTP(request.secret)
|
||||||
if not totp.verify(request.code):
|
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)
|
auth.save_totp_secret(request.secret)
|
||||||
return {"message": "2FA enabled successfully"}
|
return {"message": "2FA enabled successfully"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/setup-2fa/disable")
|
@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."""
|
"""Disable 2FA for the user."""
|
||||||
auth.save_totp_secret("")
|
auth.save_totp_secret("")
|
||||||
return {"message": "2FA disabled"}
|
return {"message": "2FA disabled"}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Agent Executor Service - Executes agent tasks and manages their lifecycle
|
Agent Executor Service - Executes agent tasks and manages their lifecycle
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
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
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -22,7 +25,7 @@ class AgentExecutor:
|
|||||||
"""Manages agent execution lifecycle"""
|
"""Manages agent execution lifecycle"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.agents: Dict[str, BaseAgent] = {}
|
self.agents: dict[str, BaseAgent] = {}
|
||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
@@ -35,7 +38,7 @@ class AgentExecutor:
|
|||||||
name=agent_data["name"],
|
name=agent_data["name"],
|
||||||
role=agent_data["role"],
|
role=agent_data["role"],
|
||||||
system_prompt=agent_data["system_prompt"],
|
system_prompt=agent_data["system_prompt"],
|
||||||
config=agent_data["config"]
|
config=agent_data["config"],
|
||||||
)
|
)
|
||||||
self.agents[agent_data["id"]] = agent
|
self.agents[agent_data["id"]] = agent
|
||||||
logger.info(f"Loaded agent: {agent.name} ({agent.agent_id})")
|
logger.info(f"Loaded agent: {agent.name} ({agent.agent_id})")
|
||||||
@@ -54,6 +57,7 @@ class AgentExecutor:
|
|||||||
print(f"[Executor] Error in executor loop: {e}")
|
print(f"[Executor] Error in executor loop: {e}")
|
||||||
logger.error(f"Error in executor loop: {e}")
|
logger.error(f"Error in executor loop: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
await asyncio.sleep(5) # Check every 5 seconds
|
await asyncio.sleep(5) # Check every 5 seconds
|
||||||
@@ -74,9 +78,11 @@ class AgentExecutor:
|
|||||||
|
|
||||||
for execution in executions:
|
for execution in executions:
|
||||||
try:
|
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
|
# Resume approved execution
|
||||||
await self._resume_approved_execution(execution)
|
await self._resume_approved_execution(execution)
|
||||||
else:
|
else:
|
||||||
@@ -87,7 +93,7 @@ class AgentExecutor:
|
|||||||
logger.error(f"Failed to execute agent {execution['agent_id']}: {e}")
|
logger.error(f"Failed to execute agent {execution['agent_id']}: {e}")
|
||||||
await self._mark_execution_failed(execution["id"], str(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"""
|
"""Execute a single agent task"""
|
||||||
agent_id = execution["agent_id"]
|
agent_id = execution["agent_id"]
|
||||||
task_id = execution["task_id"]
|
task_id = execution["task_id"]
|
||||||
@@ -127,7 +133,7 @@ class AgentExecutor:
|
|||||||
# Send completion notification
|
# Send completion notification
|
||||||
await self._send_completion_notification(agent, task, result)
|
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"""
|
"""Resume an approved execution and execute its actions"""
|
||||||
agent_id = execution["agent_id"]
|
agent_id = execution["agent_id"]
|
||||||
task_id = execution["task_id"]
|
task_id = execution["task_id"]
|
||||||
@@ -160,12 +166,9 @@ class AgentExecutor:
|
|||||||
# Send completion notification
|
# Send completion notification
|
||||||
await self._send_completion_notification(agent, task, result)
|
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"""
|
"""Build context for agent execution"""
|
||||||
context = {
|
context = {"task": task, "timestamp": datetime.utcnow().isoformat()}
|
||||||
"task": task,
|
|
||||||
"timestamp": datetime.utcnow().isoformat()
|
|
||||||
}
|
|
||||||
|
|
||||||
# Get related tasks from same project
|
# Get related tasks from same project
|
||||||
if task.get("project_id"):
|
if task.get("project_id"):
|
||||||
@@ -178,7 +181,7 @@ class AgentExecutor:
|
|||||||
|
|
||||||
return context
|
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"""
|
"""Execute proposed actions"""
|
||||||
for action in actions:
|
for action in actions:
|
||||||
action_type = action.get("type")
|
action_type = action.get("type")
|
||||||
@@ -203,7 +206,7 @@ class AgentExecutor:
|
|||||||
logger.error(f"Failed to execute action {action_type}: {e}")
|
logger.error(f"Failed to execute action {action_type}: {e}")
|
||||||
raise
|
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"""
|
"""Create a subtask"""
|
||||||
await opc_db.create_task(
|
await opc_db.create_task(
|
||||||
title=action.get("title", "Subtask"),
|
title=action.get("title", "Subtask"),
|
||||||
@@ -212,48 +215,52 @@ class AgentExecutor:
|
|||||||
priority=parent_task.get("priority", "medium"),
|
priority=parent_task.get("priority", "medium"),
|
||||||
project_id=parent_task.get("project_id"),
|
project_id=parent_task.get("project_id"),
|
||||||
tags=parent_task.get("tags", []),
|
tags=parent_task.get("tags", []),
|
||||||
actor="agent"
|
actor="agent",
|
||||||
)
|
)
|
||||||
logger.info(f"Created subtask: {action.get('title')}")
|
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"""
|
"""Update task status"""
|
||||||
new_status = action.get("status")
|
new_status = action.get("status")
|
||||||
if new_status:
|
if new_status:
|
||||||
await opc_db.update_task(
|
await opc_db.update_task(task_id=task["id"], updates={"status": new_status}, actor="agent")
|
||||||
task_id=task["id"],
|
|
||||||
updates={"status": new_status},
|
|
||||||
actor="agent"
|
|
||||||
)
|
|
||||||
logger.info(f"Updated task {task['id']} status to {new_status}")
|
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"""
|
"""Send notification"""
|
||||||
message = action.get("message", "")
|
message = action.get("message", "")
|
||||||
if message and TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
|
if message and TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
|
||||||
await self._send_telegram(message)
|
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)"""
|
"""Add comment to task (stored in history)"""
|
||||||
comment = action.get("comment", "")
|
comment = action.get("comment", "")
|
||||||
if comment:
|
if comment:
|
||||||
# Store as task history
|
# Store as task history
|
||||||
pool = await opc_db.get_pool()
|
pool = await opc_db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
INSERT INTO task_history (task_id, action, actor, actor_type, new_value)
|
INSERT INTO task_history (task_id, action, actor, actor_type, new_value)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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):
|
async def _update_execution_status(self, execution_id: int, status: str):
|
||||||
"""Update execution status"""
|
"""Update execution status"""
|
||||||
await opc_db.update_execution_status(execution_id, 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"""
|
"""Mark execution as completed"""
|
||||||
pool = await opc_db.get_pool()
|
pool = await opc_db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
UPDATE agent_executions
|
UPDATE agent_executions
|
||||||
SET status = $1,
|
SET status = $1,
|
||||||
output_result = $2,
|
output_result = $2,
|
||||||
@@ -261,36 +268,46 @@ class AgentExecutor:
|
|||||||
actions_executed = $3,
|
actions_executed = $3,
|
||||||
completed_at = CURRENT_TIMESTAMP
|
completed_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
""", "completed",
|
""",
|
||||||
|
"completed",
|
||||||
opc_db.json.dumps(result),
|
opc_db.json.dumps(result),
|
||||||
opc_db.json.dumps(result.get("actions", [])),
|
opc_db.json.dumps(result.get("actions", [])),
|
||||||
execution_id)
|
execution_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def _mark_execution_failed(self, execution_id: int, error: str):
|
async def _mark_execution_failed(self, execution_id: int, error: str):
|
||||||
"""Mark execution as failed"""
|
"""Mark execution as failed"""
|
||||||
pool = await opc_db.get_pool()
|
pool = await opc_db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
UPDATE agent_executions
|
UPDATE agent_executions
|
||||||
SET status = $1, error_message = $2, completed_at = CURRENT_TIMESTAMP
|
SET status = $1, error_message = $2, completed_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $3
|
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"""
|
"""Mark execution as pending approval"""
|
||||||
pool = await opc_db.get_pool()
|
pool = await opc_db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
await conn.execute("""
|
await conn.execute(
|
||||||
|
"""
|
||||||
UPDATE agent_executions
|
UPDATE agent_executions
|
||||||
SET status = $1,
|
SET status = $1,
|
||||||
output_result = $2,
|
output_result = $2,
|
||||||
actions_proposed = $3,
|
actions_proposed = $3,
|
||||||
requires_approval = TRUE
|
requires_approval = TRUE
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
""", "pending_approval",
|
""",
|
||||||
|
"pending_approval",
|
||||||
opc_db.json.dumps(result),
|
opc_db.json.dumps(result),
|
||||||
opc_db.json.dumps(result.get("actions", [])),
|
opc_db.json.dumps(result.get("actions", [])),
|
||||||
execution_id)
|
execution_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def _send_telegram(self, message: str):
|
async def _send_telegram(self, message: str):
|
||||||
"""Send Telegram notification"""
|
"""Send Telegram notification"""
|
||||||
@@ -299,16 +316,12 @@ class AgentExecutor:
|
|||||||
async with httpx.AsyncClient(proxy=proxy_url, timeout=10.0) as client:
|
async with httpx.AsyncClient(proxy=proxy_url, timeout=10.0) as client:
|
||||||
await client.post(
|
await client.post(
|
||||||
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||||
data={
|
data={"chat_id": TELEGRAM_CHAT_ID, "text": message, "parse_mode": "Markdown"},
|
||||||
"chat_id": TELEGRAM_CHAT_ID,
|
|
||||||
"text": message,
|
|
||||||
"parse_mode": "Markdown"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send Telegram notification: {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"""
|
"""Send notification for approval request"""
|
||||||
message = f"""🤖 *Agent Approval Required*
|
message = f"""🤖 *Agent Approval Required*
|
||||||
|
|
||||||
@@ -331,11 +344,11 @@ Proposed Actions:
|
|||||||
await email_service.send_agent_approval_email(
|
await email_service.send_agent_approval_email(
|
||||||
agent_name=agent.name,
|
agent_name=agent.name,
|
||||||
task=task,
|
task=task,
|
||||||
reasoning=result.get('reasoning', 'No reasoning provided'),
|
reasoning=result.get("reasoning", "No reasoning provided"),
|
||||||
actions=result.get('actions', [])
|
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"""
|
"""Send notification for completed execution"""
|
||||||
message = f"""✅ *Agent Task Completed*
|
message = f"""✅ *Agent Task Completed*
|
||||||
|
|
||||||
@@ -349,14 +362,12 @@ Actions Executed: {len(result.get('actions', []))}
|
|||||||
|
|
||||||
# Send Email
|
# Send Email
|
||||||
await email_service.send_agent_completion_email(
|
await email_service.send_agent_completion_email(
|
||||||
agent_name=agent.name,
|
agent_name=agent.name, task=task, actions_count=len(result.get("actions", []))
|
||||||
task=task,
|
|
||||||
actions_count=len(result.get('actions', []))
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Global executor instance
|
# Global executor instance
|
||||||
_executor: Optional[AgentExecutor] = None
|
_executor: AgentExecutor | None = None
|
||||||
|
|
||||||
|
|
||||||
async def get_executor() -> AgentExecutor:
|
async def get_executor() -> AgentExecutor:
|
||||||
@@ -382,5 +393,6 @@ async def start_executor():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR in start_executor: {e}")
|
print(f"ERROR in start_executor: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Base Agent Class - Foundation for all OPC agents
|
Base Agent Class - Foundation for all OPC agents
|
||||||
"""
|
"""
|
||||||
from typing import Dict, Any, List, Optional
|
|
||||||
import json
|
import json
|
||||||
import httpx
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
class BaseAgent:
|
class BaseAgent:
|
||||||
"""Base class for all OPC agents"""
|
"""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.agent_id = agent_id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.role = role
|
self.role = role
|
||||||
@@ -20,7 +21,7 @@ class BaseAgent:
|
|||||||
self.litellm_url = os.getenv("LITELLM_URL", "http://litellm:4005")
|
self.litellm_url = os.getenv("LITELLM_URL", "http://litellm:4005")
|
||||||
self.litellm_api_key = os.getenv("LITELLM_API_KEY", "")
|
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
|
Execute agent on a task
|
||||||
Returns: {
|
Returns: {
|
||||||
@@ -40,7 +41,7 @@ class BaseAgent:
|
|||||||
|
|
||||||
return result
|
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"""
|
"""Build the prompt for the LLM"""
|
||||||
prompt = f"""You are {self.name}, a {self.role} agent.
|
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:
|
async def _call_llm(self, prompt: str) -> str:
|
||||||
"""Call LiteLLM proxy"""
|
"""Call LiteLLM proxy"""
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
model = self.config.get("model", "claude-sonnet-4-6")
|
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,
|
"model": model,
|
||||||
"messages": [
|
"messages": [
|
||||||
{"role": "system", "content": self.system_prompt},
|
{"role": "system", "content": self.system_prompt},
|
||||||
{"role": "user", "content": prompt}
|
{"role": "user", "content": prompt},
|
||||||
],
|
],
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
"max_tokens": 2000
|
"max_tokens": 2000,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
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}")
|
logger.error(f"LiteLLM call failed: {e}")
|
||||||
raise Exception(f"LLM call failed: {str(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"""
|
"""Parse LLM response and extract actions"""
|
||||||
try:
|
try:
|
||||||
# Try to extract JSON from response
|
# 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
|
# Fallback: return error action
|
||||||
return {
|
return {
|
||||||
"reasoning": f"Failed to parse response: {str(e)}",
|
"reasoning": f"Failed to parse response: {str(e)}",
|
||||||
"actions": [
|
"actions": [{"type": "error", "message": f"Agent response parsing failed: {str(e)}"}],
|
||||||
{
|
"requires_approval": False,
|
||||||
"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"""
|
"""Get agent capabilities"""
|
||||||
return self.config.get("capabilities", [])
|
return self.config.get("capabilities", [])
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Email notification service for OPC
|
Email notification service for OPC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import smtplib
|
import smtplib
|
||||||
from email.mime.text import MIMEText
|
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
import logging
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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"""
|
"""Send email for agent approval request"""
|
||||||
subject = f"Agent Approval Required: {agent_name}"
|
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:
|
body = f"""Agent approval required:
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
"""
|
"""
|
||||||
PDF Invoice Generation Service
|
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
|
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(
|
def generate_invoice_pdf(
|
||||||
invoice_number: str,
|
invoice_number: str,
|
||||||
client: Dict[str, Any],
|
client: dict[str, Any],
|
||||||
time_entries: List[Dict[str, Any]],
|
time_entries: list[dict[str, Any]],
|
||||||
hourly_rate: float = 100.0,
|
hourly_rate: float = 100.0,
|
||||||
company_name: str = "Your Company",
|
company_name: str = "Your Company",
|
||||||
company_address: str = "",
|
company_address: str = "",
|
||||||
company_email: str = "",
|
company_email: str = "",
|
||||||
company_phone: str = ""
|
company_phone: str = "",
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
Generate PDF invoice from time entries
|
Generate PDF invoice from time entries
|
||||||
@@ -34,18 +35,18 @@ def generate_invoice_pdf(
|
|||||||
|
|
||||||
# Custom styles
|
# Custom styles
|
||||||
title_style = ParagraphStyle(
|
title_style = ParagraphStyle(
|
||||||
'CustomTitle',
|
"CustomTitle",
|
||||||
parent=styles['Heading1'],
|
parent=styles["Heading1"],
|
||||||
fontSize=24,
|
fontSize=24,
|
||||||
textColor=colors.HexColor('#1F2937'),
|
textColor=colors.HexColor("#1F2937"),
|
||||||
spaceAfter=30,
|
spaceAfter=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
header_style = ParagraphStyle(
|
header_style = ParagraphStyle(
|
||||||
'Header',
|
"Header",
|
||||||
parent=styles['Normal'],
|
parent=styles["Normal"],
|
||||||
fontSize=10,
|
fontSize=10,
|
||||||
textColor=colors.HexColor('#6B7280'),
|
textColor=colors.HexColor("#6B7280"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Company Header
|
# Company Header
|
||||||
@@ -61,10 +62,10 @@ def generate_invoice_pdf(
|
|||||||
|
|
||||||
# Invoice Title
|
# Invoice Title
|
||||||
invoice_title = ParagraphStyle(
|
invoice_title = ParagraphStyle(
|
||||||
'InvoiceTitle',
|
"InvoiceTitle",
|
||||||
parent=styles['Heading2'],
|
parent=styles["Heading2"],
|
||||||
fontSize=18,
|
fontSize=18,
|
||||||
textColor=colors.HexColor('#4F46E5'),
|
textColor=colors.HexColor("#4F46E5"),
|
||||||
)
|
)
|
||||||
elements.append(Paragraph("INVOICE", invoice_title))
|
elements.append(Paragraph("INVOICE", invoice_title))
|
||||||
elements.append(Spacer(1, 0.2 * inch))
|
elements.append(Spacer(1, 0.2 * inch))
|
||||||
@@ -74,96 +75,93 @@ def generate_invoice_pdf(
|
|||||||
invoice_data = [
|
invoice_data = [
|
||||||
["Invoice Number:", invoice_number],
|
["Invoice Number:", invoice_number],
|
||||||
["Invoice Date:", invoice_date],
|
["Invoice Date:", invoice_date],
|
||||||
["Client:", client.get('name', 'N/A')],
|
["Client:", client.get("name", "N/A")],
|
||||||
]
|
]
|
||||||
|
|
||||||
if client.get('company'):
|
if client.get("company"):
|
||||||
invoice_data.append(["Company:", client['company']])
|
invoice_data.append(["Company:", client["company"]])
|
||||||
if client.get('email'):
|
if client.get("email"):
|
||||||
invoice_data.append(["Email:", client['email']])
|
invoice_data.append(["Email:", client["email"]])
|
||||||
|
|
||||||
invoice_table = Table(invoice_data, colWidths=[2*inch, 4*inch])
|
invoice_table = Table(invoice_data, colWidths=[2 * inch, 4 * inch])
|
||||||
invoice_table.setStyle(TableStyle([
|
invoice_table.setStyle(
|
||||||
('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
|
TableStyle(
|
||||||
('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
|
[
|
||||||
('FONTSIZE', (0, 0), (-1, -1), 10),
|
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
||||||
('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#374151')),
|
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
|
||||||
('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#1F2937')),
|
("FONTSIZE", (0, 0), (-1, -1), 10),
|
||||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
("TEXTCOLOR", (0, 0), (0, -1), colors.HexColor("#374151")),
|
||||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
("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(invoice_table)
|
||||||
elements.append(Spacer(1, 0.4 * inch))
|
elements.append(Spacer(1, 0.4 * inch))
|
||||||
|
|
||||||
# Time Entries Table
|
# Time Entries Table
|
||||||
table_data = [
|
table_data = [["Date", "Description", "Hours", "Rate", "Amount"]]
|
||||||
["Date", "Description", "Hours", "Rate", "Amount"]
|
|
||||||
]
|
|
||||||
|
|
||||||
total_hours = 0
|
total_hours = 0
|
||||||
total_amount = 0
|
total_amount = 0
|
||||||
|
|
||||||
for entry in time_entries:
|
for entry in time_entries:
|
||||||
hours = entry['duration_minutes'] / 60
|
hours = entry["duration_minutes"] / 60
|
||||||
rate = entry.get('hourly_rate', hourly_rate)
|
rate = entry.get("hourly_rate", hourly_rate)
|
||||||
amount = hours * rate
|
amount = hours * rate
|
||||||
|
|
||||||
total_hours += hours
|
total_hours += hours
|
||||||
total_amount += amount
|
total_amount += amount
|
||||||
|
|
||||||
date_str = entry['started_at'].strftime("%Y-%m-%d") if entry.get('started_at') else "N/A"
|
date_str = entry["started_at"].strftime("%Y-%m-%d") if entry.get("started_at") else "N/A"
|
||||||
desc = entry.get('description', 'Work performed')
|
desc = entry.get("description", "Work performed")
|
||||||
|
|
||||||
table_data.append([
|
table_data.append(
|
||||||
date_str,
|
[date_str, desc[:50] + "..." if len(desc) > 50 else desc, f"{hours:.2f}", f"${rate:.2f}", f"${amount:.2f}"]
|
||||||
desc[:50] + "..." if len(desc) > 50 else desc,
|
)
|
||||||
f"{hours:.2f}",
|
|
||||||
f"${rate:.2f}",
|
|
||||||
f"${amount:.2f}"
|
|
||||||
])
|
|
||||||
|
|
||||||
# Add totals row
|
# Add totals row
|
||||||
table_data.append([
|
table_data.append(["", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}"])
|
||||||
"", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}"
|
|
||||||
])
|
|
||||||
|
|
||||||
# Create table
|
# Create table
|
||||||
time_table = Table(table_data, colWidths=[1.2*inch, 2.8*inch, 0.8*inch, 0.8*inch, 1*inch])
|
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.setStyle(
|
||||||
# Header row
|
TableStyle(
|
||||||
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4F46E5')),
|
[
|
||||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
# Header row
|
||||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4F46E5")),
|
||||||
('FONTSIZE', (0, 0), (-1, 0), 11),
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
||||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||||
|
("FONTSIZE", (0, 0), (-1, 0), 11),
|
||||||
# Data rows
|
("BOTTOMPADDING", (0, 0), (-1, 0), 12),
|
||||||
('FONTNAME', (0, 1), (-1, -2), 'Helvetica'),
|
# Data rows
|
||||||
('FONTSIZE', (0, 1), (-1, -2), 9),
|
("FONTNAME", (0, 1), (-1, -2), "Helvetica"),
|
||||||
('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#F9FAFB')]),
|
("FONTSIZE", (0, 1), (-1, -2), 9),
|
||||||
('GRID', (0, 0), (-1, -2), 0.5, colors.HexColor('#E5E7EB')),
|
("ROWBACKGROUNDS", (0, 1), (-1, -2), [colors.white, colors.HexColor("#F9FAFB")]),
|
||||||
|
("GRID", (0, 0), (-1, -2), 0.5, colors.HexColor("#E5E7EB")),
|
||||||
# Total row
|
# Total row
|
||||||
('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#F3F4F6')),
|
("BACKGROUND", (0, -1), (-1, -1), colors.HexColor("#F3F4F6")),
|
||||||
('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'),
|
("FONTNAME", (0, -1), (-1, -1), "Helvetica-Bold"),
|
||||||
('FONTSIZE', (0, -1), (-1, -1), 11),
|
("FONTSIZE", (0, -1), (-1, -1), 11),
|
||||||
('TOPPADDING', (0, -1), (-1, -1), 12),
|
("TOPPADDING", (0, -1), (-1, -1), 12),
|
||||||
('BOTTOMPADDING', (0, -1), (-1, -1), 12),
|
("BOTTOMPADDING", (0, -1), (-1, -1), 12),
|
||||||
|
# Alignment
|
||||||
# Alignment
|
("ALIGN", (2, 0), (-1, -1), "RIGHT"),
|
||||||
('ALIGN', (2, 0), (-1, -1), 'RIGHT'),
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
]
|
||||||
]))
|
)
|
||||||
|
)
|
||||||
|
|
||||||
elements.append(time_table)
|
elements.append(time_table)
|
||||||
elements.append(Spacer(1, 0.5 * inch))
|
elements.append(Spacer(1, 0.5 * inch))
|
||||||
|
|
||||||
# Payment Terms
|
# Payment Terms
|
||||||
terms_style = ParagraphStyle(
|
terms_style = ParagraphStyle(
|
||||||
'Terms',
|
"Terms",
|
||||||
parent=styles['Normal'],
|
parent=styles["Normal"],
|
||||||
fontSize=9,
|
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(Paragraph("<b>Payment Terms:</b> Due within 30 days", terms_style))
|
||||||
elements.append(Spacer(1, 0.1 * inch))
|
elements.append(Spacer(1, 0.1 * inch))
|
||||||
@@ -179,10 +177,7 @@ def generate_invoice_pdf(
|
|||||||
|
|
||||||
|
|
||||||
def generate_invoice_from_project(
|
def generate_invoice_from_project(
|
||||||
project_id: int,
|
project_id: int, client: dict[str, Any], time_entries: list[dict[str, Any]], invoice_number: str = None
|
||||||
client: Dict[str, Any],
|
|
||||||
time_entries: List[Dict[str, Any]],
|
|
||||||
invoice_number: str = None
|
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""Generate invoice for a project"""
|
"""Generate invoice for a project"""
|
||||||
if not invoice_number:
|
if not invoice_number:
|
||||||
@@ -195,5 +190,5 @@ def generate_invoice_from_project(
|
|||||||
company_name="Your OPC",
|
company_name="Your OPC",
|
||||||
company_address="123 Business St, City, State 12345",
|
company_address="123 Business St, City, State 12345",
|
||||||
company_email="billing@youropc.com",
|
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.
|
Shared pytest fixtures for backend tests.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
import pytest
|
import os
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
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
|
@pytest.fixture
|
||||||
@@ -30,8 +41,10 @@ def mock_config(temp_volume_root, monkeypatch):
|
|||||||
monkeypatch.setenv("GITEA_TOKEN", "mock-token")
|
monkeypatch.setenv("GITEA_TOKEN", "mock-token")
|
||||||
|
|
||||||
# Reload config module to pick up new env vars
|
# Reload config module to pick up new env vars
|
||||||
import config
|
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
importlib.reload(config)
|
importlib.reload(config)
|
||||||
|
|
||||||
return config
|
return config
|
||||||
@@ -41,12 +54,7 @@ def mock_config(temp_volume_root, monkeypatch):
|
|||||||
def temp_auth_file(temp_volume_root):
|
def temp_auth_file(temp_volume_root):
|
||||||
"""Create temporary auth.json file."""
|
"""Create temporary auth.json file."""
|
||||||
auth_file = os.path.join(temp_volume_root, "docker/nas-dashboard/auth.json")
|
auth_file = os.path.join(temp_volume_root, "docker/nas-dashboard/auth.json")
|
||||||
data = {
|
data = {"totp_secret": "", "passkey_credentials": [], "token_version": 0, "last_totp_ts": 0}
|
||||||
"totp_secret": "",
|
|
||||||
"passkey_credentials": [],
|
|
||||||
"token_version": 0,
|
|
||||||
"last_totp_ts": 0
|
|
||||||
}
|
|
||||||
with open(auth_file, "w") as f:
|
with open(auth_file, "w") as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
return auth_file
|
return auth_file
|
||||||
@@ -60,9 +68,9 @@ def temp_rbac_file(temp_volume_root):
|
|||||||
"role_defaults": {
|
"role_defaults": {
|
||||||
"admin": {"pages": "*", "sidebar_links": "*"},
|
"admin": {"pages": "*", "sidebar_links": "*"},
|
||||||
"member": {"pages": ["dashboard", "docker"], "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:
|
with open(rbac_file, "w") as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
@@ -73,16 +81,15 @@ def temp_rbac_file(temp_volume_root):
|
|||||||
def valid_access_token(mock_config):
|
def valid_access_token(mock_config):
|
||||||
"""Generate a valid access token for testing."""
|
"""Generate a valid access token for testing."""
|
||||||
from auth_service import create_access_token
|
from auth_service import create_access_token
|
||||||
return create_access_token(
|
|
||||||
data={"sub": "testuser", "role": "admin"},
|
return create_access_token(data={"sub": "testuser", "role": "admin"}, expires_delta=timedelta(minutes=30))
|
||||||
expires_delta=timedelta(minutes=30)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def valid_refresh_token(mock_config, temp_auth_file):
|
def valid_refresh_token(mock_config, temp_auth_file):
|
||||||
"""Generate a valid refresh token for testing."""
|
"""Generate a valid refresh token for testing."""
|
||||||
from auth_service import create_refresh_token
|
from auth_service import create_refresh_token
|
||||||
|
|
||||||
return create_refresh_token(data={"sub": "testuser", "role": "admin"})
|
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):
|
def expired_access_token(mock_config):
|
||||||
"""Generate an expired access token for testing."""
|
"""Generate an expired access token for testing."""
|
||||||
from auth_service import create_access_token
|
from auth_service import create_access_token
|
||||||
return create_access_token(
|
|
||||||
data={"sub": "testuser", "role": "admin"},
|
return create_access_token(data={"sub": "testuser", "role": "admin"}, expires_delta=timedelta(seconds=-1))
|
||||||
expires_delta=timedelta(seconds=-1)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -118,10 +123,7 @@ def mock_docker_client():
|
|||||||
container.status = "running"
|
container.status = "running"
|
||||||
container.image = mock_image
|
container.image = mock_image
|
||||||
container.ports = {"80/tcp": [{"HostPort": "8080"}]}
|
container.ports = {"80/tcp": [{"HostPort": "8080"}]}
|
||||||
container.attrs = {
|
container.attrs = {"State": {"Health": {"Status": "healthy"}}, "Config": {"Image": "test-image:latest"}}
|
||||||
"State": {"Health": {"Status": "healthy"}},
|
|
||||||
"Config": {"Image": "test-image:latest"}
|
|
||||||
}
|
|
||||||
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
||||||
|
|
||||||
client.containers.list.return_value = [container]
|
client.containers.list.return_value = [container]
|
||||||
@@ -170,8 +172,13 @@ def mock_docker_client_factory():
|
|||||||
image_tag="my-image:v1.0"
|
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()
|
client = Mock()
|
||||||
|
|
||||||
# Mock container image
|
# Mock container image
|
||||||
@@ -187,10 +194,7 @@ def mock_docker_client_factory():
|
|||||||
container.status = container_status
|
container.status = container_status
|
||||||
container.image = mock_image
|
container.image = mock_image
|
||||||
container.ports = {"80/tcp": [{"HostPort": "8080"}]}
|
container.ports = {"80/tcp": [{"HostPort": "8080"}]}
|
||||||
container.attrs = {
|
container.attrs = {"State": {"Health": {"Status": "healthy"}}, "Config": {"Image": image_tag}}
|
||||||
"State": {"Health": {"Status": "healthy"}},
|
|
||||||
"Config": {"Image": image_tag}
|
|
||||||
}
|
|
||||||
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
||||||
|
|
||||||
client.containers.list.return_value = [container]
|
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):
|
def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client):
|
||||||
"""Create FastAPI test client with mocked dependencies."""
|
"""Create FastAPI test client with mocked dependencies."""
|
||||||
# Reload routers to pick up new config values
|
# Reload routers to pick up new config values
|
||||||
import routers.files
|
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
import routers.files
|
||||||
|
|
||||||
importlib.reload(routers.files)
|
importlib.reload(routers.files)
|
||||||
|
|
||||||
# Patch Docker client before importing main
|
# 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):
|
with patch("slowapi.Limiter", return_value=mock_limiter):
|
||||||
from main import app
|
from main import app
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
yield client
|
yield client
|
||||||
|
|
||||||
@@ -258,14 +265,11 @@ def mock_rbac_data():
|
|||||||
"role_defaults": {
|
"role_defaults": {
|
||||||
"admin": {"pages": "*", "sidebar_links": "*"},
|
"admin": {"pages": "*", "sidebar_links": "*"},
|
||||||
"member": {"pages": ["dashboard", "docker", "files"], "sidebar_links": "*"},
|
"member": {"pages": ["dashboard", "docker", "files"], "sidebar_links": "*"},
|
||||||
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}
|
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]},
|
||||||
},
|
},
|
||||||
"user_overrides": {
|
"user_overrides": {
|
||||||
"customuser": {
|
"customuser": {"pages": ["dashboard", "docker"], "sidebar_links": ["dashboard", "docker", "files"]}
|
||||||
"pages": ["dashboard", "docker"],
|
},
|
||||||
"sidebar_links": ["dashboard", "docker", "files"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for authentication flow.
|
Integration tests for authentication flow.
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
import pyotp
|
import pyotp
|
||||||
from fastapi import status
|
|
||||||
from auth_service import get_password_hash, save_totp_secret, save_password_hash
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoginFlow:
|
class TestLoginFlow:
|
||||||
@@ -12,14 +10,15 @@ class TestLoginFlow:
|
|||||||
|
|
||||||
def test_login_success_no_2fa(self, test_app, mock_config, temp_auth_file):
|
def test_login_success_no_2fa(self, test_app, mock_config, temp_auth_file):
|
||||||
"""Test successful login without 2FA."""
|
"""Test successful login without 2FA."""
|
||||||
|
from auth_service import get_password_hash, save_password_hash
|
||||||
|
|
||||||
# Set up password
|
# Set up password
|
||||||
password = "test_password"
|
password = "test_password"
|
||||||
hashed = get_password_hash(password)
|
hashed = get_password_hash(password)
|
||||||
save_password_hash(hashed)
|
save_password_hash(hashed)
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
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):
|
def test_login_success_with_2fa(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||||
"""Test successful login with 2FA."""
|
"""Test successful login with 2FA."""
|
||||||
|
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||||
|
|
||||||
password = "test_password"
|
password = "test_password"
|
||||||
hashed = get_password_hash(password)
|
hashed = get_password_hash(password)
|
||||||
save_password_hash(hashed)
|
save_password_hash(hashed)
|
||||||
@@ -42,8 +43,7 @@ class TestLoginFlow:
|
|||||||
valid_code = totp.now()
|
valid_code = totp.now()
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": valid_code}
|
||||||
json={"username": "testadmin", "password": password, "totp_code": valid_code}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -53,13 +53,14 @@ class TestLoginFlow:
|
|||||||
|
|
||||||
def test_login_wrong_password(self, test_app, mock_config, temp_auth_file):
|
def test_login_wrong_password(self, test_app, mock_config, temp_auth_file):
|
||||||
"""Test login with wrong password."""
|
"""Test login with wrong password."""
|
||||||
|
from auth_service import get_password_hash, save_password_hash
|
||||||
|
|
||||||
password = "correct_password"
|
password = "correct_password"
|
||||||
hashed = get_password_hash(password)
|
hashed = get_password_hash(password)
|
||||||
save_password_hash(hashed)
|
save_password_hash(hashed)
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login", json={"username": "testadmin", "password": "wrong_password", "totp_code": ""}
|
||||||
json={"username": "testadmin", "password": "wrong_password", "totp_code": ""}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
@@ -67,27 +68,29 @@ class TestLoginFlow:
|
|||||||
|
|
||||||
def test_login_wrong_username(self, test_app, mock_config, temp_auth_file):
|
def test_login_wrong_username(self, test_app, mock_config, temp_auth_file):
|
||||||
"""Test login with wrong username."""
|
"""Test login with wrong username."""
|
||||||
|
from auth_service import get_password_hash, save_password_hash
|
||||||
|
|
||||||
password = "test_password"
|
password = "test_password"
|
||||||
hashed = get_password_hash(password)
|
hashed = get_password_hash(password)
|
||||||
save_password_hash(hashed)
|
save_password_hash(hashed)
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login", json={"username": "wronguser", "password": password, "totp_code": ""}
|
||||||
json={"username": "wronguser", "password": password, "totp_code": ""}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_login_missing_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
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."""
|
"""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"
|
password = "test_password"
|
||||||
hashed = get_password_hash(password)
|
hashed = get_password_hash(password)
|
||||||
save_password_hash(hashed)
|
save_password_hash(hashed)
|
||||||
save_totp_secret(sample_totp_secret)
|
save_totp_secret(sample_totp_secret)
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
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):
|
def test_login_invalid_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||||
"""Test login with invalid 2FA code."""
|
"""Test login with invalid 2FA code."""
|
||||||
|
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||||
|
|
||||||
password = "test_password"
|
password = "test_password"
|
||||||
hashed = get_password_hash(password)
|
hashed = get_password_hash(password)
|
||||||
save_password_hash(hashed)
|
save_password_hash(hashed)
|
||||||
save_totp_secret(sample_totp_secret)
|
save_totp_secret(sample_totp_secret)
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": "000000"}
|
||||||
json={"username": "testadmin", "password": password, "totp_code": "000000"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_login_sets_cookies(self, test_app, mock_config, temp_auth_file):
|
def test_login_sets_cookies(self, test_app, mock_config, temp_auth_file):
|
||||||
"""Test that login sets auth cookies."""
|
"""Test that login sets auth cookies."""
|
||||||
|
from auth_service import get_password_hash, save_password_hash
|
||||||
|
|
||||||
password = "test_password"
|
password = "test_password"
|
||||||
hashed = get_password_hash(password)
|
hashed = get_password_hash(password)
|
||||||
save_password_hash(hashed)
|
save_password_hash(hashed)
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
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):
|
def test_refresh_with_valid_token(self, test_app, mock_config, temp_auth_file, valid_refresh_token):
|
||||||
"""Test refreshing with valid refresh token."""
|
"""Test refreshing with valid refresh token."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_refresh_token})
|
||||||
"/api/auth/refresh",
|
|
||||||
json={"refresh_token": valid_refresh_token}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -152,19 +154,13 @@ class TestRefreshFlow:
|
|||||||
|
|
||||||
def test_refresh_with_invalid_token(self, test_app, mock_config, temp_auth_file):
|
def test_refresh_with_invalid_token(self, test_app, mock_config, temp_auth_file):
|
||||||
"""Test refreshing with invalid token."""
|
"""Test refreshing with invalid token."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/auth/refresh", json={"refresh_token": "invalid.token.here"})
|
||||||
"/api/auth/refresh",
|
|
||||||
json={"refresh_token": "invalid.token.here"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_refresh_with_expired_token(self, test_app, mock_config, temp_auth_file, expired_access_token):
|
def test_refresh_with_expired_token(self, test_app, mock_config, temp_auth_file, expired_access_token):
|
||||||
"""Test refreshing with expired token."""
|
"""Test refreshing with expired token."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/auth/refresh", json={"refresh_token": expired_access_token})
|
||||||
"/api/auth/refresh",
|
|
||||||
json={"refresh_token": expired_access_token}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
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):
|
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."""
|
"""Test that access token cannot be used for refresh."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_access_token})
|
||||||
"/api/auth/refresh",
|
|
||||||
json={"refresh_token": valid_access_token}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert "Invalid token type" in response.json()["detail"]
|
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):
|
def test_proxy_auth_from_trusted_source(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||||
"""Test proxy auth from trusted proxy."""
|
"""Test proxy auth from trusted proxy."""
|
||||||
response = test_app.get(
|
response = test_app.get(
|
||||||
"/api/auth/proxy",
|
"/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"}
|
||||||
headers={
|
|
||||||
"Remote-User": "proxyuser",
|
|
||||||
"Remote-Groups": "dashboard_admin"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Note: This test may need adjustment based on actual proxy auth implementation
|
# Note: This test may need adjustment based on actual proxy auth implementation
|
||||||
@@ -256,11 +245,7 @@ class TestProxyAuth:
|
|||||||
"""Test proxy auth from untrusted source."""
|
"""Test proxy auth from untrusted source."""
|
||||||
# Mock request from untrusted IP
|
# Mock request from untrusted IP
|
||||||
response = test_app.get(
|
response = test_app.get(
|
||||||
"/api/auth/proxy",
|
"/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"}
|
||||||
headers={
|
|
||||||
"Remote-User": "proxyuser",
|
|
||||||
"Remote-Groups": "dashboard_admin"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should reject untrusted proxy
|
# 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):
|
def test_change_password_success(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||||
"""Test successful password change."""
|
"""Test successful password change."""
|
||||||
|
from auth_service import get_password_hash, save_password_hash
|
||||||
|
|
||||||
old_password = "old_password"
|
old_password = "old_password"
|
||||||
new_password = "new_password_123"
|
new_password = "new_password_123"
|
||||||
|
|
||||||
@@ -282,16 +269,15 @@ class TestPasswordChange:
|
|||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/change-password",
|
"/api/auth/change-password",
|
||||||
headers=admin_headers,
|
headers=admin_headers,
|
||||||
json={
|
json={"current_password": old_password, "new_password": new_password},
|
||||||
"current_password": old_password,
|
|
||||||
"new_password": new_password
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
def test_change_password_wrong_old_password(self, test_app, mock_config, temp_auth_file, admin_headers):
|
def test_change_password_wrong_old_password(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||||
"""Test password change with wrong old password."""
|
"""Test password change with wrong old password."""
|
||||||
|
from auth_service import get_password_hash, save_password_hash
|
||||||
|
|
||||||
old_password = "old_password"
|
old_password = "old_password"
|
||||||
hashed = get_password_hash(old_password)
|
hashed = get_password_hash(old_password)
|
||||||
save_password_hash(hashed)
|
save_password_hash(hashed)
|
||||||
@@ -299,22 +285,233 @@ class TestPasswordChange:
|
|||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/change-password",
|
"/api/auth/change-password",
|
||||||
headers=admin_headers,
|
headers=admin_headers,
|
||||||
json={
|
json={"current_password": "wrong_password", "new_password": "new_password_123"},
|
||||||
"current_password": "wrong_password",
|
|
||||||
"new_password": "new_password_123"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
def test_change_password_without_auth(self, test_app, mock_config, temp_auth_file):
|
def test_change_password_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||||
"""Test password change without authentication."""
|
"""Test password change without authentication."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/auth/change-password", json={"current_password": "old", "new_password": "new"})
|
||||||
"/api/auth/change-password",
|
|
||||||
json={
|
assert response.status_code == 401
|
||||||
"current_password": "old",
|
|
||||||
"new_password": "new"
|
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
|
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.
|
Integration tests for Docker operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import Mock, patch
|
|
||||||
|
|
||||||
|
|
||||||
class TestDockerContainerList:
|
class TestDockerContainerList:
|
||||||
@@ -35,10 +35,7 @@ class TestDockerContainerActions:
|
|||||||
"""Test starting a container."""
|
"""Test starting a container."""
|
||||||
container_id = "abc123"
|
container_id = "abc123"
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(f"/api/docker/containers/{container_id}/start", headers=admin_headers)
|
||||||
f"/api/docker/containers/{container_id}/start",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -48,10 +45,7 @@ class TestDockerContainerActions:
|
|||||||
"""Test stopping a container."""
|
"""Test stopping a container."""
|
||||||
container_id = "abc123"
|
container_id = "abc123"
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(f"/api/docker/containers/{container_id}/stop", headers=admin_headers)
|
||||||
f"/api/docker/containers/{container_id}/stop",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -59,10 +53,7 @@ class TestDockerContainerActions:
|
|||||||
"""Test restarting a container."""
|
"""Test restarting a container."""
|
||||||
container_id = "abc123"
|
container_id = "abc123"
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post(f"/api/docker/containers/{container_id}/restart", headers=admin_headers)
|
||||||
f"/api/docker/containers/{container_id}/restart",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -74,10 +65,7 @@ class TestDockerContainerActions:
|
|||||||
|
|
||||||
def test_container_action_invalid_action(self, test_app, admin_headers):
|
def test_container_action_invalid_action(self, test_app, admin_headers):
|
||||||
"""Test container action with invalid action."""
|
"""Test container action with invalid action."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/docker/containers/abc123/invalid_action", headers=admin_headers)
|
||||||
"/api/docker/containers/abc123/invalid_action",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -91,10 +79,7 @@ class TestDockerContainerLogs:
|
|||||||
"""Test getting container logs."""
|
"""Test getting container logs."""
|
||||||
container_id = "abc123"
|
container_id = "abc123"
|
||||||
|
|
||||||
response = test_app.get(
|
response = test_app.get(f"/api/docker/containers/{container_id}/logs", headers=admin_headers)
|
||||||
f"/api/docker/containers/{container_id}/logs",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -104,10 +89,7 @@ class TestDockerContainerLogs:
|
|||||||
"""Test getting container logs with tail limit."""
|
"""Test getting container logs with tail limit."""
|
||||||
container_id = "abc123"
|
container_id = "abc123"
|
||||||
|
|
||||||
response = test_app.get(
|
response = test_app.get(f"/api/docker/containers/{container_id}/logs?tail=100", headers=admin_headers)
|
||||||
f"/api/docker/containers/{container_id}/logs?tail=100",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@@ -122,10 +104,7 @@ class TestDockerContainerLogs:
|
|||||||
# Note: In this test setup, the mock always returns a container
|
# Note: In this test setup, the mock always returns a container
|
||||||
# Testing actual error handling would require a different approach
|
# Testing actual error handling would require a different approach
|
||||||
# For now, verify the endpoint works with the mock
|
# For now, verify the endpoint works with the mock
|
||||||
response = test_app.get(
|
response = test_app.get("/api/docker/containers/nonexistent/logs", headers=admin_headers)
|
||||||
"/api/docker/containers/nonexistent/logs",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
# With the current mock setup, this will succeed
|
# With the current mock setup, this will succeed
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -137,12 +116,11 @@ class TestDockerPermissions:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def member_token(self, mock_config, temp_auth_file, temp_rbac_file):
|
def member_token(self, mock_config, temp_auth_file, temp_rbac_file):
|
||||||
"""Create token for member role."""
|
"""Create token for member role."""
|
||||||
from auth_service import create_access_token
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
return create_access_token(
|
|
||||||
data={"sub": "memberuser", "role": "member"},
|
from auth_service import create_access_token
|
||||||
expires_delta=timedelta(minutes=30)
|
|
||||||
)
|
return create_access_token(data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30))
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def member_headers(self, member_token):
|
def member_headers(self, member_token):
|
||||||
@@ -158,22 +136,19 @@ class TestDockerPermissions:
|
|||||||
|
|
||||||
def test_member_cannot_start_container(self, test_app, member_headers):
|
def test_member_cannot_start_container(self, test_app, member_headers):
|
||||||
"""Test that member cannot start containers (admin only)."""
|
"""Test that member cannot start containers (admin only)."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/docker/containers/abc123/start", headers=member_headers)
|
||||||
"/api/docker/containers/abc123/start",
|
|
||||||
headers=member_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should be forbidden for non-admin
|
# Should be forbidden for non-admin
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
def test_viewer_can_list_containers(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
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."""
|
"""Test that viewer can list containers if they have docker page access."""
|
||||||
from auth_service import create_access_token
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from auth_service import create_access_token
|
||||||
|
|
||||||
viewer_token = create_access_token(
|
viewer_token = create_access_token(
|
||||||
data={"sub": "vieweruser", "role": "viewer"},
|
data={"sub": "vieweruser", "role": "viewer"}, expires_delta=timedelta(minutes=30)
|
||||||
expires_delta=timedelta(minutes=30)
|
|
||||||
)
|
)
|
||||||
headers = {"Authorization": f"Bearer {viewer_token}"}
|
headers = {"Authorization": f"Bearer {viewer_token}"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for file operations.
|
Integration tests for file operations.
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
@@ -10,11 +10,7 @@ class TestFileBrowse:
|
|||||||
|
|
||||||
def test_browse_root(self, test_app, admin_headers, temp_volume_root):
|
def test_browse_root(self, test_app, admin_headers, temp_volume_root):
|
||||||
"""Test browsing root directory."""
|
"""Test browsing root directory."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/"})
|
||||||
"/api/files/browse",
|
|
||||||
headers=admin_headers,
|
|
||||||
params={"path": "/"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -28,22 +24,14 @@ class TestFileBrowse:
|
|||||||
|
|
||||||
def test_browse_blocked_directory(self, test_app, admin_headers):
|
def test_browse_blocked_directory(self, test_app, admin_headers):
|
||||||
"""Test browsing blocked directory."""
|
"""Test browsing blocked directory."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/@appstore"})
|
||||||
"/api/files/browse",
|
|
||||||
headers=admin_headers,
|
|
||||||
params={"path": "/@appstore"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should be forbidden
|
# Should be forbidden
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
def test_browse_path_traversal_attempt(self, test_app, admin_headers):
|
def test_browse_path_traversal_attempt(self, test_app, admin_headers):
|
||||||
"""Test path traversal protection."""
|
"""Test path traversal protection."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/../../../etc"})
|
||||||
"/api/files/browse",
|
|
||||||
headers=admin_headers,
|
|
||||||
params={"path": "/../../../etc"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should be forbidden
|
# Should be forbidden
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -57,12 +45,7 @@ class TestFileUpload:
|
|||||||
test_content = b"test file content"
|
test_content = b"test file content"
|
||||||
files = {"file": ("test.txt", test_content, "text/plain")}
|
files = {"file": ("test.txt", test_content, "text/plain")}
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post("/api/files/upload", headers=admin_headers, files=files, params={"path": "/"})
|
||||||
"/api/files/upload",
|
|
||||||
headers=admin_headers,
|
|
||||||
files=files,
|
|
||||||
params={"path": "/"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code in [200, 201]
|
assert response.status_code in [200, 201]
|
||||||
|
|
||||||
@@ -70,32 +53,23 @@ class TestFileUpload:
|
|||||||
"""Test upload without authentication."""
|
"""Test upload without authentication."""
|
||||||
files = {"file": ("test.txt", b"content", "text/plain")}
|
files = {"file": ("test.txt", b"content", "text/plain")}
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post("/api/files/upload", files=files, params={"path": "/"})
|
||||||
"/api/files/upload",
|
|
||||||
files=files,
|
|
||||||
params={"path": "/"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_upload_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
def test_upload_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||||
"""Test that non-admin cannot upload."""
|
"""Test that non-admin cannot upload."""
|
||||||
from auth_service import create_access_token
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from auth_service import create_access_token
|
||||||
|
|
||||||
member_token = create_access_token(
|
member_token = create_access_token(
|
||||||
data={"sub": "memberuser", "role": "member"},
|
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||||
expires_delta=timedelta(minutes=30)
|
|
||||||
)
|
)
|
||||||
headers = {"Authorization": f"Bearer {member_token}"}
|
headers = {"Authorization": f"Bearer {member_token}"}
|
||||||
files = {"file": ("test.txt", b"content", "text/plain")}
|
files = {"file": ("test.txt", b"content", "text/plain")}
|
||||||
|
|
||||||
response = test_app.post(
|
response = test_app.post("/api/files/upload", headers=headers, files=files, params={"path": "/"})
|
||||||
"/api/files/upload",
|
|
||||||
headers=headers,
|
|
||||||
files=files,
|
|
||||||
params={"path": "/"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Upload should be admin-only
|
# Upload should be admin-only
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -111,39 +85,28 @@ class TestFileDelete:
|
|||||||
with open(test_file, "w") as f:
|
with open(test_file, "w") as f:
|
||||||
f.write("test content")
|
f.write("test content")
|
||||||
|
|
||||||
response = test_app.delete(
|
response = test_app.delete("/api/files/delete", headers=admin_headers, params={"path": "/test_delete.txt"})
|
||||||
"/api/files/delete",
|
|
||||||
headers=admin_headers,
|
|
||||||
params={"path": "/test_delete.txt"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code in [200, 204]
|
assert response.status_code in [200, 204]
|
||||||
|
|
||||||
def test_delete_without_auth(self, test_app):
|
def test_delete_without_auth(self, test_app):
|
||||||
"""Test delete without authentication."""
|
"""Test delete without authentication."""
|
||||||
response = test_app.delete(
|
response = test_app.delete("/api/files/delete", params={"path": "/test.txt"})
|
||||||
"/api/files/delete",
|
|
||||||
params={"path": "/test.txt"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_delete_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
def test_delete_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||||
"""Test that non-admin cannot delete."""
|
"""Test that non-admin cannot delete."""
|
||||||
from auth_service import create_access_token
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from auth_service import create_access_token
|
||||||
|
|
||||||
member_token = create_access_token(
|
member_token = create_access_token(
|
||||||
data={"sub": "memberuser", "role": "member"},
|
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||||
expires_delta=timedelta(minutes=30)
|
|
||||||
)
|
)
|
||||||
headers = {"Authorization": f"Bearer {member_token}"}
|
headers = {"Authorization": f"Bearer {member_token}"}
|
||||||
|
|
||||||
response = test_app.delete(
|
response = test_app.delete("/api/files/delete", headers=headers, params={"path": "/test.txt"})
|
||||||
"/api/files/delete",
|
|
||||||
headers=headers,
|
|
||||||
params={"path": "/test.txt"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete should be admin-only
|
# Delete should be admin-only
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
@@ -160,30 +123,19 @@ class TestFileDownload:
|
|||||||
with open(test_file, "w") as f:
|
with open(test_file, "w") as f:
|
||||||
f.write(test_content)
|
f.write(test_content)
|
||||||
|
|
||||||
response = test_app.get(
|
response = test_app.get("/api/files/download", headers=admin_headers, params={"path": "/test_download.txt"})
|
||||||
"/api/files/download",
|
|
||||||
headers=admin_headers,
|
|
||||||
params={"path": "/test_download.txt"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert test_content in response.text or response.content == test_content.encode()
|
assert test_content in response.text or response.content == test_content.encode()
|
||||||
|
|
||||||
def test_download_without_auth(self, test_app):
|
def test_download_without_auth(self, test_app):
|
||||||
"""Test download without authentication."""
|
"""Test download without authentication."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/files/download", params={"path": "/test.txt"})
|
||||||
"/api/files/download",
|
|
||||||
params={"path": "/test.txt"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
def test_download_nonexistent_file(self, test_app, admin_headers):
|
def test_download_nonexistent_file(self, test_app, admin_headers):
|
||||||
"""Test downloading nonexistent file."""
|
"""Test downloading nonexistent file."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/files/download", headers=admin_headers, params={"path": "/nonexistent.txt"})
|
||||||
"/api/files/download",
|
|
||||||
headers=admin_headers,
|
|
||||||
params={"path": "/nonexistent.txt"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for Gitea operations.
|
Integration tests for Gitea operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
@@ -27,19 +28,13 @@ class TestGiteaCommits:
|
|||||||
@pytest.mark.skip(reason="Requires external Gitea API - tested manually")
|
@pytest.mark.skip(reason="Requires external Gitea API - tested manually")
|
||||||
def test_list_commits(self, test_app, admin_headers):
|
def test_list_commits(self, test_app, admin_headers):
|
||||||
"""Test listing commits for a repository."""
|
"""Test listing commits for a repository."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/gitea/repos/owner/repo/commits", headers=admin_headers)
|
||||||
"/api/gitea/repos/owner/repo/commits",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
# This would require a real Gitea instance
|
# This would require a real Gitea instance
|
||||||
assert response.status_code in [200, 404, 500, 503]
|
assert response.status_code in [200, 404, 500, 503]
|
||||||
|
|
||||||
def test_list_commits_invalid_owner(self, test_app, admin_headers):
|
def test_list_commits_invalid_owner(self, test_app, admin_headers):
|
||||||
"""Test listing commits with invalid owner name."""
|
"""Test listing commits with invalid owner name."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/gitea/repos/invalid@owner/repo/commits", headers=admin_headers)
|
||||||
"/api/gitea/repos/invalid@owner/repo/commits",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -48,10 +43,7 @@ class TestGiteaCommits:
|
|||||||
|
|
||||||
def test_list_commits_invalid_repo(self, test_app, admin_headers):
|
def test_list_commits_invalid_repo(self, test_app, admin_headers):
|
||||||
"""Test listing commits with invalid repo name."""
|
"""Test listing commits with invalid repo name."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/gitea/repos/owner/invalid@repo/commits", headers=admin_headers)
|
||||||
"/api/gitea/repos/owner/invalid@repo/commits",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -60,19 +52,13 @@ class TestGiteaCommits:
|
|||||||
|
|
||||||
def test_list_commits_special_chars_in_owner(self, test_app, admin_headers):
|
def test_list_commits_special_chars_in_owner(self, test_app, admin_headers):
|
||||||
"""Test listing commits with special characters in owner."""
|
"""Test listing commits with special characters in owner."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/gitea/repos/owner$/repo/commits", headers=admin_headers)
|
||||||
"/api/gitea/repos/owner$/repo/commits",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
def test_list_commits_special_chars_in_repo(self, test_app, admin_headers):
|
def test_list_commits_special_chars_in_repo(self, test_app, admin_headers):
|
||||||
"""Test listing commits with special characters in repo."""
|
"""Test listing commits with special characters in repo."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/gitea/repos/owner/repo!/commits", headers=admin_headers)
|
||||||
"/api/gitea/repos/owner/repo!/commits",
|
|
||||||
headers=admin_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
@@ -81,10 +67,7 @@ class TestGiteaCommits:
|
|||||||
"""Test that valid names with allowed characters pass validation."""
|
"""Test that valid names with allowed characters pass validation."""
|
||||||
# Valid characters: a-zA-Z0-9._-
|
# Valid characters: a-zA-Z0-9._-
|
||||||
# This will fail at the HTTP call level, but should pass validation
|
# This will fail at the HTTP call level, but should pass validation
|
||||||
response = test_app.get(
|
response = test_app.get("/api/gitea/repos/valid-owner_123/repo.name-456/commits", headers=admin_headers)
|
||||||
"/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)
|
# Should not be 400 (validation error), but may be 500/503 (HTTP error)
|
||||||
assert response.status_code != 400
|
assert response.status_code != 400
|
||||||
@@ -94,4 +77,3 @@ class TestGiteaCommits:
|
|||||||
response = test_app.get("/api/gitea/repos/owner/repo/commits")
|
response = test_app.get("/api/gitea/repos/owner/repo/commits")
|
||||||
|
|
||||||
assert response.status_code == 401
|
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.
|
Integration tests for system operations.
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
import os
|
import os
|
||||||
from unittest.mock import patch, Mock
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
class TestSystemStats:
|
class TestSystemStats:
|
||||||
@@ -17,7 +19,7 @@ class TestSystemStats:
|
|||||||
mock_disk.used = 500000000000
|
mock_disk.used = 500000000000
|
||||||
mock_disk.free = 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)
|
response = test_app.get("/api/system/stats", headers=admin_headers)
|
||||||
|
|
||||||
assert response.status_code == 200
|
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):
|
def test_get_audit_log_with_limit(self, test_app, admin_headers, temp_volume_root):
|
||||||
"""Test getting audit log with line limit."""
|
"""Test getting audit log with line limit."""
|
||||||
response = test_app.get(
|
response = test_app.get("/api/system/audit-log", headers=admin_headers, params={"lines": 50})
|
||||||
"/api/system/audit-log",
|
|
||||||
headers=admin_headers,
|
|
||||||
params={"lines": 50}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -104,22 +102,14 @@ class TestNotification:
|
|||||||
@pytest.mark.skip(reason="Requires Telegram bot token and makes external API call")
|
@pytest.mark.skip(reason="Requires Telegram bot token and makes external API call")
|
||||||
def test_send_notification_success(self, test_app, admin_headers):
|
def test_send_notification_success(self, test_app, admin_headers):
|
||||||
"""Test sending a notification."""
|
"""Test sending a notification."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/system/notify", headers=admin_headers, json={"message": "Test notification"})
|
||||||
"/api/system/notify",
|
|
||||||
headers=admin_headers,
|
|
||||||
json={"message": "Test notification"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# This will fail without proper Telegram config
|
# This will fail without proper Telegram config
|
||||||
assert response.status_code in [200, 400]
|
assert response.status_code in [200, 400]
|
||||||
|
|
||||||
def test_send_notification_without_message(self, test_app, admin_headers):
|
def test_send_notification_without_message(self, test_app, admin_headers):
|
||||||
"""Test sending notification without message."""
|
"""Test sending notification without message."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/system/notify", headers=admin_headers, json={})
|
||||||
"/api/system/notify",
|
|
||||||
headers=admin_headers,
|
|
||||||
json={}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
@@ -127,10 +117,7 @@ class TestNotification:
|
|||||||
|
|
||||||
def test_send_notification_without_auth(self, test_app):
|
def test_send_notification_without_auth(self, test_app):
|
||||||
"""Test sending notification without authentication."""
|
"""Test sending notification without authentication."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/system/notify", json={"message": "Test"})
|
||||||
"/api/system/notify",
|
|
||||||
json={"message": "Test"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
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):
|
def test_get_openclaw_token_as_admin(self, test_app, admin_headers, mock_config, monkeypatch):
|
||||||
"""Test getting OpenClaw token as admin from LAN."""
|
"""Test getting OpenClaw token as admin from LAN."""
|
||||||
|
|
||||||
# Mock the IP check to return a LAN IP
|
# Mock the IP check to return a LAN IP
|
||||||
def mock_get_client_ip(request):
|
def mock_get_client_ip(request):
|
||||||
return "192.168.1.100"
|
return "192.168.1.100"
|
||||||
@@ -148,6 +136,7 @@ class TestOpenClawToken:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
monkeypatch.setattr(config, "get_client_ip", mock_get_client_ip)
|
monkeypatch.setattr(config, "get_client_ip", mock_get_client_ip)
|
||||||
monkeypatch.setattr(config, "is_lan_ip", mock_is_lan_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):
|
def test_get_openclaw_token_non_lan(self, test_app, admin_headers, monkeypatch):
|
||||||
"""Test getting OpenClaw token from non-LAN IP."""
|
"""Test getting OpenClaw token from non-LAN IP."""
|
||||||
|
|
||||||
# Mock the IP check to return a non-LAN IP
|
# Mock the IP check to return a non-LAN IP
|
||||||
def mock_get_client_ip(request):
|
def mock_get_client_ip(request):
|
||||||
return "1.2.3.4"
|
return "1.2.3.4"
|
||||||
@@ -167,6 +157,7 @@ class TestOpenClawToken:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
monkeypatch.setattr(config, "get_client_ip", mock_get_client_ip)
|
monkeypatch.setattr(config, "get_client_ip", mock_get_client_ip)
|
||||||
monkeypatch.setattr(config, "is_lan_ip", mock_is_lan_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):
|
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."""
|
"""Test that non-admin cannot get OpenClaw token."""
|
||||||
from auth_service import create_access_token
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from auth_service import create_access_token
|
||||||
|
|
||||||
member_token = create_access_token(
|
member_token = create_access_token(
|
||||||
data={"sub": "memberuser", "role": "member"},
|
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||||
expires_delta=timedelta(minutes=30)
|
|
||||||
)
|
)
|
||||||
headers = {"Authorization": f"Bearer {member_token}"}
|
headers = {"Authorization": f"Bearer {member_token}"}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for TOTP 2FA operations.
|
Integration tests for TOTP 2FA operations.
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
import pyotp
|
import pyotp
|
||||||
|
|
||||||
|
|
||||||
@@ -38,9 +38,7 @@ class TestTOTPSetup:
|
|||||||
|
|
||||||
# Verify the code
|
# Verify the code
|
||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/setup-2fa/verify",
|
"/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": secret, "code": valid_code}
|
||||||
headers=admin_headers,
|
|
||||||
json={"secret": secret, "code": valid_code}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -51,9 +49,7 @@ class TestTOTPSetup:
|
|||||||
def test_verify_2fa_setup_invalid_code(self, test_app, admin_headers):
|
def test_verify_2fa_setup_invalid_code(self, test_app, admin_headers):
|
||||||
"""Test verifying 2FA with invalid code."""
|
"""Test verifying 2FA with invalid code."""
|
||||||
response = test_app.post(
|
response = test_app.post(
|
||||||
"/api/auth/setup-2fa/verify",
|
"/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": "JBSWY3DPEHPK3PXP", "code": "000000"}
|
||||||
headers=admin_headers,
|
|
||||||
json={"secret": "JBSWY3DPEHPK3PXP", "code": "000000"}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
@@ -63,10 +59,7 @@ class TestTOTPSetup:
|
|||||||
|
|
||||||
def test_verify_2fa_without_auth(self, test_app):
|
def test_verify_2fa_without_auth(self, test_app):
|
||||||
"""Test verifying 2FA without authentication."""
|
"""Test verifying 2FA without authentication."""
|
||||||
response = test_app.post(
|
response = test_app.post("/api/auth/setup-2fa/verify", json={"secret": "JBSWY3DPEHPK3PXP", "code": "123456"})
|
||||||
"/api/auth/setup-2fa/verify",
|
|
||||||
json={"secret": "JBSWY3DPEHPK3PXP", "code": "123456"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
|
|
||||||
@@ -100,14 +93,13 @@ class TestTOTPWorkflow:
|
|||||||
totp = pyotp.TOTP(secret)
|
totp = pyotp.TOTP(secret)
|
||||||
valid_code = totp.now()
|
valid_code = totp.now()
|
||||||
verify_response = test_app.post(
|
verify_response = test_app.post(
|
||||||
"/api/auth/setup-2fa/verify",
|
"/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": secret, "code": valid_code}
|
||||||
headers=admin_headers,
|
|
||||||
json={"secret": secret, "code": valid_code}
|
|
||||||
)
|
)
|
||||||
assert verify_response.status_code == 200
|
assert verify_response.status_code == 200
|
||||||
|
|
||||||
# Step 3: Verify 2FA is enabled by checking auth_service
|
# Step 3: Verify 2FA is enabled by checking auth_service
|
||||||
from auth_service import load_totp_secret
|
from auth_service import load_totp_secret
|
||||||
|
|
||||||
saved_secret = load_totp_secret()
|
saved_secret = load_totp_secret()
|
||||||
assert saved_secret == secret
|
assert saved_secret == secret
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Basic diagnostic tests to validate test environment.
|
Basic diagnostic tests to validate test environment.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
|
||||||
def test_python_version():
|
def test_python_version():
|
||||||
@@ -13,18 +13,19 @@ def test_python_version():
|
|||||||
def test_imports():
|
def test_imports():
|
||||||
"""Verify all required modules can be imported."""
|
"""Verify all required modules can be imported."""
|
||||||
try:
|
try:
|
||||||
import fastapi
|
import asyncssh
|
||||||
import uvicorn
|
|
||||||
import docker
|
import docker
|
||||||
|
import fastapi
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
import passlib
|
import passlib
|
||||||
import pyotp
|
import pyotp
|
||||||
import asyncssh
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
import pytest_cov
|
import pytest_cov
|
||||||
import pytest_mock
|
import pytest_mock
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
assert True
|
assert True
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
pytest.fail(f"Import failed: {e}")
|
pytest.fail(f"Import failed: {e}")
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests for auth.py - JWT, TOTP, password hashing, encryption.
|
Unit tests for auth.py - JWT, TOTP, password hashing, encryption.
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pyotp
|
import pyotp
|
||||||
from datetime import datetime, timedelta, timezone
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from auth_service import (
|
from auth_service import (
|
||||||
verify_password,
|
_decrypt,
|
||||||
get_password_hash,
|
_encrypt,
|
||||||
|
clear_passkey_credentials,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
create_refresh_token,
|
create_refresh_token,
|
||||||
user_from_access_token,
|
get_password_hash,
|
||||||
verify_totp,
|
|
||||||
load_totp_secret,
|
|
||||||
save_totp_secret,
|
|
||||||
load_password_hash,
|
|
||||||
save_password_hash,
|
|
||||||
increment_token_version,
|
increment_token_version,
|
||||||
verify_token_version,
|
|
||||||
_encrypt,
|
|
||||||
_decrypt,
|
|
||||||
load_passkey_credentials,
|
load_passkey_credentials,
|
||||||
|
load_password_hash,
|
||||||
|
load_totp_secret,
|
||||||
save_passkey_credential,
|
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:
|
class TestPasswordHashing:
|
||||||
@@ -79,8 +82,8 @@ class TestJWTTokens:
|
|||||||
token = create_access_token(data, expires_delta)
|
token = create_access_token(data, expires_delta)
|
||||||
|
|
||||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||||
exp_time = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
exp_time = datetime.fromtimestamp(payload["exp"], tz=UTC)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
# Should expire in approximately 60 minutes
|
# Should expire in approximately 60 minutes
|
||||||
time_diff = (exp_time - now).total_seconds()
|
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):
|
def test_user_from_expired_token(self, mock_config, temp_rbac_file):
|
||||||
"""Test that expired token raises HTTPException."""
|
"""Test that expired token raises HTTPException."""
|
||||||
token = create_access_token(
|
token = create_access_token({"sub": "testuser", "role": "admin"}, expires_delta=timedelta(seconds=-1))
|
||||||
{"sub": "testuser", "role": "admin"},
|
|
||||||
expires_delta=timedelta(seconds=-1)
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
user_from_access_token(token)
|
user_from_access_token(token)
|
||||||
@@ -141,9 +141,9 @@ class TestJWTTokens:
|
|||||||
"""Test that token without username fails."""
|
"""Test that token without username fails."""
|
||||||
# Create token without 'sub' field
|
# Create token without 'sub' field
|
||||||
token = jwt.encode(
|
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,
|
mock_config.SECRET_KEY,
|
||||||
algorithm=mock_config.ALGORITHM
|
algorithm=mock_config.ALGORITHM,
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as exc_info:
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
@@ -209,7 +209,9 @@ class TestTOTP:
|
|||||||
|
|
||||||
monkeypatch.setenv("TOTP_SECRET", "ENV_SECRET_KEY")
|
monkeypatch.setenv("TOTP_SECRET", "ENV_SECRET_KEY")
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
importlib.reload(config)
|
importlib.reload(config)
|
||||||
|
|
||||||
# When no secret in file, should return env var
|
# When no secret in file, should return env var
|
||||||
@@ -237,7 +239,9 @@ class TestTOTP:
|
|||||||
# Also clear environment variable fallback
|
# Also clear environment variable fallback
|
||||||
monkeypatch.setenv("TOTP_SECRET", "")
|
monkeypatch.setenv("TOTP_SECRET", "")
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
import config
|
import config
|
||||||
|
|
||||||
importlib.reload(config)
|
importlib.reload(config)
|
||||||
# No secret saved, should return True (2FA disabled)
|
# No secret saved, should return True (2FA disabled)
|
||||||
assert verify_totp("any_code")
|
assert verify_totp("any_code")
|
||||||
@@ -248,10 +252,11 @@ class TestTOTP:
|
|||||||
|
|
||||||
# Ensure auth file is properly initialized with last_totp_ts
|
# Ensure auth file is properly initialized with last_totp_ts
|
||||||
import json
|
import json
|
||||||
with open(temp_auth_file, 'r') as f:
|
|
||||||
|
with open(temp_auth_file) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
data['last_totp_ts'] = 0
|
data["last_totp_ts"] = 0
|
||||||
with open(temp_auth_file, 'w') as f:
|
with open(temp_auth_file, "w") as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
|
|
||||||
totp = pyotp.TOTP(sample_totp_secret)
|
totp = pyotp.TOTP(sample_totp_secret)
|
||||||
@@ -280,15 +285,18 @@ class TestPasswordPersistence:
|
|||||||
"""Test loading password hash from environment variable."""
|
"""Test loading password hash from environment variable."""
|
||||||
# Clear any hash in the file first
|
# Clear any hash in the file first
|
||||||
import json
|
import json
|
||||||
with open(temp_auth_file, 'r') as f:
|
|
||||||
|
with open(temp_auth_file) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
data['password_hash'] = ""
|
data["password_hash"] = ""
|
||||||
with open(temp_auth_file, 'w') as f:
|
with open(temp_auth_file, "w") as f:
|
||||||
json.dump(data, f)
|
json.dump(data, f)
|
||||||
|
|
||||||
# Reload auth_service to clear any cached data
|
# Reload auth_service to clear any cached data
|
||||||
import auth_service
|
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
import auth_service
|
||||||
|
|
||||||
importlib.reload(auth_service)
|
importlib.reload(auth_service)
|
||||||
|
|
||||||
# When no hash in file, should return env var
|
# When no hash in file, should return env var
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests for config.py - IP parsing, environment validation.
|
Unit tests for config.py - IP parsing, environment validation.
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from config import (
|
from config import (
|
||||||
_parse_ip,
|
|
||||||
_ip_matches_ranges,
|
_ip_matches_ranges,
|
||||||
|
_parse_ip,
|
||||||
|
get_client_ip,
|
||||||
|
get_forwarded_client_ip,
|
||||||
is_lan_ip,
|
is_lan_ip,
|
||||||
is_tailscale_ip,
|
is_tailscale_ip,
|
||||||
is_trusted_proxy,
|
is_trusted_proxy,
|
||||||
get_forwarded_client_ip,
|
|
||||||
get_client_ip,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -146,15 +149,15 @@ class TestIsTailscaleIP:
|
|||||||
def test_is_tailscale_ip_invalid(self):
|
def test_is_tailscale_ip_invalid(self):
|
||||||
"""Test non-Tailscale IPs."""
|
"""Test non-Tailscale IPs."""
|
||||||
assert not is_tailscale_ip("100.63.255.255") # Just below range
|
assert not is_tailscale_ip("100.63.255.255") # Just below range
|
||||||
assert not is_tailscale_ip("100.128.0.0") # Just above range
|
assert not is_tailscale_ip("100.128.0.0") # Just above range
|
||||||
assert not is_tailscale_ip("192.168.1.1")
|
assert not is_tailscale_ip("192.168.1.1")
|
||||||
assert not is_tailscale_ip("10.0.0.1")
|
assert not is_tailscale_ip("10.0.0.1")
|
||||||
|
|
||||||
def test_is_tailscale_ip_boundary(self):
|
def test_is_tailscale_ip_boundary(self):
|
||||||
"""Test Tailscale IP range boundaries."""
|
"""Test Tailscale IP range boundaries."""
|
||||||
# 100.64.0.0/10 means 100.64.0.0 to 100.127.255.255
|
# 100.64.0.0/10 means 100.64.0.0 to 100.127.255.255
|
||||||
assert is_tailscale_ip("100.64.0.0") # Start of range
|
assert is_tailscale_ip("100.64.0.0") # Start of range
|
||||||
assert is_tailscale_ip("100.127.255.255") # End of range
|
assert is_tailscale_ip("100.127.255.255") # End of range
|
||||||
|
|
||||||
|
|
||||||
class TestIsTrustedProxy:
|
class TestIsTrustedProxy:
|
||||||
@@ -283,8 +286,10 @@ class TestConfigValidation:
|
|||||||
monkeypatch.setenv("SECRET_KEY", "short")
|
monkeypatch.setenv("SECRET_KEY", "short")
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
||||||
import config
|
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
importlib.reload(config)
|
importlib.reload(config)
|
||||||
|
|
||||||
def test_secret_key_empty(self, monkeypatch):
|
def test_secret_key_empty(self, monkeypatch):
|
||||||
@@ -292,6 +297,8 @@ class TestConfigValidation:
|
|||||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
||||||
import config
|
|
||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
importlib.reload(config)
|
importlib.reload(config)
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Unit tests for rbac.py - Role-based access control.
|
Unit tests for rbac.py - Role-based access control.
|
||||||
"""
|
"""
|
||||||
import pytest
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from rbac import (
|
from rbac import (
|
||||||
load_rbac,
|
DEFAULT_RBAC,
|
||||||
save_rbac,
|
ROLE_GROUP_MAP,
|
||||||
update_rbac,
|
|
||||||
resolve_role,
|
|
||||||
get_pages,
|
get_pages,
|
||||||
get_sidebar_links,
|
get_sidebar_links,
|
||||||
get_sidebar_order,
|
get_sidebar_order,
|
||||||
DEFAULT_RBAC,
|
load_rbac,
|
||||||
ROLE_GROUP_MAP,
|
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")
|
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||||
test_data = {
|
test_data = {
|
||||||
"role_defaults": {"admin": {"pages": "*"}},
|
"role_defaults": {"admin": {"pages": "*"}},
|
||||||
"user_overrides": {"testuser": {"pages": ["dashboard"]}}
|
"user_overrides": {"testuser": {"pages": ["dashboard"]}},
|
||||||
}
|
}
|
||||||
|
|
||||||
save_rbac(test_data)
|
save_rbac(test_data)
|
||||||
@@ -74,6 +75,7 @@ class TestSaveRBAC:
|
|||||||
|
|
||||||
# Remove directory
|
# Remove directory
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
if os.path.exists(os.path.dirname(rbac_file)):
|
if os.path.exists(os.path.dirname(rbac_file)):
|
||||||
shutil.rmtree(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):
|
def test_update_rbac_with_mutator(self, mock_config, temp_rbac_file):
|
||||||
"""Test updating RBAC with mutator function."""
|
"""Test updating RBAC with mutator function."""
|
||||||
|
|
||||||
def add_user_override(data):
|
def add_user_override(data):
|
||||||
data["user_overrides"]["newuser"] = {"pages": ["dashboard", "docker"]}
|
data["user_overrides"]["newuser"] = {"pages": ["dashboard", "docker"]}
|
||||||
return "success"
|
return "success"
|
||||||
@@ -188,6 +191,7 @@ class TestGetPages:
|
|||||||
|
|
||||||
def test_get_pages_with_user_override(self, mock_config, temp_rbac_file):
|
def test_get_pages_with_user_override(self, mock_config, temp_rbac_file):
|
||||||
"""Test getting pages with user-specific override."""
|
"""Test getting pages with user-specific override."""
|
||||||
|
|
||||||
# Add user override
|
# Add user override
|
||||||
def add_override(data):
|
def add_override(data):
|
||||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard", "files"]}
|
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):
|
def test_get_sidebar_links_with_user_override(self, mock_config, temp_rbac_file):
|
||||||
"""Test getting sidebar links with user override."""
|
"""Test getting sidebar links with user override."""
|
||||||
|
|
||||||
def add_override(data):
|
def add_override(data):
|
||||||
data["user_overrides"]["customuser"] = {
|
data["user_overrides"]["customuser"] = {"pages": "*", "sidebar_links": ["dashboard", "docker", "files"]}
|
||||||
"pages": "*",
|
|
||||||
"sidebar_links": ["dashboard", "docker", "files"]
|
|
||||||
}
|
|
||||||
|
|
||||||
update_rbac(add_override)
|
update_rbac(add_override)
|
||||||
|
|
||||||
@@ -236,6 +238,7 @@ class TestGetSidebarLinks:
|
|||||||
|
|
||||||
def test_get_sidebar_links_no_override(self, mock_config, temp_rbac_file):
|
def test_get_sidebar_links_no_override(self, mock_config, temp_rbac_file):
|
||||||
"""Test that user without sidebar_links override gets role default."""
|
"""Test that user without sidebar_links override gets role default."""
|
||||||
|
|
||||||
def add_override(data):
|
def add_override(data):
|
||||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard"]}
|
data["user_overrides"]["customuser"] = {"pages": ["dashboard"]}
|
||||||
# No sidebar_links specified
|
# No sidebar_links specified
|
||||||
@@ -256,10 +259,7 @@ class TestGetSidebarOrder:
|
|||||||
|
|
||||||
def test_get_sidebar_order_with_override(self, mock_config, temp_rbac_file):
|
def test_get_sidebar_order_with_override(self, mock_config, temp_rbac_file):
|
||||||
"""Test getting sidebar order with user override."""
|
"""Test getting sidebar order with user override."""
|
||||||
custom_order = {
|
custom_order = {"Main": ["dashboard", "docker"], "Media": ["navidrome", "immich"]}
|
||||||
"Main": ["dashboard", "docker"],
|
|
||||||
"Media": ["navidrome", "immich"]
|
|
||||||
}
|
|
||||||
|
|
||||||
def add_override(data):
|
def add_override(data):
|
||||||
data["user_overrides"]["customuser"] = {"sidebar_order": custom_order}
|
data["user_overrides"]["customuser"] = {"sidebar_order": custom_order}
|
||||||
|
|||||||
Reference in New Issue
Block a user