This repository has been archived on 2026-10-28. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
karldns.de/tests/test_admin.py
T

286 lines
12 KiB
Python
Raw Normal View History

2026-05-20 23:55:27 +02:00
from pathlib import Path
from fastapi.testclient import TestClient
2026-05-21 17:23:08 +02:00
from karldns import DdnsSettings, DdnsStore, make_app
from karldns.security import admin_csrf_token
2026-05-20 23:55:27 +02:00
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
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"
2026-05-21 10:05:40 +02:00
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
2026-05-20 23:55:27 +02:00
assert "Operator console" in response.text
assert "Generate credentials" not in response.text
assert 'action="/admin/accounts"' not in response.text
2026-05-21 14:18:39 +02:00
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
2026-05-20 23:55:27 +02:00
assert "admin-table-scroll" in response.text
2026-05-21 10:05:40 +02:00
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 "Update events" in response.text
2026-05-20 23:55:27 +02:00
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
2026-05-21 17:23:08 +02:00
assert 'href="/ops/records"' not in response.text
assert 'href="/ops/events"' not in response.text
2026-05-20 23:55:27 +02:00
assert 'action="/ops/cleanup"' in response.text
2026-05-21 17:23:08 +02:00
assert 'action="/ops/hostnames/rotate"' in response.text
2026-05-20 23:55:27 +02:00
assert 'action="/admin/cleanup"' not in response.text
2026-05-21 17:23:08 +02:00
assert client.get("/ops/records", auth=("admin", "admin")).status_code == 404
2026-05-20 23:55:27 +02:00
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)
2026-05-21 17:23:08 +02:00
records = client.get("/api/v1/admin/records?limit=1&offset=1", auth=("admin", "admin")).json()
events = client.get("/api/v1/admin/events?limit=1&offset=1", auth=("admin", "admin")).json()
2026-05-20 23:55:27 +02:00
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
2026-05-21 10:05:40 +02:00
assert "next_before" in events
2026-05-20 23:55:27 +02:00
assert events["total"] == 2
assert len(events["items"]) == 1
2026-05-21 17:23:08 +02:00
def test_admin_api_exposes_dashboard_data_and_history(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
account = store.create_account("home.example.net", "router")
store.upsert(account["hostname"], "203.0.113.10", None)
store.log_update_event(account["hostname"], "203.0.113.10", None, "updated", "ok", "198.51.100.1")
store.increment_metric("update_requests_total")
settings = DdnsSettings(database_path=database_path, admin_password="admin")
app = make_app(settings)
client = TestClient(app)
assert client.get("/api/v1/admin/dashboard").status_code == 401
dashboard = client.get("/api/v1/admin/dashboard?limit=10", auth=("admin", "admin"))
hostnames = client.get("/api/v1/admin/hostnames?limit=1&offset=0", auth=("admin", "admin"))
records = client.get("/api/v1/admin/records?limit=1&offset=0", auth=("admin", "admin"))
events = client.get("/api/v1/admin/events?limit=1&offset=0", auth=("admin", "admin"))
assert dashboard.status_code == 200
assert dashboard.headers["cache-control"] == "no-store"
dashboard_json = dashboard.json()
assert dashboard_json["totals"]["hostnames"] == 1
assert dashboard_json["totals"]["records"] == 1
assert dashboard_json["totals"]["update_events"] == 1
assert dashboard_json["metrics"]["values"]["update_requests_total"] == 1
assert dashboard_json["hostnames"][0]["hostname"] == "home.example.net"
assert dashboard_json["hostnames"][0]["status"] == "active"
assert dashboard_json["hostnames"][0]["ipv4"] == "203.0.113.10"
assert dashboard_json["update_events"][0]["status"] == "updated"
assert dashboard_json["scheduled_tasks"][0]["name"] == "cleanup"
assert hostnames.json()["items"][0]["hostname"] == "home.example.net"
assert records.json()["items"][0]["ipv4"] == "203.0.113.10"
assert events.json()["items"][0]["hostname"] == "home.example.net"
2026-05-21 10:05:40 +02:00
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)
2026-05-21 17:23:08 +02:00
records = client.get("/api/v1/admin/records?offset=10001", auth=("admin", "admin"))
events = client.get("/api/v1/admin/events?offset=10001", auth=("admin", "admin"))
2026-05-21 10:05:40 +02:00
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)
2026-05-21 17:23:08 +02:00
first = client.get("/api/v1/admin/events?limit=1", auth=("admin", "admin")).json()
second = client.get(f"/api/v1/admin/events?limit=1&before={first['next_before']}", auth=("admin", "admin")).json()
2026-05-21 10:05:40 +02:00
assert first["items"][0]["hostname"] == "raw-new.example.net"
assert second["items"][0]["hostname"] == "raw-old.example.net"
2026-05-20 23:55:27 +02:00
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(
2026-05-21 17:23:08 +02:00
"/admin/hostnames/rotate",
2026-05-20 23:55:27 +02:00
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
)
assert update.status_code == 200
2026-05-21 10:05:40 +02:00
assert "Update URL changed" in update.text
2026-05-21 17:23:08 +02:00
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
2026-05-20 23:55:27 +02:00
management = client.post(
2026-05-21 17:23:08 +02:00
"/admin/hostnames/rotate",
2026-05-20 23:55:27 +02:00
auth=("admin", "admin"),
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
)
assert management.status_code == 200
2026-05-21 10:05:40 +02:00
assert "Dashboard link changed" in management.text
2026-05-20 23:55:27 +02:00
assert "/m/" in management.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
2026-05-21 17:23:08 +02:00
assert "admin_router_credentials_rotated" not in statuses
2026-05-20 23:55:27 +02:00
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(
2026-05-21 17:23:08 +02:00
"/admin/hostnames/delete",
2026-05-20 23:55:27 +02:00
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",
'<script>alert("xss")</script>',
"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 '<script>alert("xss")</script>' not in response.text
assert "&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;" in response.text