2026-05-20 23:55:27 +02:00
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
import time
|
|
|
|
|
from contextlib import contextmanager
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
|
|
|
|
import router_dyndns.app as app_module
|
|
|
|
|
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:/magic", 1) is True
|
|
|
|
|
assert store.allow_rate_limit("198.51.100.1:/magic", 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:/magic", 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(challenge_hours=1, 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",
|
|
|
|
|
"domain_challenges",
|
|
|
|
|
"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"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 == 1
|
|
|
|
|
assert {
|
|
|
|
|
"idx_accounts_created_at",
|
|
|
|
|
"idx_domain_challenges_claim_hash",
|
|
|
|
|
"idx_domain_challenges_unverified_created",
|
|
|
|
|
"idx_rate_limits_window",
|
|
|
|
|
"idx_update_events_hostname_created_at",
|
|
|
|
|
"idx_update_event_rollups_hostname_last_seen_at",
|
|
|
|
|
}.issubset(index_names)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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(challenge_hours=1, 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_setup_flows(tmp_path: Path) -> None:
|
|
|
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
|
|
|
app = make_app(
|
|
|
|
|
DdnsSettings(
|
|
|
|
|
database_path=database_path,
|
|
|
|
|
admin_password="admin",
|
|
|
|
|
cleanup_challenge_hours=1,
|
|
|
|
|
cleanup_unused_account_hours=1,
|
|
|
|
|
cleanup_interval_seconds=1,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
store = DdnsStore(database_path)
|
|
|
|
|
|
|
|
|
|
with TestClient(app):
|
|
|
|
|
abandoned_claim = store.create_domain_challenge("abandoned.example.net")
|
|
|
|
|
verified_claim = store.create_domain_challenge("verified.example.net")
|
|
|
|
|
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 domain_challenges SET created_at = ? WHERE domain = ?", (old, abandoned_claim["domain"]))
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE domain_challenges SET created_at = ?, verified_at = ? WHERE domain = ?",
|
|
|
|
|
(old, old, verified_claim["domain"]),
|
|
|
|
|
)
|
|
|
|
|
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_domain_challenge("abandoned.example.net") is None
|
|
|
|
|
assert store.get_domain_challenge("verified.example.net") is not None
|
|
|
|
|
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(challenge_hours, unused_account_hours, event_history_hours):
|
|
|
|
|
raise RuntimeError("cleanup exploded")
|
|
|
|
|
|
|
|
|
|
caplog.set_level(logging.ERROR, logger="router_dyndns.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
|