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

+4 -18
View File
@@ -6,20 +6,8 @@ from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
from router_dyndns.ddns_security import admin_csrf_token
def test_shared_secret_is_not_admin_password_fallback(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
client = TestClient(app)
response = client.get("/admin", auth=("admin", "secret"))
assert response.status_code == 500
assert response.json()["detail"] == "DDNS_ADMIN_PASSWORD is required"
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
store.create_domain_challenge("example.net")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
@@ -32,10 +20,10 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
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") == 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 response.text.count("data-admin-table") == 2
assert response.text.count("data-table-search") == 2
assert response.text.count("data-table-page-size") == 2
assert response.text.count("data-table-pagination") == 2
assert "admin-table-scroll" in response.text
assert "Cleanup" in response.text
assert "Run now" in response.text
@@ -43,9 +31,7 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
assert "Metrics" in response.text
assert "Blocked requests" in response.text
assert "Hostnames" in response.text
assert "Domain claims" in response.text
assert "Update events" in response.text
assert "example.net" in response.text
def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
-102
View File
@@ -63,11 +63,8 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
assert "/events" not in schema["paths"]
assert "/" not in schema["paths"]
assert "/magic" not in schema["paths"]
assert "/request-domain" not in schema["paths"]
assert "/verify-domain" not in schema["paths"]
assert "/accounts" not in schema["paths"]
assert "/m/{management_slug}" not in schema["paths"]
assert "/d/{claim_secret}" not in schema["paths"]
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
assert all(tag["name"] != "admin" for tag in schema["tags"])
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management")
@@ -113,9 +110,6 @@ def test_public_redoc_only_exposes_api_and_router_endpoints(tmp_path: Path) -> N
schema = client.get("/openapi.json").json()
expected_public_paths = {
"/api/v1/domains/challenges",
"/api/v1/domains/verify",
"/api/v1/hostnames/custom",
"/api/v1/hostnames/magic",
"/api/v1/management/{management_slug}",
"/api/v1/updates/{update_slug}",
@@ -144,16 +138,8 @@ def test_openapi_marks_generated_update_links_as_secret(tmp_path: Path) -> None:
("path", "payload"),
[
("/api/v1/hostnames/magic", {"username": "router", "unexpected": True}),
("/api/v1/domains/challenges", {"domain": "example.net", "unexpected": True}),
("/api/v1/domains/verify", {"domain": "example.net", "claim_secret": "secret", "unexpected": True}),
(
"/api/v1/hostnames/custom",
{"hostname": "home.example.net", "claim_secret": "secret", "unexpected": True},
),
],
)
def test_public_write_models_reject_extra_json_fields(tmp_path: Path, path: str, payload: dict[str, object]) -> None:
app = make_app(
DdnsSettings(
@@ -235,50 +221,6 @@ def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypa
assert store.list_update_events()[0]["status"] == "dns_failed"
def test_api_custom_hostname_requires_verified_domain(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
assert challenge.status_code == 201
challenge_body = challenge.json()
verified = client.post(
"/api/v1/domains/verify",
json={"domain": "example.net", "claim_secret": challenge_body["claim_secret"]},
)
assert verified.status_code == 200
assert verified.json()["verified"] is True
hostname = client.post(
"/api/v1/hostnames/custom",
json={
"hostname": "home.example.net",
"claim_secret": challenge_body["claim_secret"],
"username": "router",
},
)
assert hostname.status_code == 201
assert hostname.json()["hostname"] == "home.example.net"
def test_domain_challenge_verification_requires_current_secret_and_owner(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
first = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
second = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
assert store.verify_domain_challenge("example.net", first["claim_secret"], owner_hostname="owner.example.net") is None
assert store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="other.example.net") is None
assert store.get_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")["verified_at"] is None
verified = store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")
assert verified is not None
assert verified["verified_at"]
def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
@@ -309,47 +251,3 @@ def test_api_invalid_ip_returns_clear_400(tmp_path: Path) -> None:
assert response.status_code == 400
assert response.json()["detail"] == "myipv6 must be an IPv6 address"
assert store.list_records() == []
def test_verified_domain_challenge_cannot_be_overwritten(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
verified = client.post(
"/api/v1/domains/verify",
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
)
overwrite = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
assert verified.status_code == 200
assert overwrite.status_code == 409
def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
dns_provider="cloudflare",
cloudflare_api_token="token",
cloudflare_zone_id="zone",
dns_zones={"ddns.example.net"},
)
)
client = TestClient(app)
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
client.post(
"/api/v1/domains/verify",
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
)
hostname = client.post(
"/api/v1/hostnames/custom",
json={"hostname": "home.example.net", "claim_secret": challenge["claim_secret"]},
)
assert hostname.status_code == 403
assert hostname.json()["detail"] == "hostname is outside configured DNS publishing zones"
+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")
-17
View File
@@ -51,22 +51,6 @@ def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
assert response.status_code == 401
def test_shared_secret_without_allowed_hosts_cannot_update_arbitrary_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
app = make_app(DdnsSettings(database_path=database_path, shared_secret="secret", admin_password="admin"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "other.example.net", "myip": "203.0.113.9", "password": "secret"},
)
assert response.status_code == 401
assert response.text == "badauth"
assert store.list_records() == []
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
@@ -121,7 +105,6 @@ def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path
"/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:
+33 -166
View File
@@ -3,7 +3,6 @@ from pathlib import Path
from fastapi.testclient import TestClient
import router_dyndns.ddns_service as ddns_service
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
@@ -32,8 +31,8 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert "CNAME target:" in response.text
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Copy router fields" in response.text
assert "Copy CNAME" in response.text
assert "Router fields" in response.text
assert "CNAME" in response.text
assert "Manual updates" in response.text
assert "curl -sS" in response.text
assert "203.0.113.10" in response.text
@@ -46,6 +45,23 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
assert "/m/" in response.text
def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
hostname_suffix="ddns.example.net",
)
)
client = TestClient(app)
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"})
assert response.status_code == 400
assert response.json()["detail"] == "custom hostnames are not supported; create a KarlDNS hostname and use a CNAME"
assert app.state.store.count_accounts() == 0
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
@@ -66,10 +82,10 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
assert "Benutzername (optional):" in response.text
assert "Kennwort (optional):" in response.text
assert "Private dashboard:" in response.text
assert "Copy router fields" in response.text
assert "Copy CNAME" in response.text
assert "Router fields" in response.text
assert "CNAME" in response.text
assert "FRITZ!Box fills in" in response.text
assert "Copy curl" in response.text
assert "Copy manual update commands" in response.text
assert response.text.count("Secret") >= 3
assert response.text.count("Public") >= 3
assert ".ddns.example.net" in response.text
@@ -99,8 +115,8 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
assert "CNAME target:" in management.text
assert "Benutzername (optional):" in management.text
assert "Kennwort (optional):" in management.text
assert "Copy router fields" in management.text
assert "Copy CNAME" in management.text
assert "Router fields" in management.text
assert "CNAME" in management.text
assert "Manual updates" in management.text
assert "curl -sS" in management.text
assert "Public:" in management.text
@@ -391,14 +407,11 @@ def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_pat
rotate = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
delete = client.post(f"/m/{account['management_slug']}/delete")
claim = client.post(f"/m/{account['management_slug']}/domains/request", data={"domain": "example.net"})
assert rotate.status_code == 401
assert delete.status_code == 401
assert claim.status_code == 401
assert store.get_account_by_hostname("locked.ddns.example.net")["update_slug"] == account["update_slug"]
assert store.get_account_by_slug(account["update_slug"]) is not None
assert store.get_domain_challenge("example.net") is None
def test_management_page_shows_ip_history(tmp_path: Path) -> None:
@@ -418,157 +431,6 @@ def test_management_page_shows_ip_history(tmp_path: Path) -> None:
assert "203.0.113.10" in response.text
def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("base.ddns.example.net", "router")
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
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 "Claim created" in challenge.text
assert "TXT name" in challenge.text
assert "TXT value" in challenge.text
assert "Claim secret" in challenge.text
assert "Private claim link" in challenge.text
assert "This claim belongs to this dashboard" in challenge.text
assert "data-secret-toggle" in challenge.text
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
verified = client.post(
f"/m/{account['management_slug']}/domains/verify",
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
)
assert verified.status_code == 200
assert "Domain verified" 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 "Hostname created" in created.text
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
assert "Private dashboard:" in created.text
def test_dashboard_domain_claims_are_bound_to_creating_dashboard(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
owner = store.create_account("owner.ddns.example.net", "owner")
other = store.create_account("other.ddns.example.net", "other")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
client = TestClient(app)
owner_dashboard = client.get(f"/m/{owner['management_slug']}")
challenge = client.post(f"/m/{owner['management_slug']}/domains/request", data={"csrf": _csrf(owner_dashboard.text), "domain": "example.net"})
assert challenge.status_code == 200
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
verify_other = client.post(
f"/m/{other['management_slug']}/domains/verify",
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "domain": "example.net", "claim_secret": claim_secret},
)
create_other = client.post(
f"/m/{other['management_slug']}/domains/hostnames",
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "hostname": "home.example.net", "claim_secret": claim_secret},
)
verify_owner = client.post(
f"/m/{owner['management_slug']}/domains/verify",
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
)
assert verify_other.status_code == 404
assert create_other.status_code == 403
assert verify_owner.status_code == 200
assert "Domain verified" in verify_owner.text
assert store.get_account_by_hostname("home.example.net") is None
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
assert response.status_code == 403
def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
store = DdnsStore(tmp_path / "ddns.sqlite3")
challenge_data = store.create_domain_challenge("example.net")
verify = client.post(
"/verify-domain",
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
)
assert verify.status_code == 200
response = client.post(
"/accounts",
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": challenge_data["claim_secret"]},
)
assert response.status_code == 200
assert "home.example.net" in response.text
assert "Update-URL:" in response.text
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
challenge = client.post("/request-domain", data={"domain": "example.net"})
assert challenge.status_code == 200
assert "Private claim link" in challenge.text
assert "/d/" in challenge.text
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
claim_page = client.get(f"/d/{claim_secret}")
assert claim_page.status_code == 200
assert "TXT value" in claim_page.text
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
assert verify.status_code == 200
assert "Create hostname" in verify.text
created = client.post(
"/accounts",
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
)
assert created.status_code == 200
assert "home.example.net" in created.text
assert "Update-URL:" in created.text
def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
@@ -586,6 +448,7 @@ def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path:
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "You get a hostname, a private update URL, and router fields if your router needs them." in response.text
assert "Add a CNAME" 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
@@ -619,10 +482,10 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
assert response.status_code == 200
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="/clipboard.js?v=3" 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="/app.css?v=5" 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
@@ -668,7 +531,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
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 "Made with" in response.text
assert "❤️" in response.text
assert "by karl" in response.text
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
assert 'href="https://karl.fail"' in response.text
assert 'href="https://karlcom.de"' in response.text
@@ -682,7 +547,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text
assert "protected router passwords" in privacy.text
assert "Made by Karl" in privacy.text
assert "Made with" in privacy.text
assert "❤️" in privacy.text
assert "by karl" in privacy.text
def test_public_robots_and_sitemap_are_available(tmp_path: Path) -> None:
+36 -47
View File
@@ -10,23 +10,20 @@ from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsRec
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
shared_secret="secret",
admin_password="admin",
allowed_hosts={"home.example.net"},
)
)
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={
"hostname": "home.example.net",
"hostname": "home.ddns.example.net",
"myip": "203.0.113.8",
"myipv6": "2001:db8::8",
"password": "secret",
"username": account["username"],
"password": account["password"],
},
)
@@ -34,36 +31,36 @@ def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
assert response.text == "good 203.0.113.8"
records = client.get("/records", auth=("admin", "admin")).json()
assert records["total"] == 1
assert records["items"][0]["hostname"] == "home.example.net"
assert records["items"][0]["hostname"] == "home.ddns.example.net"
assert records["items"][0]["ipv4"] == "203.0.113.8"
assert records["items"][0]["ipv6"] == "2001:db8::8"
def test_update_rejects_wrong_secret(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
def test_update_rejects_wrong_password(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "home.example.net", "myip": "203.0.113.8", "password": "wrong"},
params={"hostname": account["hostname"], "myip": "203.0.113.8", "username": account["username"], "password": "wrong"},
)
assert response.status_code == 401
def test_update_rejects_unlisted_host(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
shared_secret="secret",
allowed_hosts={"home.example.net"},
)
)
def test_update_rejects_hostname_outside_provider_suffix(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "other.example.net", "myip": "203.0.113.8", "password": "secret"},
params={"hostname": account["hostname"], "myip": "203.0.113.8", "username": account["username"], "password": account["password"]},
)
assert response.status_code == 403
@@ -229,24 +226,17 @@ def test_unchanged_slug_update_is_read_only_for_history_and_record(tmp_path: Pat
assert published == []
def test_shared_secret_still_updates_explicitly_allowed_hostname(tmp_path: Path) -> None:
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
shared_secret="secret",
admin_password="admin",
allowed_hosts={"home.example.net"},
)
)
def test_slug_update_rejects_hostname_outside_provider_suffix(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "home.example.net", "myip": "203.0.113.9", "password": "secret"},
)
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
assert response.status_code == 200
assert response.text == "good 203.0.113.9"
assert response.status_code == 404
assert store.list_records() == []
def test_omitted_ip_family_is_preserved(tmp_path: Path, monkeypatch) -> None:
@@ -326,19 +316,18 @@ def test_wrong_family_legacy_ip_is_badip_and_preserves_records(tmp_path: Path) -
def test_nic_update_invalid_ip_returns_badip(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
app = make_app(
DdnsSettings(
database_path=database_path,
shared_secret="secret",
admin_password="admin",
allowed_hosts={"home.example.net"},
)
)
account = store.create_account("home.ddns.example.net", "router")
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
client = TestClient(app)
response = client.get(
"/nic/update",
params={"hostname": "home.example.net", "myipv6": "203.0.113.9", "password": "secret"},
params={
"hostname": account["hostname"],
"myipv6": "203.0.113.9",
"username": account["username"],
"password": account["password"],
},
)
assert response.status_code == 400