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_ui.py
T

817 lines
32 KiB
Python
Raw Normal View History

2026-05-20 23:55:27 +02:00
import re
import types
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.routers import assets
from karldns.web import scripts, styles
2026-05-20 23:55:27 +02:00
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)
2026-05-21 17:23:08 +02:00
response = client.post("/")
2026-05-20 23:55:27 +02:00
assert response.status_code == 200
2026-05-21 17:58:20 +02:00
assert "/m/" in str(response.url)
2026-05-20 23:55:27 +02:00
assert "Update-URL:" in response.text
2026-05-21 18:30:15 +02:00
assert "Domainnamen:" in response.text
assert "CNAME target:" not in response.text
assert "Register URL:" not in response.text
2026-05-21 17:58:20 +02:00
assert "Router setup." in response.text
assert "Use your own DNS name." in response.text
2026-05-20 23:55:27 +02:00
assert "Manual updates" in response.text
assert "curl -sS" in response.text
assert "203.0.113.10" in response.text
2026-05-21 18:57:45 +02:00
assert "2001:db8::10" not in response.text
2026-05-20 23:55:27 +02:00
assert "Public:" in response.text
assert "Secret:" in response.text
assert ".ddns.example.net" in response.text
2026-05-21 17:23:08 +02:00
assert "/api/v1/router/update/" in response.text
2026-05-21 18:30:15 +02:00
assert "/api/v1/router/register/" not in response.text
2026-05-20 23:55:27 +02:00
assert "/m/" in response.text
2026-05-21 17:58:20 +02:00
assert 'type="password"' not in response.text
assert "Copy setup values" not in response.text
2026-05-20 23:55:27 +02:00
2026-05-21 14:18:39 +02:00
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",
2026-05-21 17:23:08 +02:00
public_base_url="https://karldns.de",
2026-05-21 14:18:39 +02:00
)
)
client = TestClient(app)
2026-05-22 17:05:06 +02:00
response = client.post(
"/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"}
)
2026-05-21 14:18:39 +02:00
2026-05-21 17:23:08 +02:00
assert response.status_code == 404
2026-05-21 14:18:39 +02:00
assert app.state.store.count_accounts() == 0
2026-05-21 17:23:08 +02:00
def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: Path) -> None:
2026-05-20 23:55:27 +02:00
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)
2026-05-21 17:23:08 +02:00
response = client.post("/")
2026-05-20 23:55:27 +02:00
assert response.status_code == 200
2026-05-21 17:58:20 +02:00
assert "/m/" in str(response.url)
2026-05-20 23:55:27 +02:00
assert "Update-URL:" in response.text
2026-05-21 18:30:15 +02:00
assert "Domainnamen:" in response.text
assert "CNAME target:" not in response.text
assert "Register URL:" not in response.text
2026-05-21 10:05:40 +02:00
assert "Private dashboard:" in response.text
2026-05-21 17:23:08 +02:00
assert "Pending setup" in response.text
assert "Register hostname" in response.text
2026-05-21 17:58:20 +02:00
assert "Pending setups are cleaned up after 24 hours." in response.text
assert "Router fields" not in response.text
assert "Copy setup values" not in response.text
assert "Paste this exact URL into the FRITZ!Box" in response.text
2026-05-21 14:18:39 +02:00
assert "Copy manual update commands" in response.text
2026-05-21 18:30:15 +02:00
assert response.text.count("copy-field-secret") >= 2
assert response.text.count("copy-field-public") >= 1
2026-05-20 23:55:27 +02:00
assert ".ddns.example.net" in response.text
2026-05-21 17:58:20 +02:00
assert 'type="password"' not in response.text
2026-05-20 23:55:27 +02:00
2026-05-21 17:23:08 +02:00
def test_public_examples_use_visitor_public_ip(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="https://karldns.de",
)
)
client = TestClient(app, client=("8.8.8.8", 12345))
home = client.get("/")
created = client.post("/")
assert home.status_code == 200
assert created.status_code == 200
assert "myip=8.8.8.8" in home.text
assert "good 8.8.8.8" in home.text
assert "myip=8.8.8.8" in created.text
2026-05-21 18:15:40 +02:00
def test_public_examples_use_cloudflare_ip_from_private_reverse_proxy(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="https://karldns.de",
)
)
client = TestClient(app, client=("172.18.0.5", 12345))
home = client.get("/", headers={"cf-connecting-ip": "8.8.4.4"})
created = client.post("/", headers={"x-forwarded-for": "8.8.4.4, 172.18.0.5"})
assert home.status_code == 200
assert created.status_code == 200
assert "myip=8.8.4.4" in home.text
assert "good 8.8.4.4" in home.text
assert "myip=8.8.4.4" in created.text
2026-05-21 18:57:45 +02:00
def test_pending_examples_use_real_ipv6_when_request_is_ipv6(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="https://karldns.de",
)
)
client = TestClient(app, client=("2001:4860:4860::8888", 12345))
response = client.post("/")
assert response.status_code == 200
assert "myipv6=2001:4860:4860::8888" in response.text
assert "myip=203.0.113.10" not in response.text
2026-05-21 17:23:08 +02:00
def test_generated_hostname_must_be_activated_before_updates_work(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
app = make_app(
DdnsSettings(
database_path=database_path,
admin_password="admin",
hostname_suffix="ddns.example.net",
public_base_url="http://ddns.example.net",
)
)
client = TestClient(app)
created = client.post("/")
management_slug = re.search(r'action="/m/([^"]+)/activate"', created.text).group(1)
account = app.state.store.get_account_by_management_slug(management_slug)
assert account["accepted_at"] is None
2026-05-22 17:05:06 +02:00
assert (
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
)
2026-05-21 17:23:08 +02:00
activated = client.post(f"/m/{management_slug}/activate", data={"csrf": _csrf(created.text)})
assert activated.status_code == 200
assert "Hostname registered" in activated.text
assert app.state.store.get_account_by_management_slug(management_slug)["accepted_at"]
2026-05-22 17:05:06 +02:00
assert (
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 200
)
2026-05-21 17:23:08 +02:00
def test_old_magic_route_is_gone(tmp_path: Path) -> None:
2026-05-20 23:55:27 +02:00
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/magic", follow_redirects=False)
2026-05-21 17:23:08 +02:00
assert response.status_code == 404
2026-05-20 23:55:27 +02:00
2026-05-21 17:23:08 +02:00
def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
2026-05-20 23:55:27 +02:00
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
2026-05-22 16:30:37 +02:00
assert '<link href="/admin.css?v=5" rel="stylesheet">' in management.text
2026-05-20 23:55:27 +02:00
assert "home.ddns.example.net" in management.text
assert "CNAME target:" in management.text
2026-05-21 14:18:39 +02:00
assert "Router fields" in management.text
assert "CNAME" in management.text
2026-05-20 23:55:27 +02:00
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
2026-05-21 17:23:08 +02:00
update = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
2026-05-20 23:55:27 +02:00
assert update.status_code == 404
def test_ui_uses_bearnie_inspired_structural_patterns(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)
home = client.get("/")
dashboard = client.get(f"/m/{account['management_slug']}")
app_css = client.get("/app.css")
assert home.status_code == 200
assert dashboard.status_code == 200
assert app_css.status_code == 200
assert 'data-ui="hero-command"' in home.text
assert 'data-slot="card"' in dashboard.text
assert 'data-slot="card-header"' in dashboard.text
assert 'data-slot="card-content"' in dashboard.text
assert 'class="ui-badge ui-badge-secret"' in dashboard.text
assert 'class="ui-badge ui-badge-public"' in dashboard.text
assert 'class="empty-state"' in dashboard.text
assert "--ui-background:" in app_css.text
assert "--ui-card:" in app_css.text
assert "--ui-muted-foreground:" in app_css.text
2026-05-20 23:55:27 +02:00
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
2026-05-21 17:23:08 +02:00
update = locked_client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
2026-05-20 23:55:27 +02:00
assert update.status_code == 200
assert update.text == "good 203.0.113.9"
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
2026-05-21 10:05:40 +02:00
assert "Incorrect password." in bad_unlock.text
2026-05-20 23:55:27 +02:00
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",
2026-05-22 17:05:06 +02:00
data={
"csrf": _csrf(client.get(f"/m/{account['management_slug']}").text),
"dashboard_password": "correct horse",
},
2026-05-20 23:55:27 +02:00
)
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
2026-05-21 10:05:40 +02:00
assert "Current password is incorrect." in change.text
2026-05-20 23:55:27 +02:00
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
2026-05-21 17:23:08 +02:00
def test_management_dashboard_rotates_update_url(tmp_path: Path) -> None:
2026-05-20 23:55:27 +02:00
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
2026-05-21 10:05:40 +02:00
assert "New update URL" in dashboard.text
2026-05-20 23:55:27 +02:00
assert "data-secret-toggle" in dashboard.text
assert 'type="password"' in dashboard.text
2026-05-22 17:05:06 +02:00
rotated_update = client.post(
f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"}
)
2026-05-20 23:55:27 +02:00
assert rotated_update.status_code == 200
2026-05-21 10:05:40 +02:00
assert "Update URL changed. Update your router now." in rotated_update.text
2026-05-20 23:55:27 +02:00
assert "data-secret-input" in rotated_update.text
2026-05-22 17:05:06 +02:00
assert (
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
)
statuses = {
event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)
}
2026-05-20 23:55:27 +02:00
assert "dashboard_update_url_rotated" in statuses
2026-05-21 17:23:08 +02:00
assert "dashboard_router_credentials_rotated" not in statuses
2026-05-20 23:55:27 +02:00
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']}")
2026-05-22 17:05:06 +02:00
rotated = client.post(
f"/m/{first['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"}
)
2026-05-20 23:55:27 +02:00
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"]
2026-05-22 17:05:06 +02:00
assert (
client.get(f"/api/v1/router/update/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
)
assert (
client.get(f"/api/v1/router/update/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
)
2026-05-20 23:55:27 +02:00
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",
public_base_url="https://karldns.de",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
2026-05-21 17:23:08 +02:00
assert 'href="/admin"' not in response.text
assert 'href="#create"' not in response.text
assert response.text.count(">Create hostname<") == 1
assert "Router login name" not in response.text
assert 'name="username"' not in response.text
assert "Create a hostname, save the private values, register it, done." in response.text
assert "Random hostname and private links." in response.text
assert "Activate the hostname when you are ready." in response.text
assert "home.example.com. CNAME" not 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 "User install" not in response.text
assert "--delete-config" not 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://ddns.example.net",
)
)
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
assert "https://ddns.example.net/api/v1/router/update/&lt;secret&gt;" in response.text
assert "https://karldns.de/api/v1/router/update/&lt;secret&gt;" not in response.text
2026-05-20 23:55:27 +02:00
assert "http://127.0.0.1" not in response.text
assert "http://localhost" not in response.text
2026-05-21 17:23:08 +02:00
def test_public_examples_use_karldns_domain_and_no_shell_prompt(tmp_path: Path) -> None:
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 "https://karldns.de/api/v1/router/update/&lt;secret&gt;" in response.text
assert "http://127.0.0.1:8099/api/v1/router/update/&lt;secret&gt;" not in response.text
assert "$ curl" not in response.text
2026-05-22 17:05:06 +02:00
assert (
'data-copy="curl -sS &#x27;https://karldns.de/api/v1/router/update/&lt;secret&gt;?myip=203.0.113.10&#x27;"'
in response.text
)
2026-05-21 17:23:08 +02:00
2026-05-20 23:55:27 +02:00
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
2026-05-21 10:05:40 +02:00
assert '<script src="/theme.js?v=2"></script>' in response.text
2026-05-21 14:18:39 +02:00
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
2026-05-21 18:51:18 +02:00
assert '<script src="/visitor-ip.js?v=1" defer></script>' in response.text
assert '<script src="/admin-tables.js?v=3" defer></script>' not in response.text
2026-05-21 10:05:40 +02:00
assert '<link href="/bootstrap-subset.css?v=3" rel="stylesheet">' in response.text
assert '<link href="/app.css?v=14" rel="stylesheet">' in response.text
2026-05-21 10:05:40 +02:00
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
2026-05-20 23:55:27 +02:00
assert "<style>" not in response.text
2026-05-21 10:05:40 +02:00
assert "script-src 'self' 'unsafe-inline'" in response.headers["content-security-policy"]
assert "style-src 'self'" in response.headers["content-security-policy"]
2026-05-22 17:05:06 +02:00
assert (
"connect-src 'self' https://api4.ipify.org https://api6.ipify.org"
in response.headers["content-security-policy"]
)
2026-05-20 23:55:27 +02:00
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
2026-05-21 10:05:40 +02:00
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
2026-05-21 18:51:18 +02:00
visitor_ip_js = client.get("/visitor-ip.js")
assert visitor_ip_js.status_code == 200
assert "api4.ipify.org" in visitor_ip_js.text
assert "api6.ipify.org" in visitor_ip_js.text
2026-05-21 10:05:40 +02:00
assert client.get("/admin-tables.js").status_code == 200
2026-05-20 23:55:27 +02:00
app_css = client.get("/app.css")
assert app_css.status_code == 200
assert "text/css" in app_css.headers["content-type"]
2026-05-21 10:05:40 +02:00
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"
2026-05-20 23:55:27 +02:00
def test_css_and_javascript_assets_are_served_from_files(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
expected_assets = {
"/app.css": "app.css",
"/admin.css": "admin.css",
"/theme.js": "theme.js",
"/clipboard.js": "clipboard.js",
"/admin-tables.js": "admin-tables.js",
"/visitor-ip.js": "visitor-ip.js",
}
for route, filename in expected_assets.items():
asset_file = assets.ASSET_DIR / filename
response = client.get(route)
assert asset_file.exists()
assert response.status_code == 200
assert response.text == asset_file.read_text(encoding="utf-8")
def test_clipboard_asset_has_fallback_and_visible_failure_state(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/clipboard.js")
assert response.status_code == 200
assert 'document.execCommand("copy")' in response.text
assert "Copy failed" in response.text
def test_visitor_ip_asset_fetches_ipv6_only_when_ipv6_placeholder_is_present(tmp_path: Path) -> None:
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
client = TestClient(app)
response = client.get("/visitor-ip.js")
assert response.status_code == 200
assert "api6.ipify.org" in response.text
assert 'needsIpv6() || document.querySelector(".terminal-card.manual-code")' not in response.text
def test_web_style_and_script_modules_do_not_embed_assets() -> None:
assert [name for name, value in vars(styles).items() if name.endswith("_CSS") and isinstance(value, str)] == []
assert [name for name, value in vars(scripts).items() if name.endswith("_JS") and isinstance(value, str)] == []
assert isinstance(styles, types.ModuleType)
assert isinstance(scripts, types.ModuleType)
2026-05-20 23:55:27 +02:00
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
2026-05-21 10:05:40 +02:00
app = make_app(
DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
public_base_url="https://karldns.de",
)
)
2026-05-20 23:55:27 +02:00
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200
2026-05-21 10:05:40 +02:00
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
2026-05-22 17:05:06 +02:00
assert "application/ld+json" in response.text
2026-05-21 10:05:40 +02:00
assert "NetworkingApplication" in response.text
2026-05-21 14:18:39 +02:00
assert "Made with" in response.text
assert "❤️" in response.text
assert "by karl" in response.text
2026-05-21 17:23:08 +02:00
assert response.text.count('class="lucide-icon footer-icon"') == 5
assert 'class="lucide-icon theme-icon theme-icon-moon"' in response.text
assert 'class="lucide-icon copy-icon"' in response.text
2026-05-20 23:55:27 +02:00
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
assert 'href="/privacy"' in response.text
privacy = client.get("/privacy")
assert privacy.status_code == 200
2026-05-21 10:05:40 +02:00
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
2026-05-20 23:55:27 +02:00
assert "Legal and privacy" in privacy.text
assert "Data processed" in privacy.text
2026-05-21 17:23:08 +02:00
assert "private update links" in privacy.text
2026-05-21 14:18:39 +02:00
assert "Made with" in privacy.text
assert "❤️" in privacy.text
assert "by karl" in privacy.text
2026-05-20 23:55:27 +02:00
2026-05-21 10:05:40 +02:00
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
2026-05-20 23:55:27 +02:00
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)
response = client.get("/")
assert response.status_code == 200
assert 'href="/favicon.ico"' in response.text
assert 'href="/favicon-32x32.png"' in response.text
assert 'href="/favicon-16x16.png"' in response.text
assert 'href="/apple-touch-icon.png"' in response.text
assert 'href="/site.webmanifest"' in response.text
for path in [
"/favicon.ico",
2026-05-21 10:05:40 +02:00
"/bootstrap-subset.css",
2026-05-20 23:55:27 +02:00
"/favicon-32x32.png",
"/favicon-16x16.png",
"/apple-touch-icon.png",
2026-05-21 10:05:40 +02:00
"/logo-nav.webp",
"/logo.webp",
2026-05-20 23:55:27 +02:00
"/logo-192.png",
"/logo-512.png",
2026-05-21 10:05:40 +02:00
"/social-preview.png",
"/social-preview.webp",
2026-05-20 23:55:27 +02:00
]:
asset = client.get(path)
assert asset.status_code == 200
assert asset.content
manifest = client.get("/site.webmanifest")
assert manifest.status_code == 200
assert manifest.json()["icons"] == [
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"},
]