"""Tests for the setup router (POST /api/setup, GET /api/setup, GET /api/setup/timezone).""" from __future__ import annotations from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import aiosqlite import pytest from httpx import ASGITransport, AsyncClient from app.config import Settings from app.db import init_db from app.main import _lifespan, create_app from app.models.server import ServerStatus from app.services import setup_service # --------------------------------------------------------------------------- # Shared setup payload # --------------------------------------------------------------------------- _SETUP_PAYLOAD: dict[str, object] = { "master_password": "Supersecret1!", "database_path": "bangui.db", "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", "timezone": "UTC", "session_duration_minutes": 60, } # --------------------------------------------------------------------------- # Fixture for tests that need direct access to app.state # --------------------------------------------------------------------------- @pytest.fixture async def app_and_client(tmp_path: Path) -> tuple[object, AsyncClient]: # type: ignore[misc] """Yield ``(app, client)`` for tests that inspect ``app.state`` directly. Args: tmp_path: Pytest-provided isolated temporary directory. Yields: A tuple of ``(FastAPI app instance, AsyncClient)``. """ config_dir = tmp_path / "fail2ban" (config_dir / "jail.d").mkdir(parents=True) (config_dir / "filter.d").mkdir(parents=True) (config_dir / "action.d").mkdir(parents=True) settings = Settings( database_path=str(tmp_path / "setup_cache_test.db"), fail2ban_socket="/tmp/fake_fail2ban.sock", fail2ban_config_dir=str(config_dir), session_secret="test-setup-cache-secret-that-is-long-enough", session_duration_minutes=60, timezone="UTC", log_level="debug", ) app = create_app(settings=settings) db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path) db.row_factory = aiosqlite.Row await init_db(db) app.state.db = db transport: ASGITransport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield app, ac await db.close() class TestGetSetupStatus: """GET /api/setup — check setup completion state.""" async def test_returns_not_completed_on_fresh_db(self, client: AsyncClient) -> None: """Status endpoint reports setup not done on a fresh database.""" response = await client.get("/api/v1/setup") assert response.status_code == 200 assert response.json() == {"completed": False} async def test_returns_completed_after_setup(self, client: AsyncClient) -> None: """Status endpoint reports setup done after POST /api/setup.""" await client.post( "/api/v1/setup", json={ "master_password": "Supersecret1!", "database_path": "bangui.db", "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", "timezone": "UTC", "session_duration_minutes": 60, }, ) response = await client.get("/api/v1/setup") assert response.status_code == 200 assert response.json() == {"completed": True} class TestPostSetup: """POST /api/setup — run the first-run configuration wizard.""" async def test_accepts_valid_payload(self, client: AsyncClient) -> None: """Setup endpoint returns 201 for a valid first-run payload.""" response = await client.post( "/api/v1/setup", json={ "master_password": "Supersecret1!", "database_path": "bangui.db", "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", "timezone": "UTC", "session_duration_minutes": 60, }, ) assert response.status_code == 201 body = response.json() assert "message" in body async def test_rejects_short_password(self, client: AsyncClient) -> None: """Setup endpoint rejects passwords shorter than 8 characters.""" response = await client.post( "/api/v1/setup", json={"master_password": "short"}, ) assert response.status_code == 400 async def test_rejects_missing_uppercase_password(self, client: AsyncClient) -> None: """Setup endpoint rejects passwords missing an uppercase character.""" response = await client.post( "/api/v1/setup", json={"master_password": "lowercase1!"}, ) assert response.status_code == 400 body = response.json() assert body["code"] == "invalid_input" async def test_rejects_missing_number_password(self, client: AsyncClient) -> None: """Setup endpoint rejects passwords missing a numeric character.""" response = await client.post( "/api/v1/setup", json={"master_password": "NoNumbers!"}, ) assert response.status_code == 400 body = response.json() assert body["code"] == "invalid_input" async def test_rejects_missing_special_character_password( self, client: AsyncClient ) -> None: """Setup endpoint rejects passwords missing a required special character.""" response = await client.post( "/api/v1/setup", json={"master_password": "NoSpecial1"}, ) assert response.status_code == 400 body = response.json() assert body["code"] == "invalid_input" async def test_rejects_second_call(self, client: AsyncClient) -> None: """Setup endpoint returns 409 if setup has already been completed.""" payload = { "master_password": "Supersecret1!", "database_path": "bangui.db", "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", "timezone": "UTC", "session_duration_minutes": 60, } first = await client.post("/api/v1/setup", json=payload) assert first.status_code == 201 second = await client.post("/api/v1/setup", json=payload) assert second.status_code == 409 async def test_accepts_defaults_for_optional_fields( self, client: AsyncClient ) -> None: """Setup endpoint uses defaults when optional fields are omitted.""" response = await client.post( "/api/v1/setup", json={"master_password": "Supersecret1!"}, ) assert response.status_code == 201 class TestPostSetupRuntimeState: async def test_updates_runtime_settings_after_setup( self, app_and_client: tuple[object, AsyncClient] ) -> None: """App state should reflect setup settings immediately after setup.""" app, client = app_and_client payload = { "master_password": "Supersecret1!", "database_path": "bangui.db", "fail2ban_socket": "/tmp/persisted.sock", "timezone": "Europe/Berlin", "session_duration_minutes": 90, } response = await client.post("/api/v1/setup", json=payload) assert response.status_code == 201 assert app.state.runtime_settings is not None assert app.state.runtime_settings.database_path == payload["database_path"] assert app.state.runtime_settings.fail2ban_socket == payload["fail2ban_socket"] assert app.state.runtime_settings.timezone == payload["timezone"] assert app.state.runtime_settings.session_duration_minutes == payload["session_duration_minutes"] assert app.state.settings.database_path != payload["database_path"] class TestSetupRedirectMiddleware: """Verify that the setup-redirect middleware enforces setup-first.""" async def test_protected_endpoint_redirects_before_setup( self, client: AsyncClient ) -> None: """Non-setup API requests redirect to /api/setup on a fresh instance.""" response = await client.get( "/api/v1/auth/login", follow_redirects=False, ) # Middleware issues 307 redirect to /api/setup assert response.status_code == 307 assert response.headers["location"] == "/api/v1/setup" async def test_health_always_reachable_before_setup( self, client: AsyncClient ) -> None: """Health endpoint is always reachable even before setup.""" response = await client.get("/api/v1/health") assert response.status_code == 200 async def test_no_redirect_after_setup(self, client: AsyncClient) -> None: """Protected endpoints are reachable (no redirect) after setup.""" await client.post( "/api/v1/setup", json={"master_password": "Supersecret1!"}, ) # /api/auth/login should now be reachable (returns 405 GET not allowed, # not a setup redirect) response = await client.post( "/api/v1/auth/login", json={"password": "wrong"}, follow_redirects=False, ) # 401 wrong password — not a 307 redirect assert response.status_code == 401 class TestGetTimezone: """GET /api/setup/timezone — return the configured IANA timezone.""" async def test_returns_utc_before_setup(self, client: AsyncClient) -> None: """Timezone endpoint returns 'UTC' on a fresh database (no setup yet).""" response = await client.get("/api/v1/setup/timezone") assert response.status_code == 200 assert response.json() == {"timezone": "UTC"} async def test_returns_configured_timezone(self, client: AsyncClient) -> None: """Timezone endpoint returns the value set during setup.""" await client.post( "/api/v1/setup", json={ "master_password": "Supersecret1!", "timezone": "Europe/Berlin", }, ) response = await client.get("/api/v1/setup/timezone") assert response.status_code == 200 assert response.json() == {"timezone": "Europe/Berlin"} async def test_endpoint_always_reachable_before_setup( self, client: AsyncClient ) -> None: """Timezone endpoint is reachable before setup (no redirect).""" response = await client.get( "/api/v1/setup/timezone", follow_redirects=False, ) # Should return 200, not a 307 redirect, because /api/setup paths # are always allowed by the SetupRedirectMiddleware. assert response.status_code == 200 # --------------------------------------------------------------------------- # Setup-complete flag caching in SetupRedirectMiddleware (Task 4) # --------------------------------------------------------------------------- class TestSetupCompleteCaching: """SetupRedirectMiddleware caches the setup_complete flag in ``app.state``.""" async def test_cache_flag_set_after_post_setup( self, app_and_client: tuple[object, AsyncClient] ) -> None: """``setup_complete_cached`` is set to True immediately after setup.""" from fastapi import FastAPI app, client = app_and_client assert isinstance(app, FastAPI) resp = await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) assert resp.status_code == 201 assert app.state.setup_complete_cached is True async def test_cached_path_skips_is_setup_complete( self, app_and_client: tuple[object, AsyncClient], ) -> None: """Subsequent requests do not call ``is_setup_complete`` once flag is cached. After the flag is set, the middleware must not touch the database for any further requests — even if ``is_setup_complete`` would raise. """ from fastapi import FastAPI app, client = app_and_client assert isinstance(app, FastAPI) # Do setup and warm the cache. await client.post("/api/v1/setup", json=_SETUP_PAYLOAD) await client.post("/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}) assert app.state.setup_complete_cached is True call_count = 0 async def _counting(_db: aiosqlite.Connection) -> bool: nonlocal call_count call_count += 1 return True with patch("app.services.setup_service.is_setup_complete", side_effect=_counting): await client.post( "/api/v1/auth/login", json={"password": _SETUP_PAYLOAD["master_password"]}, ) # Cache was warm — is_setup_complete must not have been called. assert call_count == 0 # --------------------------------------------------------------------------- # Task 0.1 — Lifespan creates the database parent directory (Task 0.1) # --------------------------------------------------------------------------- class TestLifespanDatabaseDirectoryCreation: """App lifespan creates the database parent directory when it does not exist.""" async def test_creates_nested_database_directory(self, tmp_path: Path) -> None: """Lifespan creates intermediate directories for the database path. Verifies that a deeply-nested database path is handled correctly — the parent directories are created before ``aiosqlite.connect`` is called so the app does not crash on a fresh volume. """ nested_db = tmp_path / "deep" / "nested" / "bangui.db" assert not nested_db.parent.exists() # Settings requires the database parent to exist at construction time, # so create the immediate parent. The intermediate directory # (nested_db.parent.parent) is deliberately left non-existent to verify # lifespan creates it. nested_db.parent.mkdir(parents=True, exist_ok=True) config_dir = tmp_path / "fail2ban" config_dir.mkdir(parents=True, exist_ok=True) settings = Settings( database_path=str(nested_db), fail2ban_socket="/tmp/fake.sock", fail2ban_config_dir=str(config_dir), session_secret="test-lifespan-mkdir-secret-that-is-long-enough", session_duration_minutes=60, timezone="UTC", log_level="debug", ) app = create_app(settings=settings) mock_scheduler = MagicMock() mock_scheduler.start = MagicMock() mock_scheduler.shutdown = MagicMock() with ( patch("app.services.geo_service.init_geoip"), patch( "app.services.geo_service.load_cache_from_db", new=AsyncMock(return_value=None), ), patch("app.tasks.health_check.register"), patch("app.tasks.blocklist_import.register"), patch("app.tasks.geo_cache_flush.register"), patch("app.tasks.geo_re_resolve.register"), patch("app.tasks.history_sync.register"), patch("app.startup.AsyncIOScheduler", return_value=mock_scheduler), patch("app.startup.ensure_jail_configs"), ): async with _lifespan(app): assert nested_db.parent.exists(), ( "Expected lifespan to create database parent directory" ) async def test_existing_database_directory_is_not_an_error( self, tmp_path: Path ) -> None: """Lifespan does not raise when the database directory already exists. ``mkdir(exist_ok=True)`` must be used so that re-starts on an existing volume do not fail. """ db_path = tmp_path / "bangui.db" # tmp_path already exists — this simulates a pre-existing volume. config_dir = tmp_path / "fail2ban" config_dir.mkdir(parents=True, exist_ok=True) settings = Settings( database_path=str(db_path), fail2ban_socket="/tmp/fake.sock", fail2ban_config_dir=str(config_dir), session_secret="test-lifespan-exist-ok-secret-that-is-long-enough", session_duration_minutes=60, timezone="UTC", log_level="debug", ) app = create_app(settings=settings) mock_scheduler = MagicMock() mock_scheduler.start = MagicMock() mock_scheduler.shutdown = MagicMock() with ( patch("app.services.geo_service.init_geoip"), patch( "app.services.geo_service.load_cache_from_db", new=AsyncMock(return_value=None), ), patch("app.tasks.health_check.register"), patch("app.tasks.blocklist_import.register"), patch("app.tasks.geo_cache_flush.register"), patch("app.tasks.geo_re_resolve.register"), patch("app.tasks.history_sync.register"), patch("app.startup.AsyncIOScheduler", return_value=mock_scheduler), patch("app.startup.ensure_jail_configs"), ): # Should not raise FileExistsError or similar. async with _lifespan(app): assert tmp_path.exists() class TestLifespanSetupCache: """Verify that app startup resolves setup completion into app.state.""" async def test_startup_caches_setup_completion(self, tmp_path: Path) -> None: """Lifespan should populate ``setup_complete_cached`` based on the DB.""" config_dir = tmp_path / "fail2ban" (config_dir / "jail.d").mkdir(parents=True) (config_dir / "filter.d").mkdir(parents=True) (config_dir / "action.d").mkdir(parents=True) settings = Settings( database_path=str(tmp_path / "bangui.db"), fail2ban_socket="/tmp/fake.sock", fail2ban_config_dir=str(config_dir), session_secret="test-lifespan-setup-cache-secret-that-is-long", session_duration_minutes=60, timezone="UTC", log_level="debug", ) app = create_app(settings=settings) db = await aiosqlite.connect(settings.database_path) db.row_factory = aiosqlite.Row await init_db(db) await setup_service.run_setup( db, master_password="Supersecret1!", database_path=settings.database_path, fail2ban_socket=settings.fail2ban_socket, timezone=settings.timezone, session_duration_minutes=settings.session_duration_minutes, ) await db.close() mock_scheduler = MagicMock() mock_scheduler.start = MagicMock() mock_scheduler.shutdown = MagicMock() with ( patch("app.services.geo_service.init_geoip"), patch( "app.services.geo_service.load_cache_from_db", new=AsyncMock(return_value=None), ), patch("app.tasks.health_check.register"), patch("app.tasks.blocklist_import.register"), patch("app.tasks.geo_cache_flush.register"), patch("app.tasks.geo_re_resolve.register"), patch("app.tasks.history_sync.register"), patch("app.startup.AsyncIOScheduler", return_value=mock_scheduler), patch("app.startup.ensure_jail_configs"), ): async with _lifespan(app): assert app.state.setup_complete_cached is True # --------------------------------------------------------------------------- # Task 0.2 — Middleware redirects when app.state.db is None # --------------------------------------------------------------------------- class TestSetupRedirectMiddlewareDbNone: """SetupRedirectMiddleware redirects when the database is not yet available.""" async def test_redirects_to_setup_when_db_not_set(self, tmp_path: Path) -> None: """A ``None`` db on app.state causes a 307 redirect to ``/api/setup``. Simulates the race window where a request arrives before the lifespan has finished initialising the database connection. """ config_dir = tmp_path / "fail2ban" (config_dir / "jail.d").mkdir(parents=True) (config_dir / "filter.d").mkdir(parents=True) (config_dir / "action.d").mkdir(parents=True) settings = Settings( database_path=str(tmp_path / "bangui.db"), fail2ban_socket="/tmp/fake_fail2ban.sock", fail2ban_config_dir=str(config_dir), session_secret="test-db-none-secret-that-is-long-enough", session_duration_minutes=60, timezone="UTC", log_level="debug", ) app = create_app(settings=settings) # Deliberately do NOT set app.state.db to simulate startup not complete. transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test" ) as ac: response = await ac.get("/api/v1/auth/login", follow_redirects=False) assert response.status_code == 307 assert response.headers["location"] == "/api/v1/setup" async def test_health_reachable_when_db_not_set(self, tmp_path: Path) -> None: """Health endpoint is always reachable even when db is not initialised.""" config_dir = tmp_path / "fail2ban" (config_dir / "jail.d").mkdir(parents=True) (config_dir / "filter.d").mkdir(parents=True) (config_dir / "action.d").mkdir(parents=True) settings = Settings( database_path=str(tmp_path / "bangui.db"), fail2ban_socket="/tmp/fake_fail2ban.sock", fail2ban_config_dir=str(config_dir), session_secret="test-db-none-health-secret-that-is-long", session_duration_minutes=60, timezone="UTC", log_level="debug", ) app = create_app(settings=settings) app.state.server_status = ServerStatus(online=True) transport = ASGITransport(app=app) async with AsyncClient( transport=transport, base_url="http://test" ) as ac: response = await ac.get("/api/v1/health") assert response.status_code == 200