2026-05-20 23:55:27 +02:00
|
|
|
import re
|
2026-06-17 23:17:31 +02:00
|
|
|
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
|
2026-06-17 23:17:31 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-17 23:17:31 +02:00
|
|
|
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/<secret>" in response.text
|
|
|
|
|
assert "https://karldns.de/api/v1/router/update/<secret>" 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/<secret>" in response.text
|
|
|
|
|
assert "http://127.0.0.1:8099/api/v1/router/update/<secret>" not in response.text
|
|
|
|
|
assert "$ curl" not in response.text
|
2026-05-22 17:05:06 +02:00
|
|
|
assert (
|
|
|
|
|
'data-copy="curl -sS 'https://karldns.de/api/v1/router/update/<secret>?myip=203.0.113.10'"'
|
|
|
|
|
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
|
2026-06-17 23:17:31 +02:00
|
|
|
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
|
2026-06-17 23:17:31 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
2026-06-17 23:17:31 +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"},
|
|
|
|
|
]
|