from pathlib import Path from fastapi.testclient import TestClient 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) response = client.get("/admin", auth=("admin", "admin")) assert response.status_code == 200 assert response.headers["cache-control"] == "no-store" 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 "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 "Domain claims" in response.text assert "Recent events" in response.text assert "example.net" in response.text def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None: settings = DdnsSettings( database_path=tmp_path / "ddns.sqlite3", admin_password="admin", admin_path="/ops", ) store = DdnsStore(settings.database_path) store.create_account("home.example.net", "router") app = make_app(settings) client = TestClient(app) response = client.get("/ops", auth=("admin", "admin")) assert response.status_code == 200 assert 'href="/ops/records"' in response.text assert 'href="/ops/events"' in response.text assert 'action="/ops/cleanup"' in response.text assert 'action="/ops/accounts/rotate"' in response.text assert 'action="/admin/cleanup"' not in response.text assert client.get("/ops/records", auth=("admin", "admin")).status_code == 200 assert client.get("/records", auth=("admin", "admin")).status_code == 404 assert client.get("/admin", auth=("admin", "admin")).status_code == 404 def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) first = store.create_account("first.example.net", "router") second = store.create_account("second.example.net", "router") store.upsert(first["hostname"], "203.0.113.1", None) store.upsert(second["hostname"], "203.0.113.2", None) store.log_update_event(first["hostname"], "203.0.113.1", None, "updated", "ok", "198.51.100.1") store.log_update_event(second["hostname"], "203.0.113.2", None, "updated", "ok", "198.51.100.2") app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) client = TestClient(app) records = client.get("/records?limit=1&offset=1", auth=("admin", "admin")).json() events = client.get("/events?limit=1&offset=1", auth=("admin", "admin")).json() assert records["limit"] == 1 assert records["offset"] == 1 assert records["total"] == 2 assert len(records["items"]) == 1 assert events["limit"] == 1 assert events["offset"] == 1 assert events["total"] == 2 assert len(events["items"]) == 1 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) response = client.post( "/admin/cleanup", auth=("admin", "admin"), data={}, ) assert response.status_code == 403 def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None: app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")) client = TestClient(app) response = client.get("/admin", params={"password": "admin"}) assert response.status_code == 401 def test_admin_routes_are_rate_limited(tmp_path: Path) -> None: app = make_app( DdnsSettings( database_path=tmp_path / "ddns.sqlite3", admin_password="admin", admin_rate_limit_per_minute=1, ) ) client = TestClient(app) first = client.get("/admin", auth=("admin", "wrong")) second = client.get("/admin", auth=("admin", "wrong")) assert first.status_code == 401 assert second.status_code == 429 def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None: settings = DdnsSettings( database_path=tmp_path / "ddns.sqlite3", admin_password="admin", public_base_url="http://ddns.example.net", ) store = DdnsStore(settings.database_path) account = store.create_account("home.example.net", "router") app = make_app(settings) client = TestClient(app) csrf = admin_csrf_token(settings) update = client.post( "/admin/accounts/rotate", auth=("admin", "admin"), data={"csrf": csrf, "hostname": "home.example.net", "action": "update"}, ) assert update.status_code == 200 assert "Update URL rotated" in update.text assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404 management = client.post( "/admin/accounts/rotate", auth=("admin", "admin"), data={"csrf": csrf, "hostname": "home.example.net", "action": "management"}, ) assert management.status_code == 200 assert "Private status page rotated" in management.text assert "/m/" in management.text password = client.post( "/admin/accounts/rotate", auth=("admin", "admin"), 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 "/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)} assert "admin_update_url_rotated" in statuses assert "admin_management_url_rotated" in statuses assert "admin_router_credentials_rotated" in statuses def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -> None: settings = DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin") store = DdnsStore(settings.database_path) store.create_account("home.example.net", "router") app = make_app(settings) client = TestClient(app) response = client.post( "/admin/accounts/delete", auth=("admin", "admin"), data={"csrf": admin_csrf_token(settings), "hostname": " HOME.EXAMPLE.NET. "}, follow_redirects=False, ) assert response.status_code == 303 assert store.get_account_by_hostname("home.example.net") is None events = store.list_update_events_for_hostname("home.example.net", 10) assert events[0]["status"] == "admin_hostname_deleted" assert "secret" not in str(events[0]["detail"]).lower() def test_admin_template_escapes_update_event_html(tmp_path: Path) -> None: database_path = tmp_path / "ddns.sqlite3" store = DdnsStore(database_path) store.create_account("home.example.net", "router") store.log_update_event( "home.example.net", "203.0.113.10", None, "dns_failed", '', "198.51.100.20", ) app = make_app(DdnsSettings(database_path=database_path, admin_password="admin")) client = TestClient(app) response = client.get("/admin", auth=("admin", "admin")) assert response.status_code == 200 assert '' not in response.text assert "<script>alert("xss")</script>" in response.text