"""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 auth_service, settings_service, 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 auth_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 settings_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 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 class TestSetupTransactionality: """Test transactional setup persistence and crash-safety.""" async def test_setup_state_machine_transitions( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """Setup state transitions through: None → in_progress → complete.""" # Initial state: no setup_state key initial_state = await settings_repo.get_setting(db, "setup_state") assert initial_state is None # After setup completes, state should be "complete" 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, ) final_state = await settings_repo.get_setting(db, "setup_state") assert final_state == "complete" async def test_password_hash_idempotency_across_retries( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """Password hash computed once and remains consistent across phases. Verify that if we could retry setup (in practice prevented by is_setup_complete check), the same password hash would be used. This tests that hash is computed once, not freshly on each operation. """ password = "mypassword1" await setup_service.run_setup( db, master_password=password, database_path=str(tmp_path / "test.db"), fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) # Get the hash from runtime DB runtime_db = await aiosqlite.connect(str(tmp_path / "test.db")) runtime_db.row_factory = aiosqlite.Row try: stored_hash = await settings_repo.get_setting(runtime_db, "master_password_hash") finally: await runtime_db.close() assert stored_hash is not None # Verify the hash is stable (bcrypt deterministically verifies the same password) import bcrypt assert bcrypt.checkpw(password.encode(), stored_hash.encode()) async def test_runtime_settings_written_atomically_in_batch( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """All runtime settings are written in a single atomic transaction. This ensures that either all settings are persisted or none are. Verify by checking that all expected settings exist in runtime DB. """ await setup_service.run_setup( db, master_password="mypassword1", database_path=str(tmp_path / "test.db"), fail2ban_socket="/tmp/f2b.sock", timezone="Europe/London", session_duration_minutes=90, ) runtime_db = await aiosqlite.connect(str(tmp_path / "test.db")) runtime_db.row_factory = aiosqlite.Row try: settings = await settings_repo.get_all_settings(runtime_db) finally: await runtime_db.close() # Verify all settings are present (atomicity means all-or-nothing) expected_keys = { "master_password_hash", "database_path", "fail2ban_socket", "timezone", "session_duration_minutes", "map_color_threshold_high", "map_color_threshold_medium", "map_color_threshold_low", } assert expected_keys.issubset(settings.keys()) async def test_bootstrap_db_has_setup_state_after_setup( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """Bootstrap DB stores setup_state for crash recovery detection.""" 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, ) # Bootstrap DB should have setup_state = "complete" bootstrap_state = await settings_repo.get_setting(db, "setup_state") assert bootstrap_state == "complete" # Bootstrap DB should also have setup_completed = "1" (for backward compat) bootstrap_completed = await settings_repo.get_setting(db, "setup_completed") assert bootstrap_completed == "1" async def test_database_path_written_to_both_dbs( self, db: aiosqlite.Connection, tmp_path: Path ) -> None: """database_path is written to bootstrap DB and runtime DB.""" db_path = str(tmp_path / "test.db") await setup_service.run_setup( db, master_password="mypassword1", database_path=db_path, fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) # Check bootstrap DB bootstrap_path = await settings_repo.get_setting(db, "database_path") assert bootstrap_path == db_path # Check runtime DB runtime_db = await aiosqlite.connect(db_path) runtime_db.row_factory = aiosqlite.Row try: runtime_path = await settings_repo.get_setting(runtime_db, "database_path") finally: await runtime_db.close() assert runtime_path == db_path async def test_runtime_db_not_written_until_all_settings_ready( self, db: aiosqlite.Connection, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """If runtime settings write fails, database is not left in partial state. Simulate a failure during batch write and verify that the database can be cleaned up or retried without inconsistency. """ db_path = str(tmp_path / "test.db") # Mock set_settings_batch to raise an error original_batch = settings_repo.set_settings_batch async def failing_batch( conn: aiosqlite.Connection, settings: dict[str, str] ) -> None: """Simulate write failure.""" raise RuntimeError("Simulated failure during batch write") monkeypatch.setattr(settings_repo, "set_settings_batch", failing_batch) # Setup should fail during runtime DB write with pytest.raises(RuntimeError, match="Failed to write settings to runtime database"): await setup_service.run_setup( db, master_password="mypassword1", database_path=db_path, fail2ban_socket="/var/run/fail2ban/fail2ban.sock", timezone="UTC", session_duration_minutes=60, ) # Restore original function monkeypatch.setattr(settings_repo, "set_settings_batch", original_batch) # Bootstrap DB should have setup_state = "in_progress" (partial state detected) bootstrap_state = await settings_repo.get_setting(db, "setup_state") assert bootstrap_state == "in_progress" # Setup is NOT marked complete is_complete = await setup_service.is_setup_complete(db) assert is_complete is False