Polish KarlDNS production UI and docs

This commit is contained in:
StasonJatham committed 2026-05-21 10:05:40 +02:00
1 parent ca92dc211a
commit 5beee0e5c6
61 files changed
+1566 -523

No files matched your search

+66 -13
View File
@@ -27,20 +27,24 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert '<link href="/admin.css?v=2" rel="stylesheet">' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' in response.text
assert "Operator console" in response.text
assert "Generate credentials" not in response.text
assert 'action="/admin/accounts"' not in response.text
assert response.text.count("data-admin-table") == 4
assert response.text.count("data-table-search") == 4
assert response.text.count("data-table-page-size") == 4
assert response.text.count("data-table-pagination") == 4
assert response.text.count("data-admin-table") == 3
assert response.text.count("data-table-search") == 3
assert response.text.count("data-table-page-size") == 3
assert response.text.count("data-table-pagination") == 3
assert "admin-table-scroll" in response.text
assert "Scheduled cleanup" in response.text
assert "Scheduler state" in response.text
assert "database lease" in response.text
assert "Active credentials" in response.text
assert "Cleanup" in response.text
assert "Run now" in response.text
assert "Next run" in response.text
assert "Metrics" in response.text
assert "Blocked requests" in response.text
assert "Hostnames" in response.text
assert "Domain claims" in response.text
assert "Recent events" in response.text
assert "Update events" in response.text
assert "example.net" in response.text
@@ -89,10 +93,59 @@ def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
assert len(records["items"]) == 1
assert events["limit"] == 1
assert events["offset"] == 1
assert "next_before" in events
assert events["total"] == 2
assert len(events["items"]) == 1
def test_admin_records_and_events_reject_excessive_offsets(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
records = client.get("/records?offset=10001", auth=("admin", "admin"))
events = client.get("/events?offset=10001", auth=("admin", "admin"))
assert records.status_code == 422
assert events.status_code == 422
def test_update_events_page_orders_raw_and_rollup_events_in_sql(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
store.log_update_event("raw-old.example.net", "203.0.113.1", None, "updated", "ok", "198.51.100.1")
store.log_update_event("raw-new.example.net", "203.0.113.2", None, "updated", "ok", "198.51.100.2")
store.log_update_rollup("rollup-mid.example.net", "203.0.113.3", None, "unchanged", "ok", "198.51.100.3")
with store.connect() as conn:
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-01T00:00:00+00:00", "raw-old.example.net"))
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-03T00:00:00+00:00", "raw-new.example.net"))
conn.execute(
"UPDATE update_event_rollups SET last_seen_at = ? WHERE hostname = ?",
("2026-01-02T00:00:00+00:00", "rollup-mid.example.net"),
)
page = store.list_update_events_page(limit=2, offset=1)
assert [event["hostname"] for event in page] == ["rollup-mid.example.net", "raw-old.example.net"]
def test_update_events_support_keyset_pagination(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.log_update_event("raw-old.example.net", "203.0.113.1", None, "updated", "ok", "198.51.100.1")
store.log_update_event("raw-new.example.net", "203.0.113.2", None, "updated", "ok", "198.51.100.2")
with store.connect() as conn:
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-01T00:00:00+00:00", "raw-old.example.net"))
conn.execute("UPDATE update_events SET created_at = ? WHERE hostname = ?", ("2026-01-02T00:00:00+00:00", "raw-new.example.net"))
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
first = client.get("/events?limit=1", auth=("admin", "admin")).json()
second = client.get(f"/events?limit=1&before={first['next_before']}", auth=("admin", "admin")).json()
assert first["items"][0]["hostname"] == "raw-new.example.net"
assert second["items"][0]["hostname"] == "raw-old.example.net"
def test_admin_posts_require_csrf(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
@@ -150,7 +203,7 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
)
assert update.status_code == 200
assert "Update URL rotated" in update.text
assert "Update URL changed" in update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
management = client.post(
@@ -159,7 +212,7 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
)
assert management.status_code == 200
assert "Private status page rotated" in management.text
assert "Dashboard link changed" in management.text
assert "/m/" in management.text
password = client.post(
@@ -168,8 +221,8 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
)
assert password.status_code == 200
assert "Router password rotated" in password.text
assert "One-box update URL:" in password.text
assert "Router password changed" in password.text
assert "Full update URL:" in password.text
assert "/nic/update?" in password.text
assert "Kennwort (optional):" in password.text
statuses = {event["status"] for event in store.list_update_events_for_hostname("home.example.net", 10)}
+4
View File
@@ -46,6 +46,9 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
assert docs.status_code == 200
assert redoc.status_code == 200
assert docs.headers["x-robots-tag"] == "noindex, nofollow"
assert redoc.headers["x-robots-tag"] == "noindex, nofollow"
assert client.get("/openapi.json").headers["x-robots-tag"] == "noindex, nofollow"
assert "unsafe-inline" in docs.headers["content-security-policy"]
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
@@ -181,6 +184,7 @@ def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
assert created.status_code == 201
assert created.headers["cache-control"] == "no-store"
assert created.headers["x-robots-tag"] == "noindex, nofollow"
body = created.json()
assert body["hostname"].endswith(".ddns.example.net")
assert body["update_url"].startswith("http://ddns.example.net/u/")
+1
View File
@@ -104,6 +104,7 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
"rate_limits",
"records",
"scheduled_tasks",
"service_metrics",
"update_events",
}.issubset(tables)
assert len(store.list_scheduled_tasks()) == 1
+149 -3
View File
@@ -1,3 +1,5 @@
import hashlib
import hmac
import logging
from pathlib import Path
@@ -5,8 +7,9 @@ import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
from router_dyndns.ddns_dns import validate_dns_provider_settings
from router_dyndns.ddns_security import DASHBOARD_UNLOCK_COOKIE
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
@@ -74,11 +77,92 @@ def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
)
client = TestClient(app)
first = client.get("/m/not-a-real-token")
second = client.get("/m/another-token")
first = client.get(f"/m/{'a' * 43}")
second = client.get(f"/m/{'b' * 43}")
assert first.status_code == 404
assert second.status_code == 429
assert app.state.store.get_service_metrics()["rate_limit_hits_total"] == 1
def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path, caplog) -> None:
caplog.set_level(logging.INFO, logger="router_dyndns.access")
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
redacted_access_log=True,
)
)
account = app.state.store.create_account("home.example.net", "router")
client = TestClient(app)
client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.1"})
client.get(
"/nic/update",
params={
"hostname": account["hostname"],
"username": account["username"],
"password": account["password"],
"myip": "203.0.113.2",
},
)
assert "/u/<secret>" in caplog.text
assert "/nic/update?<redacted>" in caplog.text
assert account["update_slug"] not in caplog.text
assert account["password"] not in caplog.text
@pytest.mark.parametrize(
"path",
[
"/u/short",
"/api/v1/updates/short",
"/m/short",
"/api/v1/management/short",
"/d/short",
],
)
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get(path)
assert response.status_code == 422
def test_dashboard_unlock_cookie_rejects_password_hash_signed_forgery(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "router")
store.set_dashboard_password("home.example.net", "correct horse")
settings = DdnsSettings(
database_path=database_path,
admin_password="admin",
dashboard_cookie_secret="s" * 32,
)
app = make_app(settings)
client = TestClient(app)
stored = store.get_account_by_management_slug(account["management_slug"])
password_hash = str(stored["dashboard_password_hash"])
timestamp = 1_900_000_000
max_age = settings.dashboard_unlock_seconds
payload = f"{account['hostname']}\0{account['management_slug']}\0{timestamp}\0{max_age}".encode()
forged = f"v2:{timestamp}:{max_age}:{hmac.new(password_hash.encode(), payload, hashlib.sha256).hexdigest()}"
client.cookies.set(DASHBOARD_UNLOCK_COOKIE, forged, path=f"/m/{account['management_slug']}")
response = client.get(f"/m/{account['management_slug']}")
assert response.status_code == 200
assert "Dashboard locked" in response.text
assert "Update-URL:" not in response.text
def test_settings_reject_unknown_constructor_fields() -> None:
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
DdnsSettings(admn_password="typo")
def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None:
@@ -246,6 +330,40 @@ def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) ->
)
def test_required_dns_provider_requires_strong_dashboard_cookie_signing_source(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_DASHBOARD_COOKIE_SECRET"):
make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="a" * 16,
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="rfc2136",
rfc2136_server="192.0.2.53",
rfc2136_zone="example.net",
require_dns_provider=True,
)
)
def test_required_dns_provider_accepts_explicit_dashboard_cookie_secret(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="a" * 16,
dashboard_cookie_secret="s" * 32,
public_base_url="http://ddns.example.net",
hostname_suffix="ddns.example.net",
dns_provider="rfc2136",
rfc2136_server="192.0.2.53",
rfc2136_zone="example.net",
require_dns_provider=True,
)
)
assert app.state.settings.dashboard_cookie_secret == "s" * 32
def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE"):
make_app(
@@ -261,6 +379,34 @@ def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> N
)
def test_cloudflare_publish_reconciles_provider_state(monkeypatch) -> None:
import router_dyndns.dns.cloudflare as cloudflare
calls = {"find": 0}
def fake_find(settings, hostname, record_type):
calls["find"] += 1
if calls["find"] == 1:
return None
return {"id": "record-id", "content": "203.0.113.99"}
monkeypatch.setattr(cloudflare, "_cloudflare_find_record", fake_find)
monkeypatch.setattr(cloudflare, "_cloudflare_request", lambda *args, **kwargs: {"success": True, "result": []})
with pytest.raises(DnsReconciliationError, match="did not match"):
cloudflare.publish_cloudflare(
UpdateResult(
hostname="home.example.net",
ipv4="203.0.113.10",
ipv6=None,
changed=True,
update_ipv4=True,
update_ipv6=False,
),
DdnsSettings(cloudflare_api_token="token", cloudflare_zone_id="zone"),
)
def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
import dns.query
import dns.rcode
+97 -33
View File
@@ -28,12 +28,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "Copy router fields" in response.text
assert "Copy CNAME" in response.text
assert "Manual updates" in response.text
assert "curl -sS" in response.text
assert "203.0.113.10" in response.text
@@ -61,15 +61,15 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
assert response.status_code == 200
assert "Update-URL:" in response.text
assert "One-box update URL:" in response.text
assert "Full update URL:" in response.text
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Private status page:" in response.text
assert "Copy complete FRITZ!Box fields" in response.text
assert "Copy CNAME record" in response.text
assert "FRITZ!Box replaces" in response.text
assert "Copy curl examples" in response.text
assert "Private dashboard:" in response.text
assert "Copy router fields" in response.text
assert "Copy CNAME" in response.text
assert "FRITZ!Box fills in" in response.text
assert "Copy curl" in response.text
assert response.text.count("Secret") >= 3
assert response.text.count("Public") >= 3
assert ".ddns.example.net" in response.text
@@ -94,12 +94,13 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
management = client.get(f"/m/{account['management_slug']}")
assert management.status_code == 200
assert '<link href="/admin.css?v=2" rel="stylesheet">' in management.text
assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text
assert "Benutzername (optional):" in management.text
assert "Kennwort (optional):" in management.text
assert "Copy complete FRITZ!Box fields" in management.text
assert "Copy CNAME record" in management.text
assert "Copy router fields" in management.text
assert "Copy CNAME" in management.text
assert "Manual updates" in management.text
assert "curl -sS" in management.text
assert "Public:" in management.text
@@ -174,7 +175,7 @@ def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> N
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"},
)
assert bad_unlock.status_code == 401
assert "Incorrect dashboard password." in bad_unlock.text
assert "Incorrect password." in bad_unlock.text
assert "Update-URL:" not in bad_unlock.text
good_unlock = locked_client.post(
@@ -312,7 +313,7 @@ def test_existing_dashboard_password_requires_current_password_to_change_or_remo
assert change.status_code == 400
assert remove.status_code == 400
assert "Current dashboard password is incorrect." in change.text
assert "Current password is incorrect." in change.text
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
@@ -331,21 +332,21 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
dashboard = client.get(f"/m/{account['management_slug']}")
assert dashboard.status_code == 200
assert "Rotate Update-URL" in dashboard.text
assert "Rotate router credentials" in dashboard.text
assert "New update URL" in dashboard.text
assert "New password" in dashboard.text
assert "data-secret-toggle" in dashboard.text
assert 'type="password"' in dashboard.text
rotated_update = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"})
assert rotated_update.status_code == 200
assert "Update-URL rotated." in rotated_update.text
assert "Update URL changed. Update your router now." in rotated_update.text
assert "data-secret-input" in rotated_update.text
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
rotated_password = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(rotated_update.text), "action": "password"})
assert rotated_password.status_code == 200
assert "Router credentials rotated" in rotated_password.text
assert "One-box update URL:" in rotated_password.text
assert "Router password changed" in rotated_password.text
assert "Full update URL:" in rotated_password.text
assert "Kennwort (optional):" in rotated_password.text
assert "data-secret-toggle" in rotated_password.text
assert client.get(
@@ -434,7 +435,7 @@ def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypat
dashboard = client.get(f"/m/{account['management_slug']}")
challenge = client.post(f"/m/{account['management_slug']}/domains/request", data={"csrf": _csrf(dashboard.text), "domain": "example.net"})
assert challenge.status_code == 200
assert "Domain claim created" in challenge.text
assert "Claim created" in challenge.text
assert "TXT name" in challenge.text
assert "TXT value" in challenge.text
assert "Claim secret" in challenge.text
@@ -449,17 +450,17 @@ def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypat
)
assert verified.status_code == 200
assert "Domain verified" in verified.text
assert "Create custom hostname" in verified.text
assert "Create hostname" in verified.text
created = client.post(
f"/m/{account['management_slug']}/domains/hostnames",
data={"csrf": _csrf(verified.text), "hostname": "home.example.net", "claim_secret": claim_secret},
)
assert created.status_code == 200
assert "Custom hostname created" in created.text
assert "Hostname created" in created.text
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
assert "Private status page:" in created.text
assert "Private dashboard:" in created.text
def test_dashboard_domain_claims_are_bound_to_creating_dashboard(tmp_path: Path, monkeypatch) -> None:
@@ -557,7 +558,7 @@ def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) ->
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
assert verify.status_code == 200
assert "Create credentials" in verify.text
assert "Create hostname" in verify.text
created = client.post(
"/accounts",
@@ -584,7 +585,7 @@ def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path:
assert 'href="/admin"' not in response.text
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "generates the hostname, secret update URL, and optional router compatibility credentials automatically" in response.text
assert "You get a hostname, a private update URL, and router fields if your router needs them." in response.text
assert "Linux cron updater" in response.text
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
@@ -617,27 +618,56 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
response = client.get("/")
assert response.status_code == 200
assert '<script src="/app.js" defer></script>' in response.text
assert '<link href="/app.css" rel="stylesheet">' in response.text
assert '<script src="/theme.js?v=2"></script>' in response.text
assert '<script src="/clipboard.js?v=2" defer></script>' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' not in response.text
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
assert '<link href="/app.css?v=2" rel="stylesheet">' in response.text
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
assert "<style>" not in response.text
assert "script-src 'self'" in response.headers["content-security-policy"]
assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
assert "style-src 'self' https://cdn.jsdelivr.net" in response.headers["content-security-policy"]
assert "script-src 'self' 'unsafe-inline'" in response.headers["content-security-policy"]
assert "style-src 'self'" in response.headers["content-security-policy"]
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
assert client.get("/app.js").status_code == 200
bootstrap_css = client.get("/bootstrap-subset.css")
assert bootstrap_css.status_code == 200
assert "text/css" in bootstrap_css.headers["content-type"]
assert bootstrap_css.headers["cache-control"] == "public, max-age=31536000, immutable"
assert client.get("/theme.js").status_code == 200
assert client.get("/clipboard.js").status_code == 200
assert client.get("/admin-tables.js").status_code == 200
app_css = client.get("/app.css")
assert app_css.status_code == 200
assert "text/css" in app_css.headers["content-type"]
assert app_css.headers["cache-control"] == "public, max-age=3600"
assert app_css.headers["cache-control"] == "public, max-age=31536000, immutable"
admin_css = client.get("/admin.css")
assert admin_css.status_code == 200
assert "text/css" in admin_css.headers["content-type"]
assert admin_css.headers["cache-control"] == "public, max-age=31536000, immutable"
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert '<link rel="canonical" href="https://karldns.de/">' in response.text
assert 'property="og:title" content="KarlDNS"' in response.text
assert 'property="og:url" content="https://karldns.de/"' in response.text
assert 'property="og:image" content="https://karldns.de/social-preview.png"' in response.text
assert 'property="og:image:width" content="1200"' in response.text
assert 'property="og:image:height" content="630"' in response.text
assert 'name="twitter:card" content="summary_large_image"' in response.text
assert 'application/ld+json' in response.text
assert "NetworkingApplication" in response.text
assert "Made by Karl" in response.text
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
assert 'href="https://karl.fail"' in response.text
@@ -646,12 +676,41 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
privacy = client.get("/privacy")
assert privacy.status_code == 200
assert '<link rel="canonical" href="https://karldns.de/privacy">' in privacy.text
assert "Privacy and legal information for KarlDNS" in privacy.text
assert "WebPage" in privacy.text
assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text
assert "hashed router passwords" in privacy.text
assert "protected router passwords" in privacy.text
assert "Made by Karl" in privacy.text
def test_public_robots_and_sitemap_are_available(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
admin_path="/ops",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
robots = client.get("/robots.txt")
sitemap = client.get("/sitemap.xml")
assert robots.status_code == 200
assert "User-agent: *" in robots.text
assert "Disallow: /ops/" in robots.text
assert "Disallow: /m/" in robots.text
assert "Disallow: /api/" in robots.text
assert "Sitemap: https://karldns.de/sitemap.xml" in robots.text
assert sitemap.status_code == 200
assert "application/xml" in sitemap.headers["content-type"]
assert "<loc>https://karldns.de/</loc>" in sitemap.text
assert "<loc>https://karldns.de/privacy</loc>" in sitemap.text
def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
@@ -666,11 +725,16 @@ def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None:
assert 'href="/site.webmanifest"' in response.text
for path in [
"/favicon.ico",
"/bootstrap-subset.css",
"/favicon-32x32.png",
"/favicon-16x16.png",
"/apple-touch-icon.png",
"/logo-nav.webp",
"/logo.webp",
"/logo-192.png",
"/logo-512.png",
"/social-preview.png",
"/social-preview.webp",
]:
asset = client.get(path)
assert asset.status_code == 200
+26 -1
View File
@@ -6,7 +6,7 @@ import pytest
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, make_app
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
@@ -145,6 +145,27 @@ def test_dns_publish_failure_does_not_update_record(tmp_path: Path, monkeypatch,
assert "dns down" not in caplog.text
def test_dns_reconciliation_failure_is_logged_separately(tmp_path: Path, monkeypatch, caplog) -> None:
def fail_publish(result, settings):
raise DnsReconciliationError("provider state mismatch")
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "alice")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 500
assert store.list_records() == []
assert store.list_update_events()[0]["status"] == "dns_reconcile_failed"
assert store.get_service_metrics()["dns_reconcile_failures_total"] == 1
assert "DNS reconciliation failed for hostname=home.example.net" in caplog.text
def test_programming_error_during_publish_is_not_mapped_to_dns_failure(tmp_path: Path, monkeypatch) -> None:
def fail_publish(result, settings):
raise AssertionError("publish bug")
@@ -178,6 +199,10 @@ def test_dns_publish_success_updates_record(tmp_path: Path, monkeypatch) -> None
assert response.text == "good 203.0.113.9"
assert len(published) == 1
assert store.list_records()[0]["ipv4"] == "203.0.113.9"
metrics = store.get_service_metrics()
assert metrics["update_requests_total"] == 1
assert metrics["update_success_total"] == 1
assert metrics["update_latency_samples_total"] == 1
def test_unchanged_slug_update_is_read_only_for_history_and_record(tmp_path: Path, monkeypatch) -> None: