Archived
Simplify DDNS flow and polish UI
This commit is contained in:
1 parent
5beee0e5c6
commit
5a90bd9d87
42 files changed
+587
-1418
No files matched your search
+4
-18
@@ -6,20 +6,8 @@ from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
from router_dyndns.ddns_security import admin_csrf_token
|
||||
|
||||
|
||||
def test_shared_secret_is_not_admin_password_fallback(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin", auth=("admin", "secret"))
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json()["detail"] == "DDNS_ADMIN_PASSWORD is required"
|
||||
|
||||
|
||||
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
store.create_domain_challenge("example.net")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
@@ -32,10 +20,10 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
|
||||
assert "Operator console" in response.text
|
||||
assert "Generate credentials" not in response.text
|
||||
assert 'action="/admin/accounts"' not in response.text
|
||||
assert response.text.count("data-admin-table") == 3
|
||||
assert response.text.count("data-table-search") == 3
|
||||
assert response.text.count("data-table-page-size") == 3
|
||||
assert response.text.count("data-table-pagination") == 3
|
||||
assert response.text.count("data-admin-table") == 2
|
||||
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 "admin-table-scroll" in response.text
|
||||
assert "Cleanup" in response.text
|
||||
assert "Run now" in response.text
|
||||
@@ -43,9 +31,7 @@ def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) ->
|
||||
assert "Metrics" in response.text
|
||||
assert "Blocked requests" in response.text
|
||||
assert "Hostnames" in response.text
|
||||
assert "Domain claims" in response.text
|
||||
assert "Update events" in response.text
|
||||
assert "example.net" in response.text
|
||||
|
||||
|
||||
def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
|
||||
|
||||
@@ -63,11 +63,8 @@ def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
|
||||
assert "/events" not in schema["paths"]
|
||||
assert "/" not in schema["paths"]
|
||||
assert "/magic" not in schema["paths"]
|
||||
assert "/request-domain" not in schema["paths"]
|
||||
assert "/verify-domain" not in schema["paths"]
|
||||
assert "/accounts" not in schema["paths"]
|
||||
assert "/m/{management_slug}" not in schema["paths"]
|
||||
assert "/d/{claim_secret}" not in schema["paths"]
|
||||
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
|
||||
assert all(tag["name"] != "admin" for tag in schema["tags"])
|
||||
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management")
|
||||
@@ -113,9 +110,6 @@ def test_public_redoc_only_exposes_api_and_router_endpoints(tmp_path: Path) -> N
|
||||
schema = client.get("/openapi.json").json()
|
||||
|
||||
expected_public_paths = {
|
||||
"/api/v1/domains/challenges",
|
||||
"/api/v1/domains/verify",
|
||||
"/api/v1/hostnames/custom",
|
||||
"/api/v1/hostnames/magic",
|
||||
"/api/v1/management/{management_slug}",
|
||||
"/api/v1/updates/{update_slug}",
|
||||
@@ -144,16 +138,8 @@ def test_openapi_marks_generated_update_links_as_secret(tmp_path: Path) -> None:
|
||||
("path", "payload"),
|
||||
[
|
||||
("/api/v1/hostnames/magic", {"username": "router", "unexpected": True}),
|
||||
("/api/v1/domains/challenges", {"domain": "example.net", "unexpected": True}),
|
||||
("/api/v1/domains/verify", {"domain": "example.net", "claim_secret": "secret", "unexpected": True}),
|
||||
(
|
||||
"/api/v1/hostnames/custom",
|
||||
{"hostname": "home.example.net", "claim_secret": "secret", "unexpected": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_public_write_models_reject_extra_json_fields(tmp_path: Path, path: str, payload: dict[str, object]) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
@@ -235,50 +221,6 @@ def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypa
|
||||
assert store.list_update_events()[0]["status"] == "dns_failed"
|
||||
|
||||
|
||||
def test_api_custom_hostname_requires_verified_domain(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
||||
assert challenge.status_code == 201
|
||||
challenge_body = challenge.json()
|
||||
|
||||
verified = client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge_body["claim_secret"]},
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert verified.json()["verified"] is True
|
||||
|
||||
hostname = client.post(
|
||||
"/api/v1/hostnames/custom",
|
||||
json={
|
||||
"hostname": "home.example.net",
|
||||
"claim_secret": challenge_body["claim_secret"],
|
||||
"username": "router",
|
||||
},
|
||||
)
|
||||
|
||||
assert hostname.status_code == 201
|
||||
assert hostname.json()["hostname"] == "home.example.net"
|
||||
|
||||
|
||||
def test_domain_challenge_verification_requires_current_secret_and_owner(tmp_path: Path) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
first = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
|
||||
second = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
|
||||
|
||||
assert store.verify_domain_challenge("example.net", first["claim_secret"], owner_hostname="owner.example.net") is None
|
||||
assert store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="other.example.net") is None
|
||||
assert store.get_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")["verified_at"] is None
|
||||
|
||||
verified = store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")
|
||||
|
||||
assert verified is not None
|
||||
assert verified["verified_at"]
|
||||
|
||||
|
||||
def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
@@ -309,47 +251,3 @@ def test_api_invalid_ip_returns_clear_400(tmp_path: Path) -> None:
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "myipv6 must be an IPv6 address"
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
def test_verified_domain_challenge_cannot_be_overwritten(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||
verified = client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
overwrite = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
||||
|
||||
assert verified.status_code == 200
|
||||
assert overwrite.status_code == 409
|
||||
|
||||
|
||||
def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
dns_zones={"ddns.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||
client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
hostname = client.post(
|
||||
"/api/v1/hostnames/custom",
|
||||
json={"hostname": "home.example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
|
||||
assert hostname.status_code == 403
|
||||
assert hostname.json()["detail"] == "hostname is outside configured DNS publishing zones"
|
||||
+55
-17
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
@@ -57,7 +58,7 @@ def test_cleanup_deletes_old_rate_limit_minute_buckets(tmp_path: Path) -> None:
|
||||
("recent", current_window - 30, 1),
|
||||
)
|
||||
|
||||
store.cleanup(challenge_hours=1, unused_account_hours=1)
|
||||
store.cleanup(unused_account_hours=1)
|
||||
|
||||
with store.connect() as conn:
|
||||
keys = {row[0] for row in conn.execute("SELECT key FROM rate_limits")}
|
||||
@@ -100,7 +101,6 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
|
||||
assert {
|
||||
"accounts",
|
||||
"cleanup_runs",
|
||||
"domain_challenges",
|
||||
"rate_limits",
|
||||
"records",
|
||||
"scheduled_tasks",
|
||||
@@ -111,6 +111,56 @@ def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: P
|
||||
assert store.list_scheduled_tasks()[0]["name"] == "cleanup"
|
||||
|
||||
|
||||
def test_startup_migrates_legacy_cleanup_run_columns(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
with sqlite3.connect(database_path) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE cleanup_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
domain_challenges_deleted INTEGER NOT NULL,
|
||||
abandoned_setups_deleted INTEGER NOT NULL,
|
||||
unused_accounts_deleted INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO cleanup_runs(
|
||||
created_at,
|
||||
domain_challenges_deleted,
|
||||
abandoned_setups_deleted,
|
||||
unused_accounts_deleted
|
||||
)
|
||||
VALUES (?, ?, ?, ?)
|
||||
""",
|
||||
("2026-01-01T00:00:00+00:00", 3, 2, 1),
|
||||
)
|
||||
|
||||
store = DdnsStore(database_path)
|
||||
|
||||
with store.connect() as conn:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(cleanup_runs)")}
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT unused_accounts_deleted
|
||||
FROM cleanup_runs
|
||||
"""
|
||||
).fetchone()
|
||||
assert columns == {"id", "created_at", "unused_accounts_deleted"}
|
||||
assert tuple(row) == (1,)
|
||||
|
||||
|
||||
def test_cleanup_records_only_unused_hostname_count(tmp_path: Path) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
|
||||
result = store.cleanup(unused_account_hours=1)
|
||||
|
||||
assert result["unused_accounts"] == 0
|
||||
assert set(store.list_cleanup_runs()[0]) == {"created_at", "unused_accounts_deleted"}
|
||||
|
||||
|
||||
def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
|
||||
@@ -147,8 +197,6 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||
assert user_version == 1
|
||||
assert {
|
||||
"idx_accounts_created_at",
|
||||
"idx_domain_challenges_claim_hash",
|
||||
"idx_domain_challenges_unverified_created",
|
||||
"idx_rate_limits_window",
|
||||
"idx_update_events_hostname_created_at",
|
||||
"idx_update_event_rollups_hostname_last_seen_at",
|
||||
@@ -193,20 +241,19 @@ def test_cleanup_prunes_old_raw_update_events(tmp_path: Path) -> None:
|
||||
("2000-01-01T00:00:00+00:00", "old.example.net"),
|
||||
)
|
||||
|
||||
result = store.cleanup(challenge_hours=1, unused_account_hours=1, event_history_hours=24)
|
||||
result = store.cleanup(unused_account_hours=1, event_history_hours=24)
|
||||
|
||||
assert result["update_events"] == 1
|
||||
events = store.list_update_events()
|
||||
assert [event["hostname"] for event in events] == ["new.example.net"]
|
||||
|
||||
|
||||
def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None:
|
||||
def test_background_cleanup_removes_abandoned_hostnames(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
cleanup_challenge_hours=1,
|
||||
cleanup_unused_account_hours=1,
|
||||
cleanup_interval_seconds=1,
|
||||
)
|
||||
@@ -214,26 +261,17 @@ def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> Non
|
||||
store = DdnsStore(database_path)
|
||||
|
||||
with TestClient(app):
|
||||
abandoned_claim = store.create_domain_challenge("abandoned.example.net")
|
||||
verified_claim = store.create_domain_challenge("verified.example.net")
|
||||
abandoned_account = store.create_account("abandoned.ddns.example.net", "router")
|
||||
active_account = store.create_account("active.ddns.example.net", "router")
|
||||
store.upsert("active.ddns.example.net", "203.0.113.10", None)
|
||||
|
||||
old = "2000-01-01T00:00:00+00:00"
|
||||
with store.connect() as conn:
|
||||
conn.execute("UPDATE domain_challenges SET created_at = ? WHERE domain = ?", (old, abandoned_claim["domain"]))
|
||||
conn.execute(
|
||||
"UPDATE domain_challenges SET created_at = ?, verified_at = ? WHERE domain = ?",
|
||||
(old, old, verified_claim["domain"]),
|
||||
)
|
||||
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, abandoned_account["hostname"]))
|
||||
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, active_account["hostname"]))
|
||||
|
||||
time.sleep(1.3)
|
||||
|
||||
assert store.get_domain_challenge("abandoned.example.net") is None
|
||||
assert store.get_domain_challenge("verified.example.net") is not None
|
||||
assert store.get_account_by_slug(abandoned_account["update_slug"]) is None
|
||||
assert store.get_account_by_slug(active_account["update_slug"]) is not None
|
||||
scheduled_task = store.list_scheduled_tasks()[0]
|
||||
@@ -248,7 +286,7 @@ def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog
|
||||
store = DdnsStore(database_path)
|
||||
store.ensure_scheduled_task(app_module.CLEANUP_TASK_NAME, settings.cleanup_interval_seconds)
|
||||
|
||||
def fail_cleanup(challenge_hours, unused_account_hours, event_history_hours):
|
||||
def fail_cleanup(unused_account_hours, event_history_hours):
|
||||
raise RuntimeError("cleanup exploded")
|
||||
|
||||
caplog.set_level(logging.ERROR, logger="router_dyndns.app")
|
||||
|
||||
@@ -51,22 +51,6 @@ def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_shared_secret_without_allowed_hosts_cannot_update_arbitrary_hostname(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
app = make_app(DdnsSettings(database_path=database_path, shared_secret="secret", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "other.example.net", "myip": "203.0.113.9", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.text == "badauth"
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
@@ -121,7 +105,6 @@ def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path
|
||||
"/api/v1/updates/short",
|
||||
"/m/short",
|
||||
"/api/v1/management/short",
|
||||
"/d/short",
|
||||
],
|
||||
)
|
||||
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None:
|
||||
|
||||
+33
-166
@@ -3,7 +3,6 @@ from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
|
||||
|
||||
@@ -32,8 +31,8 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Copy router fields" in response.text
|
||||
assert "Copy CNAME" in response.text
|
||||
assert "Router fields" in response.text
|
||||
assert "CNAME" in response.text
|
||||
assert "Manual updates" in response.text
|
||||
assert "curl -sS" in response.text
|
||||
assert "203.0.113.10" in response.text
|
||||
@@ -46,6 +45,23 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
||||
assert "/m/" 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",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "custom hostnames are not supported; create a KarlDNS hostname and use a CNAME"
|
||||
assert app.state.store.count_accounts() == 0
|
||||
|
||||
|
||||
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
@@ -66,10 +82,10 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Private dashboard:" in response.text
|
||||
assert "Copy router fields" in response.text
|
||||
assert "Copy CNAME" in response.text
|
||||
assert "Router fields" in response.text
|
||||
assert "CNAME" in response.text
|
||||
assert "FRITZ!Box fills in" in response.text
|
||||
assert "Copy curl" in response.text
|
||||
assert "Copy manual update commands" in response.text
|
||||
assert response.text.count("Secret") >= 3
|
||||
assert response.text.count("Public") >= 3
|
||||
assert ".ddns.example.net" in response.text
|
||||
@@ -99,8 +115,8 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
assert "CNAME target:" in management.text
|
||||
assert "Benutzername (optional):" in management.text
|
||||
assert "Kennwort (optional):" in management.text
|
||||
assert "Copy router fields" in management.text
|
||||
assert "Copy CNAME" 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
|
||||
@@ -391,14 +407,11 @@ def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_pat
|
||||
|
||||
rotate = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
|
||||
delete = client.post(f"/m/{account['management_slug']}/delete")
|
||||
claim = client.post(f"/m/{account['management_slug']}/domains/request", data={"domain": "example.net"})
|
||||
|
||||
assert rotate.status_code == 401
|
||||
assert delete.status_code == 401
|
||||
assert claim.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
|
||||
assert store.get_domain_challenge("example.net") is None
|
||||
|
||||
|
||||
def test_management_page_shows_ip_history(tmp_path: Path) -> None:
|
||||
@@ -418,157 +431,6 @@ def test_management_page_shows_ip_history(tmp_path: Path) -> None:
|
||||
assert "203.0.113.10" in response.text
|
||||
|
||||
|
||||
def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("base.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']}")
|
||||
challenge = client.post(f"/m/{account['management_slug']}/domains/request", data={"csrf": _csrf(dashboard.text), "domain": "example.net"})
|
||||
assert challenge.status_code == 200
|
||||
assert "Claim created" in challenge.text
|
||||
assert "TXT name" in challenge.text
|
||||
assert "TXT value" in challenge.text
|
||||
assert "Claim secret" in challenge.text
|
||||
assert "Private claim link" in challenge.text
|
||||
assert "This claim belongs to this dashboard" in challenge.text
|
||||
assert "data-secret-toggle" in challenge.text
|
||||
|
||||
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
|
||||
verified = client.post(
|
||||
f"/m/{account['management_slug']}/domains/verify",
|
||||
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert "Domain verified" in verified.text
|
||||
assert "Create hostname" in verified.text
|
||||
|
||||
created = client.post(
|
||||
f"/m/{account['management_slug']}/domains/hostnames",
|
||||
data={"csrf": _csrf(verified.text), "hostname": "home.example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert "Hostname created" in created.text
|
||||
assert "home.example.net" in created.text
|
||||
assert "Update-URL:" in created.text
|
||||
assert "Private dashboard:" in created.text
|
||||
|
||||
|
||||
def test_dashboard_domain_claims_are_bound_to_creating_dashboard(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
owner = store.create_account("owner.ddns.example.net", "owner")
|
||||
other = store.create_account("other.ddns.example.net", "other")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
owner_dashboard = client.get(f"/m/{owner['management_slug']}")
|
||||
challenge = client.post(f"/m/{owner['management_slug']}/domains/request", data={"csrf": _csrf(owner_dashboard.text), "domain": "example.net"})
|
||||
assert challenge.status_code == 200
|
||||
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
|
||||
|
||||
verify_other = client.post(
|
||||
f"/m/{other['management_slug']}/domains/verify",
|
||||
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "domain": "example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
create_other = client.post(
|
||||
f"/m/{other['management_slug']}/domains/hostnames",
|
||||
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "hostname": "home.example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
verify_owner = client.post(
|
||||
f"/m/{owner['management_slug']}/domains/verify",
|
||||
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
|
||||
assert verify_other.status_code == 404
|
||||
assert create_other.status_code == 403
|
||||
assert verify_owner.status_code == 200
|
||||
assert "Domain verified" in verify_owner.text
|
||||
assert store.get_account_by_hostname("home.example.net") is None
|
||||
|
||||
|
||||
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
challenge_data = store.create_domain_challenge("example.net")
|
||||
verify = client.post(
|
||||
"/verify-domain",
|
||||
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
"/accounts",
|
||||
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": challenge_data["claim_secret"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "home.example.net" in response.text
|
||||
assert "Update-URL:" in response.text
|
||||
|
||||
|
||||
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/request-domain", data={"domain": "example.net"})
|
||||
assert challenge.status_code == 200
|
||||
assert "Private claim link" in challenge.text
|
||||
assert "/d/" in challenge.text
|
||||
|
||||
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
|
||||
claim_page = client.get(f"/d/{claim_secret}")
|
||||
assert claim_page.status_code == 200
|
||||
assert "TXT value" in claim_page.text
|
||||
|
||||
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
|
||||
assert verify.status_code == 200
|
||||
assert "Create hostname" in verify.text
|
||||
|
||||
created = client.post(
|
||||
"/accounts",
|
||||
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert "home.example.net" in created.text
|
||||
assert "Update-URL:" in created.text
|
||||
|
||||
|
||||
def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
@@ -586,6 +448,7 @@ def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path:
|
||||
assert "Router login name" not in response.text
|
||||
assert 'name="username"' not in response.text
|
||||
assert "You get a hostname, a private update URL, and router fields if your router needs them." in response.text
|
||||
assert "Add a CNAME" 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
|
||||
@@ -619,10 +482,10 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<script src="/theme.js?v=2"></script>' in response.text
|
||||
assert '<script src="/clipboard.js?v=2" defer></script>' in response.text
|
||||
assert '<script src="/clipboard.js?v=3" defer></script>' in response.text
|
||||
assert '<script src="/admin-tables.js?v=2" 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=2" rel="stylesheet">' in response.text
|
||||
assert '<link href="/app.css?v=5" 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
|
||||
@@ -668,7 +531,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
||||
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 by Karl" in response.text
|
||||
assert "Made with" in response.text
|
||||
assert "❤️" in response.text
|
||||
assert "by karl" in response.text
|
||||
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
|
||||
assert 'href="https://karl.fail"' in response.text
|
||||
assert 'href="https://karlcom.de"' in response.text
|
||||
@@ -682,7 +547,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
||||
assert "Legal and privacy" in privacy.text
|
||||
assert "Data processed" in privacy.text
|
||||
assert "protected router passwords" in privacy.text
|
||||
assert "Made by Karl" 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:
|
||||
|
||||
+36
-47
@@ -10,23 +10,20 @@ from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsRec
|
||||
|
||||
|
||||
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
admin_password="admin",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
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", hostname_suffix="ddns.example.net"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "home.example.net",
|
||||
"hostname": "home.ddns.example.net",
|
||||
"myip": "203.0.113.8",
|
||||
"myipv6": "2001:db8::8",
|
||||
"password": "secret",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -34,36 +31,36 @@ def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
||||
assert response.text == "good 203.0.113.8"
|
||||
records = client.get("/records", auth=("admin", "admin")).json()
|
||||
assert records["total"] == 1
|
||||
assert records["items"][0]["hostname"] == "home.example.net"
|
||||
assert records["items"][0]["hostname"] == "home.ddns.example.net"
|
||||
assert records["items"][0]["ipv4"] == "203.0.113.8"
|
||||
assert records["items"][0]["ipv6"] == "2001:db8::8"
|
||||
|
||||
|
||||
def test_update_rejects_wrong_secret(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
|
||||
def test_update_rejects_wrong_password(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", hostname_suffix="ddns.example.net"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "home.example.net", "myip": "203.0.113.8", "password": "wrong"},
|
||||
params={"hostname": account["hostname"], "myip": "203.0.113.8", "username": account["username"], "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_rejects_unlisted_host(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
def test_update_rejects_hostname_outside_provider_suffix(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "router")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "other.example.net", "myip": "203.0.113.8", "password": "secret"},
|
||||
params={"hostname": account["hostname"], "myip": "203.0.113.8", "username": account["username"], "password": account["password"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -229,24 +226,17 @@ def test_unchanged_slug_update_is_read_only_for_history_and_record(tmp_path: Pat
|
||||
assert published == []
|
||||
|
||||
|
||||
def test_shared_secret_still_updates_explicitly_allowed_hostname(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
admin_password="admin",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
def test_slug_update_rejects_hostname_outside_provider_suffix(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "router")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "home.example.net", "myip": "203.0.113.9", "password": "secret"},
|
||||
)
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.9"
|
||||
assert response.status_code == 404
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
def test_omitted_ip_family_is_preserved(tmp_path: Path, monkeypatch) -> None:
|
||||
@@ -326,19 +316,18 @@ def test_wrong_family_legacy_ip_is_badip_and_preserves_records(tmp_path: Path) -
|
||||
def test_nic_update_invalid_ip_returns_badip(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
shared_secret="secret",
|
||||
admin_password="admin",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "home.example.net", "myipv6": "203.0.113.9", "password": "secret"},
|
||||
params={
|
||||
"hostname": account["hostname"],
|
||||
"myipv6": "203.0.113.9",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
Reference in new issue
Block a user