import asyncio import logging import sqlite3 import time from contextlib import contextmanager from datetime import UTC, datetime, timedelta from pathlib import Path import pytest from fastapi.testclient import TestClient import karldns.app as app_module from karldns import DdnsSettings, DdnsStore, make_app from karldns.security import hash_lookup_token def test_rate_limit_reject_does_not_increment_bucket(tmp_path: Path) -> None: store = DdnsStore(tmp_path / "ddns.sqlite3") assert store.allow_rate_limit("198.51.100.1:/", 1) is True assert store.allow_rate_limit("198.51.100.1:/", 1) is False store.flush_rate_limits() window = int(time.time()) // 60 with store.connect() as conn: count = conn.execute( "SELECT count FROM rate_limits WHERE key = ? AND window = ?", ("198.51.100.1:/", window), ).fetchone()[0] assert count == 1 def test_rate_limit_flush_persists_batched_counts(tmp_path: Path) -> None: store = DdnsStore(tmp_path / "ddns.sqlite3") assert store.allow_rate_limit("198.51.100.1:/api", 60) is True assert store.allow_rate_limit("198.51.100.1:/api", 60) is True store.flush_rate_limits() window = int(time.time()) // 60 with store.connect() as conn: count = conn.execute( "SELECT count FROM rate_limits WHERE key = ? AND window = ?", ("198.51.100.1:/api", window), ).fetchone()[0] assert count == 2 def test_cleanup_deletes_old_rate_limit_minute_buckets(tmp_path: Path) -> None: store = DdnsStore(tmp_path / "ddns.sqlite3") current_window = int(time.time()) // 60 with store.connect() as conn: conn.execute( "INSERT INTO rate_limits(key, window, count) VALUES (?, ?, ?)", ("old", current_window - 61, 1), ) conn.execute( "INSERT INTO rate_limits(key, window, count) VALUES (?, ?, ?)", ("recent", current_window - 30, 1), ) store.cleanup(unused_account_hours=1) with store.connect() as conn: keys = {row[0] for row in conn.execute("SELECT key FROM rate_limits")} assert keys == {"recent"} def test_scheduled_cleanup_task_is_durable_and_lease_protected(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" first = DdnsStore(database_path) first.ensure_scheduled_task("cleanup", 3600) second = DdnsStore(database_path) task = second.list_scheduled_tasks()[0] assert task["name"] == "cleanup" assert task["interval_seconds"] == 3600 assert task["last_status"] == "pending" assert first.acquire_due_task("cleanup", "worker-a", 60) is True assert second.acquire_due_task("cleanup", "worker-b", 60) is False first.complete_scheduled_task("cleanup", 3600, True) completed = second.list_scheduled_tasks()[0] assert completed["lease_owner"] is None assert completed["lease_expires_at"] is None assert completed["last_status"] == "ok" assert completed["last_finished_at"] assert completed["next_run_at"] > str(task["next_run_at"]) def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: Path) -> None: database_path = tmp_path / "nested" / "ddns.sqlite3" settings = DdnsSettings(database_path=database_path, admin_password="admin") make_app(settings) make_app(settings) store = DdnsStore(database_path) with store.connect() as conn: tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} assert { "accounts", "cleanup_runs", "rate_limits", "records", "scheduled_tasks", "service_metrics", "update_events", }.issubset(tables) assert len(store.list_scheduled_tasks()) == 1 assert store.list_scheduled_tasks()[0]["name"] == "cleanup" def test_startup_migrates_legacy_cleanup_run_columns(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" with sqlite3.connect(database_path) as conn: conn.execute(""" CREATE TABLE cleanup_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, created_at TEXT NOT NULL, domain_challenges_deleted INTEGER NOT NULL, abandoned_setups_deleted INTEGER NOT NULL, unused_accounts_deleted INTEGER NOT NULL ) """) conn.execute( """ INSERT INTO cleanup_runs( created_at, domain_challenges_deleted, abandoned_setups_deleted, unused_accounts_deleted ) VALUES (?, ?, ?, ?) """, ("2026-01-01T00:00:00+00:00", 3, 2, 1), ) store = DdnsStore(database_path) with store.connect() as conn: columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")} row = conn.execute(""" SELECT unused_accounts_deleted FROM cleanup_runs """).fetchone() assert columns == {"id", "created_at", "unused_accounts_deleted"} assert tuple(row) == (1,) def test_cleanup_records_only_unused_hostname_count(tmp_path: Path) -> None: store = DdnsStore(tmp_path / "ddns.sqlite3") result = store.cleanup(unused_account_hours=1) assert result["unused_accounts"] == 0 assert set(store.list_cleanup_runs()[0]) == {"created_at", "unused_accounts_deleted"} def test_cleanup_removes_pending_generated_setups_after_cutoff(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("pending.ddns.example.net", "router", accepted=False) with store.connect() as conn: conn.execute( "UPDATE accounts SET created_at = ? WHERE hostname = ?", ("2000-01-01T00:00:00+00:00", account["hostname"]), ) result = store.cleanup(unused_account_hours=45 * 24) assert result["unused_accounts"] == 1 assert store.get_account_by_slug(account["update_slug"]) is None def test_cleanup_keeps_recent_pending_setup_until_pending_cutoff(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("pending.ddns.example.net", "router", accepted=False) recent = (datetime.now(UTC) - timedelta(hours=2)).isoformat() with store.connect() as conn: conn.execute( "UPDATE accounts SET created_at = ? WHERE hostname = ?", (recent, account["hostname"]), ) result = store.cleanup(unused_account_hours=1, pending_account_hours=24) assert result["unused_accounts"] == 0 assert store.get_account_by_slug(account["update_slug"]) is not None def test_pending_generated_setup_stays_pending_after_restart(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("pending.ddns.example.net", "router", accepted=False) store.close() restarted = DdnsStore(database_path) assert restarted.get_account_by_slug(account["update_slug"])["accepted_at"] is None def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" DdnsStore(database_path) store = DdnsStore(database_path) store.optimize() store.checkpoint_wal() with store.connect() as conn: busy_timeout = conn.execute("PRAGMA busy_timeout").fetchone()[0] journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0] synchronous = conn.execute("PRAGMA synchronous").fetchone()[0] cache_size = conn.execute("PRAGMA cache_size").fetchone()[0] temp_store = conn.execute("PRAGMA temp_store").fetchone()[0] mmap_size = conn.execute("PRAGMA mmap_size").fetchone()[0] user_version = conn.execute("PRAGMA user_version").fetchone()[0] index_names = {row[1] for row in conn.execute(""" SELECT type, name FROM sqlite_master WHERE type = 'index' """)} assert busy_timeout == 10_000 assert journal_mode == "wal" assert synchronous == 1 assert cache_size == -20_000 assert temp_store == 2 assert mmap_size == 256 * 1024 * 1024 assert user_version == 3 assert { "idx_accounts_created_at", "idx_rate_limits_window", "idx_update_events_hostname_created_at", "idx_update_event_rollups_hostname_last_seen_at", }.issubset(index_names) def test_accounts_schema_migration_removes_legacy_router_credentials(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" with sqlite3.connect(database_path) as conn: conn.execute("PRAGMA user_version = 2") conn.execute(""" CREATE TABLE accounts ( hostname TEXT PRIMARY KEY, username TEXT NOT NULL, token_hash TEXT NOT NULL, update_slug TEXT NOT NULL UNIQUE, management_hash TEXT UNIQUE, dashboard_password_hash TEXT, created_at TEXT NOT NULL, accepted_at TEXT, disabled INTEGER NOT NULL DEFAULT 0 ) """) conn.execute( """ INSERT INTO accounts( hostname, username, token_hash, update_slug, management_hash, dashboard_password_hash, created_at, accepted_at, disabled ) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 0) """, ( "home.ddns.example.net", "router", "old-token-hash", "update-secret", hash_lookup_token("dashboard-secret"), "2026-05-21T00:00:00+00:00", "2026-05-21T00:00:00+00:00", ), ) store = DdnsStore(database_path) account = store.get_account_by_slug("update-secret") with store.connect() as conn: columns = {row[1] for row in conn.execute("PRAGMA table_info(accounts)")} user_version = conn.execute("PRAGMA user_version").fetchone()[0] assert user_version == 3 assert "username" not in columns assert "token_hash" not in columns assert account assert account["hostname"] == "home.ddns.example.net" def test_rate_limit_flush_requeues_counts_after_database_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: store = DdnsStore(tmp_path / "ddns.sqlite3") assert store.allow_rate_limit("198.51.100.1:/api", 60) is True original_connect = store.connect @contextmanager def fail_connect(): raise RuntimeError("database temporarily unavailable") yield monkeypatch.setattr(store, "connect", fail_connect) with pytest.raises(RuntimeError, match="database temporarily unavailable"): store.flush_rate_limits() monkeypatch.setattr(store, "connect", original_connect) store.flush_rate_limits() window = int(time.time()) // 60 with store.connect() as conn: count = conn.execute( "SELECT count FROM rate_limits WHERE key = ? AND window = ?", ("198.51.100.1:/api", window), ).fetchone()[0] assert count == 1 def test_cleanup_prunes_old_raw_update_events(tmp_path: Path) -> None: store = DdnsStore(tmp_path / "ddns.sqlite3") store.log_update_event("old.example.net", "203.0.113.1", None, "updated", "old", "198.51.100.1") store.log_update_event("new.example.net", "203.0.113.2", None, "updated", "new", "198.51.100.2") with store.connect() as conn: conn.execute( "UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2000-01-01T00:00:00+00:00", "old.example.net"), ) result = store.cleanup(unused_account_hours=1, event_history_hours=24) assert result["update_events"] == 1 events = store.list_update_events() assert [event["hostname"] for event in events] == ["new.example.net"] def test_background_cleanup_removes_abandoned_hostnames(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" app = make_app( DdnsSettings( database_path=database_path, admin_password="admin", cleanup_unused_account_hours=1, cleanup_interval_seconds=1, ) ) store = DdnsStore(database_path) with TestClient(app): abandoned_account = store.create_account("abandoned.ddns.example.net", "router") active_account = store.create_account("active.ddns.example.net", "router") store.upsert("active.ddns.example.net", "203.0.113.10", None) old = "2000-01-01T00:00:00+00:00" with store.connect() as conn: conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, abandoned_account["hostname"])) conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, active_account["hostname"])) time.sleep(1.3) assert store.get_account_by_slug(abandoned_account["update_slug"]) is None assert store.get_account_by_slug(active_account["update_slug"]) is not None scheduled_task = store.list_scheduled_tasks()[0] assert scheduled_task["name"] == "cleanup" assert scheduled_task["last_status"] == "ok" assert scheduled_task["last_finished_at"] def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog) -> None: database_path = tmp_path / "ddns.sqlite3" settings = DdnsSettings(database_path=database_path, admin_password="admin", cleanup_interval_seconds=60) store = DdnsStore(database_path) store.ensure_scheduled_task(app_module.CLEANUP_TASK_NAME, settings.cleanup_interval_seconds) def fail_cleanup(unused_account_hours, event_history_hours, pending_account_hours): raise RuntimeError("cleanup exploded") caplog.set_level(logging.ERROR, logger="karldns.app") monkeypatch.setattr(store, "cleanup", fail_cleanup) asyncio.run(app_module._run_cleanup_if_due(store, settings)) scheduled_task = store.list_scheduled_tasks()[0] assert scheduled_task["last_status"] == "error" assert "RuntimeError: cleanup exploded" in str(scheduled_task["last_error"]) assert "Scheduled cleanup failed" in caplog.text