import re from pathlib import Path from fastapi.testclient import TestClient from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app def _csrf(html: str) -> str: match = re.search(r'name="csrf" value="([^"]+)"', html) assert match return match.group(1) def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) -> None: app = make_app( DdnsSettings( database_path=tmp_path / "ddns.sqlite3", admin_password="admin", hostname_suffix="ddns.example.net", public_base_url="http://ddns.example.net", ) ) client = TestClient(app) response = client.post("/accounts", data={"mode": "managed", "username": "alice"}) assert response.status_code == 200 assert "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 "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 assert "2001:db8::10" in response.text assert "Public:" in response.text assert "Secret:" in response.text assert ".ddns.example.net" in response.text assert "/u/" in response.text assert "/nic/update?" in response.text 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( database_path=tmp_path / "ddns.sqlite3", admin_password="admin", hostname_suffix="ddns.example.net", public_base_url="http://ddns.example.net", ) ) client = TestClient(app) response = client.post("/magic") assert response.status_code == 200 assert "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 dashboard:" in response.text assert "Router fields" in response.text assert "CNAME" in response.text assert "FRITZ!Box fills in" 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 def test_magic_get_redirects_to_home_form(tmp_path: Path) -> None: app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) client = TestClient(app) response = client.get("/magic", follow_redirects=False) assert response.status_code == 303 assert response.headers["location"] == "/#create" def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("home.ddns.example.net", "home") app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) client = TestClient(app) management = client.get(f"/m/{account['management_slug']}") assert management.status_code == 200 assert '' 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 "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 assert "Secret:" in management.text assert "Router updates" in management.text deleted = client.post(f"/m/{account['management_slug']}/delete", data={"csrf": _csrf(management.text)}) assert deleted.status_code == 200 update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}) assert update.status_code == 404 def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("home.ddns.example.net", "home") 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']}") set_password = client.post( f"/m/{account['management_slug']}/password", data={ "csrf": _csrf(dashboard.text), "dashboard_password": "correct horse", "dashboard_password_confirm": "correct horse", }, follow_redirects=False, ) assert set_password.status_code == 303 assert "httponly" in set_password.headers["set-cookie"].lower() assert "samesite=lax" in set_password.headers["set-cookie"].lower() assert "secure" not in set_password.headers["set-cookie"].lower() stored = app.state.store.get_account_by_management_slug(account["management_slug"]) assert stored["dashboard_password_hash"].startswith("pbkdf2_sha256$") assert stored["dashboard_password_hash"] != "correct horse" locked_client = TestClient(app) locked = locked_client.get(f"/m/{account['management_slug']}") assert locked.status_code == 200 assert "Dashboard locked" in locked.text assert "Update-URL:" not in locked.text assert "correct horse" not in locked.text update = locked_client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}) assert update.status_code == 200 assert update.text == "good 203.0.113.9" nic_update = locked_client.get( "/nic/update", params={ "hostname": account["hostname"], "myip": "203.0.113.10", "username": account["username"], "password": account["password"], }, ) assert nic_update.status_code == 200 assert nic_update.text == "good 203.0.113.10" bad_unlock = locked_client.post( f"/m/{account['management_slug']}/unlock", data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"}, ) assert bad_unlock.status_code == 401 assert "Incorrect password." in bad_unlock.text assert "Update-URL:" not in bad_unlock.text good_unlock = locked_client.post( f"/m/{account['management_slug']}/unlock", data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"}, follow_redirects=False, ) assert good_unlock.status_code == 303 assert "max-age=86400" in good_unlock.headers["set-cookie"].lower() unlocked = locked_client.get(f"/m/{account['management_slug']}") assert "Update-URL:" in unlocked.text assert "Password protection" in unlocked.text def test_management_dashboard_password_cookie_is_secure_on_https_public_url(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("home.ddns.example.net", "home") app = make_app( DdnsSettings( database_path=database_path, admin_password="admin", public_base_url="https://ddns.example.net", ) ) client = TestClient(app) dashboard = client.get(f"/m/{account['management_slug']}") response = client.post( f"/m/{account['management_slug']}/password", data={ "csrf": _csrf(dashboard.text), "dashboard_password": "correct horse", "dashboard_password_confirm": "correct horse", }, follow_redirects=False, ) assert response.status_code == 303 assert "secure" in response.headers["set-cookie"].lower() def test_management_dashboard_password_can_be_removed(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("home.ddns.example.net", "home") store.set_dashboard_password("home.ddns.example.net", "correct horse") app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) client = TestClient(app) unlock = client.post( f"/m/{account['management_slug']}/unlock", data={"csrf": _csrf(client.get(f"/m/{account['management_slug']}").text), "dashboard_password": "correct horse"}, ) assert unlock.status_code == 200 remove = client.post( f"/m/{account['management_slug']}/password/remove", data={"csrf": _csrf(unlock.text), "current_dashboard_password": "correct horse"}, ) assert remove.status_code == 200 assert "Not enabled" in remove.text fresh_client = TestClient(app) unlocked = fresh_client.get(f"/m/{account['management_slug']}") assert unlocked.status_code == 200 assert "Update-URL:" in unlocked.text def test_dashboard_stay_unlocked_cookie_uses_longer_lifetime(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("home.ddns.example.net", "home") store.set_dashboard_password("home.ddns.example.net", "correct horse") app = make_app( DdnsSettings( database_path=database_path, admin_password="admin", dashboard_unlock_seconds=3600, dashboard_stay_unlocked_seconds=30 * 24 * 60 * 60, ) ) client = TestClient(app) locked = client.get(f"/m/{account['management_slug']}") response = client.post( f"/m/{account['management_slug']}/unlock", data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse", "stay_unlocked": "1"}, follow_redirects=False, ) assert response.status_code == 303 assert "max-age=2592000" in response.headers["set-cookie"].lower() def test_unlocked_dashboard_posts_require_csrf(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("home.ddns.example.net", "home") app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) client = TestClient(app) response = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"}) assert response.status_code == 403 assert store.get_account_by_hostname("home.ddns.example.net")["update_slug"] == account["update_slug"] def test_existing_dashboard_password_requires_current_password_to_change_or_remove(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("home.ddns.example.net", "home") store.set_dashboard_password("home.ddns.example.net", "correct horse") app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) client = TestClient(app) locked = client.get(f"/m/{account['management_slug']}") unlocked = client.post( f"/m/{account['management_slug']}/unlock", data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"}, ) change = client.post( f"/m/{account['management_slug']}/password", data={ "csrf": _csrf(unlocked.text), "current_dashboard_password": "wrong", "dashboard_password": "new password", "dashboard_password_confirm": "new password", }, ) remove = client.post( f"/m/{account['management_slug']}/password/remove", data={"csrf": _csrf(unlocked.text), "current_dashboard_password": "wrong"}, ) assert change.status_code == 400 assert remove.status_code == 400 assert "Current password is incorrect." in change.text assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse") def test_management_dashboard_rotates_update_url_and_router_credentials(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", public_base_url="http://ddns.example.net", ) ) client = TestClient(app) dashboard = client.get(f"/m/{account['management_slug']}") assert dashboard.status_code == 200 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 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 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( "/nic/update", params={ "hostname": "home.ddns.example.net", "username": "router", "password": account["password"], "myip": "203.0.113.9", }, ).status_code == 401 statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)} assert "dashboard_update_url_rotated" in statuses assert "dashboard_router_credentials_rotated" in statuses def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) first = store.create_account("first.ddns.example.net", "first") second = store.create_account("second.ddns.example.net", "second") app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) client = TestClient(app) dashboard = client.get(f"/m/{first['management_slug']}") rotated = client.post(f"/m/{first['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"}) assert rotated.status_code == 200 assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"] assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"] assert client.get(f"/u/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404 assert client.get(f"/u/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200 def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("locked.ddns.example.net", "router") store.set_dashboard_password("locked.ddns.example.net", "correct horse") app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) client = TestClient(app) rotate = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"}) delete = client.post(f"/m/{account['management_slug']}/delete") assert rotate.status_code == 401 assert delete.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 def test_management_page_shows_ip_history(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) account = store.create_account("home.ddns.example.net", "router") store.log_update_event("home.ddns.example.net", "203.0.113.9", None, "updated", "ok", "198.51.100.1") store.log_update_event("home.ddns.example.net", "203.0.113.10", None, "updated", "ok", "198.51.100.1") app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) client = TestClient(app) response = client.get(f"/m/{account['management_slug']}") assert response.status_code == 200 assert "Router updates" in response.text assert "203.0.113.9" in response.text assert "203.0.113.10" in response.text def test_public_home_hides_admin_link_and_uses_automatic_router_login(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.get("/") assert response.status_code == 200 assert 'href="/admin"' not in response.text 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 assert "--delete-config" in response.text def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None: app = make_app( DdnsSettings( database_path=tmp_path / "ddns.sqlite3", admin_password="admin", hostname_suffix="karldns.de", public_base_url="https://karldns.de", ) ) client = TestClient(app) response = client.get("/") assert response.status_code == 200 assert "https://karldns.de/u/<secret>" in response.text assert "http://127.0.0.1" not in response.text assert "http://localhost" not in response.text def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None: app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) client = TestClient(app) response = client.get("/") assert response.status_code == 200 assert '' in response.text assert '' in response.text assert '' not in response.text assert '' in response.text assert '' in response.text assert '' not in response.text assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text assert "