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
+53
-27
@@ -2,8 +2,8 @@ from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
from router_dyndns.ddns_security import admin_csrf_token
|
||||
from karldns import DdnsSettings, DdnsStore, make_app
|
||||
from karldns.security import admin_csrf_token
|
||||
|
||||
|
||||
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
|
||||
@@ -48,12 +48,12 @@ def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
|
||||
response = client.get("/ops", auth=("admin", "admin"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'href="/ops/records"' in response.text
|
||||
assert 'href="/ops/events"' in response.text
|
||||
assert 'href="/ops/records"' not in response.text
|
||||
assert 'href="/ops/events"' not in response.text
|
||||
assert 'action="/ops/cleanup"' in response.text
|
||||
assert 'action="/ops/accounts/rotate"' in response.text
|
||||
assert 'action="/ops/hostnames/rotate"' in response.text
|
||||
assert 'action="/admin/cleanup"' not in response.text
|
||||
assert client.get("/ops/records", auth=("admin", "admin")).status_code == 200
|
||||
assert client.get("/ops/records", auth=("admin", "admin")).status_code == 404
|
||||
assert client.get("/records", auth=("admin", "admin")).status_code == 404
|
||||
assert client.get("/admin", auth=("admin", "admin")).status_code == 404
|
||||
|
||||
@@ -70,8 +70,8 @@ def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
records = client.get("/records?limit=1&offset=1", auth=("admin", "admin")).json()
|
||||
events = client.get("/events?limit=1&offset=1", auth=("admin", "admin")).json()
|
||||
records = client.get("/api/v1/admin/records?limit=1&offset=1", auth=("admin", "admin")).json()
|
||||
events = client.get("/api/v1/admin/events?limit=1&offset=1", auth=("admin", "admin")).json()
|
||||
|
||||
assert records["limit"] == 1
|
||||
assert records["offset"] == 1
|
||||
@@ -84,12 +84,48 @@ def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
|
||||
assert len(events["items"]) == 1
|
||||
|
||||
|
||||
def test_admin_api_exposes_dashboard_data_and_history(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "router")
|
||||
store.upsert(account["hostname"], "203.0.113.10", None)
|
||||
store.log_update_event(account["hostname"], "203.0.113.10", None, "updated", "ok", "198.51.100.1")
|
||||
store.increment_metric("update_requests_total")
|
||||
settings = DdnsSettings(database_path=database_path, admin_password="admin")
|
||||
app = make_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
assert client.get("/api/v1/admin/dashboard").status_code == 401
|
||||
|
||||
dashboard = client.get("/api/v1/admin/dashboard?limit=10", auth=("admin", "admin"))
|
||||
hostnames = client.get("/api/v1/admin/hostnames?limit=1&offset=0", auth=("admin", "admin"))
|
||||
records = client.get("/api/v1/admin/records?limit=1&offset=0", auth=("admin", "admin"))
|
||||
events = client.get("/api/v1/admin/events?limit=1&offset=0", auth=("admin", "admin"))
|
||||
|
||||
assert dashboard.status_code == 200
|
||||
assert dashboard.headers["cache-control"] == "no-store"
|
||||
dashboard_json = dashboard.json()
|
||||
assert dashboard_json["totals"]["hostnames"] == 1
|
||||
assert dashboard_json["totals"]["records"] == 1
|
||||
assert dashboard_json["totals"]["update_events"] == 1
|
||||
assert dashboard_json["metrics"]["values"]["update_requests_total"] == 1
|
||||
assert dashboard_json["hostnames"][0]["hostname"] == "home.example.net"
|
||||
assert dashboard_json["hostnames"][0]["status"] == "active"
|
||||
assert dashboard_json["hostnames"][0]["ipv4"] == "203.0.113.10"
|
||||
assert dashboard_json["update_events"][0]["status"] == "updated"
|
||||
assert dashboard_json["scheduled_tasks"][0]["name"] == "cleanup"
|
||||
|
||||
assert hostnames.json()["items"][0]["hostname"] == "home.example.net"
|
||||
assert records.json()["items"][0]["ipv4"] == "203.0.113.10"
|
||||
assert events.json()["items"][0]["hostname"] == "home.example.net"
|
||||
|
||||
|
||||
def test_admin_records_and_events_reject_excessive_offsets(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
records = client.get("/records?offset=10001", auth=("admin", "admin"))
|
||||
events = client.get("/events?offset=10001", auth=("admin", "admin"))
|
||||
records = client.get("/api/v1/admin/records?offset=10001", auth=("admin", "admin"))
|
||||
events = client.get("/api/v1/admin/events?offset=10001", auth=("admin", "admin"))
|
||||
|
||||
assert records.status_code == 422
|
||||
assert events.status_code == 422
|
||||
@@ -125,8 +161,8 @@ def test_update_events_support_keyset_pagination(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.get("/events?limit=1", auth=("admin", "admin")).json()
|
||||
second = client.get(f"/events?limit=1&before={first['next_before']}", auth=("admin", "admin")).json()
|
||||
first = client.get("/api/v1/admin/events?limit=1", auth=("admin", "admin")).json()
|
||||
second = client.get(f"/api/v1/admin/events?limit=1&before={first['next_before']}", auth=("admin", "admin")).json()
|
||||
|
||||
assert first["items"][0]["hostname"] == "raw-new.example.net"
|
||||
assert second["items"][0]["hostname"] == "raw-old.example.net"
|
||||
@@ -184,16 +220,16 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
|
||||
csrf = admin_csrf_token(settings)
|
||||
|
||||
update = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
"/admin/hostnames/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
assert "Update URL changed" in update.text
|
||||
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
assert client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
|
||||
management = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
"/admin/hostnames/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
|
||||
)
|
||||
@@ -201,20 +237,10 @@ def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
|
||||
assert "Dashboard link changed" in management.text
|
||||
assert "/m/" in management.text
|
||||
|
||||
password = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "password"},
|
||||
)
|
||||
assert password.status_code == 200
|
||||
assert "Router password changed" in password.text
|
||||
assert "Full update URL:" in password.text
|
||||
assert "/nic/update?" in password.text
|
||||
assert "Kennwort (optional):" in password.text
|
||||
statuses = {event["status"] for event in store.list_update_events_for_hostname("home.example.net", 10)}
|
||||
assert "admin_update_url_rotated" in statuses
|
||||
assert "admin_management_url_rotated" in statuses
|
||||
assert "admin_router_credentials_rotated" in statuses
|
||||
assert "admin_router_credentials_rotated" not in statuses
|
||||
|
||||
|
||||
def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -> None:
|
||||
@@ -225,7 +251,7 @@ def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/admin/accounts/delete",
|
||||
"/admin/hostnames/delete",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": admin_csrf_token(settings), "hostname": " HOME.EXAMPLE.NET. "},
|
||||
follow_redirects=False,
|
||||
|
||||
+225
-124
@@ -1,81 +1,95 @@
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, make_app
|
||||
import karldns.services.ddns as ddns_module
|
||||
from karldns import DdnsSettings, DdnsStore, DnsProviderError, make_app
|
||||
|
||||
|
||||
def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
def _path(url: str) -> str:
|
||||
parsed = urlparse(url)
|
||||
return parsed.path
|
||||
|
||||
|
||||
def test_custom_api_docs_include_only_current_api_surfaces(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="https://karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
|
||||
assert response.status_code == 201
|
||||
payload = response.json()
|
||||
one_box_url = payload["credentialed_update_url"]
|
||||
parsed = urlparse(one_box_url)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
assert parsed.path == "/nic/update"
|
||||
assert params["hostname"] == [payload["hostname"]]
|
||||
assert params["username"] == [payload["username"]]
|
||||
assert params["password"] == [payload["password"]]
|
||||
assert params["myip"] == ["<ipaddr>"]
|
||||
assert params["myipv6"] == ["<ip6addr>"]
|
||||
|
||||
|
||||
def test_api_docs_include_versioned_ddns_api(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
docs = client.get("/docs")
|
||||
redoc = client.get("/redoc")
|
||||
schema = client.get("/openapi.json").json()
|
||||
|
||||
assert docs.status_code == 200
|
||||
assert redoc.status_code == 200
|
||||
assert docs.headers["x-robots-tag"] == "noindex, nofollow"
|
||||
assert redoc.headers["x-robots-tag"] == "noindex, nofollow"
|
||||
assert redoc.status_code == 404
|
||||
assert "Small API surface." in docs.text
|
||||
assert "POST /api/v1/hostnames" in docs.text
|
||||
assert "GET or POST /api/v1/router/register/<secret>" in docs.text
|
||||
assert "GET or POST /api/v1/router/update/<secret>" in docs.text
|
||||
assert "GET /api/v1/hostnames/<secret>" in docs.text
|
||||
assert "POST /api/v1/hostnames/<secret>/rotate-update-url" in docs.text
|
||||
assert "Rotate update URL" in docs.text
|
||||
assert "GET /api/v1/admin/dashboard" in docs.text
|
||||
assert "GET /api/v1/admin/events" in docs.text
|
||||
assert "https://karldns.de/api/v1/hostnames" in docs.text
|
||||
assert "http://localhost:8080/api/v1/hostnames" not in docs.text
|
||||
assert "registration_url" in docs.text
|
||||
assert "/api/v1/management" not in docs.text
|
||||
assert "/api/v1/hostnames/magic" not in docs.text
|
||||
assert "/nic/update" not in docs.text
|
||||
assert "/u/<secret>" not in docs.text
|
||||
assert "swagger-ui" not in docs.text.lower()
|
||||
assert "redoc" not in docs.text.lower()
|
||||
assert "x-robots-tag" not in docs.headers
|
||||
assert client.get("/openapi.json").headers["x-robots-tag"] == "noindex, nofollow"
|
||||
assert "unsafe-inline" in docs.headers["content-security-policy"]
|
||||
assert "fonts.googleapis.com" in redoc.headers["content-security-policy"]
|
||||
assert "fonts.gstatic.com" in redoc.headers["content-security-policy"]
|
||||
assert "/api/v1/hostnames/magic" in schema["paths"]
|
||||
assert "/api/v1/updates/{update_slug}" in schema["paths"]
|
||||
assert "/u/{update_slug}" in schema["paths"]
|
||||
assert "/nic/update" in schema["paths"]
|
||||
assert "/ip" in schema["paths"]
|
||||
assert "/healthz" in schema["paths"]
|
||||
assert "cdn.jsdelivr.net" not in docs.headers["content-security-policy"]
|
||||
assert "fonts.googleapis.com" not in docs.headers["content-security-policy"]
|
||||
|
||||
expected_paths = {
|
||||
"/api/v1/admin/dashboard",
|
||||
"/api/v1/admin/events",
|
||||
"/api/v1/admin/hostnames",
|
||||
"/api/v1/admin/records",
|
||||
"/api/v1/hostnames",
|
||||
"/api/v1/hostnames/{dashboard_secret}",
|
||||
"/api/v1/hostnames/{dashboard_secret}/rotate-update-url",
|
||||
"/api/v1/router/register/{dashboard_secret}",
|
||||
"/api/v1/router/update/{update_slug}",
|
||||
}
|
||||
assert set(schema["paths"]) == expected_paths
|
||||
assert "/" not in schema["paths"]
|
||||
assert "/admin" not in schema["paths"]
|
||||
assert "/healthz" not in schema["paths"]
|
||||
assert "/records" not in schema["paths"]
|
||||
assert "/events" not in schema["paths"]
|
||||
assert "/" not in schema["paths"]
|
||||
assert "/magic" not in schema["paths"]
|
||||
assert "/accounts" not in schema["paths"]
|
||||
assert "/m/{management_slug}" 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")
|
||||
assert "Dashboard passwords protect only the web dashboard" in management_tag["description"]
|
||||
management_get = schema["paths"]["/api/v1/management/{management_slug}"]["get"]
|
||||
management_delete = schema["paths"]["/api/v1/management/{management_slug}"]["delete"]
|
||||
assert "Dashboard passwords protect only the web dashboard" in management_get["description"]
|
||||
assert "Dashboard passwords protect only the web dashboard" in management_delete["description"]
|
||||
assert any(tag["name"] == "router" for tag in schema["tags"])
|
||||
assert any(tag["name"] == "admin" for tag in schema["tags"])
|
||||
|
||||
|
||||
def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
|
||||
def test_custom_api_docs_use_visitor_public_ip(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, client=("8.8.4.4", 12345))
|
||||
|
||||
response = client.get("/docs")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "myip=8.8.4.4" in response.text
|
||||
|
||||
|
||||
def test_openapi_hides_custom_operator_routes(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
@@ -85,78 +99,38 @@ def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
redoc = client.get("/redoc")
|
||||
schema = client.get("/openapi.json").json()
|
||||
|
||||
forbidden_paths = {
|
||||
"/ops",
|
||||
"/ops/accounts/delete",
|
||||
"/ops/accounts/rotate",
|
||||
"/ops/hostnames/delete",
|
||||
"/ops/hostnames/rotate",
|
||||
"/ops/cleanup",
|
||||
"/ops/events",
|
||||
"/ops/records",
|
||||
"/records",
|
||||
"/events",
|
||||
}
|
||||
assert redoc.status_code == 200
|
||||
assert client.get("/redoc").status_code == 404
|
||||
assert forbidden_paths.isdisjoint(schema["paths"])
|
||||
assert all("admin" not in tag["name"].lower() for tag in schema["tags"])
|
||||
assert "/api/v1/admin/dashboard" in schema["paths"]
|
||||
|
||||
|
||||
def test_public_redoc_only_exposes_api_and_router_endpoints(tmp_path: Path) -> None:
|
||||
def test_openapi_marks_generated_links_as_secret(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
schema = client.get("/openapi.json").json()
|
||||
created = schema["components"]["schemas"]["HostnameCreateResponse"]["properties"]
|
||||
status = schema["components"]["schemas"]["HostnameStatusResponse"]["properties"]
|
||||
|
||||
expected_public_paths = {
|
||||
"/api/v1/hostnames/magic",
|
||||
"/api/v1/management/{management_slug}",
|
||||
"/api/v1/updates/{update_slug}",
|
||||
"/healthz",
|
||||
"/ip",
|
||||
"/nic/update",
|
||||
"/u/{update_slug}",
|
||||
}
|
||||
assert set(schema["paths"]) == expected_public_paths
|
||||
for name in ["update_url", "dashboard_url", "registration_url"]:
|
||||
assert "Secret" in created[name]["description"]
|
||||
for name in ["update_url", "dashboard_url"]:
|
||||
assert "Secret" in status[name]["description"]
|
||||
|
||||
|
||||
def test_openapi_marks_generated_update_links_as_secret(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
schema = client.get("/openapi.json").json()
|
||||
credentials = schema["components"]["schemas"]["HostnameCredentials"]["properties"]
|
||||
managed = schema["components"]["schemas"]["ManagedRecord"]["properties"]
|
||||
|
||||
for name in ["password", "update_url", "credentialed_update_url", "management_url"]:
|
||||
assert "Secret" in credentials[name]["description"]
|
||||
assert "Secret" in managed["update_url"]["description"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "payload"),
|
||||
[
|
||||
("/api/v1/hostnames/magic", {"username": "router", "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(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(path, json=payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["detail"][0]["type"] == "extra_forbidden"
|
||||
|
||||
|
||||
def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
|
||||
def test_hostname_api_lifecycle_and_history(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
@@ -167,38 +141,165 @@ def test_api_magic_hostname_lifecycle(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
created = client.post("/api/v1/hostnames")
|
||||
|
||||
assert created.status_code == 201
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
assert created.headers["x-robots-tag"] == "noindex, nofollow"
|
||||
body = created.json()
|
||||
assert body["hostname"].endswith(".ddns.example.net")
|
||||
assert body["update_url"].startswith("http://ddns.example.net/u/")
|
||||
assert body["management_url"].startswith("http://ddns.example.net/m/")
|
||||
assert body["status"] == "pending"
|
||||
assert body["update_url"].startswith("http://ddns.example.net/api/v1/router/update/")
|
||||
assert body["dashboard_url"].startswith("http://ddns.example.net/m/")
|
||||
assert body["registration_url"].startswith("http://ddns.example.net/api/v1/router/register/")
|
||||
|
||||
management_slug = body["management_url"].rsplit("/", 1)[1]
|
||||
managed = client.get(f"/api/v1/management/{management_slug}")
|
||||
assert managed.status_code == 200
|
||||
assert managed.json()["hostname"] == body["hostname"]
|
||||
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
|
||||
update_path = _path(body["update_url"])
|
||||
register_path = _path(body["registration_url"])
|
||||
|
||||
deleted = client.delete(f"/api/v1/management/{management_slug}")
|
||||
pending = client.get(f"/api/v1/hostnames/{dashboard_secret}")
|
||||
assert pending.status_code == 200
|
||||
assert pending.json()["status"] == "pending"
|
||||
assert pending.json()["history"] == []
|
||||
assert client.get(update_path, params={"myip": "203.0.113.11"}).status_code == 404
|
||||
|
||||
registered = client.post(register_path, params={"myip": "203.0.113.10"})
|
||||
assert registered.status_code == 200
|
||||
assert registered.text == "good registered"
|
||||
|
||||
update = client.get(update_path, params={"myip": "203.0.113.11"})
|
||||
assert update.status_code == 200
|
||||
assert update.text == "good 203.0.113.11"
|
||||
|
||||
active = client.get(f"/api/v1/hostnames/{dashboard_secret}", params={"history_limit": 10})
|
||||
assert active.status_code == 200
|
||||
payload = active.json()
|
||||
assert payload["status"] == "active"
|
||||
assert payload["ipv4"] == "203.0.113.11"
|
||||
assert payload["history"][0]["hostname"] == body["hostname"]
|
||||
assert "source_ip" not in payload["history"][0]
|
||||
|
||||
deleted = client.delete(f"/api/v1/hostnames/{dashboard_secret}")
|
||||
assert deleted.status_code == 204
|
||||
assert client.get(f"/api/v1/management/{management_slug}").status_code == 404
|
||||
assert client.get(f"/api/v1/hostnames/{dashboard_secret}").status_code == 404
|
||||
|
||||
|
||||
def test_hostname_api_can_rotate_update_url(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="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
body = client.post("/api/v1/hostnames").json()
|
||||
dashboard_secret = body["dashboard_url"].rsplit("/", 1)[1]
|
||||
old_update_path = _path(body["update_url"])
|
||||
|
||||
pending_rotate = client.post(f"/api/v1/hostnames/{dashboard_secret}/rotate-update-url")
|
||||
registered = client.post(_path(body["registration_url"]), params={"myip": "203.0.113.10"})
|
||||
rotated = client.post(f"/api/v1/hostnames/{dashboard_secret}/rotate-update-url")
|
||||
new_update_path = _path(rotated.json()["update_url"])
|
||||
|
||||
assert pending_rotate.status_code == 400
|
||||
assert registered.status_code == 200
|
||||
assert rotated.status_code == 200
|
||||
assert rotated.json()["hostname"] == body["hostname"]
|
||||
assert rotated.json()["update_url"] != body["update_url"]
|
||||
assert client.get(old_update_path, params={"myip": "203.0.113.11"}).status_code == 404
|
||||
assert client.get(new_update_path, params={"myip": "203.0.113.12"}).status_code == 200
|
||||
|
||||
|
||||
def test_hostname_api_history_is_scoped_to_dashboard_secret(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
own = store.create_account("own.ddns.example.net", "own-router")
|
||||
other = store.create_account("other.ddns.example.net", "other-router")
|
||||
store.log_update_event("own.ddns.example.net", "203.0.113.21", None, "good", "updated", "198.51.100.10")
|
||||
store.log_update_event("other.ddns.example.net", "203.0.113.22", None, "good", "updated", "198.51.100.11")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/hostnames/{own['management_slug']}", params={"history_limit": 10})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
payload = response.json()
|
||||
assert len(payload["history"]) == 1
|
||||
assert payload["history"][0]["hostname"] == "own.ddns.example.net"
|
||||
assert "source_ip" not in payload["history"][0]
|
||||
assert "other.ddns.example.net" not in response.text
|
||||
assert client.get(f"/api/v1/hostnames/{other['management_slug']}", params={"history_limit": 101}).status_code == 422
|
||||
assert client.get(f"/api/v1/hostnames/{'a' * 32}").status_code == 404
|
||||
|
||||
|
||||
def test_registration_publishes_initial_dns_when_provider_is_configured(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
body = client.post("/api/v1/hostnames").json()
|
||||
registered = client.post(_path(body["registration_url"]), params={"myip": "203.0.113.77"})
|
||||
status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}")
|
||||
|
||||
assert registered.status_code == 200
|
||||
assert status.json()["status"] == "active"
|
||||
assert status.json()["ipv4"] == "203.0.113.77"
|
||||
assert len(published) == 1
|
||||
assert published[0].hostname == body["hostname"]
|
||||
assert published[0].ipv4 == "203.0.113.77"
|
||||
|
||||
|
||||
def test_registration_requires_ip_when_provider_source_ip_is_not_public(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: None)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
public_base_url="http://ddns.example.net",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
body = client.post("/api/v1/hostnames").json()
|
||||
registered = client.post(_path(body["registration_url"]))
|
||||
status = client.get(f"/api/v1/hostnames/{body['dashboard_url'].rsplit('/', 1)[1]}")
|
||||
|
||||
assert registered.status_code == 400
|
||||
assert registered.text.startswith("badip registration needs a public source IP")
|
||||
assert status.json()["status"] == "pending"
|
||||
|
||||
|
||||
def test_api_update_publishes_before_recording_ip(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
|
||||
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"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "good", "hostname": "home.ddns.example.net", "ip": "203.0.113.11"}
|
||||
assert response.text == "good 203.0.113.11"
|
||||
assert len(published) == 1
|
||||
assert store.list_records()[0]["ipv4"] == "203.0.113.11"
|
||||
|
||||
@@ -207,14 +308,14 @@ def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypa
|
||||
def fail_publish(result, settings):
|
||||
raise DnsProviderError("dns down")
|
||||
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", fail_publish)
|
||||
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"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.11"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
@@ -232,8 +333,8 @@ def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
second = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
first = client.post("/api/v1/hostnames")
|
||||
second = client.post("/api/v1/hostnames")
|
||||
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 429
|
||||
@@ -246,8 +347,8 @@ def test_api_invalid_ip_returns_clear_400(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/api/v1/updates/{account['update_slug']}", params={"myipv6": "203.0.113.9"})
|
||||
response = client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myipv6": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "myipv6 must be an IPv6 address"
|
||||
assert response.text == "badip"
|
||||
assert store.list_records() == []
|
||||
+108
-8
@@ -3,27 +3,29 @@ import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.app as app_module
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
import karldns.app as app_module
|
||||
from karldns import DdnsSettings, DdnsStore, make_app
|
||||
from karldns.security import hash_lookup_token
|
||||
|
||||
|
||||
def test_rate_limit_reject_does_not_increment_bucket(tmp_path: Path) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
|
||||
assert store.allow_rate_limit("198.51.100.1:/magic", 1) is True
|
||||
assert store.allow_rate_limit("198.51.100.1:/magic", 1) is False
|
||||
assert store.allow_rate_limit("198.51.100.1:/", 1) is True
|
||||
assert store.allow_rate_limit("198.51.100.1:/", 1) is False
|
||||
store.flush_rate_limits()
|
||||
|
||||
window = int(time.time()) // 60
|
||||
with store.connect() as conn:
|
||||
count = conn.execute(
|
||||
"SELECT count FROM rate_limits WHERE key = ? AND window = ?",
|
||||
("198.51.100.1:/magic", window),
|
||||
("198.51.100.1:/", window),
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
@@ -161,6 +163,52 @@ def test_cleanup_records_only_unused_hostname_count(tmp_path: Path) -> None:
|
||||
assert set(store.list_cleanup_runs()[0]) == {"created_at", "unused_accounts_deleted"}
|
||||
|
||||
|
||||
def test_cleanup_removes_pending_generated_setups_after_cutoff(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("pending.ddns.example.net", "router", accepted=False)
|
||||
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET created_at = ? WHERE hostname = ?",
|
||||
("2000-01-01T00:00:00+00:00", account["hostname"]),
|
||||
)
|
||||
|
||||
result = store.cleanup(unused_account_hours=45 * 24)
|
||||
|
||||
assert result["unused_accounts"] == 1
|
||||
assert store.get_account_by_slug(account["update_slug"]) is None
|
||||
|
||||
|
||||
def test_cleanup_keeps_recent_pending_setup_until_pending_cutoff(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("pending.ddns.example.net", "router", accepted=False)
|
||||
|
||||
recent = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET created_at = ? WHERE hostname = ?",
|
||||
(recent, account["hostname"]),
|
||||
)
|
||||
|
||||
result = store.cleanup(unused_account_hours=1, pending_account_hours=24)
|
||||
|
||||
assert result["unused_accounts"] == 0
|
||||
assert store.get_account_by_slug(account["update_slug"]) is not None
|
||||
|
||||
|
||||
def test_pending_generated_setup_stays_pending_after_restart(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("pending.ddns.example.net", "router", accepted=False)
|
||||
store.close()
|
||||
|
||||
restarted = DdnsStore(database_path)
|
||||
|
||||
assert restarted.get_account_by_slug(account["update_slug"])["accepted_at"] is None
|
||||
|
||||
|
||||
def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
|
||||
@@ -194,7 +242,7 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||
assert cache_size == -20_000
|
||||
assert temp_store == 2
|
||||
assert mmap_size == 256 * 1024 * 1024
|
||||
assert user_version == 1
|
||||
assert user_version == 3
|
||||
assert {
|
||||
"idx_accounts_created_at",
|
||||
"idx_rate_limits_window",
|
||||
@@ -203,6 +251,58 @@ def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||
}.issubset(index_names)
|
||||
|
||||
|
||||
def test_accounts_schema_migration_removes_legacy_router_credentials(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
with sqlite3.connect(database_path) as conn:
|
||||
conn.execute("PRAGMA user_version = 2")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE accounts (
|
||||
hostname TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
update_slug TEXT NOT NULL UNIQUE,
|
||||
management_hash TEXT UNIQUE,
|
||||
dashboard_password_hash TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
accepted_at TEXT,
|
||||
disabled INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO accounts(
|
||||
hostname, username, token_hash, update_slug, management_hash,
|
||||
dashboard_password_hash, created_at, accepted_at, disabled
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, 0)
|
||||
""",
|
||||
(
|
||||
"home.ddns.example.net",
|
||||
"router",
|
||||
"old-token-hash",
|
||||
"update-secret",
|
||||
hash_lookup_token("dashboard-secret"),
|
||||
"2026-05-21T00:00:00+00:00",
|
||||
"2026-05-21T00:00:00+00:00",
|
||||
),
|
||||
)
|
||||
|
||||
store = DdnsStore(database_path)
|
||||
account = store.get_account_by_slug("update-secret")
|
||||
|
||||
with store.connect() as conn:
|
||||
columns = {row[1] for row in conn.execute("PRAGMA table_info(accounts)")}
|
||||
user_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
|
||||
assert user_version == 3
|
||||
assert "username" not in columns
|
||||
assert "token_hash" not in columns
|
||||
assert account
|
||||
assert account["hostname"] == "home.ddns.example.net"
|
||||
|
||||
|
||||
def test_rate_limit_flush_requeues_counts_after_database_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
|
||||
@@ -286,10 +386,10 @@ 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(unused_account_hours, event_history_hours):
|
||||
def fail_cleanup(unused_account_hours, event_history_hours, pending_account_hours):
|
||||
raise RuntimeError("cleanup exploded")
|
||||
|
||||
caplog.set_level(logging.ERROR, logger="router_dyndns.app")
|
||||
caplog.set_level(logging.ERROR, logger="karldns.app")
|
||||
monkeypatch.setattr(store, "cleanup", fail_cleanup)
|
||||
|
||||
asyncio.run(app_module._run_cleanup_if_due(store, settings))
|
||||
|
||||
+174
-4
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from router_dyndns.ddns import DdnsSettings, make_app
|
||||
from karldns import DdnsSettings, make_app
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_updater_strips_fritzbox_placeholders_and_caches_ip(tmp_path: Path) -> N
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
account = client.post("/api/v1/hostnames/magic", json={}).json()
|
||||
account = client.post("/api/v1/hostnames").json()
|
||||
update_url = account["update_url"]
|
||||
script = tmp_path / "curl"
|
||||
log_file = tmp_path / "curl.log"
|
||||
@@ -51,7 +51,6 @@ def test_updater_strips_fritzbox_placeholders_and_caches_ip(tmp_path: Path) -> N
|
||||
f"""#!/bin/sh
|
||||
printf '%s\\n' "$*" >> {log_file}
|
||||
case "$*" in
|
||||
*'/ip'*) printf '203.0.113.25\\n' ;;
|
||||
*'<ipaddr>'*|*'<ip6addr>'*) printf 'bad placeholder leaked\\n'; exit 1 ;;
|
||||
*'myip=203.0.113.25'*) printf 'good 203.0.113.25\\n' ;;
|
||||
*) printf 'bad request\\n'; exit 1 ;;
|
||||
@@ -67,9 +66,9 @@ esac
|
||||
env={
|
||||
"KARLDNS_UPDATE_URL": update_url,
|
||||
"KARLDNS_CURL": str(script),
|
||||
"KARLDNS_IP_URL": "https://karldns.de/ip",
|
||||
"KARLDNS_CACHE_FILE": str(cache_file),
|
||||
"KARLDNS_LOCK_FILE": str(lock_file),
|
||||
"KARLDNS_IPV4": "203.0.113.25",
|
||||
"KARLDNS_IPV6": "off",
|
||||
},
|
||||
)
|
||||
@@ -77,3 +76,174 @@ esac
|
||||
assert "good 203.0.113.25" in result.stdout
|
||||
assert cache_file.read_text(encoding="utf-8").strip() == "203.0.113.25|"
|
||||
assert "<ipaddr>" not in log_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_installer_registers_generated_hostname_before_use(tmp_path: Path) -> None:
|
||||
bin_dir = tmp_path / "bin"
|
||||
home_dir = tmp_path / "home"
|
||||
log_file = tmp_path / "curl.log"
|
||||
bin_dir.mkdir()
|
||||
home_dir.mkdir()
|
||||
curl = bin_dir / "curl"
|
||||
curl.write_text(
|
||||
f"""#!/bin/sh
|
||||
printf '%s\\n' "$*" >> {log_file}
|
||||
output_file=""
|
||||
previous=""
|
||||
for argument in "$@"; do
|
||||
if [ "$previous" = "-o" ]; then
|
||||
output_file="$argument"
|
||||
fi
|
||||
previous="$argument"
|
||||
done
|
||||
case "$*" in
|
||||
*'/karldns-update.sh'*)
|
||||
cat > "$output_file" <<'EOS'
|
||||
#!/bin/sh
|
||||
printf 'updater placeholder\\n'
|
||||
EOS
|
||||
;;
|
||||
*'/api/v1/hostnames'*)
|
||||
printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}'
|
||||
;;
|
||||
*'/api/v1/router/register/manage-secret'*)
|
||||
printf '%s\\n' 'good registered'
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected curl call: %s\\n' "$*" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
curl.chmod(0o700)
|
||||
|
||||
result = run_script(
|
||||
"sh",
|
||||
str(ROOT / "scripts" / "install-linux.sh"),
|
||||
"--mode",
|
||||
"user",
|
||||
"--no-cron",
|
||||
"--no-run",
|
||||
env={
|
||||
"HOME": str(home_dir),
|
||||
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
|
||||
"KARLDNS_BASE_URL": "https://karldns.test",
|
||||
"KARLDNS_RAW_BASE_URL": "https://raw.karldns.test",
|
||||
},
|
||||
)
|
||||
|
||||
log = log_file.read_text(encoding="utf-8")
|
||||
assert "/api/v1/hostnames" in log
|
||||
assert "/api/v1/router/register/manage-secret" in log
|
||||
assert "KarlDNS: registering hostname" in result.stdout
|
||||
config = home_dir / ".config" / "karldns" / "updater.env"
|
||||
config_text = config.read_text(encoding="utf-8")
|
||||
assert "KARLDNS_UPDATE_URL='https://karldns.test/api/v1/router/update/update-secret'" in config_text
|
||||
assert "KARLDNS_HOSTNAME='abc.karldns.test'" in config_text
|
||||
|
||||
|
||||
def test_user_installer_and_uninstaller_manage_cron_idempotently(tmp_path: Path) -> None:
|
||||
bin_dir = tmp_path / "bin"
|
||||
home_dir = tmp_path / "home"
|
||||
log_file = tmp_path / "curl.log"
|
||||
crontab_file = tmp_path / "crontab.txt"
|
||||
bin_dir.mkdir()
|
||||
home_dir.mkdir()
|
||||
curl = bin_dir / "curl"
|
||||
crontab = bin_dir / "crontab"
|
||||
curl.write_text(
|
||||
f"""#!/bin/sh
|
||||
printf '%s\\n' "$*" >> {log_file}
|
||||
output_file=""
|
||||
previous=""
|
||||
for argument in "$@"; do
|
||||
if [ "$previous" = "-o" ]; then
|
||||
output_file="$argument"
|
||||
fi
|
||||
previous="$argument"
|
||||
done
|
||||
case "$*" in
|
||||
*'/karldns-update.sh'*)
|
||||
cat > "$output_file" <<'EOS'
|
||||
#!/bin/sh
|
||||
printf 'updater placeholder\\n'
|
||||
EOS
|
||||
;;
|
||||
*'/api/v1/hostnames'*)
|
||||
printf '%s\\n' '{{"hostname":"abc.karldns.test","status":"pending","update_url":"https://karldns.test/api/v1/router/update/update-secret?myip=<ipaddr>&myipv6=<ip6addr>","dashboard_url":"https://karldns.test/m/manage-secret","registration_url":"https://karldns.test/api/v1/router/register/manage-secret"}}'
|
||||
;;
|
||||
*'/api/v1/router/register/manage-secret'*)
|
||||
printf '%s\\n' 'good registered'
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected curl call: %s\\n' "$*" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
crontab.write_text(
|
||||
f"""#!/bin/sh
|
||||
if [ "$1" = "-l" ]; then
|
||||
cat {crontab_file} 2>/dev/null || exit 1
|
||||
exit 0
|
||||
fi
|
||||
cp "$1" {crontab_file}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
curl.chmod(0o700)
|
||||
crontab.chmod(0o700)
|
||||
env = {
|
||||
"HOME": str(home_dir),
|
||||
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
|
||||
"KARLDNS_BASE_URL": "https://karldns.test",
|
||||
"KARLDNS_RAW_BASE_URL": "https://raw.karldns.test",
|
||||
}
|
||||
|
||||
for _ in range(2):
|
||||
run_script(
|
||||
"sh",
|
||||
str(ROOT / "scripts" / "install-linux.sh"),
|
||||
"--mode",
|
||||
"user",
|
||||
"--no-run",
|
||||
env=env,
|
||||
)
|
||||
|
||||
cron_text = crontab_file.read_text(encoding="utf-8")
|
||||
log = log_file.read_text(encoding="utf-8")
|
||||
assert cron_text.count("# karldns-update start") == 1
|
||||
assert "KARLDNS_CONFIG=" in cron_text
|
||||
assert "karldns-update" in cron_text
|
||||
assert log.count("/api/v1/hostnames") == 1
|
||||
assert log.count("/api/v1/router/register/manage-secret") == 1
|
||||
assert (home_dir / ".local" / "bin" / "karldns-update").exists()
|
||||
|
||||
run_script("sh", str(ROOT / "scripts" / "uninstall-linux.sh"), "--mode", "user", env=env)
|
||||
|
||||
assert "# karldns-update start" not in crontab_file.read_text(encoding="utf-8")
|
||||
assert not (home_dir / ".local" / "bin" / "karldns-update").exists()
|
||||
assert (home_dir / ".config" / "karldns" / "updater.env").exists()
|
||||
|
||||
run_script("sh", str(ROOT / "scripts" / "uninstall-linux.sh"), "--mode", "user", "--delete-config", env=env)
|
||||
|
||||
assert not (home_dir / ".config" / "karldns").exists()
|
||||
assert not (home_dir / ".cache" / "karldns").exists()
|
||||
|
||||
|
||||
def test_hosted_script_routes_redirect_to_github(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
for path, target in {
|
||||
"/install.sh": "install-linux.sh",
|
||||
"/uninstall.sh": "uninstall-linux.sh",
|
||||
"/karldns-update.sh": "karldns-update.sh",
|
||||
}.items():
|
||||
response = client.get(path, follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert response.headers["location"].endswith(f"/scripts/{target}")
|
||||
+31
-45
@@ -6,10 +6,10 @@ from pathlib import Path
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
|
||||
from router_dyndns.ddns_dns import validate_dns_provider_settings
|
||||
from router_dyndns.ddns_security import DASHBOARD_UNLOCK_COOKIE
|
||||
import karldns.services.ddns as ddns_module
|
||||
from karldns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, UpdateResult, make_app
|
||||
from karldns.dns import validate_dns_provider_settings
|
||||
from karldns.security import DASHBOARD_UNLOCK_COOKIE
|
||||
|
||||
|
||||
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
|
||||
@@ -20,14 +20,11 @@ def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "public-target.ddns.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
},
|
||||
f"/api/v1/router/update/{'a' * 43}",
|
||||
params={"hostname": "public-target.ddns.example.net", "myip": "203.0.113.9"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 404
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
@@ -39,16 +36,13 @@ def test_generated_account_cannot_update_other_hostname(tmp_path: Path) -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "other.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
f"/api/v1/router/update/{account['update_slug']}",
|
||||
params={"hostname": "other.example.net", "myip": "203.0.113.9"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == 200
|
||||
assert store.list_records()[0]["hostname"] == "home.example.net"
|
||||
assert store.get_account_by_hostname("other.example.net") is None
|
||||
|
||||
|
||||
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
||||
@@ -70,7 +64,7 @@ def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path, caplog) -> None:
|
||||
caplog.set_level(logging.INFO, logger="router_dyndns.access")
|
||||
caplog.set_level(logging.INFO, logger="karldns.access")
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
@@ -81,30 +75,22 @@ def test_redacted_access_log_masks_bearer_paths_and_query_strings(tmp_path: Path
|
||||
account = app.state.store.create_account("home.example.net", "router")
|
||||
client = TestClient(app)
|
||||
|
||||
client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.1"})
|
||||
client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": account["hostname"],
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
"myip": "203.0.113.2",
|
||||
},
|
||||
)
|
||||
client.get(f"/api/v1/router/update/{account['update_slug']}", params={"myip": "203.0.113.1"})
|
||||
client.get(f"/api/v1/hostnames/{account['management_slug']}")
|
||||
|
||||
assert "/u/<secret>" in caplog.text
|
||||
assert "/nic/update?<redacted>" in caplog.text
|
||||
assert "/api/v1/router/update/<secret>" in caplog.text
|
||||
assert "/api/v1/hostnames/<secret>" in caplog.text
|
||||
assert account["update_slug"] not in caplog.text
|
||||
assert account["password"] not in caplog.text
|
||||
assert account["management_slug"] not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
"/u/short",
|
||||
"/api/v1/updates/short",
|
||||
"/api/v1/router/update/short",
|
||||
"/api/v1/router/register/short",
|
||||
"/api/v1/hostnames/short",
|
||||
"/m/short",
|
||||
"/api/v1/management/short",
|
||||
],
|
||||
)
|
||||
def test_secret_path_tokens_are_constrained(tmp_path: Path, path: str) -> None:
|
||||
@@ -174,7 +160,7 @@ def test_secret_html_responses_are_not_cached(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/magic", data={"username": "router"})
|
||||
created = client.post("/")
|
||||
|
||||
assert created.status_code == 200
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
@@ -192,7 +178,7 @@ def test_oversized_request_body_is_rejected(tmp_path: Path) -> None:
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/magic", data={"username": "x" * 2000})
|
||||
response = client.post("/", data={"username": "x" * 2000})
|
||||
|
||||
assert response.status_code == 413
|
||||
|
||||
@@ -208,7 +194,7 @@ def test_invalid_utf8_form_body_is_rejected(tmp_path: Path) -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/magic",
|
||||
"/",
|
||||
content=b"username=\xff",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
@@ -229,7 +215,7 @@ def test_too_many_form_fields_are_rejected(tmp_path: Path) -> None:
|
||||
body = "&".join(f"k{index}=v" for index in range(101))
|
||||
|
||||
response = client.post(
|
||||
"/magic",
|
||||
"/",
|
||||
content=body.encode("ascii"),
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
@@ -249,8 +235,8 @@ def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> N
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.post("/magic", data={"username": "alice"}, headers={"x-forwarded-for": "198.51.100.10"})
|
||||
second = client.post("/magic", data={"username": "bob"}, headers={"x-forwarded-for": "198.51.100.11"})
|
||||
first = client.post("/", headers={"x-forwarded-for": "198.51.100.10"})
|
||||
second = client.post("/", headers={"x-forwarded-for": "198.51.100.11"})
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 429
|
||||
@@ -260,8 +246,8 @@ def test_dns_delete_failure_is_logged_without_secret_management_slug(tmp_path: P
|
||||
def fail_delete(hostname, settings):
|
||||
raise DnsProviderError("dns down")
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
|
||||
monkeypatch.setattr(ddns_service, "delete_dns_records", fail_delete)
|
||||
caplog.set_level(logging.WARNING, logger="karldns.services.ddns")
|
||||
monkeypatch.setattr(ddns_module, "delete_dns_records", fail_delete)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
@@ -363,7 +349,7 @@ def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> N
|
||||
|
||||
|
||||
def test_cloudflare_publish_reconciles_provider_state(monkeypatch) -> None:
|
||||
import router_dyndns.dns.cloudflare as cloudflare
|
||||
import karldns.dns.cloudflare as cloudflare
|
||||
|
||||
calls = {"find": 0}
|
||||
|
||||
@@ -394,7 +380,7 @@ def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
|
||||
import dns.query
|
||||
import dns.rcode
|
||||
|
||||
from router_dyndns.dns.rfc2136 import delete_rfc2136_records
|
||||
from karldns.dns.rfc2136 import delete_rfc2136_records
|
||||
|
||||
class RefusedResponse:
|
||||
def rcode(self) -> int:
|
||||
|
||||
+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
|
||||
|
||||
+50
-120
@@ -1,15 +1,18 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, make_app
|
||||
import karldns.services.ddns as ddns_module
|
||||
from karldns import DdnsSettings, DdnsStore, DnsProviderError, DnsReconciliationError, make_app
|
||||
|
||||
|
||||
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
||||
def _update_path(account: dict[str, str]) -> str:
|
||||
return f"/api/v1/router/update/{account['update_slug']}"
|
||||
|
||||
|
||||
def test_router_update_get_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
@@ -17,121 +20,70 @@ def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
_update_path(account),
|
||||
params={
|
||||
"hostname": "home.ddns.example.net",
|
||||
"myip": "203.0.113.8",
|
||||
"myipv6": "2001:db8::8",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.8"
|
||||
records = client.get("/records", auth=("admin", "admin")).json()
|
||||
records = client.get("/api/v1/admin/records", auth=("admin", "admin")).json()
|
||||
assert records["total"] == 1
|
||||
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_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": account["hostname"], "myip": "203.0.113.8", "username": account["username"], "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
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": account["hostname"], "myip": "203.0.113.8", "username": account["username"], "password": account["password"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_generated_account_can_update_own_hostname(tmp_path: Path) -> None:
|
||||
def test_router_update_post_accepts_form_and_json(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "home.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
form_response = client.post(_update_path(account), data={"myip": "203.0.113.9"})
|
||||
json_response = client.post(_update_path(account), json={"myip": "203.0.113.10"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.9"
|
||||
|
||||
slug_response = client.get(
|
||||
f"/u/{account['update_slug']}",
|
||||
params={"myip": "203.0.113.10"},
|
||||
)
|
||||
|
||||
assert slug_response.status_code == 200
|
||||
assert slug_response.text == "good 203.0.113.10"
|
||||
assert form_response.status_code == 200
|
||||
assert form_response.text == "good 203.0.113.9"
|
||||
assert json_response.status_code == 200
|
||||
assert json_response.text == "good 203.0.113.10"
|
||||
assert store.list_records()[0]["ipv4"] == "203.0.113.10"
|
||||
|
||||
|
||||
def test_one_box_update_url_can_authenticate_with_query_credentials(tmp_path: Path) -> None:
|
||||
def test_router_update_requires_registered_hostname(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
settings = DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
app = make_app(settings)
|
||||
account = store.create_account("home.example.net", "alice", accepted=False)
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
one_box_url = ddns_service.DdnsService(settings, store).credentialed_update_url(account)
|
||||
parsed = urlparse(one_box_url)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
params["myip"] = ["203.0.113.11"]
|
||||
params["myipv6"] = [""]
|
||||
response = client.get(parsed.path, params={key: value[0] for key, value in params.items()})
|
||||
before = client.get(_update_path(account), params={"myip": "203.0.113.9"})
|
||||
registered = client.get(f"/api/v1/router/register/{account['management_slug']}", params={"myip": "203.0.113.9"})
|
||||
after = client.get(_update_path(account), params={"myip": "203.0.113.10"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.11"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert before.status_code == 404
|
||||
assert registered.status_code == 200
|
||||
assert registered.text == "good registered"
|
||||
assert after.status_code == 200
|
||||
assert after.text == "good 203.0.113.10"
|
||||
|
||||
|
||||
def test_dns_publish_failure_does_not_update_record(tmp_path: Path, monkeypatch, caplog) -> None:
|
||||
def fail_publish(result, settings):
|
||||
raise DnsProviderError("dns down")
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
|
||||
caplog.set_level(logging.WARNING, logger="karldns.services.ddns")
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", fail_publish)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
response = client.get(_update_path(account), params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
@@ -146,15 +98,15 @@ def test_dns_reconciliation_failure_is_logged_separately(tmp_path: Path, monkeyp
|
||||
def fail_publish(result, settings):
|
||||
raise DnsReconciliationError("provider state mismatch")
|
||||
|
||||
caplog.set_level(logging.WARNING, logger="router_dyndns.ddns_service")
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
|
||||
caplog.set_level(logging.WARNING, logger="karldns.services.ddns")
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", fail_publish)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
response = client.get(_update_path(account), params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
@@ -167,7 +119,7 @@ def test_programming_error_during_publish_is_not_mapped_to_dns_failure(tmp_path:
|
||||
def fail_publish(result, settings):
|
||||
raise AssertionError("publish bug")
|
||||
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", fail_publish)
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", fail_publish)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
@@ -175,7 +127,7 @@ def test_programming_error_during_publish_is_not_mapped_to_dns_failure(tmp_path:
|
||||
client = TestClient(app)
|
||||
|
||||
with pytest.raises(AssertionError, match="publish bug"):
|
||||
client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
client.get(_update_path(account), params={"myip": "203.0.113.9"})
|
||||
|
||||
assert store.list_records() == []
|
||||
assert store.list_update_events() == []
|
||||
@@ -183,14 +135,14 @@ def test_programming_error_during_publish_is_not_mapped_to_dns_failure(tmp_path:
|
||||
|
||||
def test_dns_publish_success_updates_record(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
response = client.get(_update_path(account), params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.9"
|
||||
@@ -204,7 +156,7 @@ def test_dns_publish_success_updates_record(tmp_path: Path, monkeypatch) -> None
|
||||
|
||||
def test_unchanged_slug_update_is_read_only_for_history_and_record(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
@@ -213,8 +165,8 @@ def test_unchanged_slug_update_is_read_only_for_history_and_record(tmp_path: Pat
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
second_response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
response = client.get(_update_path(account), params={"myip": "203.0.113.9"})
|
||||
second_response = client.get(_update_path(account), params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "nochg 203.0.113.9"
|
||||
@@ -233,7 +185,7 @@ def test_slug_update_rejects_hostname_outside_provider_suffix(tmp_path: Path) ->
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin", hostname_suffix="ddns.example.net"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
response = client.get(_update_path(account), params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 404
|
||||
assert store.list_records() == []
|
||||
@@ -241,7 +193,7 @@ def test_slug_update_rejects_hostname_outside_provider_suffix(tmp_path: Path) ->
|
||||
|
||||
def test_omitted_ip_family_is_preserved(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
@@ -249,7 +201,7 @@ def test_omitted_ip_family_is_preserved(tmp_path: Path, monkeypatch) -> None:
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
response = client.get(_update_path(account), params={"myip": "203.0.113.9"})
|
||||
|
||||
assert response.status_code == 200
|
||||
record = store.list_records()[0]
|
||||
@@ -267,7 +219,7 @@ def test_blank_ip_params_fall_back_to_source_ip(tmp_path: Path) -> None:
|
||||
client = TestClient(app, client=("198.51.100.44", 50000))
|
||||
|
||||
response = client.get(
|
||||
f"/u/{account['update_slug']}",
|
||||
_update_path(account),
|
||||
params={"myip": "", "myipv6": ""},
|
||||
)
|
||||
|
||||
@@ -276,9 +228,9 @@ def test_blank_ip_params_fall_back_to_source_ip(tmp_path: Path) -> None:
|
||||
assert store.list_records()[0]["ipv4"] == "198.51.100.44"
|
||||
|
||||
|
||||
def test_invalid_legacy_ip_is_badip_and_does_not_change_record(tmp_path: Path, monkeypatch) -> None:
|
||||
def test_invalid_ip_is_badip_and_does_not_change_record(tmp_path: Path, monkeypatch) -> None:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "publish_dns", lambda result, settings: published.append(result))
|
||||
monkeypatch.setattr(ddns_module, "publish_dns", lambda result, settings: published.append(result))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
@@ -286,7 +238,7 @@ def test_invalid_legacy_ip_is_badip_and_does_not_change_record(tmp_path: Path, m
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "not-an-ip"})
|
||||
response = client.get(_update_path(account), params={"myip": "not-an-ip"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.text == "badip"
|
||||
@@ -296,7 +248,7 @@ def test_invalid_legacy_ip_is_badip_and_does_not_change_record(tmp_path: Path, m
|
||||
assert record["ipv6"] == "2001:db8::8"
|
||||
|
||||
|
||||
def test_wrong_family_legacy_ip_is_badip_and_preserves_records(tmp_path: Path) -> None:
|
||||
def test_wrong_ip_family_is_badip_and_preserves_records(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
@@ -304,32 +256,10 @@ def test_wrong_family_legacy_ip_is_badip_and_preserves_records(tmp_path: Path) -
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/u/{account['update_slug']}", params={"myip": "2001:db8::9"})
|
||||
response = client.get(_update_path(account), params={"myip": "2001:db8::9"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.text == "badip"
|
||||
record = store.list_records()[0]
|
||||
assert record["ipv4"] == "203.0.113.8"
|
||||
assert record["ipv6"] == "2001:db8::8"
|
||||
|
||||
|
||||
def test_nic_update_invalid_ip_returns_badip(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": account["hostname"],
|
||||
"myipv6": "203.0.113.9",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.text == "badip"
|
||||
assert store.list_records() == []
|
||||
Reference in new issue
Block a user