Archived
Preserve the GitHub history and the newer deployed source from ct_134_karldns. Exclude runtime data, credentials, AppleDouble files and local backup copies. Make Gitea the canonical source for documentation, package metadata and project links.
818 lines
32 KiB
Python
818 lines
32 KiB
Python
import re
|
|
import types
|
|
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from karldns import DdnsSettings, DdnsStore, make_app
|
|
from karldns.routers import assets
|
|
from karldns.web import scripts, styles
|
|
|
|
|
|
def _csrf(html: str) -> str:
|
|
match = re.search(r'name="csrf" value="([^"]+)"', html)
|
|
assert match
|
|
return match.group(1)
|
|
|
|
|
|
def test_self_service_managed_hostname_generates_random_account(tmp_path: Path) -> None:
|
|
app = make_app(
|
|
DdnsSettings(
|
|
database_path=tmp_path / "ddns.sqlite3",
|
|
admin_password="admin",
|
|
hostname_suffix="ddns.example.net",
|
|
public_base_url="http://ddns.example.net",
|
|
)
|
|
)
|
|
client = TestClient(app)
|
|
|
|
response = client.post("/")
|
|
|
|
assert response.status_code == 200
|
|
assert "/m/" in str(response.url)
|
|
assert "Update-URL:" in response.text
|
|
assert "Domainnamen:" in response.text
|
|
assert "CNAME target:" not in response.text
|
|
assert "Register URL:" not in response.text
|
|
assert "Router setup." in response.text
|
|
assert "Use your own DNS name." in response.text
|
|
assert "Manual updates" in response.text
|
|
assert "curl -sS" in response.text
|
|
assert "203.0.113.10" in response.text
|
|
assert "2001:db8::10" not in response.text
|
|
assert "Public:" in response.text
|
|
assert "Secret:" in response.text
|
|
assert ".ddns.example.net" in response.text
|
|
assert "/api/v1/router/update/" in response.text
|
|
assert "/api/v1/router/register/" not in response.text
|
|
assert "/m/" in response.text
|
|
assert 'type="password"' not in response.text
|
|
assert "Copy setup values" not in response.text
|
|
|
|
|
|
def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None:
|
|
app = make_app(
|
|
DdnsSettings(
|
|
database_path=tmp_path / "ddns.sqlite3",
|
|
admin_password="admin",
|
|
hostname_suffix="ddns.example.net",
|
|
public_base_url="https://karldns.de",
|
|
)
|
|
)
|
|
client = TestClient(app)
|
|
|
|
response = client.post(
|
|
"/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"}
|
|
)
|
|
|
|
assert response.status_code == 404
|
|
assert app.state.store.count_accounts() == 0
|
|
|
|
|
|
def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: Path) -> None:
|
|
app = make_app(
|
|
DdnsSettings(
|
|
database_path=tmp_path / "ddns.sqlite3",
|
|
admin_password="admin",
|
|
hostname_suffix="ddns.example.net",
|
|
public_base_url="http://ddns.example.net",
|
|
)
|
|
)
|
|
client = TestClient(app)
|
|
|
|
response = client.post("/")
|
|
|
|
assert response.status_code == 200
|
|
assert "/m/" in str(response.url)
|
|
assert "Update-URL:" in response.text
|
|
assert "Domainnamen:" in response.text
|
|
assert "CNAME target:" not in response.text
|
|
assert "Register URL:" not in response.text
|
|
assert "Private dashboard:" in response.text
|
|
assert "Pending setup" in response.text
|
|
assert "Register hostname" in response.text
|
|
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
|
|
assert "Copy manual update commands" in response.text
|
|
assert response.text.count("copy-field-secret") >= 2
|
|
assert response.text.count("copy-field-public") >= 1
|
|
assert ".ddns.example.net" in response.text
|
|
assert 'type="password"' not in response.text
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
assert (
|
|
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
|
)
|
|
|
|
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"]
|
|
assert (
|
|
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 200
|
|
)
|
|
|
|
|
|
def test_old_magic_route_is_gone(tmp_path: Path) -> None:
|
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/magic", follow_redirects=False)
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
store = DdnsStore(database_path)
|
|
account = store.create_account("home.ddns.example.net", "home")
|
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
|
client = TestClient(app)
|
|
|
|
management = client.get(f"/m/{account['management_slug']}")
|
|
assert management.status_code == 200
|
|
assert '<link href="/admin.css?v=5" rel="stylesheet">' in management.text
|
|
assert "home.ddns.example.net" in management.text
|
|
assert "CNAME target:" in management.text
|
|
assert "Router fields" in management.text
|
|
assert "CNAME" in management.text
|
|
assert "Manual updates" in management.text
|
|
assert "curl -sS" in management.text
|
|
assert "Public:" in management.text
|
|
assert "Secret:" in management.text
|
|
assert "Router updates" in management.text
|
|
|
|
deleted = client.post(f"/m/{account['management_slug']}/delete", data={"csrf": _csrf(management.text)})
|
|
assert deleted.status_code == 200
|
|
|
|
update = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
|
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
|
|
|
|
|
|
def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
store = DdnsStore(database_path)
|
|
account = store.create_account("home.ddns.example.net", "home")
|
|
app = make_app(
|
|
DdnsSettings(
|
|
database_path=database_path,
|
|
admin_password="admin",
|
|
public_base_url="http://ddns.example.net",
|
|
)
|
|
)
|
|
client = TestClient(app)
|
|
|
|
dashboard = client.get(f"/m/{account['management_slug']}")
|
|
set_password = client.post(
|
|
f"/m/{account['management_slug']}/password",
|
|
data={
|
|
"csrf": _csrf(dashboard.text),
|
|
"dashboard_password": "correct horse",
|
|
"dashboard_password_confirm": "correct horse",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert set_password.status_code == 303
|
|
assert "httponly" in set_password.headers["set-cookie"].lower()
|
|
assert "samesite=lax" in set_password.headers["set-cookie"].lower()
|
|
assert "secure" not in set_password.headers["set-cookie"].lower()
|
|
|
|
stored = app.state.store.get_account_by_management_slug(account["management_slug"])
|
|
assert stored["dashboard_password_hash"].startswith("pbkdf2_sha256$")
|
|
assert stored["dashboard_password_hash"] != "correct horse"
|
|
|
|
locked_client = TestClient(app)
|
|
locked = locked_client.get(f"/m/{account['management_slug']}")
|
|
assert locked.status_code == 200
|
|
assert "Dashboard locked" in locked.text
|
|
assert "Update-URL:" not in locked.text
|
|
assert "correct horse" not in locked.text
|
|
|
|
update = locked_client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
|
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
|
|
assert "Incorrect password." in bad_unlock.text
|
|
assert "Update-URL:" not in bad_unlock.text
|
|
|
|
good_unlock = locked_client.post(
|
|
f"/m/{account['management_slug']}/unlock",
|
|
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"},
|
|
follow_redirects=False,
|
|
)
|
|
assert good_unlock.status_code == 303
|
|
assert "max-age=86400" in good_unlock.headers["set-cookie"].lower()
|
|
unlocked = locked_client.get(f"/m/{account['management_slug']}")
|
|
assert "Update-URL:" in unlocked.text
|
|
assert "Password protection" in unlocked.text
|
|
|
|
|
|
def test_management_dashboard_password_cookie_is_secure_on_https_public_url(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
store = DdnsStore(database_path)
|
|
account = store.create_account("home.ddns.example.net", "home")
|
|
app = make_app(
|
|
DdnsSettings(
|
|
database_path=database_path,
|
|
admin_password="admin",
|
|
public_base_url="https://ddns.example.net",
|
|
)
|
|
)
|
|
client = TestClient(app)
|
|
|
|
dashboard = client.get(f"/m/{account['management_slug']}")
|
|
response = client.post(
|
|
f"/m/{account['management_slug']}/password",
|
|
data={
|
|
"csrf": _csrf(dashboard.text),
|
|
"dashboard_password": "correct horse",
|
|
"dashboard_password_confirm": "correct horse",
|
|
},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert "secure" in response.headers["set-cookie"].lower()
|
|
|
|
|
|
def test_management_dashboard_password_can_be_removed(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
store = DdnsStore(database_path)
|
|
account = store.create_account("home.ddns.example.net", "home")
|
|
store.set_dashboard_password("home.ddns.example.net", "correct horse")
|
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
|
client = TestClient(app)
|
|
|
|
unlock = client.post(
|
|
f"/m/{account['management_slug']}/unlock",
|
|
data={
|
|
"csrf": _csrf(client.get(f"/m/{account['management_slug']}").text),
|
|
"dashboard_password": "correct horse",
|
|
},
|
|
)
|
|
assert unlock.status_code == 200
|
|
|
|
remove = client.post(
|
|
f"/m/{account['management_slug']}/password/remove",
|
|
data={"csrf": _csrf(unlock.text), "current_dashboard_password": "correct horse"},
|
|
)
|
|
assert remove.status_code == 200
|
|
assert "Not enabled" in remove.text
|
|
|
|
fresh_client = TestClient(app)
|
|
unlocked = fresh_client.get(f"/m/{account['management_slug']}")
|
|
assert unlocked.status_code == 200
|
|
assert "Update-URL:" in unlocked.text
|
|
|
|
|
|
def test_dashboard_stay_unlocked_cookie_uses_longer_lifetime(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
store = DdnsStore(database_path)
|
|
account = store.create_account("home.ddns.example.net", "home")
|
|
store.set_dashboard_password("home.ddns.example.net", "correct horse")
|
|
app = make_app(
|
|
DdnsSettings(
|
|
database_path=database_path,
|
|
admin_password="admin",
|
|
dashboard_unlock_seconds=3600,
|
|
dashboard_stay_unlocked_seconds=30 * 24 * 60 * 60,
|
|
)
|
|
)
|
|
client = TestClient(app)
|
|
locked = client.get(f"/m/{account['management_slug']}")
|
|
|
|
response = client.post(
|
|
f"/m/{account['management_slug']}/unlock",
|
|
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse", "stay_unlocked": "1"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert "max-age=2592000" in response.headers["set-cookie"].lower()
|
|
|
|
|
|
def test_unlocked_dashboard_posts_require_csrf(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
store = DdnsStore(database_path)
|
|
account = store.create_account("home.ddns.example.net", "home")
|
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
|
client = TestClient(app)
|
|
|
|
response = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
|
|
|
|
assert response.status_code == 403
|
|
assert store.get_account_by_hostname("home.ddns.example.net")["update_slug"] == account["update_slug"]
|
|
|
|
|
|
def test_existing_dashboard_password_requires_current_password_to_change_or_remove(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
store = DdnsStore(database_path)
|
|
account = store.create_account("home.ddns.example.net", "home")
|
|
store.set_dashboard_password("home.ddns.example.net", "correct horse")
|
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
|
client = TestClient(app)
|
|
locked = client.get(f"/m/{account['management_slug']}")
|
|
unlocked = client.post(
|
|
f"/m/{account['management_slug']}/unlock",
|
|
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"},
|
|
)
|
|
|
|
change = client.post(
|
|
f"/m/{account['management_slug']}/password",
|
|
data={
|
|
"csrf": _csrf(unlocked.text),
|
|
"current_dashboard_password": "wrong",
|
|
"dashboard_password": "new password",
|
|
"dashboard_password_confirm": "new password",
|
|
},
|
|
)
|
|
remove = client.post(
|
|
f"/m/{account['management_slug']}/password/remove",
|
|
data={"csrf": _csrf(unlocked.text), "current_dashboard_password": "wrong"},
|
|
)
|
|
|
|
assert change.status_code == 400
|
|
assert remove.status_code == 400
|
|
assert "Current password is incorrect." in change.text
|
|
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
|
|
|
|
|
|
def test_management_dashboard_rotates_update_url(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
store = DdnsStore(database_path)
|
|
account = store.create_account("home.ddns.example.net", "router")
|
|
app = make_app(
|
|
DdnsSettings(
|
|
database_path=database_path,
|
|
admin_password="admin",
|
|
public_base_url="http://ddns.example.net",
|
|
)
|
|
)
|
|
client = TestClient(app)
|
|
|
|
dashboard = client.get(f"/m/{account['management_slug']}")
|
|
assert dashboard.status_code == 200
|
|
assert "New update URL" in dashboard.text
|
|
assert "data-secret-toggle" in dashboard.text
|
|
assert 'type="password"' in dashboard.text
|
|
|
|
rotated_update = client.post(
|
|
f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"}
|
|
)
|
|
assert rotated_update.status_code == 200
|
|
assert "Update URL changed. Update your router now." in rotated_update.text
|
|
assert "data-secret-input" in rotated_update.text
|
|
assert (
|
|
client.get(f"/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)
|
|
}
|
|
assert "dashboard_update_url_rotated" in statuses
|
|
assert "dashboard_router_credentials_rotated" not in statuses
|
|
|
|
|
|
def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -> None:
|
|
database_path = tmp_path / "ddns.sqlite3"
|
|
store = DdnsStore(database_path)
|
|
first = store.create_account("first.ddns.example.net", "first")
|
|
second = store.create_account("second.ddns.example.net", "second")
|
|
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
|
client = TestClient(app)
|
|
|
|
dashboard = client.get(f"/m/{first['management_slug']}")
|
|
rotated = client.post(
|
|
f"/m/{first['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"}
|
|
)
|
|
|
|
assert rotated.status_code == 200
|
|
assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"]
|
|
assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"]
|
|
assert (
|
|
client.get(f"/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
|
|
)
|
|
|
|
|
|
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
|
|
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
|
|
assert "http://127.0.0.1" not in response.text
|
|
assert "http://localhost" not in response.text
|
|
|
|
|
|
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
|
|
assert (
|
|
'data-copy="curl -sS 'https://karldns.de/api/v1/router/update/<secret>?myip=203.0.113.10'"'
|
|
in response.text
|
|
)
|
|
|
|
|
|
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
|
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/")
|
|
|
|
assert response.status_code == 200
|
|
assert '<script src="/theme.js?v=2"></script>' in response.text
|
|
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
|
|
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
|
|
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
|
|
assert '<link href="/admin.css" rel="stylesheet">' not in response.text
|
|
assert "cdn.jsdelivr.net/npm/bootstrap" not in response.text
|
|
assert "<style>" not in response.text
|
|
assert "script-src 'self' 'unsafe-inline'" in response.headers["content-security-policy"]
|
|
assert "style-src 'self'" in response.headers["content-security-policy"]
|
|
assert (
|
|
"connect-src 'self' https://api4.ipify.org https://api6.ipify.org"
|
|
in response.headers["content-security-policy"]
|
|
)
|
|
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
|
|
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
|
|
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
|
|
assert client.get("/admin-tables.js").status_code == 200
|
|
app_css = client.get("/app.css")
|
|
assert app_css.status_code == 200
|
|
assert "text/css" in app_css.headers["content-type"]
|
|
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"
|
|
|
|
|
|
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)
|
|
|
|
|
|
def test_footer_links_and_privacy_page_are_available(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 '<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
|
|
assert "application/ld+json" in response.text
|
|
assert "NetworkingApplication" in response.text
|
|
assert "Made with" in response.text
|
|
assert "❤️" in response.text
|
|
assert "by karl" in response.text
|
|
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
|
|
assert 'href="https://tea.karlcom.de/karl/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
|
|
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
|
|
assert "Legal and privacy" in privacy.text
|
|
assert "Data processed" in privacy.text
|
|
assert "private update links" in privacy.text
|
|
assert "Made with" in privacy.text
|
|
assert "❤️" in privacy.text
|
|
assert "by karl" in privacy.text
|
|
|
|
|
|
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
|
|
|
|
|
|
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",
|
|
"/bootstrap-subset.css",
|
|
"/favicon-32x32.png",
|
|
"/favicon-16x16.png",
|
|
"/apple-touch-icon.png",
|
|
"/logo-nav.webp",
|
|
"/logo.webp",
|
|
"/logo-192.png",
|
|
"/logo-512.png",
|
|
"/social-preview.png",
|
|
"/social-preview.webp",
|
|
]:
|
|
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"},
|
|
]
|