This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/tests/test_scheduler.py
T

442 lines
16 KiB
Python
Raw Normal View History

2026-05-20 23:55:27 +02:00
import asyncio
import logging
2026-05-21 14:18:39 +02:00
import sqlite3
2026-05-20 23:55:27 +02:00
import time
2026-05-21 17:23:08 +02:00
from datetime import UTC, datetime, timedelta
2026-05-20 23:55:27 +02:00
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
2026-05-21 17:23:08 +02:00
import karldns.app as app_module
from karldns import DdnsSettings, DdnsStore, make_app
from karldns.event_statuses import CLEANUP_PENDING_HOSTNAME_DELETED, CLEANUP_UNUSED_HOSTNAME_DELETED
2026-05-21 17:23:08 +02:00
from karldns.security import hash_lookup_token
2026-05-20 23:55:27 +02:00
def test_rate_limit_reject_does_not_increment_bucket(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
2026-05-21 17:23:08 +02:00
assert store.allow_rate_limit("198.51.100.1:/", 1) is True
assert store.allow_rate_limit("198.51.100.1:/", 1) is False
2026-05-20 23:55:27 +02:00
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 = ?",
2026-05-21 17:23:08 +02:00
("198.51.100.1:/", window),
2026-05-20 23:55:27 +02:00
).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),
)
2026-05-21 14:18:39 +02:00
store.cleanup(unused_account_hours=1)
2026-05-20 23:55:27 +02:00
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",
2026-05-21 10:05:40 +02:00
"service_metrics",
2026-05-20 23:55:27 +02:00
"update_events",
}.issubset(tables)
assert len(store.list_scheduled_tasks()) == 1
assert store.list_scheduled_tasks()[0]["name"] == "cleanup"
2026-05-21 14:18:39 +02:00
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:
2026-05-22 17:05:06 +02:00
conn.execute("""
2026-05-21 14:18:39 +02:00
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
)
2026-05-22 17:05:06 +02:00
""")
2026-05-21 14:18:39 +02:00
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)")}
2026-05-22 17:05:06 +02:00
row = conn.execute("""
2026-05-21 14:18:39 +02:00
SELECT unused_accounts_deleted
FROM cleanup_runs
2026-05-22 17:05:06 +02:00
""").fetchone()
2026-05-21 14:18:39 +02:00
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"}
2026-05-21 17:23:08 +02:00
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
cleanup_events = store.list_update_events_for_hostname(account["hostname"])
assert cleanup_events[0]["status"] == CLEANUP_PENDING_HOSTNAME_DELETED
assert cleanup_events[0]["detail"] == "cleanup deleted abandoned hostname"
2026-05-21 17:23:08 +02:00
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_cleanup_logs_event_for_accepted_unused_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("unused.ddns.example.net", "router", accepted=True)
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=1)
assert result["unused_accounts"] == 1
assert store.get_account_by_slug(account["update_slug"]) is None
cleanup_events = store.list_update_events_for_hostname(account["hostname"])
assert cleanup_events[0]["status"] == CLEANUP_UNUSED_HOSTNAME_DELETED
assert cleanup_events[0]["detail"] == "cleanup deleted abandoned hostname"
2026-05-21 17:23:08 +02:00
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
2026-05-20 23:55:27 +02:00
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]
2026-05-22 17:05:06 +02:00
index_names = {row[1] for row in conn.execute("""
2026-05-20 23:55:27 +02:00
SELECT type, name
FROM sqlite_master
WHERE type = 'index'
2026-05-22 17:05:06 +02:00
""")}
2026-05-20 23:55:27 +02:00
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
2026-05-21 17:23:08 +02:00
assert user_version == 3
2026-05-20 23:55:27 +02:00
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)
2026-05-21 17:23:08 +02:00
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")
2026-05-22 17:05:06 +02:00
conn.execute("""
2026-05-21 17:23:08 +02:00
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
)
2026-05-22 17:05:06 +02:00
""")
2026-05-21 17:23:08 +02:00
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"
2026-05-22 17:05:06 +02:00
def test_rate_limit_flush_requeues_counts_after_database_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
2026-05-20 23:55:27 +02:00
store = DdnsStore(tmp_path / "ddns.sqlite3")
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
original_connect = store.connect
def fail_connect():
raise RuntimeError("database temporarily unavailable")
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"),
)
2026-05-21 14:18:39 +02:00
result = store.cleanup(unused_account_hours=1, event_history_hours=24)
2026-05-20 23:55:27 +02:00
assert result["update_events"] == 1
events = store.list_update_events()
assert [event["hostname"] for event in events] == ["new.example.net"]
2026-05-21 14:18:39 +02:00
def test_background_cleanup_removes_abandoned_hostnames(tmp_path: Path) -> None:
2026-05-20 23:55:27 +02:00
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)
2026-05-21 17:23:08 +02:00
def fail_cleanup(unused_account_hours, event_history_hours, pending_account_hours):
2026-05-20 23:55:27 +02:00
raise RuntimeError("cleanup exploded")
2026-05-21 17:23:08 +02:00
caplog.set_level(logging.ERROR, logger="karldns.app")
2026-05-20 23:55:27 +02:00
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
def test_scheduler_loop_survives_iteration_exception(tmp_path: Path, monkeypatch, caplog) -> None:
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
cleanup_interval_seconds=60,
)
store = DdnsStore(settings.database_path)
calls = 0
async def fail_once_then_cancel(inner_store, inner_settings):
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("post-cleanup crash")
raise asyncio.CancelledError
async def no_sleep(_seconds: float) -> None:
return None
caplog.set_level(logging.ERROR, logger="karldns.app")
monkeypatch.setattr(app_module, "_run_cleanup_if_due", fail_once_then_cancel)
monkeypatch.setattr(app_module.asyncio, "sleep", no_sleep)
with pytest.raises(asyncio.CancelledError):
asyncio.run(app_module._scheduler_loop(store, settings))
assert calls == 2
assert "Scheduled cleanup loop crashed; continuing" in caplog.text