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:
@@ -1,12 +1,23 @@
|
||||
"""
|
||||
Shared pytest fixtures for backend tests.
|
||||
"""
|
||||
import os
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
|
||||
# Set up environment variables before any imports during pytest collection
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-at-least-32-chars-long")
|
||||
os.environ.setdefault("ADMIN_USER", "testadmin")
|
||||
os.environ.setdefault("ADMIN_PASSWORD_HASH", "$pbkdf2-sha256$29000$N2YMIQQgBAAgxBgjhPD.vw$1234567890abcdef")
|
||||
os.environ.setdefault("VOLUME_ROOT", "/tmp/test-volume")
|
||||
os.environ.setdefault("DOCKER_HOST", "tcp://mock-docker:2375")
|
||||
os.environ.setdefault("GITEA_URL", "http://mock-gitea:3000")
|
||||
os.environ.setdefault("GITEA_TOKEN", "mock-token")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -30,8 +41,10 @@ def mock_config(temp_volume_root, monkeypatch):
|
||||
monkeypatch.setenv("GITEA_TOKEN", "mock-token")
|
||||
|
||||
# Reload config module to pick up new env vars
|
||||
import config
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
return config
|
||||
@@ -41,12 +54,7 @@ def mock_config(temp_volume_root, monkeypatch):
|
||||
def temp_auth_file(temp_volume_root):
|
||||
"""Create temporary auth.json file."""
|
||||
auth_file = os.path.join(temp_volume_root, "docker/nas-dashboard/auth.json")
|
||||
data = {
|
||||
"totp_secret": "",
|
||||
"passkey_credentials": [],
|
||||
"token_version": 0,
|
||||
"last_totp_ts": 0
|
||||
}
|
||||
data = {"totp_secret": "", "passkey_credentials": [], "token_version": 0, "last_totp_ts": 0}
|
||||
with open(auth_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
return auth_file
|
||||
@@ -60,9 +68,9 @@ def temp_rbac_file(temp_volume_root):
|
||||
"role_defaults": {
|
||||
"admin": {"pages": "*", "sidebar_links": "*"},
|
||||
"member": {"pages": ["dashboard", "docker"], "sidebar_links": "*"},
|
||||
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}
|
||||
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]},
|
||||
},
|
||||
"user_overrides": {}
|
||||
"user_overrides": {},
|
||||
}
|
||||
with open(rbac_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
@@ -73,16 +81,15 @@ def temp_rbac_file(temp_volume_root):
|
||||
def valid_access_token(mock_config):
|
||||
"""Generate a valid access token for testing."""
|
||||
from auth_service import create_access_token
|
||||
return create_access_token(
|
||||
data={"sub": "testuser", "role": "admin"},
|
||||
expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
|
||||
return create_access_token(data={"sub": "testuser", "role": "admin"}, expires_delta=timedelta(minutes=30))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_refresh_token(mock_config, temp_auth_file):
|
||||
"""Generate a valid refresh token for testing."""
|
||||
from auth_service import create_refresh_token
|
||||
|
||||
return create_refresh_token(data={"sub": "testuser", "role": "admin"})
|
||||
|
||||
|
||||
@@ -90,10 +97,8 @@ def valid_refresh_token(mock_config, temp_auth_file):
|
||||
def expired_access_token(mock_config):
|
||||
"""Generate an expired access token for testing."""
|
||||
from auth_service import create_access_token
|
||||
return create_access_token(
|
||||
data={"sub": "testuser", "role": "admin"},
|
||||
expires_delta=timedelta(seconds=-1)
|
||||
)
|
||||
|
||||
return create_access_token(data={"sub": "testuser", "role": "admin"}, expires_delta=timedelta(seconds=-1))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -118,10 +123,7 @@ def mock_docker_client():
|
||||
container.status = "running"
|
||||
container.image = mock_image
|
||||
container.ports = {"80/tcp": [{"HostPort": "8080"}]}
|
||||
container.attrs = {
|
||||
"State": {"Health": {"Status": "healthy"}},
|
||||
"Config": {"Image": "test-image:latest"}
|
||||
}
|
||||
container.attrs = {"State": {"Health": {"Status": "healthy"}}, "Config": {"Image": "test-image:latest"}}
|
||||
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
||||
|
||||
client.containers.list.return_value = [container]
|
||||
@@ -170,8 +172,13 @@ def mock_docker_client_factory():
|
||||
image_tag="my-image:v1.0"
|
||||
)
|
||||
"""
|
||||
def _create_mock(container_name="test-container", container_status="running",
|
||||
image_tag="test-image:latest", container_id="abc123"):
|
||||
|
||||
def _create_mock(
|
||||
container_name="test-container",
|
||||
container_status="running",
|
||||
image_tag="test-image:latest",
|
||||
container_id="abc123",
|
||||
):
|
||||
client = Mock()
|
||||
|
||||
# Mock container image
|
||||
@@ -187,10 +194,7 @@ def mock_docker_client_factory():
|
||||
container.status = container_status
|
||||
container.image = mock_image
|
||||
container.ports = {"80/tcp": [{"HostPort": "8080"}]}
|
||||
container.attrs = {
|
||||
"State": {"Health": {"Status": "healthy"}},
|
||||
"Config": {"Image": image_tag}
|
||||
}
|
||||
container.attrs = {"State": {"Health": {"Status": "healthy"}}, "Config": {"Image": image_tag}}
|
||||
container.logs.return_value = b"test log line 1\ntest log line 2\n"
|
||||
|
||||
client.containers.list.return_value = [container]
|
||||
@@ -229,8 +233,10 @@ def mock_httpx_client():
|
||||
def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client):
|
||||
"""Create FastAPI test client with mocked dependencies."""
|
||||
# Reload routers to pick up new config values
|
||||
import routers.files
|
||||
import importlib
|
||||
|
||||
import routers.files
|
||||
|
||||
importlib.reload(routers.files)
|
||||
|
||||
# Patch Docker client before importing main
|
||||
@@ -241,6 +247,7 @@ def test_app(mock_config, temp_auth_file, temp_rbac_file, mock_docker_client):
|
||||
|
||||
with patch("slowapi.Limiter", return_value=mock_limiter):
|
||||
from main import app
|
||||
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
@@ -258,14 +265,11 @@ def mock_rbac_data():
|
||||
"role_defaults": {
|
||||
"admin": {"pages": "*", "sidebar_links": "*"},
|
||||
"member": {"pages": ["dashboard", "docker", "files"], "sidebar_links": "*"},
|
||||
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]}
|
||||
"viewer": {"pages": ["dashboard"], "sidebar_links": ["dashboard"]},
|
||||
},
|
||||
"user_overrides": {
|
||||
"customuser": {
|
||||
"pages": ["dashboard", "docker"],
|
||||
"sidebar_links": ["dashboard", "docker", "files"]
|
||||
}
|
||||
}
|
||||
"customuser": {"pages": ["dashboard", "docker"], "sidebar_links": ["dashboard", "docker", "files"]}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"""
|
||||
Integration tests for authentication flow.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import pyotp
|
||||
from fastapi import status
|
||||
from auth_service import get_password_hash, save_totp_secret, save_password_hash
|
||||
|
||||
|
||||
class TestLoginFlow:
|
||||
@@ -12,14 +10,15 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_success_no_2fa(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test successful login without 2FA."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
# Set up password
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -33,6 +32,8 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_success_with_2fa(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test successful login with 2FA."""
|
||||
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
@@ -42,8 +43,7 @@ class TestLoginFlow:
|
||||
valid_code = totp.now()
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": valid_code}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": valid_code}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -53,13 +53,14 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_wrong_password(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test login with wrong password."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
password = "correct_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": "wrong_password", "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": "wrong_password", "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -67,27 +68,29 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_wrong_username(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test login with wrong username."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "wronguser", "password": password, "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "wronguser", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_missing_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test login with 2FA enabled but no code provided."""
|
||||
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -95,27 +98,29 @@ class TestLoginFlow:
|
||||
|
||||
def test_login_invalid_2fa_code(self, test_app, mock_config, temp_auth_file, sample_totp_secret):
|
||||
"""Test login with invalid 2FA code."""
|
||||
from auth_service import get_password_hash, save_password_hash, save_totp_secret
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
save_totp_secret(sample_totp_secret)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": "000000"}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": "000000"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_login_sets_cookies(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test that login sets auth cookies."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
password = "test_password"
|
||||
hashed = get_password_hash(password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
"/api/auth/login", json={"username": "testadmin", "password": password, "totp_code": ""}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -129,10 +134,7 @@ class TestRefreshFlow:
|
||||
|
||||
def test_refresh_with_valid_token(self, test_app, mock_config, temp_auth_file, valid_refresh_token):
|
||||
"""Test refreshing with valid refresh token."""
|
||||
response = test_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": valid_refresh_token}
|
||||
)
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_refresh_token})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -152,19 +154,13 @@ class TestRefreshFlow:
|
||||
|
||||
def test_refresh_with_invalid_token(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test refreshing with invalid token."""
|
||||
response = test_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": "invalid.token.here"}
|
||||
)
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": "invalid.token.here"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_refresh_with_expired_token(self, test_app, mock_config, temp_auth_file, expired_access_token):
|
||||
"""Test refreshing with expired token."""
|
||||
response = test_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": expired_access_token}
|
||||
)
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": expired_access_token})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -177,10 +173,7 @@ class TestRefreshFlow:
|
||||
|
||||
def test_refresh_with_access_token_fails(self, test_app, mock_config, temp_auth_file, valid_access_token):
|
||||
"""Test that access token cannot be used for refresh."""
|
||||
response = test_app.post(
|
||||
"/api/auth/refresh",
|
||||
json={"refresh_token": valid_access_token}
|
||||
)
|
||||
response = test_app.post("/api/auth/refresh", json={"refresh_token": valid_access_token})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token type" in response.json()["detail"]
|
||||
@@ -241,11 +234,7 @@ class TestProxyAuth:
|
||||
def test_proxy_auth_from_trusted_source(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test proxy auth from trusted proxy."""
|
||||
response = test_app.get(
|
||||
"/api/auth/proxy",
|
||||
headers={
|
||||
"Remote-User": "proxyuser",
|
||||
"Remote-Groups": "dashboard_admin"
|
||||
}
|
||||
"/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"}
|
||||
)
|
||||
|
||||
# Note: This test may need adjustment based on actual proxy auth implementation
|
||||
@@ -256,11 +245,7 @@ class TestProxyAuth:
|
||||
"""Test proxy auth from untrusted source."""
|
||||
# Mock request from untrusted IP
|
||||
response = test_app.get(
|
||||
"/api/auth/proxy",
|
||||
headers={
|
||||
"Remote-User": "proxyuser",
|
||||
"Remote-Groups": "dashboard_admin"
|
||||
}
|
||||
"/api/auth/proxy", headers={"Remote-User": "proxyuser", "Remote-Groups": "dashboard_admin"}
|
||||
)
|
||||
|
||||
# Should reject untrusted proxy
|
||||
@@ -272,6 +257,8 @@ class TestPasswordChange:
|
||||
|
||||
def test_change_password_success(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test successful password change."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
old_password = "old_password"
|
||||
new_password = "new_password_123"
|
||||
|
||||
@@ -282,16 +269,15 @@ class TestPasswordChange:
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
headers=admin_headers,
|
||||
json={
|
||||
"current_password": old_password,
|
||||
"new_password": new_password
|
||||
}
|
||||
json={"current_password": old_password, "new_password": new_password},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_change_password_wrong_old_password(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test password change with wrong old password."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
old_password = "old_password"
|
||||
hashed = get_password_hash(old_password)
|
||||
save_password_hash(hashed)
|
||||
@@ -299,22 +285,233 @@ class TestPasswordChange:
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
headers=admin_headers,
|
||||
json={
|
||||
"current_password": "wrong_password",
|
||||
"new_password": "new_password_123"
|
||||
}
|
||||
json={"current_password": "wrong_password", "new_password": "new_password_123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_change_password_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test password change without authentication."""
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
json={
|
||||
"current_password": "old",
|
||||
"new_password": "new"
|
||||
}
|
||||
)
|
||||
response = test_app.post("/api/auth/change-password", json={"current_password": "old", "new_password": "new"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_change_password_too_short(self, test_app, mock_config, temp_auth_file, admin_headers):
|
||||
"""Test password change with password too short."""
|
||||
from auth_service import get_password_hash, save_password_hash
|
||||
|
||||
old_password = "old_password"
|
||||
hashed = get_password_hash(old_password)
|
||||
save_password_hash(hashed)
|
||||
|
||||
response = test_app.post(
|
||||
"/api/auth/change-password",
|
||||
headers=admin_headers,
|
||||
json={"current_password": old_password, "new_password": "short"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "at least 8 characters" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestRBACConfig:
|
||||
"""Test RBAC configuration endpoints."""
|
||||
|
||||
def test_get_rbac_config_as_admin(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test getting RBAC config as admin."""
|
||||
response = test_app.get("/api/auth/rbac/config", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "role_defaults" in data or "user_overrides" in data
|
||||
|
||||
def test_get_rbac_config_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot get RBAC config."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.get("/api/auth/rbac/config", headers=headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_get_rbac_config_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test getting RBAC config without authentication."""
|
||||
response = test_app.get("/api/auth/rbac/config")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_set_user_override(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test setting user page override."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/overrides/testuser", headers=admin_headers, json={"pages": ["overview", "docker"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_set_user_override_missing_pages(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test setting user override without pages field."""
|
||||
response = test_app.put("/api/auth/rbac/overrides/testuser", headers=admin_headers, json={})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing pages field" in response.json()["detail"]
|
||||
|
||||
def test_set_user_override_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot set user override."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.put("/api/auth/rbac/overrides/testuser", headers=headers, json={"pages": ["overview"]})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_delete_user_override(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test deleting user override."""
|
||||
# First set an override
|
||||
test_app.put("/api/auth/rbac/overrides/testuser", headers=admin_headers, json={"pages": ["overview"]})
|
||||
|
||||
# Then delete it
|
||||
response = test_app.delete("/api/auth/rbac/overrides/testuser", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_delete_user_override_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot delete user override."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.delete("/api/auth/rbac/overrides/testuser", headers=headers)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_update_role_config(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test updating role configuration."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/roles/member",
|
||||
headers=admin_headers,
|
||||
json={"pages": ["overview", "docker"], "sidebar_links": ["navidrome", "jellyfin"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_update_role_config_with_wildcard(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test updating role with wildcard pages."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/roles/admin", headers=admin_headers, json={"pages": "*", "sidebar_links": "*"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_update_role_config_missing_pages(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test updating role without pages field."""
|
||||
response = test_app.put(
|
||||
"/api/auth/rbac/roles/member", headers=admin_headers, json={"sidebar_links": ["navidrome"]}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing pages field" in response.json()["detail"]
|
||||
|
||||
def test_update_role_config_invalid_pages_type(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test updating role with invalid pages type."""
|
||||
response = test_app.put("/api/auth/rbac/roles/member", headers=admin_headers, json={"pages": "invalid"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "pages must be" in response.json()["detail"]
|
||||
|
||||
def test_update_role_config_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot update role config."""
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.put("/api/auth/rbac/roles/member", headers=headers, json={"pages": ["overview"]})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
class TestPreferences:
|
||||
"""Test user preferences endpoints."""
|
||||
|
||||
def test_get_preferences(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test getting user preferences."""
|
||||
response = test_app.get("/api/auth/preferences", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "sidebar_order" in data
|
||||
|
||||
def test_get_preferences_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test getting preferences without authentication."""
|
||||
response = test_app.get("/api/auth/preferences")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_save_preferences(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test saving user preferences."""
|
||||
response = test_app.put(
|
||||
"/api/auth/preferences",
|
||||
headers=admin_headers,
|
||||
json={"sidebar_order": {"main": ["overview", "docker", "files"], "media": ["navidrome", "jellyfin"]}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_save_preferences_missing_sidebar_order(
|
||||
self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers
|
||||
):
|
||||
"""Test saving preferences without sidebar_order."""
|
||||
response = test_app.put("/api/auth/preferences", headers=admin_headers, json={})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing sidebar_order" in response.json()["detail"]
|
||||
|
||||
def test_save_preferences_invalid_type(self, test_app, mock_config, temp_auth_file, temp_rbac_file, admin_headers):
|
||||
"""Test saving preferences with invalid type."""
|
||||
response = test_app.put("/api/auth/preferences", headers=admin_headers, json={"sidebar_order": "invalid"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Missing sidebar_order dict" in response.json()["detail"]
|
||||
|
||||
def test_save_preferences_without_auth(self, test_app, mock_config, temp_auth_file):
|
||||
"""Test saving preferences without authentication."""
|
||||
response = test_app.put("/api/auth/preferences", json={"sidebar_order": {}})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Integration tests for chat summary operations.
|
||||
"""
|
||||
import pytest
|
||||
import aiosqlite
|
||||
import os
|
||||
from unittest.mock import patch, AsyncMock, MagicMock
|
||||
|
||||
|
||||
class TestChatSummaryDates:
|
||||
"""Test chat summary dates endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dates_empty(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test listing dates when database is empty."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create empty database with schema
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)")
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/dates", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_dates_with_data(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test listing dates with summaries."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create database with test data
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)")
|
||||
await db.execute(
|
||||
"INSERT INTO summaries VALUES (?, ?, ?)", ("2024-01-01", "Summary 1", "2024-01-01T10:00:00")
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO summaries VALUES (?, ?, ?)", ("2024-01-02", "Summary 2", "2024-01-02T10:00:00")
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/dates", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
assert "2024-01-02" in data
|
||||
assert "2024-01-01" in data
|
||||
|
||||
def test_list_dates_without_auth(self, test_app, mock_config):
|
||||
"""Test listing dates without authentication."""
|
||||
response = test_app.get("/api/chat-summary/dates")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestChatSummaryGet:
|
||||
"""Test get summary endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_summary_success(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test getting a summary for a specific date."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create database with test data
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)")
|
||||
await db.execute(
|
||||
"INSERT INTO summaries VALUES (?, ?, ?)",
|
||||
("2024-01-01", "Test summary content", "2024-01-01T10:00:00"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/summary/2024-01-01", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["date"] == "2024-01-01"
|
||||
assert data["content"] == "Test summary content"
|
||||
assert data["created_at"] == "2024-01-01T10:00:00"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_summary_not_found(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test getting a summary for a date that doesn't exist."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create empty database
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE summaries (date TEXT, content TEXT, created_at TEXT)")
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/summary/2024-01-01", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "No summary for this date" in data["detail"]
|
||||
|
||||
def test_get_summary_without_auth(self, test_app, mock_config):
|
||||
"""Test getting summary without authentication."""
|
||||
response = test_app.get("/api/chat-summary/summary/2024-01-01")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestChatSummaryMessages:
|
||||
"""Test get messages endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_success(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test getting messages for a specific date."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create database with test data
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE messages (group_name TEXT, sender_name TEXT, text TEXT, timestamp TEXT)")
|
||||
await db.execute(
|
||||
"INSERT INTO messages VALUES (?, ?, ?, ?)",
|
||||
("Group1", "User1", "Hello", "2024-01-01 10:00:00"),
|
||||
)
|
||||
await db.execute(
|
||||
"INSERT INTO messages VALUES (?, ?, ?, ?)",
|
||||
("Group1", "User2", "Hi there", "2024-01-01 10:01:00"),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/messages/2024-01-01", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 2
|
||||
assert data[0]["group"] == "Group1"
|
||||
assert data[0]["sender"] == "User1"
|
||||
assert data[0]["text"] == "Hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_empty(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test getting messages when none exist for date."""
|
||||
db_path = tmp_path / "test_chat.db"
|
||||
|
||||
# Create empty database
|
||||
async with aiosqlite.connect(str(db_path)) as db:
|
||||
await db.execute("CREATE TABLE messages (group_name TEXT, sender_name TEXT, text TEXT, timestamp TEXT)")
|
||||
await db.commit()
|
||||
|
||||
with patch("routers.chat_summary.CHAT_SUMMARY_DB", str(db_path)):
|
||||
response = test_app.get("/api/chat-summary/messages/2024-01-01", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
assert len(data) == 0
|
||||
|
||||
def test_get_messages_without_auth(self, test_app, mock_config):
|
||||
"""Test getting messages without authentication."""
|
||||
response = test_app.get("/api/chat-summary/messages/2024-01-01")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestChatSummaryTrigger:
|
||||
"""Test trigger summary endpoint."""
|
||||
|
||||
def test_trigger_summary_success(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test triggering a summary generation."""
|
||||
trigger_path = tmp_path / "trigger"
|
||||
|
||||
with patch.dict(os.environ, {"CHAT_SUMMARY_TRIGGER": str(trigger_path)}):
|
||||
response = test_app.post("/api/chat-summary/trigger", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "triggered"
|
||||
assert trigger_path.exists()
|
||||
assert trigger_path.read_text() == "1"
|
||||
|
||||
def test_trigger_summary_creates_directory(self, test_app, mock_config, admin_headers, tmp_path):
|
||||
"""Test that trigger creates parent directory if needed."""
|
||||
trigger_path = tmp_path / "subdir" / "trigger"
|
||||
|
||||
with patch.dict(os.environ, {"CHAT_SUMMARY_TRIGGER": str(trigger_path)}):
|
||||
response = test_app.post("/api/chat-summary/trigger", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert trigger_path.parent.exists()
|
||||
assert trigger_path.exists()
|
||||
|
||||
def test_trigger_summary_without_auth(self, test_app, mock_config):
|
||||
"""Test triggering summary without authentication."""
|
||||
response = test_app.post("/api/chat-summary/trigger")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Integration tests for Docker operations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
class TestDockerContainerList:
|
||||
@@ -35,10 +35,7 @@ class TestDockerContainerActions:
|
||||
"""Test starting a container."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.post(
|
||||
f"/api/docker/containers/{container_id}/start",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.post(f"/api/docker/containers/{container_id}/start", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -48,10 +45,7 @@ class TestDockerContainerActions:
|
||||
"""Test stopping a container."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.post(
|
||||
f"/api/docker/containers/{container_id}/stop",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.post(f"/api/docker/containers/{container_id}/stop", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -59,10 +53,7 @@ class TestDockerContainerActions:
|
||||
"""Test restarting a container."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.post(
|
||||
f"/api/docker/containers/{container_id}/restart",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.post(f"/api/docker/containers/{container_id}/restart", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -74,10 +65,7 @@ class TestDockerContainerActions:
|
||||
|
||||
def test_container_action_invalid_action(self, test_app, admin_headers):
|
||||
"""Test container action with invalid action."""
|
||||
response = test_app.post(
|
||||
"/api/docker/containers/abc123/invalid_action",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.post("/api/docker/containers/abc123/invalid_action", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
@@ -91,10 +79,7 @@ class TestDockerContainerLogs:
|
||||
"""Test getting container logs."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.get(
|
||||
f"/api/docker/containers/{container_id}/logs",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.get(f"/api/docker/containers/{container_id}/logs", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -104,10 +89,7 @@ class TestDockerContainerLogs:
|
||||
"""Test getting container logs with tail limit."""
|
||||
container_id = "abc123"
|
||||
|
||||
response = test_app.get(
|
||||
f"/api/docker/containers/{container_id}/logs?tail=100",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.get(f"/api/docker/containers/{container_id}/logs?tail=100", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -122,10 +104,7 @@ class TestDockerContainerLogs:
|
||||
# Note: In this test setup, the mock always returns a container
|
||||
# Testing actual error handling would require a different approach
|
||||
# For now, verify the endpoint works with the mock
|
||||
response = test_app.get(
|
||||
"/api/docker/containers/nonexistent/logs",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.get("/api/docker/containers/nonexistent/logs", headers=admin_headers)
|
||||
|
||||
# With the current mock setup, this will succeed
|
||||
assert response.status_code == 200
|
||||
@@ -137,12 +116,11 @@ class TestDockerPermissions:
|
||||
@pytest.fixture
|
||||
def member_token(self, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Create token for member role."""
|
||||
from auth_service import create_access_token
|
||||
from datetime import timedelta
|
||||
return create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"},
|
||||
expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
return create_access_token(data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30))
|
||||
|
||||
@pytest.fixture
|
||||
def member_headers(self, member_token):
|
||||
@@ -158,22 +136,19 @@ class TestDockerPermissions:
|
||||
|
||||
def test_member_cannot_start_container(self, test_app, member_headers):
|
||||
"""Test that member cannot start containers (admin only)."""
|
||||
response = test_app.post(
|
||||
"/api/docker/containers/abc123/start",
|
||||
headers=member_headers
|
||||
)
|
||||
response = test_app.post("/api/docker/containers/abc123/start", headers=member_headers)
|
||||
|
||||
# Should be forbidden for non-admin
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_viewer_can_list_containers(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that viewer can list containers if they have docker page access."""
|
||||
from auth_service import create_access_token
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
viewer_token = create_access_token(
|
||||
data={"sub": "vieweruser", "role": "viewer"},
|
||||
expires_delta=timedelta(minutes=30)
|
||||
data={"sub": "vieweruser", "role": "viewer"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {viewer_token}"}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Integration tests for file operations.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import os
|
||||
|
||||
|
||||
@@ -10,11 +10,7 @@ class TestFileBrowse:
|
||||
|
||||
def test_browse_root(self, test_app, admin_headers, temp_volume_root):
|
||||
"""Test browsing root directory."""
|
||||
response = test_app.get(
|
||||
"/api/files/browse",
|
||||
headers=admin_headers,
|
||||
params={"path": "/"}
|
||||
)
|
||||
response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -28,22 +24,14 @@ class TestFileBrowse:
|
||||
|
||||
def test_browse_blocked_directory(self, test_app, admin_headers):
|
||||
"""Test browsing blocked directory."""
|
||||
response = test_app.get(
|
||||
"/api/files/browse",
|
||||
headers=admin_headers,
|
||||
params={"path": "/@appstore"}
|
||||
)
|
||||
response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/@appstore"})
|
||||
|
||||
# Should be forbidden
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_browse_path_traversal_attempt(self, test_app, admin_headers):
|
||||
"""Test path traversal protection."""
|
||||
response = test_app.get(
|
||||
"/api/files/browse",
|
||||
headers=admin_headers,
|
||||
params={"path": "/../../../etc"}
|
||||
)
|
||||
response = test_app.get("/api/files/browse", headers=admin_headers, params={"path": "/../../../etc"})
|
||||
|
||||
# Should be forbidden
|
||||
assert response.status_code == 403
|
||||
@@ -57,12 +45,7 @@ class TestFileUpload:
|
||||
test_content = b"test file content"
|
||||
files = {"file": ("test.txt", test_content, "text/plain")}
|
||||
|
||||
response = test_app.post(
|
||||
"/api/files/upload",
|
||||
headers=admin_headers,
|
||||
files=files,
|
||||
params={"path": "/"}
|
||||
)
|
||||
response = test_app.post("/api/files/upload", headers=admin_headers, files=files, params={"path": "/"})
|
||||
|
||||
assert response.status_code in [200, 201]
|
||||
|
||||
@@ -70,32 +53,23 @@ class TestFileUpload:
|
||||
"""Test upload without authentication."""
|
||||
files = {"file": ("test.txt", b"content", "text/plain")}
|
||||
|
||||
response = test_app.post(
|
||||
"/api/files/upload",
|
||||
files=files,
|
||||
params={"path": "/"}
|
||||
)
|
||||
response = test_app.post("/api/files/upload", files=files, params={"path": "/"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_upload_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot upload."""
|
||||
from auth_service import create_access_token
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"},
|
||||
expires_delta=timedelta(minutes=30)
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
files = {"file": ("test.txt", b"content", "text/plain")}
|
||||
|
||||
response = test_app.post(
|
||||
"/api/files/upload",
|
||||
headers=headers,
|
||||
files=files,
|
||||
params={"path": "/"}
|
||||
)
|
||||
response = test_app.post("/api/files/upload", headers=headers, files=files, params={"path": "/"})
|
||||
|
||||
# Upload should be admin-only
|
||||
assert response.status_code == 403
|
||||
@@ -111,39 +85,28 @@ class TestFileDelete:
|
||||
with open(test_file, "w") as f:
|
||||
f.write("test content")
|
||||
|
||||
response = test_app.delete(
|
||||
"/api/files/delete",
|
||||
headers=admin_headers,
|
||||
params={"path": "/test_delete.txt"}
|
||||
)
|
||||
response = test_app.delete("/api/files/delete", headers=admin_headers, params={"path": "/test_delete.txt"})
|
||||
|
||||
assert response.status_code in [200, 204]
|
||||
|
||||
def test_delete_without_auth(self, test_app):
|
||||
"""Test delete without authentication."""
|
||||
response = test_app.delete(
|
||||
"/api/files/delete",
|
||||
params={"path": "/test.txt"}
|
||||
)
|
||||
response = test_app.delete("/api/files/delete", params={"path": "/test.txt"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_delete_as_member_forbidden(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot delete."""
|
||||
from auth_service import create_access_token
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"},
|
||||
expires_delta=timedelta(minutes=30)
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
response = test_app.delete(
|
||||
"/api/files/delete",
|
||||
headers=headers,
|
||||
params={"path": "/test.txt"}
|
||||
)
|
||||
response = test_app.delete("/api/files/delete", headers=headers, params={"path": "/test.txt"})
|
||||
|
||||
# Delete should be admin-only
|
||||
assert response.status_code == 403
|
||||
@@ -160,30 +123,19 @@ class TestFileDownload:
|
||||
with open(test_file, "w") as f:
|
||||
f.write(test_content)
|
||||
|
||||
response = test_app.get(
|
||||
"/api/files/download",
|
||||
headers=admin_headers,
|
||||
params={"path": "/test_download.txt"}
|
||||
)
|
||||
response = test_app.get("/api/files/download", headers=admin_headers, params={"path": "/test_download.txt"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert test_content in response.text or response.content == test_content.encode()
|
||||
|
||||
def test_download_without_auth(self, test_app):
|
||||
"""Test download without authentication."""
|
||||
response = test_app.get(
|
||||
"/api/files/download",
|
||||
params={"path": "/test.txt"}
|
||||
)
|
||||
response = test_app.get("/api/files/download", params={"path": "/test.txt"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_download_nonexistent_file(self, test_app, admin_headers):
|
||||
"""Test downloading nonexistent file."""
|
||||
response = test_app.get(
|
||||
"/api/files/download",
|
||||
headers=admin_headers,
|
||||
params={"path": "/nonexistent.txt"}
|
||||
)
|
||||
response = test_app.get("/api/files/download", headers=admin_headers, params={"path": "/nonexistent.txt"})
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Integration tests for Gitea operations.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -27,19 +28,13 @@ class TestGiteaCommits:
|
||||
@pytest.mark.skip(reason="Requires external Gitea API - tested manually")
|
||||
def test_list_commits(self, test_app, admin_headers):
|
||||
"""Test listing commits for a repository."""
|
||||
response = test_app.get(
|
||||
"/api/gitea/repos/owner/repo/commits",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.get("/api/gitea/repos/owner/repo/commits", headers=admin_headers)
|
||||
# This would require a real Gitea instance
|
||||
assert response.status_code in [200, 404, 500, 503]
|
||||
|
||||
def test_list_commits_invalid_owner(self, test_app, admin_headers):
|
||||
"""Test listing commits with invalid owner name."""
|
||||
response = test_app.get(
|
||||
"/api/gitea/repos/invalid@owner/repo/commits",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.get("/api/gitea/repos/invalid@owner/repo/commits", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
@@ -48,10 +43,7 @@ class TestGiteaCommits:
|
||||
|
||||
def test_list_commits_invalid_repo(self, test_app, admin_headers):
|
||||
"""Test listing commits with invalid repo name."""
|
||||
response = test_app.get(
|
||||
"/api/gitea/repos/owner/invalid@repo/commits",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.get("/api/gitea/repos/owner/invalid@repo/commits", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.json()
|
||||
@@ -60,19 +52,13 @@ class TestGiteaCommits:
|
||||
|
||||
def test_list_commits_special_chars_in_owner(self, test_app, admin_headers):
|
||||
"""Test listing commits with special characters in owner."""
|
||||
response = test_app.get(
|
||||
"/api/gitea/repos/owner$/repo/commits",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.get("/api/gitea/repos/owner$/repo/commits", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_list_commits_special_chars_in_repo(self, test_app, admin_headers):
|
||||
"""Test listing commits with special characters in repo."""
|
||||
response = test_app.get(
|
||||
"/api/gitea/repos/owner/repo!/commits",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.get("/api/gitea/repos/owner/repo!/commits", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -81,10 +67,7 @@ class TestGiteaCommits:
|
||||
"""Test that valid names with allowed characters pass validation."""
|
||||
# Valid characters: a-zA-Z0-9._-
|
||||
# This will fail at the HTTP call level, but should pass validation
|
||||
response = test_app.get(
|
||||
"/api/gitea/repos/valid-owner_123/repo.name-456/commits",
|
||||
headers=admin_headers
|
||||
)
|
||||
response = test_app.get("/api/gitea/repos/valid-owner_123/repo.name-456/commits", headers=admin_headers)
|
||||
|
||||
# Should not be 400 (validation error), but may be 500/503 (HTTP error)
|
||||
assert response.status_code != 400
|
||||
@@ -94,4 +77,3 @@ class TestGiteaCommits:
|
||||
response = test_app.get("/api/gitea/repos/owner/repo/commits")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Integration tests for LiteLLM health check operations.
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
||||
class TestLiteLLMHealth:
|
||||
"""Test LiteLLM health check endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_success(self, test_app, mock_config, admin_headers, monkeypatch):
|
||||
"""Test successful health check."""
|
||||
monkeypatch.setenv("LITELLM_URL", "http://mock-litellm:4000")
|
||||
monkeypatch.setenv("LITELLM_HEALTH_API_KEY", "")
|
||||
|
||||
# Mock successful response
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = True
|
||||
mock_response.status_code = 200
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
|
||||
|
||||
response = test_app.get("/api/litellm/health", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["status"] == "up"
|
||||
assert "latency_ms" in data
|
||||
assert data["http_status"] == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_auth_required(self, test_app, mock_config, admin_headers, monkeypatch):
|
||||
"""Test health check when auth is required but not provided."""
|
||||
monkeypatch.setenv("LITELLM_URL", "http://mock-litellm:4000")
|
||||
monkeypatch.setenv("LITELLM_HEALTH_API_KEY", "")
|
||||
|
||||
# Mock 401 response without API key
|
||||
mock_response = AsyncMock()
|
||||
mock_response.is_success = False
|
||||
mock_response.status_code = 401
|
||||
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get.return_value = mock_response
|
||||
|
||||
response = test_app.get("/api/litellm/health", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["status"] == "auth_required"
|
||||
assert data["http_status"] == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_check_connection_error(self, test_app, mock_config, admin_headers, monkeypatch):
|
||||
"""Test health check when connection fails."""
|
||||
monkeypatch.setenv("LITELLM_URL", "http://mock-litellm:4000")
|
||||
monkeypatch.setenv("LITELLM_HEALTH_API_KEY", "")
|
||||
|
||||
# Mock connection error
|
||||
with patch("httpx.AsyncClient") as mock_client:
|
||||
mock_client.return_value.__aenter__.return_value.get.side_effect = Exception("Connection refused")
|
||||
|
||||
response = test_app.get("/api/litellm/health", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is False
|
||||
assert data["status"] == "down"
|
||||
assert "Connection refused" in data["error"]
|
||||
|
||||
def test_health_check_without_auth(self, test_app, mock_config):
|
||||
"""Test health check without authentication."""
|
||||
response = test_app.get("/api/litellm/health")
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
Integration tests for system operations.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import os
|
||||
from unittest.mock import patch, Mock
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSystemStats:
|
||||
@@ -17,7 +19,7 @@ class TestSystemStats:
|
||||
mock_disk.used = 500000000000
|
||||
mock_disk.free = 500000000000
|
||||
|
||||
with patch('shutil.disk_usage', return_value=mock_disk):
|
||||
with patch("shutil.disk_usage", return_value=mock_disk):
|
||||
response = test_app.get("/api/system/stats", headers=admin_headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -81,11 +83,7 @@ class TestAuditLog:
|
||||
|
||||
def test_get_audit_log_with_limit(self, test_app, admin_headers, temp_volume_root):
|
||||
"""Test getting audit log with line limit."""
|
||||
response = test_app.get(
|
||||
"/api/system/audit-log",
|
||||
headers=admin_headers,
|
||||
params={"lines": 50}
|
||||
)
|
||||
response = test_app.get("/api/system/audit-log", headers=admin_headers, params={"lines": 50})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -104,22 +102,14 @@ class TestNotification:
|
||||
@pytest.mark.skip(reason="Requires Telegram bot token and makes external API call")
|
||||
def test_send_notification_success(self, test_app, admin_headers):
|
||||
"""Test sending a notification."""
|
||||
response = test_app.post(
|
||||
"/api/system/notify",
|
||||
headers=admin_headers,
|
||||
json={"message": "Test notification"}
|
||||
)
|
||||
response = test_app.post("/api/system/notify", headers=admin_headers, json={"message": "Test notification"})
|
||||
|
||||
# This will fail without proper Telegram config
|
||||
assert response.status_code in [200, 400]
|
||||
|
||||
def test_send_notification_without_message(self, test_app, admin_headers):
|
||||
"""Test sending notification without message."""
|
||||
response = test_app.post(
|
||||
"/api/system/notify",
|
||||
headers=admin_headers,
|
||||
json={}
|
||||
)
|
||||
response = test_app.post("/api/system/notify", headers=admin_headers, json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
@@ -127,10 +117,7 @@ class TestNotification:
|
||||
|
||||
def test_send_notification_without_auth(self, test_app):
|
||||
"""Test sending notification without authentication."""
|
||||
response = test_app.post(
|
||||
"/api/system/notify",
|
||||
json={"message": "Test"}
|
||||
)
|
||||
response = test_app.post("/api/system/notify", json={"message": "Test"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -140,6 +127,7 @@ class TestOpenClawToken:
|
||||
|
||||
def test_get_openclaw_token_as_admin(self, test_app, admin_headers, mock_config, monkeypatch):
|
||||
"""Test getting OpenClaw token as admin from LAN."""
|
||||
|
||||
# Mock the IP check to return a LAN IP
|
||||
def mock_get_client_ip(request):
|
||||
return "192.168.1.100"
|
||||
@@ -148,6 +136,7 @@ class TestOpenClawToken:
|
||||
return True
|
||||
|
||||
import config
|
||||
|
||||
monkeypatch.setattr(config, "get_client_ip", mock_get_client_ip)
|
||||
monkeypatch.setattr(config, "is_lan_ip", mock_is_lan_ip)
|
||||
|
||||
@@ -159,6 +148,7 @@ class TestOpenClawToken:
|
||||
|
||||
def test_get_openclaw_token_non_lan(self, test_app, admin_headers, monkeypatch):
|
||||
"""Test getting OpenClaw token from non-LAN IP."""
|
||||
|
||||
# Mock the IP check to return a non-LAN IP
|
||||
def mock_get_client_ip(request):
|
||||
return "1.2.3.4"
|
||||
@@ -167,6 +157,7 @@ class TestOpenClawToken:
|
||||
return False
|
||||
|
||||
import config
|
||||
|
||||
monkeypatch.setattr(config, "get_client_ip", mock_get_client_ip)
|
||||
monkeypatch.setattr(config, "is_lan_ip", mock_is_lan_ip)
|
||||
|
||||
@@ -176,12 +167,12 @@ class TestOpenClawToken:
|
||||
|
||||
def test_get_openclaw_token_as_member(self, test_app, mock_config, temp_auth_file, temp_rbac_file):
|
||||
"""Test that non-admin cannot get OpenClaw token."""
|
||||
from auth_service import create_access_token
|
||||
from datetime import timedelta
|
||||
|
||||
from auth_service import create_access_token
|
||||
|
||||
member_token = create_access_token(
|
||||
data={"sub": "memberuser", "role": "member"},
|
||||
expires_delta=timedelta(minutes=30)
|
||||
data={"sub": "memberuser", "role": "member"}, expires_delta=timedelta(minutes=30)
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {member_token}"}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Integration tests for TOTP 2FA operations.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import pyotp
|
||||
|
||||
|
||||
@@ -38,9 +38,7 @@ class TestTOTPSetup:
|
||||
|
||||
# Verify the code
|
||||
response = test_app.post(
|
||||
"/api/auth/setup-2fa/verify",
|
||||
headers=admin_headers,
|
||||
json={"secret": secret, "code": valid_code}
|
||||
"/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": secret, "code": valid_code}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -51,9 +49,7 @@ class TestTOTPSetup:
|
||||
def test_verify_2fa_setup_invalid_code(self, test_app, admin_headers):
|
||||
"""Test verifying 2FA with invalid code."""
|
||||
response = test_app.post(
|
||||
"/api/auth/setup-2fa/verify",
|
||||
headers=admin_headers,
|
||||
json={"secret": "JBSWY3DPEHPK3PXP", "code": "000000"}
|
||||
"/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": "JBSWY3DPEHPK3PXP", "code": "000000"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -63,10 +59,7 @@ class TestTOTPSetup:
|
||||
|
||||
def test_verify_2fa_without_auth(self, test_app):
|
||||
"""Test verifying 2FA without authentication."""
|
||||
response = test_app.post(
|
||||
"/api/auth/setup-2fa/verify",
|
||||
json={"secret": "JBSWY3DPEHPK3PXP", "code": "123456"}
|
||||
)
|
||||
response = test_app.post("/api/auth/setup-2fa/verify", json={"secret": "JBSWY3DPEHPK3PXP", "code": "123456"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -100,14 +93,13 @@ class TestTOTPWorkflow:
|
||||
totp = pyotp.TOTP(secret)
|
||||
valid_code = totp.now()
|
||||
verify_response = test_app.post(
|
||||
"/api/auth/setup-2fa/verify",
|
||||
headers=admin_headers,
|
||||
json={"secret": secret, "code": valid_code}
|
||||
"/api/auth/setup-2fa/verify", headers=admin_headers, json={"secret": secret, "code": valid_code}
|
||||
)
|
||||
assert verify_response.status_code == 200
|
||||
|
||||
# Step 3: Verify 2FA is enabled by checking auth_service
|
||||
from auth_service import load_totp_secret
|
||||
|
||||
saved_secret = load_totp_secret()
|
||||
assert saved_secret == secret
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Basic diagnostic tests to validate test environment.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
|
||||
def test_python_version():
|
||||
@@ -13,18 +13,19 @@ def test_python_version():
|
||||
def test_imports():
|
||||
"""Verify all required modules can be imported."""
|
||||
try:
|
||||
import fastapi
|
||||
import uvicorn
|
||||
import asyncssh
|
||||
import docker
|
||||
import fastapi
|
||||
import httpx
|
||||
import jwt
|
||||
import passlib
|
||||
import pyotp
|
||||
import asyncssh
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import pytest_cov
|
||||
import pytest_mock
|
||||
import uvicorn
|
||||
|
||||
assert True
|
||||
except ImportError as e:
|
||||
pytest.fail(f"Import failed: {e}")
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
"""
|
||||
Unit tests for auth.py - JWT, TOTP, password hashing, encryption.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pyotp
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from auth_service import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
_decrypt,
|
||||
_encrypt,
|
||||
clear_passkey_credentials,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
user_from_access_token,
|
||||
verify_totp,
|
||||
load_totp_secret,
|
||||
save_totp_secret,
|
||||
load_password_hash,
|
||||
save_password_hash,
|
||||
get_password_hash,
|
||||
increment_token_version,
|
||||
verify_token_version,
|
||||
_encrypt,
|
||||
_decrypt,
|
||||
load_passkey_credentials,
|
||||
load_password_hash,
|
||||
load_totp_secret,
|
||||
save_passkey_credential,
|
||||
clear_passkey_credentials,
|
||||
save_password_hash,
|
||||
save_totp_secret,
|
||||
user_from_access_token,
|
||||
verify_password,
|
||||
verify_token_version,
|
||||
verify_totp,
|
||||
)
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
class TestPasswordHashing:
|
||||
@@ -79,8 +82,8 @@ class TestJWTTokens:
|
||||
token = create_access_token(data, expires_delta)
|
||||
|
||||
payload = jwt.decode(token, mock_config.SECRET_KEY, algorithms=[mock_config.ALGORITHM])
|
||||
exp_time = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
||||
now = datetime.now(timezone.utc)
|
||||
exp_time = datetime.fromtimestamp(payload["exp"], tz=UTC)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Should expire in approximately 60 minutes
|
||||
time_diff = (exp_time - now).total_seconds()
|
||||
@@ -111,10 +114,7 @@ class TestJWTTokens:
|
||||
|
||||
def test_user_from_expired_token(self, mock_config, temp_rbac_file):
|
||||
"""Test that expired token raises HTTPException."""
|
||||
token = create_access_token(
|
||||
{"sub": "testuser", "role": "admin"},
|
||||
expires_delta=timedelta(seconds=-1)
|
||||
)
|
||||
token = create_access_token({"sub": "testuser", "role": "admin"}, expires_delta=timedelta(seconds=-1))
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
user_from_access_token(token)
|
||||
@@ -141,9 +141,9 @@ class TestJWTTokens:
|
||||
"""Test that token without username fails."""
|
||||
# Create token without 'sub' field
|
||||
token = jwt.encode(
|
||||
{"role": "admin", "type": "access", "exp": datetime.now(timezone.utc) + timedelta(minutes=30)},
|
||||
{"role": "admin", "type": "access", "exp": datetime.now(UTC) + timedelta(minutes=30)},
|
||||
mock_config.SECRET_KEY,
|
||||
algorithm=mock_config.ALGORITHM
|
||||
algorithm=mock_config.ALGORITHM,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -209,7 +209,9 @@ class TestTOTP:
|
||||
|
||||
monkeypatch.setenv("TOTP_SECRET", "ENV_SECRET_KEY")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
# When no secret in file, should return env var
|
||||
@@ -237,7 +239,9 @@ class TestTOTP:
|
||||
# Also clear environment variable fallback
|
||||
monkeypatch.setenv("TOTP_SECRET", "")
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
# No secret saved, should return True (2FA disabled)
|
||||
assert verify_totp("any_code")
|
||||
@@ -248,10 +252,11 @@ class TestTOTP:
|
||||
|
||||
# Ensure auth file is properly initialized with last_totp_ts
|
||||
import json
|
||||
with open(temp_auth_file, 'r') as f:
|
||||
|
||||
with open(temp_auth_file) as f:
|
||||
data = json.load(f)
|
||||
data['last_totp_ts'] = 0
|
||||
with open(temp_auth_file, 'w') as f:
|
||||
data["last_totp_ts"] = 0
|
||||
with open(temp_auth_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
totp = pyotp.TOTP(sample_totp_secret)
|
||||
@@ -280,15 +285,18 @@ class TestPasswordPersistence:
|
||||
"""Test loading password hash from environment variable."""
|
||||
# Clear any hash in the file first
|
||||
import json
|
||||
with open(temp_auth_file, 'r') as f:
|
||||
|
||||
with open(temp_auth_file) as f:
|
||||
data = json.load(f)
|
||||
data['password_hash'] = ""
|
||||
with open(temp_auth_file, 'w') as f:
|
||||
data["password_hash"] = ""
|
||||
with open(temp_auth_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# Reload auth_service to clear any cached data
|
||||
import auth_service
|
||||
import importlib
|
||||
|
||||
import auth_service
|
||||
|
||||
importlib.reload(auth_service)
|
||||
|
||||
# When no hash in file, should return env var
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
"""
|
||||
Unit tests for config.py - IP parsing, environment validation.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from config import (
|
||||
_parse_ip,
|
||||
_ip_matches_ranges,
|
||||
_parse_ip,
|
||||
get_client_ip,
|
||||
get_forwarded_client_ip,
|
||||
is_lan_ip,
|
||||
is_tailscale_ip,
|
||||
is_trusted_proxy,
|
||||
get_forwarded_client_ip,
|
||||
get_client_ip,
|
||||
)
|
||||
|
||||
|
||||
@@ -146,15 +149,15 @@ class TestIsTailscaleIP:
|
||||
def test_is_tailscale_ip_invalid(self):
|
||||
"""Test non-Tailscale IPs."""
|
||||
assert not is_tailscale_ip("100.63.255.255") # Just below range
|
||||
assert not is_tailscale_ip("100.128.0.0") # Just above range
|
||||
assert not is_tailscale_ip("100.128.0.0") # Just above range
|
||||
assert not is_tailscale_ip("192.168.1.1")
|
||||
assert not is_tailscale_ip("10.0.0.1")
|
||||
|
||||
def test_is_tailscale_ip_boundary(self):
|
||||
"""Test Tailscale IP range boundaries."""
|
||||
# 100.64.0.0/10 means 100.64.0.0 to 100.127.255.255
|
||||
assert is_tailscale_ip("100.64.0.0") # Start of range
|
||||
assert is_tailscale_ip("100.127.255.255") # End of range
|
||||
assert is_tailscale_ip("100.64.0.0") # Start of range
|
||||
assert is_tailscale_ip("100.127.255.255") # End of range
|
||||
|
||||
|
||||
class TestIsTrustedProxy:
|
||||
@@ -283,8 +286,10 @@ class TestConfigValidation:
|
||||
monkeypatch.setenv("SECRET_KEY", "short")
|
||||
|
||||
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
||||
import config
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
def test_secret_key_empty(self, monkeypatch):
|
||||
@@ -292,6 +297,8 @@ class TestConfigValidation:
|
||||
monkeypatch.delenv("SECRET_KEY", raising=False)
|
||||
|
||||
with pytest.raises(RuntimeError, match="SECRET_KEY must be set and at least 32 characters"):
|
||||
import config
|
||||
import importlib
|
||||
|
||||
import config
|
||||
|
||||
importlib.reload(config)
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
"""
|
||||
Unit tests for rbac.py - Role-based access control.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from rbac import (
|
||||
load_rbac,
|
||||
save_rbac,
|
||||
update_rbac,
|
||||
resolve_role,
|
||||
DEFAULT_RBAC,
|
||||
ROLE_GROUP_MAP,
|
||||
get_pages,
|
||||
get_sidebar_links,
|
||||
get_sidebar_order,
|
||||
DEFAULT_RBAC,
|
||||
ROLE_GROUP_MAP,
|
||||
load_rbac,
|
||||
resolve_role,
|
||||
save_rbac,
|
||||
update_rbac,
|
||||
)
|
||||
|
||||
|
||||
@@ -58,7 +59,7 @@ class TestSaveRBAC:
|
||||
rbac_file = os.path.join(temp_volume_root, "docker/nas-dashboard/rbac.json")
|
||||
test_data = {
|
||||
"role_defaults": {"admin": {"pages": "*"}},
|
||||
"user_overrides": {"testuser": {"pages": ["dashboard"]}}
|
||||
"user_overrides": {"testuser": {"pages": ["dashboard"]}},
|
||||
}
|
||||
|
||||
save_rbac(test_data)
|
||||
@@ -74,6 +75,7 @@ class TestSaveRBAC:
|
||||
|
||||
# Remove directory
|
||||
import shutil
|
||||
|
||||
if os.path.exists(os.path.dirname(rbac_file)):
|
||||
shutil.rmtree(os.path.dirname(rbac_file))
|
||||
|
||||
@@ -88,6 +90,7 @@ class TestUpdateRBAC:
|
||||
|
||||
def test_update_rbac_with_mutator(self, mock_config, temp_rbac_file):
|
||||
"""Test updating RBAC with mutator function."""
|
||||
|
||||
def add_user_override(data):
|
||||
data["user_overrides"]["newuser"] = {"pages": ["dashboard", "docker"]}
|
||||
return "success"
|
||||
@@ -188,6 +191,7 @@ class TestGetPages:
|
||||
|
||||
def test_get_pages_with_user_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting pages with user-specific override."""
|
||||
|
||||
# Add user override
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard", "files"]}
|
||||
@@ -223,11 +227,9 @@ class TestGetSidebarLinks:
|
||||
|
||||
def test_get_sidebar_links_with_user_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar links with user override."""
|
||||
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {
|
||||
"pages": "*",
|
||||
"sidebar_links": ["dashboard", "docker", "files"]
|
||||
}
|
||||
data["user_overrides"]["customuser"] = {"pages": "*", "sidebar_links": ["dashboard", "docker", "files"]}
|
||||
|
||||
update_rbac(add_override)
|
||||
|
||||
@@ -236,6 +238,7 @@ class TestGetSidebarLinks:
|
||||
|
||||
def test_get_sidebar_links_no_override(self, mock_config, temp_rbac_file):
|
||||
"""Test that user without sidebar_links override gets role default."""
|
||||
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"pages": ["dashboard"]}
|
||||
# No sidebar_links specified
|
||||
@@ -256,10 +259,7 @@ class TestGetSidebarOrder:
|
||||
|
||||
def test_get_sidebar_order_with_override(self, mock_config, temp_rbac_file):
|
||||
"""Test getting sidebar order with user override."""
|
||||
custom_order = {
|
||||
"Main": ["dashboard", "docker"],
|
||||
"Media": ["navidrome", "immich"]
|
||||
}
|
||||
custom_order = {"Main": ["dashboard", "docker"], "Media": ["navidrome", "immich"]}
|
||||
|
||||
def add_override(data):
|
||||
data["user_overrides"]["customuser"] = {"sidebar_order": custom_order}
|
||||
|
||||
Reference in New Issue
Block a user