Simplify DDNS flow and polish UI

This commit is contained in:
StasonJatham committed 2026-05-21 14:18:39 +02:00
1 parent 5beee0e5c6
commit 5a90bd9d87
42 files changed
+587 -1418

No files matched your search

+55 -17
View File
@@ -1,5 +1,6 @@
import asyncio
import logging
import sqlite3
import time
from contextlib import contextmanager
from pathlib import Path
@@ -57,7 +58,7 @@ def test_cleanup_deletes_old_rate_limit_minute_buckets(tmp_path: Path) -> None:
("recent", current_window - 30, 1),
)
store.cleanup(challenge_hours=1, unused_account_hours=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")}
@@ -100,7 +101,6 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
assert {
"accounts",
"cleanup_runs",
"domain_challenges",
"rate_limits",
"records",
"scheduled_tasks",
@@ -111,6 +111,56 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
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_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
@@ -147,8 +197,6 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
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",
@@ -193,20 +241,19 @@ def test_cleanup_prunes_old_raw_update_events(tmp_path: Path) -> None:
("2000-01-01T00:00:00+00:00", "old.example.net"),
)
result = store.cleanup(challenge_hours=1, unused_account_hours=1, event_history_hours=24)
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_setup_flows(tmp_path: Path) -> None:
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_challenge_hours=1,
cleanup_unused_account_hours=1,
cleanup_interval_seconds=1,
)
@@ -214,26 +261,17 @@ def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> Non
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]
@@ -248,7 +286,7 @@ def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog
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):
def fail_cleanup(unused_account_hours, event_history_hours):
raise RuntimeError("cleanup exploded")
caplog.set_level(logging.ERROR, logger="router_dyndns.app")