"""Tests for setup_service and settings_repo.""" from __future__ import annotations import asyncio import inspect from pathlib import Path from unittest.mock import AsyncMock import aiosqlite import pytest from app.db import init_db from app.repositories import settings_repo from app.services import setup_service @pytest.fixture async def db(tmp_path: Path) -> aiosqlite.Connection: # type: ignore[misc] """Provide an initialised aiosqlite connection for service-level tests.""" conn: aiosqlite.Connection = await aiosqlite.connect(str(tmp_path / "test.db")) conn.row_factory = aiosqlite.Row await init_db(conn) yield conn await conn.close() class TestIsSetupComplete: async def test_returns_false_on_fresh_db( self, db: aiosqlite.Connection ) -> None: """Setup is not complete on a fresh database.""" assert await setup_service.is_setup_complete(db) is False async def test_returns_true_after_run_setup( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """Setup is marked complete after run_setup() succeeds.""" await setup_service.run_setup( db, master_password="mypassword1", database_path=str(tmp_path / "test.db"), fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) assert await setup_service.is_setup_complete(db) is True class TestRunSetup: async def test_persists_all_settings( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """run_setup() stores every provided setting when runtime DB equals the bootstrap DB.""" await setup_service.run_setup( db, master_password="mypassword1", database_path=str(tmp_path / "test.db"), fail2ban_socket="/tmp/f2b.sock", timezone="Europe/Berlin", session_duration_minutes=120, ) all_settings = await settings_repo.get_all_settings(db) assert all_settings["database_path"] == str(tmp_path / "test.db") assert all_settings["fail2ban_socket"] == "/tmp/f2b.sock" assert all_settings["timezone"] == "Europe/Berlin" assert all_settings["session_duration_minutes"] == "120" async def test_runs_setup_into_separate_runtime_database( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """run_setup() stores runtime configuration in the runtime DB only.""" runtime_db_path = str(tmp_path / "runtime.db") await setup_service.run_setup( db, master_password="mypassword1", database_path=runtime_db_path, fail2ban_socket="/tmp/f2b.sock", timezone="Europe/Berlin", session_duration_minutes=120, ) bootstrap_settings = await settings_repo.get_all_settings(db) assert bootstrap_settings["database_path"] == runtime_db_path assert bootstrap_settings["setup_completed"] == "1" assert "fail2ban_socket" not in bootstrap_settings assert "timezone" not in bootstrap_settings assert "session_duration_minutes" not in bootstrap_settings runtime_db = await aiosqlite.connect(runtime_db_path) runtime_db.row_factory = aiosqlite.Row try: runtime_settings = await settings_repo.get_all_settings(runtime_db) finally: await runtime_db.close() assert runtime_settings["fail2ban_socket"] == "/tmp/f2b.sock" assert runtime_settings["timezone"] == "Europe/Berlin" assert runtime_settings["session_duration_minutes"] == "120" assert runtime_settings["master_password_hash"] != "mypassword1" async def test_password_stored_as_bcrypt_hash( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """The master password is stored as a bcrypt hash, not plain text.""" import bcrypt await setup_service.run_setup( db, master_password="mypassword1", database_path=str(tmp_path / "test.db"), fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) stored = await setup_service.get_password_hash(db) assert stored is not None assert stored != "mypassword1" # Verify it is a valid bcrypt hash. assert bcrypt.checkpw(b"mypassword1", stored.encode()) async def test_raises_if_setup_already_complete( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """run_setup() raises RuntimeError if called a second time.""" kwargs = { "master_password": "mypassword1", "database_path": str(tmp_path / "test.db"), "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", "timezone": "UTC", "session_duration_minutes": 60, } await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type] with pytest.raises(RuntimeError, match="already been completed"): await setup_service.run_setup(db, **kwargs) # type: ignore[arg-type] async def test_does_not_mark_setup_complete_on_runtime_db_failure( self, db: aiosqlite.Connection, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """run_setup() must not mark setup complete when runtime DB init fails.""" monkeypatch.setattr( setup_service, "_ensure_database_initialized", AsyncMock(return_value=False), ) with pytest.raises(RuntimeError, match="Runtime database could not be initialized"): await setup_service.run_setup( db, master_password="mypassword1", database_path=str(tmp_path / "runtime.db"), fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) assert await setup_service.is_setup_complete(db) is False bootstrap_settings = await settings_repo.get_all_settings(db) assert "setup_completed" not in bootstrap_settings assert not (tmp_path / "runtime.db").exists() async def test_initializes_map_color_thresholds_with_defaults( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """run_setup() initializes map color thresholds with default values.""" await setup_service.run_setup( db, master_password="mypassword1", database_path=str(tmp_path / "test.db"), fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) high, medium, low = await setup_service.get_map_color_thresholds(db) assert high == 100 assert medium == 50 assert low == 20 class TestGetTimezone: async def test_returns_utc_on_fresh_db(self, db: aiosqlite.Connection) -> None: """get_timezone() returns 'UTC' before setup is run.""" assert await setup_service.get_timezone(db) == "UTC" async def test_returns_configured_timezone( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """get_timezone() returns the value set during setup.""" await setup_service.run_setup( db, master_password="mypassword1", database_path=str(tmp_path / "test.db"), fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="America/New_York", session_duration_minutes=60, ) assert await setup_service.get_timezone(db) == "America/New_York" class TestMapColorThresholds: async def test_get_map_color_thresholds_returns_defaults_on_fresh_db( self, db: aiosqlite.Connection ) -> None: """get_map_color_thresholds() returns default values on a fresh database.""" high, medium, low = await setup_service.get_map_color_thresholds(db) assert high == 100 assert medium == 50 assert low == 20 async def test_set_map_color_thresholds_persists_values( self, db: aiosqlite.Connection ) -> None: """set_map_color_thresholds() stores and retrieves custom values.""" await setup_service.set_map_color_thresholds( db, threshold_high=200, threshold_medium=80, threshold_low=30 ) high, medium, low = await setup_service.get_map_color_thresholds(db) assert high == 200 assert medium == 80 assert low == 30 async def test_set_map_color_thresholds_rejects_non_positive( self, db: aiosqlite.Connection ) -> None: """set_map_color_thresholds() raises ValueError for non-positive thresholds.""" with pytest.raises(ValueError, match="positive integers"): await setup_service.set_map_color_thresholds( db, threshold_high=100, threshold_medium=50, threshold_low=0 ) with pytest.raises(ValueError, match="positive integers"): await setup_service.set_map_color_thresholds( db, threshold_high=-10, threshold_medium=50, threshold_low=20 ) async def test_set_map_color_thresholds_rejects_invalid_order( self, db: aiosqlite.Connection ) -> None: """ set_map_color_thresholds() rejects invalid ordering. """ with pytest.raises(ValueError, match="high > medium > low"): await setup_service.set_map_color_thresholds( db, threshold_high=50, threshold_medium=50, threshold_low=20 ) with pytest.raises(ValueError, match="high > medium > low"): await setup_service.set_map_color_thresholds( db, threshold_high=100, threshold_medium=30, threshold_low=50 ) async def test_run_setup_initializes_default_thresholds( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """run_setup() initializes map color thresholds with defaults.""" await setup_service.run_setup( db, master_password="mypassword1", database_path=str(tmp_path / "test.db"), fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) high, medium, low = await setup_service.get_map_color_thresholds(db) assert high == 100 assert medium == 50 assert low == 20 class TestRunSetupAsync: """Verify the async/non-blocking bcrypt behavior of run_setup.""" async def test_run_setup_is_coroutine_function(self) -> None: """run_setup must be declared as an async function.""" assert inspect.iscoroutinefunction(setup_service.run_setup) async def test_password_hash_does_not_block_event_loop( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """run_setup completes without blocking; other coroutines can interleave.""" async def noop() -> str: """A trivial coroutine that should run concurrently with setup.""" await asyncio.sleep(0) return "ok" setup_coro = setup_service.run_setup( db, master_password="mypassword1", database_path=str(tmp_path / "test.db"), fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) # Both tasks should finish without error. results = await asyncio.gather(setup_coro, noop()) assert results[1] == "ok" assert await setup_service.is_setup_complete(db) is True