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
+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:
|
||||
|
||||
Reference in new issue
Block a user