"""Tests for /api/logging endpoints. Covers: - GET /api/logging/config — returns correct shape with log_level and flags - GET /api/logging/files — lists log files - POST /api/logging/test — test log trigger - POST /api/logging/cleanup — file cleanup - GET /api/logging/files/{name}/tail — tail endpoint These tests guard against regressions of the 404 that previously caused the config modal to fail on load. """ from __future__ import annotations import time from pathlib import Path from unittest.mock import Mock, patch import pytest from httpx import ASGITransport, AsyncClient from src.server.fastapi_app import app from src.server.models.config import AppConfig, LoggingConfig from src.server.services.auth_service import auth_service # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(autouse=True) def reset_auth_state(): """Reset auth service state before each test.""" auth_service._hash = None auth_service._failed.clear() if not auth_service.is_configured(): auth_service.setup_master_password("TestPass123!") yield auth_service._hash = None auth_service._failed.clear() @pytest.fixture async def client(): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac @pytest.fixture async def auth_client(client): """Authenticated async client.""" response = await client.post("/api/auth/login", json={"password": "TestPass123!"}) assert response.status_code == 200, response.text token = response.json()["access_token"] client.headers.update({"Authorization": f"Bearer {token}"}) yield client @pytest.fixture def mock_config_service(): """Return a mock config service with minimal logging config.""" service = Mock() app_cfg = Mock(spec=AppConfig) app_cfg.logging = LoggingConfig( level="INFO", file=None, max_bytes=None, backup_count=3, ) service.load_config = Mock(return_value=app_cfg) return service # --------------------------------------------------------------------------- # GET /api/logging/config # --------------------------------------------------------------------------- class TestGetLoggingConfig: """GET /api/logging/config returns the expected shape.""" @pytest.mark.asyncio async def test_returns_success_shape(self, auth_client, mock_config_service): with patch( "src.server.api.logging.get_config_service", return_value=mock_config_service, ): response = await auth_client.get("/api/logging/config") assert response.status_code == 200 body = response.json() assert body["success"] is True assert "config" in body @pytest.mark.asyncio async def test_config_contains_log_level(self, auth_client, mock_config_service): with patch( "src.server.api.logging.get_config_service", return_value=mock_config_service, ): response = await auth_client.get("/api/logging/config") config = response.json()["config"] assert config["log_level"] == "INFO" @pytest.mark.asyncio async def test_config_contains_boolean_flags(self, auth_client, mock_config_service): with patch( "src.server.api.logging.get_config_service", return_value=mock_config_service, ): response = await auth_client.get("/api/logging/config") config = response.json()["config"] assert "enable_console_logging" in config assert "enable_fail2ban_logging" in config assert isinstance(config["enable_console_logging"], bool) @pytest.mark.asyncio async def test_requires_authentication(self, client): response = await client.get("/api/logging/config") assert response.status_code in (401, 403) # --------------------------------------------------------------------------- # GET /api/logging/files # --------------------------------------------------------------------------- class TestListLogFiles: """GET /api/logging/files lists available log files.""" @pytest.mark.asyncio async def test_returns_success_and_files_list(self, auth_client, tmp_path, monkeypatch): # Create a fake log file (tmp_path / "server.log").write_text("line1\nline2\n") monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path) response = await auth_client.get("/api/logging/files") assert response.status_code == 200 body = response.json() assert body["success"] is True assert isinstance(body["files"], list) @pytest.mark.asyncio async def test_file_metadata_shape(self, auth_client, tmp_path, monkeypatch): (tmp_path / "app.log").write_text("test content") monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path) response = await auth_client.get("/api/logging/files") files = response.json()["files"] assert len(files) >= 1 f = files[0] assert "name" in f assert "size_mb" in f assert "modified" in f @pytest.mark.asyncio async def test_empty_log_dir(self, auth_client, tmp_path, monkeypatch): monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path) response = await auth_client.get("/api/logging/files") assert response.status_code == 200 assert response.json()["files"] == [] # --------------------------------------------------------------------------- # GET /api/logging/files/{filename}/tail # --------------------------------------------------------------------------- class TestTailLogFile: """GET /api/logging/files/{filename}/tail returns log lines.""" @pytest.mark.asyncio async def test_returns_last_n_lines(self, auth_client, tmp_path, monkeypatch): log_content = "\n".join(f"line {i}" for i in range(200)) (tmp_path / "server.log").write_text(log_content) monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path) response = await auth_client.get("/api/logging/files/server.log/tail?lines=50") assert response.status_code == 200 body = response.json() assert body["success"] is True assert len(body["lines"]) == 50 assert body["showing_lines"] == 50 assert body["total_lines"] == 200 @pytest.mark.asyncio async def test_returns_404_for_missing_file(self, auth_client, tmp_path, monkeypatch): monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path) response = await auth_client.get("/api/logging/files/nonexistent.log/tail") assert response.status_code == 404 @pytest.mark.asyncio async def test_prevents_path_traversal(self, auth_client, tmp_path, monkeypatch): monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path) response = await auth_client.get( "/api/logging/files/..%2F..%2Fetc%2Fpasswd/tail" ) # Either 404 (file not found) or 400 is acceptable — must NOT return 200 assert response.status_code != 200 # --------------------------------------------------------------------------- # POST /api/logging/test # --------------------------------------------------------------------------- class TestTestLogging: """POST /api/logging/test writes test log messages.""" @pytest.mark.asyncio async def test_returns_success(self, auth_client): response = await auth_client.post("/api/logging/test") assert response.status_code == 200 assert response.json()["success"] is True @pytest.mark.asyncio async def test_requires_authentication(self, client): response = await client.post("/api/logging/test") assert response.status_code in (401, 403) # --------------------------------------------------------------------------- # POST /api/logging/cleanup # --------------------------------------------------------------------------- class TestCleanupLogs: """POST /api/logging/cleanup removes old log files.""" @pytest.mark.asyncio async def test_removes_old_files(self, auth_client, tmp_path, monkeypatch): old_file = tmp_path / "old.log" old_file.write_text("old content") # Make it look very old old_ts = time.time() - (40 * 86400) import os os.utime(old_file, (old_ts, old_ts)) monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path) response = await auth_client.post("/api/logging/cleanup", json={"days": 30}) assert response.status_code == 200 body = response.json() assert body["success"] is True assert "old.log" in body["removed"] assert not old_file.exists() @pytest.mark.asyncio async def test_keeps_recent_files(self, auth_client, tmp_path, monkeypatch): new_file = tmp_path / "new.log" new_file.write_text("recent content") monkeypatch.setattr("src.server.api.logging._LOG_DIR", tmp_path) response = await auth_client.post("/api/logging/cleanup", json={"days": 30}) assert response.status_code == 200 assert "new.log" not in response.json()["removed"] assert new_file.exists() @pytest.mark.asyncio async def test_invalid_days_returns_400(self, auth_client): response = await auth_client.post("/api/logging/cleanup", json={"days": 0}) assert response.status_code == 400