Archived
Polish KarlDNS production UI and docs
This commit is contained in:
1 parent
ca92dc211a
commit
5beee0e5c6
61 files changed
+1566
-523
No files matched your search
+66
-13
@@ -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)}
|
||||
|
||||
@@ -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/")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user