Archived
Prepare KarlDNS production deployment
This commit is contained in:
1 parent
5a90bd9d87
commit
ce5b4e0ff2
101 files changed
+3364
-2018
No files matched your search
+127
-83
@@ -3,7 +3,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
from karldns import DdnsSettings, DdnsStore, make_app
|
||||
|
||||
|
||||
def _csrf(html: str) -> str:
|
||||
@@ -23,14 +23,12 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
|
||||
response = client.post("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert "Full update URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Register URL:" in response.text
|
||||
assert "Router fields" in response.text
|
||||
assert "CNAME" in response.text
|
||||
assert "Manual updates" in response.text
|
||||
@@ -40,8 +38,8 @@ def test_self_service_managed_hostname_generates_random_account(tmp_path: Path)
|
||||
assert "Public:" in response.text
|
||||
assert "Secret:" in response.text
|
||||
assert ".ddns.example.net" in response.text
|
||||
assert "/u/" in response.text
|
||||
assert "/nic/update?" in response.text
|
||||
assert "/api/v1/router/update/" in response.text
|
||||
assert "/api/v1/router/register/" in response.text
|
||||
assert "/m/" in response.text
|
||||
|
||||
|
||||
@@ -51,18 +49,18 @@ def test_legacy_custom_account_form_is_rejected(tmp_path: Path) -> None:
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net", "claim_secret": "legacy"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "custom hostnames are not supported; create a KarlDNS hostname and use a CNAME"
|
||||
assert response.status_code == 404
|
||||
assert app.state.store.count_accounts() == 0
|
||||
|
||||
|
||||
def test_magic_hostname_without_login_generates_update_and_management_links(tmp_path: Path) -> None:
|
||||
def test_hostname_without_login_generates_update_and_dashboard_links(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
@@ -73,15 +71,16 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/magic")
|
||||
response = client.post("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert "Full update URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Register URL:" in response.text
|
||||
assert "Private dashboard:" in response.text
|
||||
assert "Pending setup" in response.text
|
||||
assert "Register hostname" in response.text
|
||||
assert "Unregistered setups are removed after 24 hours." in response.text
|
||||
assert "Router fields" in response.text
|
||||
assert "CNAME" in response.text
|
||||
assert "FRITZ!Box fills in" in response.text
|
||||
@@ -91,17 +90,64 @@ def test_magic_hostname_without_login_generates_update_and_management_links(tmp_
|
||||
assert ".ddns.example.net" in response.text
|
||||
|
||||
|
||||
def test_magic_get_redirects_to_home_form(tmp_path: Path) -> None:
|
||||
def test_public_examples_use_visitor_public_ip(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app, client=("8.8.8.8", 12345))
|
||||
|
||||
home = client.get("/")
|
||||
created = client.post("/")
|
||||
|
||||
assert home.status_code == 200
|
||||
assert created.status_code == 200
|
||||
assert "myip=8.8.8.8" in home.text
|
||||
assert "good 8.8.8.8" in home.text
|
||||
assert "myip=8.8.8.8" in created.text
|
||||
|
||||
|
||||
def test_generated_hostname_must_be_activated_before_updates_work(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/")
|
||||
management_slug = re.search(r'action="/m/([^"]+)/activate"', created.text).group(1)
|
||||
account = app.state.store.get_account_by_management_slug(management_slug)
|
||||
|
||||
assert account["accepted_at"] is None
|
||||
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
|
||||
activated = client.post(f"/m/{management_slug}/activate", data={"csrf": _csrf(created.text)})
|
||||
|
||||
assert activated.status_code == 200
|
||||
assert "Hostname registered" in activated.text
|
||||
assert app.state.store.get_account_by_management_slug(management_slug)["accepted_at"]
|
||||
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 200
|
||||
|
||||
|
||||
def test_old_magic_route_is_gone(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/magic", follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == "/#create"
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
def test_dashboard_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "home")
|
||||
@@ -113,8 +159,6 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
assert '<link href="/admin.css?v=2" rel="stylesheet">' in management.text
|
||||
assert "home.ddns.example.net" in management.text
|
||||
assert "CNAME target:" in management.text
|
||||
assert "Benutzername (optional):" in management.text
|
||||
assert "Kennwort (optional):" in management.text
|
||||
assert "Router fields" in management.text
|
||||
assert "CNAME" in management.text
|
||||
assert "Manual updates" in management.text
|
||||
@@ -126,7 +170,7 @@ def test_magic_management_link_can_delete_hostname(tmp_path: Path) -> None:
|
||||
deleted = client.post(f"/m/{account['management_slug']}/delete", data={"csrf": _csrf(management.text)})
|
||||
assert deleted.status_code == 200
|
||||
|
||||
update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
update = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
assert update.status_code == 404
|
||||
|
||||
|
||||
@@ -170,22 +214,10 @@ def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> N
|
||||
assert "Update-URL:" not in locked.text
|
||||
assert "correct horse" not in locked.text
|
||||
|
||||
update = locked_client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
update = locked_client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
assert update.status_code == 200
|
||||
assert update.text == "good 203.0.113.9"
|
||||
|
||||
nic_update = locked_client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": account["hostname"],
|
||||
"myip": "203.0.113.10",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
assert nic_update.status_code == 200
|
||||
assert nic_update.text == "good 203.0.113.10"
|
||||
|
||||
bad_unlock = locked_client.post(
|
||||
f"/m/{account['management_slug']}/unlock",
|
||||
data={"csrf": _csrf(locked.text), "dashboard_password": "wrong"},
|
||||
@@ -333,7 +365,7 @@ def test_existing_dashboard_password_requires_current_password_to_change_or_remo
|
||||
assert app.state.store.verify_dashboard_password("home.ddns.example.net", "correct horse")
|
||||
|
||||
|
||||
def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path: Path) -> None:
|
||||
def test_management_dashboard_rotates_update_url(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
@@ -349,7 +381,6 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
|
||||
dashboard = client.get(f"/m/{account['management_slug']}")
|
||||
assert dashboard.status_code == 200
|
||||
assert "New update URL" in dashboard.text
|
||||
assert "New password" in dashboard.text
|
||||
assert "data-secret-toggle" in dashboard.text
|
||||
assert 'type="password"' in dashboard.text
|
||||
|
||||
@@ -357,26 +388,10 @@ def test_management_dashboard_rotates_update_url_and_router_credentials(tmp_path
|
||||
assert rotated_update.status_code == 200
|
||||
assert "Update URL changed. Update your router now." in rotated_update.text
|
||||
assert "data-secret-input" in rotated_update.text
|
||||
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
|
||||
rotated_password = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(rotated_update.text), "action": "password"})
|
||||
assert rotated_password.status_code == 200
|
||||
assert "Router password changed" in rotated_password.text
|
||||
assert "Full update URL:" in rotated_password.text
|
||||
assert "Kennwort (optional):" in rotated_password.text
|
||||
assert "data-secret-toggle" in rotated_password.text
|
||||
assert client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "home.ddns.example.net",
|
||||
"username": "router",
|
||||
"password": account["password"],
|
||||
"myip": "203.0.113.9",
|
||||
},
|
||||
).status_code == 401
|
||||
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
statuses = {event["status"] for event in app.state.store.list_update_events_for_hostname("home.ddns.example.net", 10)}
|
||||
assert "dashboard_update_url_rotated" in statuses
|
||||
assert "dashboard_router_credentials_rotated" in statuses
|
||||
assert "dashboard_router_credentials_rotated" not in statuses
|
||||
|
||||
|
||||
def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -> None:
|
||||
@@ -393,8 +408,8 @@ def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -
|
||||
assert rotated.status_code == 200
|
||||
assert store.get_account_by_hostname("first.ddns.example.net")["update_slug"] != first["update_slug"]
|
||||
assert store.get_account_by_hostname("second.ddns.example.net")["update_slug"] == second["update_slug"]
|
||||
assert client.get(f"/u/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
|
||||
assert client.get(f"/u/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
|
||||
assert client.get(f"/api/v1/router/update/{first['update_slug']}", params={"myip": "203.0.113.1"}).status_code == 404
|
||||
assert client.get(f"/api/v1/router/update/{second['update_slug']}", params={"myip": "203.0.113.2"}).status_code == 200
|
||||
|
||||
|
||||
def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None:
|
||||
@@ -437,30 +452,6 @@ def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path:
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'href="/admin"' not in response.text
|
||||
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
|
||||
assert "--delete-config" in response.text
|
||||
|
||||
|
||||
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="karldns.de",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
@@ -469,11 +460,61 @@ def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) ->
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "https://karldns.de/u/<secret>" in response.text
|
||||
assert 'href="/admin"' not in response.text
|
||||
assert 'href="#create"' not in response.text
|
||||
assert response.text.count(">Create hostname<") == 1
|
||||
assert "Router login name" not in response.text
|
||||
assert 'name="username"' not in response.text
|
||||
assert "Create a hostname, save the private values, register it, done." in response.text
|
||||
assert "Random hostname and private links." in response.text
|
||||
assert "Activate the hostname when you are ready." in response.text
|
||||
assert "home.example.com. CNAME" not in response.text
|
||||
assert "Linux cron updater" in response.text
|
||||
assert "curl -fsSL https://karldns.de/install.sh | sudo sh" in response.text
|
||||
assert "curl -fsSL https://karldns.de/uninstall.sh | sudo sh" in response.text
|
||||
assert "User install" not in response.text
|
||||
assert "--delete-config" not in response.text
|
||||
|
||||
|
||||
def test_public_home_examples_use_configured_public_base_url(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="karldns.de",
|
||||
public_base_url="https://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "https://ddns.example.net/api/v1/router/update/<secret>" in response.text
|
||||
assert "https://karldns.de/api/v1/router/update/<secret>" not in response.text
|
||||
assert "http://127.0.0.1" not in response.text
|
||||
assert "http://localhost" not in response.text
|
||||
|
||||
|
||||
def test_public_examples_use_karldns_domain_and_no_shell_prompt(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "https://karldns.de/api/v1/router/update/<secret>" in response.text
|
||||
assert "http://127.0.0.1:8099/api/v1/router/update/<secret>" not in response.text
|
||||
assert "$ curl" not in response.text
|
||||
assert 'data-copy="curl -sS 'https://karldns.de/api/v1/router/update/<secret>?myip=203.0.113.10'"' in response.text
|
||||
|
||||
|
||||
def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
@@ -485,7 +526,7 @@ def test_application_pages_use_stricter_script_csp(tmp_path: Path) -> None:
|
||||
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=5" rel="stylesheet">' in response.text
|
||||
assert '<link href="/app.css?v=12" 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
|
||||
@@ -534,6 +575,9 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
||||
assert "Made with" in response.text
|
||||
assert "❤️" in response.text
|
||||
assert "by karl" in response.text
|
||||
assert response.text.count('class="lucide-icon footer-icon"') == 5
|
||||
assert 'class="lucide-icon theme-icon theme-icon-moon"' in response.text
|
||||
assert 'class="lucide-icon copy-icon"' in response.text
|
||||
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
|
||||
assert 'href="https://karl.fail"' in response.text
|
||||
assert 'href="https://karlcom.de"' in response.text
|
||||
@@ -546,7 +590,7 @@ def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
||||
assert "WebPage" in privacy.text
|
||||
assert "Legal and privacy" in privacy.text
|
||||
assert "Data processed" in privacy.text
|
||||
assert "protected router passwords" in privacy.text
|
||||
assert "private update links" in privacy.text
|
||||
assert "Made with" in privacy.text
|
||||
assert "❤️" in privacy.text
|
||||
assert "by karl" in privacy.text
|
||||
|
||||
Reference in new issue
Block a user