Refactor UI assets and harden quality checks

This commit is contained in:
StasonJatham committed 2026-06-17 23:18:00 +02:00
1 parent 23ab466c52
commit 4ecc8eb874
62 files changed
+3314 -3206

No files matched your search

+4 -1
View File
@@ -16,7 +16,7 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert '<link href="/admin.css?v=5" rel="stylesheet">' in response.text
assert '<script src="/admin-tables.js?v=2" defer></script>' in response.text
assert '<script src="/admin-tables.js?v=3" defer></script>' in response.text
assert "Operator console" in response.text
assert "Generate credentials" not in response.text
assert 'action="/admin/accounts"' not in response.text
@@ -24,6 +24,9 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
assert response.text.count("data-table-search") == 2
assert response.text.count("data-table-page-size") == 2
assert response.text.count("data-table-pagination") == 2
assert 'placeholder="Filter visible hostnames"' in response.text
assert 'placeholder="Filter visible update events"' in response.text
assert 'placeholder="Search hostnames"' not in response.text
assert "admin-table-scroll" in response.text
assert "Cleanup" in response.text
assert "Run now" in response.text
+90
View File
@@ -0,0 +1,90 @@
from pathlib import Path
from karldns import DdnsStore
from karldns.routers.admin import _admin_cleanup_notice, _admin_table_offsets
from karldns.web.templates_admin import (
_account_rows,
_admin_page_context,
_cleanup_accounts_removed,
_cleanup_last_run,
_cleanup_summary,
_cleanup_task,
_event_rows,
)
def test_admin_page_context_collects_counts_and_offset_rows(tmp_path: Path) -> None:
store = DdnsStore(tmp_path / "ddns.sqlite3")
first = store.create_account("first.example.net", "router")
second = store.create_account("second.example.net", "router")
store.log_update_event(first["hostname"], None, None, "first", "first detail", "198.51.100.1")
store.log_update_event(second["hostname"], None, None, "second", "second detail", "198.51.100.2")
context = _admin_page_context(
store,
table_offsets={"accounts": 1, "update_events": 1},
table_limit=1,
notice_html=None,
)
assert context.accounts[0]["hostname"] == "second.example.net"
assert context.update_events[0]["hostname"] == "first.example.net"
assert context.table_counts["accounts"] == 2
assert context.table_counts["update_events"] == 2
assert context.table_limit == 1
def test_admin_rows_render_empty_states_when_tables_have_no_rows() -> None:
assert "No hostnames yet." in _account_rows([], csrf="csrf", admin_path="/admin")
assert "No update events yet." in _event_rows([])
def test_admin_table_offsets_groups_query_values() -> None:
assert _admin_table_offsets(accounts_offset=1, cleanup_runs_offset=2, update_events_offset=3) == {
"accounts": 1,
"cleanup_runs": 2,
"update_events": 3,
}
def test_admin_cleanup_notice_is_empty_without_done_flag() -> None:
assert _admin_cleanup_notice(cleanup_done=False, cleanup_unused=1, cleanup_events=2) is None
assert "Removed 1 hostnames and 2 old events" in _admin_cleanup_notice(
cleanup_done=True,
cleanup_unused=1,
cleanup_events=2,
)
def test_cleanup_task_prefers_named_cleanup_task() -> None:
task = _cleanup_task(
[
{"name": "other", "next_run_at": "wrong"},
{"name": "cleanup", "next_run_at": "right"},
]
)
assert task["next_run_at"] == "right"
def test_cleanup_summary_uses_task_and_last_run_fallback() -> None:
summary = _cleanup_summary(
cleanup_runs=[{"created_at": "2026-01-02T03:04:05+00:00", "unused_accounts_deleted": 3}],
scheduled_tasks=[{"name": "cleanup", "interval_seconds": 3600}],
fallback_interval_seconds=7200,
)
assert summary.last_run == "2026-01-02T03:04:05+00:00"
assert summary.next_run is None
assert summary.status == "pending"
assert summary.interval_seconds == 3600
assert summary.accounts_removed == 3
def test_cleanup_summary_helpers_prefer_task_and_default_missing_cleanup_values() -> None:
task = {"last_finished_at": "2026-01-03T00:00:00+00:00"}
cleanup = {"created_at": "2026-01-02T00:00:00+00:00"}
assert _cleanup_last_run(task, cleanup) == "2026-01-03T00:00:00+00:00"
assert _cleanup_last_run({}, cleanup) == "2026-01-02T00:00:00+00:00"
assert _cleanup_accounts_removed({}) == 0
+10
View File
@@ -0,0 +1,10 @@
from karldns.web.templates_admin_tables import _admin_event_row_view
def test_admin_event_row_normalizes_missing_values() -> None:
row = _admin_event_row_view({"created_at": "", "status": "updated"})
assert row.created_raw == "-"
assert row.hostname_raw == "-"
assert row.detail_raw == "-"
assert row.count == "1"
+38
View File
@@ -0,0 +1,38 @@
from karldns.web.components import _code_copy_line, _code_copy_value, _copy_row, _CopyRowParts
def test_copy_row_parts_derive_secret_state_and_badge() -> None:
parts = _CopyRowParts.from_values("Update-URL:", "https://example.test/update", "Secret", mask_secret=True)
assert parts.is_secret is True
assert parts.should_mask is True
assert parts.row_class == "copy-field copy-field-secret"
assert "ui-badge-secret" in parts.sensitivity_badge
def test_copy_row_masks_secret_values_by_default() -> None:
html = _copy_row("Update-URL:", "https://example.test/update", "Secret")
assert 'class="copy-field copy-field-secret"' in html
assert 'type="password"' in html
assert "data-secret-toggle" in html
assert 'data-copy="https://example.test/update"' in html
def test_copy_row_can_render_unmasked_secret_values() -> None:
html = _copy_row("Update-URL:", "https://example.test/update", "Secret", mask_secret=False)
assert 'class="copy-field copy-field-secret"' in html
assert 'type="password"' not in html
assert '<code class="copy-pill-value copy-value">https://example.test/update</code>' in html
def test_code_copy_value_prefers_prompt_lines_when_present() -> None:
commands = "intro\n$ first command\noutput\n$ second command"
assert _code_copy_value(commands) == "first command\nsecond command"
def test_code_copy_line_strips_shell_prompt() -> None:
assert _code_copy_line("$ curl example") == "curl example"
assert _code_copy_line("plain output") == "plain output"
+57
View File
@@ -0,0 +1,57 @@
import pytest
from karldns.services.ddns import (
INITIAL_PLACEHOLDER_IPV4,
BadIpError,
UpdateIpParams,
_initial_dns_plan,
_required_ip_param,
)
def test_update_ip_params_expose_initial_plan_arguments() -> None:
params = UpdateIpParams(myip="203.0.113.9", ipaddr=None, myipv6="2001:db8::9", ip6addr=None)
assert params.initial_plan_args == ("203.0.113.9", None, "2001:db8::9", None)
def test_required_ip_param_rejects_invalid_or_wrong_version_values() -> None:
assert _required_ip_param("myip", "203.0.113.77", version=4) == "203.0.113.77"
with pytest.raises(BadIpError, match="valid IPv4"):
_required_ip_param("myip", "not-an-ip", version=4)
with pytest.raises(BadIpError, match="IPv4"):
_required_ip_param("myip", "2001:db8::1", version=4)
def test_initial_dns_plan_uses_placeholder_without_supplied_or_public_source_ip() -> None:
plan = _initial_dns_plan(
source_ip="172.18.0.5",
myip=None,
ipaddr=None,
myipv6=None,
ip6addr=None,
)
assert plan.ipv4 == INITIAL_PLACEHOLDER_IPV4
assert plan.ipv6 is None
assert plan.update_ipv4 is True
assert plan.update_ipv6 is False
assert plan.detail == "initial placeholder dns registered"
def test_initial_dns_plan_uses_supplied_ip_values() -> None:
plan = _initial_dns_plan(
source_ip="172.18.0.5",
myip="203.0.113.77",
ipaddr=None,
myipv6=None,
ip6addr=None,
)
assert plan.ipv4 == "203.0.113.77"
assert plan.ipv6 is None
assert plan.update_ipv4 is True
assert plan.update_ipv6 is False
assert plan.detail == "initial dns registered"
+14
View File
@@ -0,0 +1,14 @@
import base64
from karldns.dependencies import _basic_auth_password
def _basic_header(username: str, password: str) -> str:
token = base64.b64encode(f"{username}:{password}".encode()).decode()
return f"Basic {token}"
def test_basic_auth_password_extracts_only_basic_passwords() -> None:
assert _basic_auth_password(_basic_header("admin", "secret")) == "secret"
assert _basic_auth_password("Bearer token") is None
assert _basic_auth_password(None) is None
+46
View File
@@ -0,0 +1,46 @@
from karldns.web.html_utils import trusted
from karldns.web.layout import PageOptions, _page, _social_meta_tags, _structured_data_tag
def test_page_options_render_metadata_and_preloads() -> None:
html = _page(
"KarlDNS",
trusted("<main id='main'></main>"),
PageOptions(
description="Dynamic DNS",
preload_logo=True,
canonical_url="https://karldns.example/",
image_url="https://karldns.example/social.png",
image_alt="Preview",
structured_data={"name": "KarlDNS"},
),
)
assert 'content="Dynamic DNS"' in html
assert 'rel="canonical" href="https://karldns.example/"' in html
assert 'rel="preload" as="image" href="/logo.webp?v=3"' in html
assert 'property="og:image" content="https://karldns.example/social.png"' in html
assert 'type="application/ld+json"' in html
def test_social_meta_tags_include_large_card_when_image_exists() -> None:
html = _social_meta_tags(
title="KarlDNS",
description="Dynamic DNS",
canonical_url="https://karldns.de/",
image_url="https://karldns.de/social-preview.png",
image_alt="Preview",
)
assert 'property="og:title" content="KarlDNS"' in html
assert 'property="og:image" content="https://karldns.de/social-preview.png"' in html
assert 'name="twitter:card" content="summary_large_image"' in html
assert 'name="twitter:image:alt" content="Preview"' in html
def test_structured_data_tag_escapes_json_for_html() -> None:
html = _structured_data_tag({"name": "KarlDNS", "description": "<dynamic>"})
assert 'type="application/ld+json"' in html
assert "&lt;dynamic&gt;" in html
assert "<dynamic>" not in html
+48
View File
@@ -0,0 +1,48 @@
import asyncio
from starlette.responses import HTMLResponse
from karldns.routers.management_passwords import (
_dashboard_password_error,
_dashboard_password_error_response,
_DashboardPasswordErrorContext,
)
def test_dashboard_password_error_requires_minimum_length() -> None:
assert _dashboard_password_error("short", "short") == "Password must be at least 8 characters."
def test_dashboard_password_error_requires_confirmation_match() -> None:
assert _dashboard_password_error("correct horse", "different horse") == "The passwords do not match."
def test_dashboard_password_error_accepts_valid_password() -> None:
assert _dashboard_password_error("correct horse", "correct horse") is None
def test_dashboard_password_error_response_renders_new_password_errors(monkeypatch) -> None:
async def fake_render_management_page_async(_service: object, context: object) -> str:
return f"message={context.message}"
monkeypatch.setattr(
"karldns.routers.management_passwords.render_management_page_async",
fake_render_management_page_async,
)
response = asyncio.run(
_dashboard_password_error_response(
object(),
_DashboardPasswordErrorContext(
management_slug="manage-secret",
account={"hostname": "home.example.net"},
form={"dashboard_password": ["short"], "dashboard_password_confirm": ["short"]},
request_ip="198.51.100.1",
current_password_error=None,
),
)
)
assert isinstance(response, HTMLResponse)
assert response.status_code == 400
assert b"Password must be at least 8 characters." in response.body
+46
View File
@@ -0,0 +1,46 @@
from karldns.routers.management import _activation_ip_params_from_query
from karldns.services.ddns import UpdateIpParams
from karldns.web.templates_management import ManagementPageContext, _management_page_view, _user_event_row_view
def test_management_page_context_builds_account_for_secret_urls() -> None:
context = ManagementPageContext(
management_slug="manage-secret",
account={"hostname": "home.example.net", "update_slug": "update-secret"},
csrf_token="csrf",
)
assert context.account_for_url == {
"hostname": "home.example.net",
"update_slug": "update-secret",
"management_slug": "manage-secret",
}
def test_management_page_view_escapes_missing_status_values() -> None:
context = ManagementPageContext(
management_slug="manage-secret",
account={"hostname": "home.example.net", "update_slug": "update-secret"},
csrf_token="csrf",
)
view = _management_page_view(update_url="https://example.test/update", context=context)
assert view.hostname == "home.example.net"
assert view.ipv4 == "-"
assert view.ipv6 == "-"
assert view.updated == "Never"
def test_activation_ip_params_from_query_builds_domain_params() -> None:
assert _activation_ip_params_from_query(myip=None, ipaddr="203.0.113.8", myipv6=None, ip6addr=None) == (
UpdateIpParams(ipaddr="203.0.113.8")
)
def test_user_event_row_normalizes_status_badge() -> None:
row = _user_event_row_view({"status": "dns_failed", "detail": ""})
assert row.status == "DNS update failed"
assert row.badge_class == "status-badge-error"
assert row.detail == "-"
+25
View File
@@ -0,0 +1,25 @@
from karldns.dns.rfc2136 import _apply_record_update, _RecordUpdate
class _FakeUpdate:
def __init__(self) -> None:
self.calls: list[tuple[object, ...]] = []
def replace(self, *args: object) -> None:
self.calls.append(("replace", *args))
def delete(self, *args: object) -> None:
self.calls.append(("delete", *args))
def test_apply_record_update_replaces_deletes_or_skips_record_type() -> None:
update = _FakeUpdate()
_apply_record_update(update, "home", _RecordUpdate(ttl=60, record_type="A", value="203.0.113.7", enabled=True))
_apply_record_update(update, "home", _RecordUpdate(ttl=60, record_type="AAAA", value=None, enabled=True))
_apply_record_update(update, "home", _RecordUpdate(ttl=60, record_type="TXT", value="ignored", enabled=False))
assert update.calls == [
("replace", "home", 60, "A", "203.0.113.7"),
("delete", "home", "AAAA"),
]
+5 -4
View File
@@ -2,7 +2,6 @@ import asyncio
import logging
import sqlite3
import time
from contextlib import contextmanager
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -324,10 +323,8 @@ def test_rate_limit_flush_requeues_counts_after_database_failure(
original_connect = store.connect
@contextmanager
def fail_connect():
raise RuntimeError("database temporarily unavailable")
yield
monkeypatch.setattr(store, "connect", fail_connect)
with pytest.raises(RuntimeError, match="database temporarily unavailable"):
@@ -416,7 +413,11 @@ def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog
def test_scheduler_loop_survives_iteration_exception(tmp_path: Path, monkeypatch, caplog) -> None:
settings = DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin", cleanup_interval_seconds=60)
settings = DdnsSettings(
database_path=tmp_path / "ddns.sqlite3",
admin_password="admin",
cleanup_interval_seconds=60,
)
store = DdnsStore(settings.database_path)
calls = 0
+22
View File
@@ -0,0 +1,22 @@
from karldns.schemas import _account_status, _row_text
def test_row_text_returns_none_for_missing_or_empty_values():
row = {
"empty": "",
"missing": None,
"number": 42,
"text": "hello",
}
assert _row_text(row, "empty") is None
assert _row_text(row, "missing") is None
assert _row_text(row, "unknown") is None
assert _row_text(row, "number") == "42"
assert _row_text(row, "text") == "hello"
def test_account_status_prefers_disabled_before_lifecycle_state():
assert _account_status({"disabled": True, "accepted_at": "2026-01-01"}) == "disabled"
assert _account_status({"accepted_at": "2026-01-01"}) == "active"
assert _account_status({}) == "pending"
+25 -2
View File
@@ -395,6 +395,15 @@ def test_invalid_dns_provider_is_rejected() -> None:
validate_dns_provider_settings(settings)
def test_dns_backend_enabled_recognizes_explicit_or_legacy_rfc2136() -> None:
from karldns.dns.base import _dns_backend_enabled
assert _dns_backend_enabled("cloudflare", DdnsSettings()) is True
assert _dns_backend_enabled("rfc2136", DdnsSettings()) is True
assert _dns_backend_enabled("", DdnsSettings(rfc2136_server="192.0.2.53")) is True
assert _dns_backend_enabled("", DdnsSettings()) is False
def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) -> None:
with pytest.raises(RuntimeError, match="DDNS_CLOUDFLARE_API_TOKEN"):
make_app(
@@ -470,7 +479,7 @@ def test_cloudflare_publish_reconciles_provider_state(monkeypatch) -> None:
return {"id": "record-id", "content": "203.0.113.99"}
monkeypatch.setattr(cloudflare, "_cloudflare_find_record", fake_find)
monkeypatch.setattr(cloudflare, "_cloudflare_request", lambda *args, **kwargs: {"success": True, "result": []})
monkeypatch.setattr(cloudflare, "_cloudflare_request", lambda *args, **_kwargs: {"success": True, "result": []})
with pytest.raises(DnsReconciliationError, match="did not match"):
cloudflare.publish_cloudflare(
@@ -496,7 +505,7 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
def rcode(self) -> int:
return dns.rcode.REFUSED
monkeypatch.setattr(dns.query, "tcp", lambda *args, **kwargs: RefusedResponse())
monkeypatch.setattr(dns.query, "tcp", lambda *args, **_kwargs: RefusedResponse())
with pytest.raises(DnsProviderError, match="RFC2136 delete failed"):
delete_rfc2136_records(
@@ -505,6 +514,20 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
)
def test_rfc2136_zone_uses_explicit_setting_or_hostname_parent() -> None:
from karldns.dns.rfc2136 import _rfc2136_zone
assert _rfc2136_zone("home.example.net", DdnsSettings(rfc2136_zone="example.net")) == "example.net"
assert _rfc2136_zone("home.example.net", DdnsSettings()) == "example.net"
def test_rfc2136_relative_name_uses_at_for_zone_apex() -> None:
from karldns.dns.rfc2136 import _rfc2136_relative_name
assert _rfc2136_relative_name("home.example.net", "example.net") == "home"
assert _rfc2136_relative_name("example.net", "example.net") == "@"
def test_invalid_integer_env_value_fails_startup(monkeypatch) -> None:
monkeypatch.setenv("DDNS_RATE_LIMIT_PER_MINUTE", "not-an-int")
+43
View File
@@ -0,0 +1,43 @@
from karldns.security import (
DashboardUnlockIdentity,
_dashboard_unlock_cookie_parts,
_strip_header_ip,
_trusted_proxy_candidate,
)
def test_dashboard_unlock_identity_payload_is_stable() -> None:
identity = DashboardUnlockIdentity("home.example.net", "manage-secret", "hash")
assert identity.payload(123, 3600) == b"home.example.net\x00manage-secret\x00hash\x00123\x003600"
def test_trusted_proxy_candidate_matches_cidr_and_single_ip() -> None:
assert _trusted_proxy_candidate("10.10.0.1", "10.0.0.0/8") is True
assert _trusted_proxy_candidate("203.0.113.10", "203.0.113.10") is True
def test_trusted_proxy_candidate_ignores_invalid_values() -> None:
assert _trusted_proxy_candidate("not-an-ip", "10.0.0.0/8") is False
assert _trusted_proxy_candidate("10.10.0.1", "not-a-network") is False
def test_dashboard_unlock_cookie_parts_parse_v3_cookie() -> None:
parts = _dashboard_unlock_cookie_parts("v3:123:3600:signature")
assert parts is not None
assert parts.timestamp == 123
assert parts.max_age_seconds == 3600
assert parts.signature == "signature"
def test_dashboard_unlock_cookie_parts_reject_invalid_cookie() -> None:
assert _dashboard_unlock_cookie_parts("v2:123:3600:signature") is None
assert _dashboard_unlock_cookie_parts("v3:not-int:3600:signature") is None
def test_strip_header_ip_handles_common_proxy_formats() -> None:
assert _strip_header_ip('"203.0.113.10"') == "203.0.113.10"
assert _strip_header_ip("[2001:db8::10]:443") == "2001:db8::10"
assert _strip_header_ip("203.0.113.10:443") == "203.0.113.10"
assert _strip_header_ip("unknown") == ""
+16
View File
@@ -0,0 +1,16 @@
import pytest
from karldns.settings import _is_reserved_admin_path, _validate_launch_secrets
def test_reserved_admin_path_matches_exact_and_nested_public_routes() -> None:
assert _is_reserved_admin_path("/api")
assert _is_reserved_admin_path("/api/v1")
assert _is_reserved_admin_path("/logo.png")
assert not _is_reserved_admin_path("/ops")
assert not _is_reserved_admin_path("/apix")
def test_launch_secrets_require_strong_admin_password() -> None:
with pytest.raises(RuntimeError, match="DDNS_ADMIN_PASSWORD"):
_validate_launch_secrets("short", "")
+20
View File
@@ -0,0 +1,20 @@
from karldns.store.metrics import _update_metric_increments
def test_update_metric_increments_include_requested_outcomes() -> None:
increments = _update_metric_increments(
42.5,
is_success=True,
is_dns_failure=True,
is_reconcile_failure=False,
is_bad_ip=True,
)
assert increments == {
"update_requests_total": 1,
"update_latency_ms_total": 42.5,
"update_latency_samples_total": 1,
"update_success_total": 1,
"dns_failures_total": 1,
"bad_ip_total": 1,
}
+35
View File
@@ -0,0 +1,35 @@
from karldns.store.records import RecordUpdateInput, UpdateEventInput, _planned_record_update
def test_planned_record_update_preserves_disabled_ip_versions() -> None:
plan = _planned_record_update(
RecordUpdateInput(
hostname="home.example.net",
ipv4="203.0.113.10",
ipv6=None,
update_ipv4=True,
update_ipv6=False,
),
current_ipv4="198.51.100.4",
current_ipv6="2001:db8::4",
existing=True,
)
assert plan.ipv4 == "203.0.113.10"
assert plan.ipv6 == "2001:db8::4"
assert plan.changed is True
def test_update_event_input_preserves_event_fields() -> None:
event = UpdateEventInput(
hostname="home.example.net",
ipv4="203.0.113.10",
ipv6=None,
status="updated",
detail="ok",
source_ip="198.51.100.1",
)
assert event.hostname == "home.example.net"
assert event.ipv4 == "203.0.113.10"
assert event.source_ip == "198.51.100.1"
+82 -2
View File
@@ -1,9 +1,12 @@
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:
@@ -226,6 +229,32 @@ def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
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)
@@ -595,9 +624,9 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
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=2" defer></script>' not 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=13" 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
@@ -629,6 +658,57 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
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(
+62
View File
@@ -55,6 +55,68 @@ def test_router_update_post_accepts_form_and_json(tmp_path: Path) -> None:
assert store.list_records()[0]["ipv4"] == "203.0.113.10"
def test_router_post_json_parser_keeps_only_string_values() -> None:
from karldns.routers.updates import _json_post_values
values = _json_post_values(b'{"myip":"203.0.113.10","count":1,"empty":"","nested":{"x":1}}')
assert values == {"myip": "203.0.113.10", "empty": ""}
def test_router_post_form_parser_handles_invalid_utf8_as_empty() -> None:
from karldns.routers.updates import _form_post_values
assert _form_post_values(b"\xff") == {}
def test_router_ip_params_merge_body_without_overriding_query_values() -> None:
from karldns.routers.updates import _IpParams
params = _IpParams(myip="203.0.113.8", ipaddr=None, myipv6="", ip6addr=None)
merged = params.with_body_values(
{
"myip": "203.0.113.9",
"ipaddr": "198.51.100.9",
"myipv6": "2001:db8::9",
}
)
assert merged == _IpParams(
myip="203.0.113.8",
ipaddr="198.51.100.9",
myipv6="2001:db8::9",
ip6addr=None,
)
def test_router_ip_params_from_query_builds_domain_params() -> None:
from karldns.routers.updates import _ip_params_from_query
from karldns.services.ddns import UpdateIpParams
assert _ip_params_from_query(myip="203.0.113.8", ipaddr=None, myipv6=None, ip6addr="2001:db8::8") == (
UpdateIpParams(myip="203.0.113.8", ipaddr=None, myipv6=None, ip6addr="2001:db8::8")
)
def test_registration_hostname_rejects_disabled_or_unmanaged_accounts() -> None:
from fastapi import HTTPException
from karldns.routers.updates import _registration_hostname
class Service:
def hostname_is_managed(self, hostname: str) -> bool:
return hostname.endswith(".managed.example")
assert _registration_hostname({"hostname": "home.managed.example", "disabled": False}, Service()) == (
"home.managed.example"
)
with pytest.raises(HTTPException):
_registration_hostname({"hostname": "home.managed.example", "disabled": True}, Service())
with pytest.raises(HTTPException):
_registration_hostname({"hostname": "home.other.example", "disabled": False}, Service())
def test_router_update_requires_registered_hostname(tmp_path: Path) -> None:
database_path = tmp_path / "ddns.sqlite3"
store = DdnsStore(database_path)
+30
View File
@@ -0,0 +1,30 @@
from karldns.web.html_utils import SafeHtml
from karldns.web.sections.public_home import (
_manual_updates_section,
_public_home_hero,
_setup_flow_section,
)
def test_public_home_sections_render_independently() -> None:
hero = _public_home_hero(
suffix="ddns.example.net",
demo_update_url="https://karldns.example/api/v1/router/update/<secret>?myip=<ipaddr>",
)
setup = _setup_flow_section(
create_demo="curl -sS 'https://karldns.example/api/v1/router/update/<secret>?myip=203.0.113.10'",
create_demo_command="curl -sS 'https://karldns.example/api/v1/router/update/<secret>?myip=203.0.113.10'",
)
manual = _manual_updates_section(
manual_demo="# explicit IPv4\ncurl -sS 'https://karldns.example/api/v1/router/update/<secret>?myip=203.0.113.10'",
)
assert isinstance(hero, SafeHtml)
assert 'data-ui="hero-command"' in hero
assert "ddns.example.net" in hero
assert isinstance(setup, SafeHtml)
assert "KarlDNS setup flow" in setup
assert "Copy update example" in setup
assert isinstance(manual, SafeHtml)
assert 'id="manual-updates"' in manual
assert "Copy manual update examples" in manual