Archived
Refactor UI assets and harden quality checks
This commit is contained in:
1 parent
23ab466c52
commit
4ecc8eb874
62 files changed
+3314
-3206
No files matched your search
+4
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 "<dynamic>" in html
|
||||
assert "<dynamic>" not in html
|
||||
@@ -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
|
||||
@@ -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 == "-"
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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") == ""
|
||||
@@ -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", "")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user