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,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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user