Archived
Build KarlDNS self-hosted DynDNS service
This commit is contained in:
1 parent
45bc38d950
commit
ca92dc211a
65 files changed
+9200
-3570
No files matched your search
@@ -0,0 +1,221 @@
|
||||
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
|
||||
|
||||
|
||||
def test_shared_secret_is_not_admin_password_fallback(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin", auth=("admin", "secret"))
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json()["detail"] == "DDNS_ADMIN_PASSWORD is required"
|
||||
|
||||
|
||||
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
store.create_domain_challenge("example.net")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin", auth=("admin", "admin"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "Operator console" in response.text
|
||||
assert "Generate credentials" not in response.text
|
||||
assert 'action="/admin/accounts"' not in response.text
|
||||
assert response.text.count("data-admin-table") == 4
|
||||
assert response.text.count("data-table-search") == 4
|
||||
assert response.text.count("data-table-page-size") == 4
|
||||
assert response.text.count("data-table-pagination") == 4
|
||||
assert "admin-table-scroll" in response.text
|
||||
assert "Scheduled cleanup" in response.text
|
||||
assert "Scheduler state" in response.text
|
||||
assert "database lease" in response.text
|
||||
assert "Active credentials" in response.text
|
||||
assert "Domain claims" in response.text
|
||||
assert "Recent events" in response.text
|
||||
assert "example.net" in response.text
|
||||
|
||||
|
||||
def test_custom_admin_path_moves_operator_routes(tmp_path: Path) -> None:
|
||||
settings = DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
admin_path="/ops",
|
||||
)
|
||||
store = DdnsStore(settings.database_path)
|
||||
store.create_account("home.example.net", "router")
|
||||
app = make_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
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 'action="/ops/cleanup"' in response.text
|
||||
assert 'action="/ops/accounts/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("/records", auth=("admin", "admin")).status_code == 404
|
||||
assert client.get("/admin", auth=("admin", "admin")).status_code == 404
|
||||
|
||||
|
||||
def test_admin_records_and_events_are_paginated(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
first = store.create_account("first.example.net", "router")
|
||||
second = store.create_account("second.example.net", "router")
|
||||
store.upsert(first["hostname"], "203.0.113.1", None)
|
||||
store.upsert(second["hostname"], "203.0.113.2", None)
|
||||
store.log_update_event(first["hostname"], "203.0.113.1", None, "updated", "ok", "198.51.100.1")
|
||||
store.log_update_event(second["hostname"], "203.0.113.2", None, "updated", "ok", "198.51.100.2")
|
||||
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()
|
||||
|
||||
assert records["limit"] == 1
|
||||
assert records["offset"] == 1
|
||||
assert records["total"] == 2
|
||||
assert len(records["items"]) == 1
|
||||
assert events["limit"] == 1
|
||||
assert events["offset"] == 1
|
||||
assert events["total"] == 2
|
||||
assert len(events["items"]) == 1
|
||||
|
||||
|
||||
def test_admin_posts_require_csrf(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/admin/cleanup",
|
||||
auth=("admin", "admin"),
|
||||
data={},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin", params={"password": "admin"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_admin_routes_are_rate_limited(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
admin_rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.get("/admin", auth=("admin", "wrong"))
|
||||
second = client.get("/admin", auth=("admin", "wrong"))
|
||||
|
||||
assert first.status_code == 401
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
|
||||
settings = DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
store = DdnsStore(settings.database_path)
|
||||
account = store.create_account("home.example.net", "router")
|
||||
app = make_app(settings)
|
||||
client = TestClient(app)
|
||||
csrf = admin_csrf_token(settings)
|
||||
|
||||
update = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
assert "Update URL rotated" in update.text
|
||||
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
|
||||
management = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
|
||||
)
|
||||
assert management.status_code == 200
|
||||
assert "Private status page rotated" 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 rotated" in password.text
|
||||
assert "One-box 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
|
||||
|
||||
|
||||
def test_admin_delete_normalizes_hostname_and_logs_audit_event(tmp_path: Path) -> None:
|
||||
settings = DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin")
|
||||
store = DdnsStore(settings.database_path)
|
||||
store.create_account("home.example.net", "router")
|
||||
app = make_app(settings)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/admin/accounts/delete",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": admin_csrf_token(settings), "hostname": " HOME.EXAMPLE.NET. "},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert store.get_account_by_hostname("home.example.net") is None
|
||||
events = store.list_update_events_for_hostname("home.example.net", 10)
|
||||
assert events[0]["status"] == "admin_hostname_deleted"
|
||||
assert "secret" not in str(events[0]["detail"]).lower()
|
||||
|
||||
|
||||
def test_admin_template_escapes_update_event_html(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
store.create_account("home.example.net", "router")
|
||||
store.log_update_event(
|
||||
"home.example.net",
|
||||
"203.0.113.10",
|
||||
None,
|
||||
"dns_failed",
|
||||
'<script>alert("xss")</script>',
|
||||
"198.51.100.20",
|
||||
)
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin", auth=("admin", "admin"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<script>alert("xss")</script>' not in response.text
|
||||
assert "<script>alert("xss")</script>" in response.text
|
||||
@@ -0,0 +1,351 @@
|
||||
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, make_app
|
||||
|
||||
|
||||
def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
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 "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 "/admin" 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 "/request-domain" not in schema["paths"]
|
||||
assert "/verify-domain" not in schema["paths"]
|
||||
assert "/accounts" not in schema["paths"]
|
||||
assert "/m/{management_slug}" not in schema["paths"]
|
||||
assert "/d/{claim_secret}" not in schema["paths"]
|
||||
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
|
||||
assert all(tag["name"] != "admin" for tag in schema["tags"])
|
||||
management_tag = next(tag for tag in schema["tags"] if tag["name"] == "management")
|
||||
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"]
|
||||
|
||||
|
||||
def test_public_redoc_hides_custom_operator_routes(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
admin_path="/ops",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
redoc = client.get("/redoc")
|
||||
schema = client.get("/openapi.json").json()
|
||||
|
||||
forbidden_paths = {
|
||||
"/ops",
|
||||
"/ops/accounts/delete",
|
||||
"/ops/accounts/rotate",
|
||||
"/ops/cleanup",
|
||||
"/ops/events",
|
||||
"/ops/records",
|
||||
"/records",
|
||||
"/events",
|
||||
}
|
||||
assert redoc.status_code == 200
|
||||
assert forbidden_paths.isdisjoint(schema["paths"])
|
||||
assert all("admin" not in tag["name"].lower() for tag in schema["tags"])
|
||||
|
||||
|
||||
def test_public_redoc_only_exposes_api_and_router_endpoints(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()
|
||||
|
||||
expected_public_paths = {
|
||||
"/api/v1/domains/challenges",
|
||||
"/api/v1/domains/verify",
|
||||
"/api/v1/hostnames/custom",
|
||||
"/api/v1/hostnames/magic",
|
||||
"/api/v1/management/{management_slug}",
|
||||
"/api/v1/updates/{update_slug}",
|
||||
"/healthz",
|
||||
"/ip",
|
||||
"/nic/update",
|
||||
"/u/{update_slug}",
|
||||
}
|
||||
assert set(schema["paths"]) == expected_public_paths
|
||||
|
||||
|
||||
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}),
|
||||
("/api/v1/domains/challenges", {"domain": "example.net", "unexpected": True}),
|
||||
("/api/v1/domains/verify", {"domain": "example.net", "claim_secret": "secret", "unexpected": True}),
|
||||
(
|
||||
"/api/v1/hostnames/custom",
|
||||
{"hostname": "home.example.net", "claim_secret": "secret", "unexpected": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_public_write_models_reject_extra_json_fields(tmp_path: Path, path: str, payload: dict[str, object]) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
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:
|
||||
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)
|
||||
|
||||
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
assert created.status_code == 201
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
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/")
|
||||
|
||||
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"]
|
||||
|
||||
deleted = client.delete(f"/api/v1/management/{management_slug}")
|
||||
assert deleted.status_code == 204
|
||||
assert client.get(f"/api/v1/management/{management_slug}").status_code == 404
|
||||
|
||||
|
||||
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))
|
||||
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"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "good", "hostname": "home.ddns.example.net", "ip": "203.0.113.11"}
|
||||
assert len(published) == 1
|
||||
assert store.list_records()[0]["ipv4"] == "203.0.113.11"
|
||||
|
||||
|
||||
def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypatch) -> None:
|
||||
def fail_publish(result, settings):
|
||||
raise DnsProviderError("dns down")
|
||||
|
||||
monkeypatch.setattr(ddns_service, "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"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
assert store.list_update_events()[0]["status"] == "dns_failed"
|
||||
|
||||
|
||||
def test_api_custom_hostname_requires_verified_domain(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
||||
assert challenge.status_code == 201
|
||||
challenge_body = challenge.json()
|
||||
|
||||
verified = client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge_body["claim_secret"]},
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert verified.json()["verified"] is True
|
||||
|
||||
hostname = client.post(
|
||||
"/api/v1/hostnames/custom",
|
||||
json={
|
||||
"hostname": "home.example.net",
|
||||
"claim_secret": challenge_body["claim_secret"],
|
||||
"username": "router",
|
||||
},
|
||||
)
|
||||
|
||||
assert hostname.status_code == 201
|
||||
assert hostname.json()["hostname"] == "home.example.net"
|
||||
|
||||
|
||||
def test_domain_challenge_verification_requires_current_secret_and_owner(tmp_path: Path) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
first = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
|
||||
second = store.create_domain_challenge("example.net", owner_hostname="owner.example.net")
|
||||
|
||||
assert store.verify_domain_challenge("example.net", first["claim_secret"], owner_hostname="owner.example.net") is None
|
||||
assert store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="other.example.net") is None
|
||||
assert store.get_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")["verified_at"] is None
|
||||
|
||||
verified = store.verify_domain_challenge("example.net", second["claim_secret"], owner_hostname="owner.example.net")
|
||||
|
||||
assert verified is not None
|
||||
assert verified["verified_at"]
|
||||
|
||||
|
||||
def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
second = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
def test_api_invalid_ip_returns_clear_400(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(f"/api/v1/updates/{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 store.list_records() == []
|
||||
|
||||
|
||||
def test_verified_domain_challenge_cannot_be_overwritten(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||
verified = client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
overwrite = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
||||
|
||||
assert verified.status_code == 200
|
||||
assert overwrite.status_code == 409
|
||||
|
||||
|
||||
def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
dns_zones={"ddns.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||
client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
hostname = client.post(
|
||||
"/api/v1/hostnames/custom",
|
||||
json={"hostname": "home.example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
|
||||
assert hostname.status_code == 403
|
||||
assert hostname.json()["detail"] == "hostname is outside configured DNS publishing zones"
|
||||
@@ -1,976 +0,0 @@
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
from router_dyndns.ddns_security import admin_csrf_token
|
||||
|
||||
|
||||
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "home.example.net",
|
||||
"myip": "203.0.113.8",
|
||||
"myipv6": "2001:db8::8",
|
||||
"password": "secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.8"
|
||||
records = client.get("/records", auth=("admin", "secret")).json()
|
||||
assert records[0]["hostname"] == "home.example.net"
|
||||
assert records[0]["ipv4"] == "203.0.113.8"
|
||||
assert records[0]["ipv6"] == "2001:db8::8"
|
||||
|
||||
|
||||
def test_update_rejects_wrong_secret(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "home.example.net", "myip": "203.0.113.8", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_rejects_unlisted_host(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "other.example.net", "myip": "203.0.113.8", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_generated_account_can_update_own_hostname(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"],
|
||||
},
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_generated_credentials_include_one_box_update_url(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
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_one_box_update_url_can_authenticate_with_query_credentials(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)
|
||||
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"]
|
||||
response = client.get(parsed.path, params={key: value[0] for key, value in params.items()})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.11"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
store.create_account("public-target.ddns.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "public-target.ddns.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
def test_dns_publish_failure_does_not_update_record(tmp_path: Path, monkeypatch) -> None:
|
||||
def fail_publish(result, settings):
|
||||
raise RuntimeError("dns down")
|
||||
|
||||
monkeypatch.setattr(ddns_service, "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"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
events = store.list_update_events()
|
||||
assert events[0]["status"] == "dns_failed"
|
||||
|
||||
|
||||
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))
|
||||
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"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.9"
|
||||
assert len(published) == 1
|
||||
assert store.list_records()[0]["ipv4"] == "203.0.113.9"
|
||||
|
||||
|
||||
def test_generated_account_cannot_update_other_hostname(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": "other.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_admin_page_shows_operator_views_without_create_form(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
store.create_domain_challenge("example.net")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin", auth=("admin", "admin"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
assert "Operator console" in response.text
|
||||
assert "Generate credentials" not in response.text
|
||||
assert 'action="/admin/accounts"' not in response.text
|
||||
assert response.text.count("data-admin-table") == 4
|
||||
assert response.text.count("data-table-search") == 4
|
||||
assert response.text.count("data-table-page-size") == 4
|
||||
assert response.text.count("data-table-pagination") == 4
|
||||
assert "admin-table-scroll" in response.text
|
||||
assert "Scheduled cleanup" in response.text
|
||||
assert "Active credentials" in response.text
|
||||
assert "Domain claims" in response.text
|
||||
assert "Recent events" in response.text
|
||||
assert "example.net" in response.text
|
||||
|
||||
|
||||
def test_admin_posts_require_csrf(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/admin/cleanup",
|
||||
auth=("admin", "admin"),
|
||||
data={},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_admin_password_query_string_is_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/admin", params={"password": "admin"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_admin_routes_are_rate_limited(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
admin_rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.get("/admin", auth=("admin", "wrong"))
|
||||
second = client.get("/admin", auth=("admin", "wrong"))
|
||||
|
||||
assert first.status_code == 401
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.get("/m/not-a-real-token")
|
||||
second = client.get("/m/another-token")
|
||||
|
||||
assert first.status_code == 404
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
def test_admin_can_rotate_links_and_password(tmp_path: Path) -> None:
|
||||
settings = DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
store = DdnsStore(settings.database_path)
|
||||
account = store.create_account("home.example.net", "router")
|
||||
app = make_app(settings)
|
||||
client = TestClient(app)
|
||||
csrf = admin_csrf_token(settings)
|
||||
|
||||
update = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "update"},
|
||||
)
|
||||
assert update.status_code == 200
|
||||
assert "Update URL rotated" in update.text
|
||||
assert client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"}).status_code == 404
|
||||
|
||||
management = client.post(
|
||||
"/admin/accounts/rotate",
|
||||
auth=("admin", "admin"),
|
||||
data={"csrf": csrf, "hostname": "home.example.net", "action": "management"},
|
||||
)
|
||||
assert management.status_code == 200
|
||||
assert "Private status page rotated" 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 rotated" in password.text
|
||||
assert "One-box update URL:" in password.text
|
||||
assert "/nic/update?" in password.text
|
||||
assert "Kennwort (optional):" in password.text
|
||||
|
||||
|
||||
def test_self_service_managed_hostname_generates_random_account(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)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert "One-box update URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Copy complete FRITZ!Box fields" in response.text
|
||||
assert "Copy CNAME record" in response.text
|
||||
assert "Manual updates" in response.text
|
||||
assert "curl -sS" in response.text
|
||||
assert "203.0.113.10" in response.text
|
||||
assert "2001:db8::10" in response.text
|
||||
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 "/m/" in response.text
|
||||
|
||||
|
||||
def test_magic_hostname_without_login_generates_update_and_management_links(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)
|
||||
|
||||
response = client.post("/magic")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert "One-box update URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Private status page:" in response.text
|
||||
assert "Copy complete FRITZ!Box fields" in response.text
|
||||
assert "Copy CNAME record" in response.text
|
||||
assert "FRITZ!Box replaces" in response.text
|
||||
assert "Copy curl examples" in response.text
|
||||
assert response.text.count("Secret") >= 3
|
||||
assert response.text.count("Public") >= 3
|
||||
assert ".ddns.example.net" in response.text
|
||||
|
||||
|
||||
def test_magic_get_redirects_to_home_form(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"
|
||||
|
||||
|
||||
def test_magic_management_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")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
management = client.get(f"/m/{account['management_slug']}")
|
||||
assert management.status_code == 200
|
||||
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 "Copy complete FRITZ!Box fields" in management.text
|
||||
assert "Copy CNAME record" in management.text
|
||||
assert "Manual updates" in management.text
|
||||
assert "curl -sS" in management.text
|
||||
assert "Public:" in management.text
|
||||
assert "Secret:" in management.text
|
||||
assert "Router updates" in management.text
|
||||
|
||||
deleted = client.post(f"/m/{account['management_slug']}/delete")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
update = client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
assert update.status_code == 404
|
||||
|
||||
|
||||
def test_management_page_shows_ip_history(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
store.log_update_event("home.ddns.example.net", "203.0.113.9", None, "updated", "ok", "198.51.100.1")
|
||||
store.log_update_event("home.ddns.example.net", "203.0.113.10", None, "updated", "ok", "198.51.100.1")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/m/{account['management_slug']}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Router updates" in response.text
|
||||
assert "203.0.113.9" in response.text
|
||||
assert "203.0.113.10" in response.text
|
||||
|
||||
|
||||
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
challenge_data = store.create_domain_challenge("example.net")
|
||||
verify = client.post(
|
||||
"/verify-domain",
|
||||
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
"/accounts",
|
||||
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": challenge_data["claim_secret"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "home.example.net" in response.text
|
||||
assert "Update-URL:" in response.text
|
||||
|
||||
|
||||
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/request-domain", data={"domain": "example.net"})
|
||||
assert challenge.status_code == 200
|
||||
assert "Private claim link" in challenge.text
|
||||
assert "/d/" in challenge.text
|
||||
|
||||
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
|
||||
claim_page = client.get(f"/d/{claim_secret}")
|
||||
assert claim_page.status_code == 200
|
||||
assert "TXT value" in claim_page.text
|
||||
|
||||
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
|
||||
assert verify.status_code == 200
|
||||
assert "Create credentials" in verify.text
|
||||
|
||||
created = client.post(
|
||||
"/accounts",
|
||||
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert "home.example.net" in created.text
|
||||
assert "Update-URL:" in created.text
|
||||
|
||||
|
||||
def test_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 "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 "/admin" not in schema["paths"]
|
||||
assert "/records" not in schema["paths"]
|
||||
assert "/events" not in schema["paths"]
|
||||
assert any(tag["name"] == "hostnames" for tag in schema["tags"])
|
||||
assert all(tag["name"] != "admin" for tag in schema["tags"])
|
||||
|
||||
|
||||
def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.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 "generates the hostname, secret update URL, and optional router compatibility credentials automatically" 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",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "https://karldns.de/u/<secret>" in response.text
|
||||
assert "http://127.0.0.1" not in response.text
|
||||
assert "http://localhost" not 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)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<script src="/app.js" defer></script>' in response.text
|
||||
assert "script-src 'self'" in response.headers["content-security-policy"]
|
||||
assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
|
||||
assert client.get("/app.js").status_code == 200
|
||||
|
||||
|
||||
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Made with ❤️ by Karl" in response.text
|
||||
assert 'href="https://github.com/StasonJatham/router-dyndns"' in response.text
|
||||
assert 'href="https://karl.fail"' in response.text
|
||||
assert 'href="https://karlcom.de"' in response.text
|
||||
assert 'href="/privacy"' in response.text
|
||||
|
||||
privacy = client.get("/privacy")
|
||||
assert privacy.status_code == 200
|
||||
assert "Legal and privacy" in privacy.text
|
||||
assert "Data processed" in privacy.text
|
||||
assert "hashed router passwords" in privacy.text
|
||||
assert "Made with ❤️ by Karl" in privacy.text
|
||||
|
||||
|
||||
def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'href="/favicon.ico"' in response.text
|
||||
assert 'href="/favicon-32x32.png"' in response.text
|
||||
assert 'href="/favicon-16x16.png"' in response.text
|
||||
assert 'href="/apple-touch-icon.png"' in response.text
|
||||
assert 'href="/site.webmanifest"' in response.text
|
||||
for path in [
|
||||
"/favicon.ico",
|
||||
"/favicon-32x32.png",
|
||||
"/favicon-16x16.png",
|
||||
"/apple-touch-icon.png",
|
||||
"/logo-192.png",
|
||||
"/logo-512.png",
|
||||
]:
|
||||
asset = client.get(path)
|
||||
assert asset.status_code == 200
|
||||
assert asset.content
|
||||
manifest = client.get("/site.webmanifest")
|
||||
assert manifest.status_code == 200
|
||||
assert manifest.json()["icons"] == [
|
||||
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"},
|
||||
]
|
||||
|
||||
|
||||
def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
trusted_hosts={"ddns.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/", headers={"host": "evil.example.net"})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_magic_hostname_lifecycle(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)
|
||||
|
||||
created = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
assert created.status_code == 201
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
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/")
|
||||
|
||||
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"]
|
||||
|
||||
deleted = client.delete(f"/api/v1/management/{management_slug}")
|
||||
assert deleted.status_code == 204
|
||||
assert client.get(f"/api/v1/management/{management_slug}").status_code == 404
|
||||
|
||||
|
||||
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))
|
||||
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"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "good", "hostname": "home.ddns.example.net", "ip": "203.0.113.11"}
|
||||
assert len(published) == 1
|
||||
assert store.list_records()[0]["ipv4"] == "203.0.113.11"
|
||||
|
||||
|
||||
def test_api_dns_publish_failure_keeps_record_unchanged(tmp_path: Path, monkeypatch) -> None:
|
||||
def fail_publish(result, settings):
|
||||
raise RuntimeError("dns down")
|
||||
|
||||
monkeypatch.setattr(ddns_service, "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"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
assert store.list_update_events()[0]["status"] == "dns_failed"
|
||||
|
||||
|
||||
def test_api_custom_hostname_requires_verified_domain(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
||||
assert challenge.status_code == 201
|
||||
challenge_body = challenge.json()
|
||||
|
||||
verified = client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge_body["claim_secret"]},
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert verified.json()["verified"] is True
|
||||
|
||||
hostname = client.post(
|
||||
"/api/v1/hostnames/custom",
|
||||
json={
|
||||
"hostname": "home.example.net",
|
||||
"claim_secret": challenge_body["claim_secret"],
|
||||
"username": "router",
|
||||
},
|
||||
)
|
||||
|
||||
assert hostname.status_code == 201
|
||||
assert hostname.json()["hostname"] == "home.example.net"
|
||||
|
||||
|
||||
def test_api_creation_is_rate_limited(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
second = client.post("/api/v1/hostnames/magic", json={"username": "router"})
|
||||
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
def test_secret_html_responses_are_not_cached(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/magic", data={"username": "router"})
|
||||
|
||||
assert created.status_code == 200
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
assert created.headers["pragma"] == "no-cache"
|
||||
|
||||
|
||||
def test_oversized_request_body_is_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
max_request_body_bytes=1024,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/magic", data={"username": "x" * 2000})
|
||||
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
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"})
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
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))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
store.upsert("home.example.net", "203.0.113.8", "2001:db8::8")
|
||||
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"})
|
||||
|
||||
assert response.status_code == 200
|
||||
record = store.list_records()[0]
|
||||
assert record["ipv4"] == "203.0.113.9"
|
||||
assert record["ipv6"] == "2001:db8::8"
|
||||
assert published[0].update_ipv4 is True
|
||||
assert published[0].update_ipv6 is False
|
||||
|
||||
|
||||
def test_verified_domain_challenge_cannot_be_overwritten(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||
verified = client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
overwrite = client.post("/api/v1/domains/challenges", json={"domain": "example.net"})
|
||||
|
||||
assert verified.status_code == 200
|
||||
assert overwrite.status_code == 409
|
||||
|
||||
|
||||
def test_custom_hostname_must_be_inside_publishable_dns_zone(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
dns_zones={"ddns.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/api/v1/domains/challenges", json={"domain": "example.net"}).json()
|
||||
client.post(
|
||||
"/api/v1/domains/verify",
|
||||
json={"domain": "example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
hostname = client.post(
|
||||
"/api/v1/hostnames/custom",
|
||||
json={"hostname": "home.example.net", "claim_secret": challenge["claim_secret"]},
|
||||
)
|
||||
|
||||
assert hostname.status_code == 403
|
||||
assert hostname.json()["detail"] == "hostname is outside configured DNS publishing zones"
|
||||
|
||||
|
||||
def test_require_dns_provider_fails_startup_without_backend(tmp_path: Path) -> None:
|
||||
try:
|
||||
make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
require_dns_provider=True,
|
||||
)
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
assert "no DNS backend" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected startup failure")
|
||||
|
||||
|
||||
def test_launch_mode_requires_strong_admin_password(tmp_path: Path) -> None:
|
||||
try:
|
||||
make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
require_dns_provider=True,
|
||||
)
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
assert "DDNS_ADMIN_PASSWORD" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected startup failure")
|
||||
|
||||
|
||||
def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
cleanup_challenge_hours=1,
|
||||
cleanup_unused_account_hours=1,
|
||||
cleanup_interval_seconds=1,
|
||||
)
|
||||
)
|
||||
store = DdnsStore(database_path)
|
||||
|
||||
with TestClient(app):
|
||||
abandoned_claim = store.create_domain_challenge("abandoned.example.net")
|
||||
verified_claim = store.create_domain_challenge("verified.example.net")
|
||||
abandoned_account = store.create_account("abandoned.ddns.example.net", "router")
|
||||
active_account = store.create_account("active.ddns.example.net", "router")
|
||||
store.upsert("active.ddns.example.net", "203.0.113.10", None)
|
||||
|
||||
old = "2000-01-01T00:00:00+00:00"
|
||||
with store.connect() as conn:
|
||||
conn.execute("UPDATE domain_challenges SET created_at = ? WHERE domain = ?", (old, abandoned_claim["domain"]))
|
||||
conn.execute(
|
||||
"UPDATE domain_challenges SET created_at = ?, verified_at = ? WHERE domain = ?",
|
||||
(old, old, verified_claim["domain"]),
|
||||
)
|
||||
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, abandoned_account["hostname"]))
|
||||
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, active_account["hostname"]))
|
||||
|
||||
time.sleep(1.3)
|
||||
|
||||
assert store.get_domain_challenge("abandoned.example.net") is None
|
||||
assert store.get_domain_challenge("verified.example.net") is not None
|
||||
assert store.get_account_by_slug(abandoned_account["update_slug"]) is None
|
||||
assert store.get_account_by_slug(active_account["update_slug"]) is not None
|
||||
@@ -0,0 +1,261 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
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),
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_rate_limit_flush_persists_batched_counts(tmp_path: Path) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
|
||||
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
|
||||
assert store.allow_rate_limit("198.51.100.1:/api", 60) is True
|
||||
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:/api", window),
|
||||
).fetchone()[0]
|
||||
assert count == 2
|
||||
|
||||
|
||||
def test_cleanup_deletes_old_rate_limit_minute_buckets(tmp_path: Path) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
current_window = int(time.time()) // 60
|
||||
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO rate_limits(key, window, count) VALUES (?, ?, ?)",
|
||||
("old", current_window - 61, 1),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO rate_limits(key, window, count) VALUES (?, ?, ?)",
|
||||
("recent", current_window - 30, 1),
|
||||
)
|
||||
|
||||
store.cleanup(challenge_hours=1, unused_account_hours=1)
|
||||
|
||||
with store.connect() as conn:
|
||||
keys = {row[0] for row in conn.execute("SELECT key FROM rate_limits")}
|
||||
assert keys == {"recent"}
|
||||
|
||||
|
||||
def test_scheduled_cleanup_task_is_durable_and_lease_protected(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
first = DdnsStore(database_path)
|
||||
first.ensure_scheduled_task("cleanup", 3600)
|
||||
|
||||
second = DdnsStore(database_path)
|
||||
task = second.list_scheduled_tasks()[0]
|
||||
assert task["name"] == "cleanup"
|
||||
assert task["interval_seconds"] == 3600
|
||||
assert task["last_status"] == "pending"
|
||||
|
||||
assert first.acquire_due_task("cleanup", "worker-a", 60) is True
|
||||
assert second.acquire_due_task("cleanup", "worker-b", 60) is False
|
||||
|
||||
first.complete_scheduled_task("cleanup", 3600, True)
|
||||
completed = second.list_scheduled_tasks()[0]
|
||||
assert completed["lease_owner"] is None
|
||||
assert completed["lease_expires_at"] is None
|
||||
assert completed["last_status"] == "ok"
|
||||
assert completed["last_finished_at"]
|
||||
assert completed["next_run_at"] > str(task["next_run_at"])
|
||||
|
||||
|
||||
def test_first_run_creates_schema_and_repeated_startup_is_idempotent(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "nested" / "ddns.sqlite3"
|
||||
settings = DdnsSettings(database_path=database_path, admin_password="admin")
|
||||
|
||||
make_app(settings)
|
||||
make_app(settings)
|
||||
|
||||
store = DdnsStore(database_path)
|
||||
with store.connect() as conn:
|
||||
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
||||
assert {
|
||||
"accounts",
|
||||
"cleanup_runs",
|
||||
"domain_challenges",
|
||||
"rate_limits",
|
||||
"records",
|
||||
"scheduled_tasks",
|
||||
"update_events",
|
||||
}.issubset(tables)
|
||||
assert len(store.list_scheduled_tasks()) == 1
|
||||
assert store.list_scheduled_tasks()[0]["name"] == "cleanup"
|
||||
|
||||
|
||||
def test_sqlite_startup_tuning_is_idempotent(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
|
||||
DdnsStore(database_path)
|
||||
store = DdnsStore(database_path)
|
||||
store.optimize()
|
||||
store.checkpoint_wal()
|
||||
|
||||
with store.connect() as conn:
|
||||
busy_timeout = conn.execute("PRAGMA busy_timeout").fetchone()[0]
|
||||
journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
synchronous = conn.execute("PRAGMA synchronous").fetchone()[0]
|
||||
cache_size = conn.execute("PRAGMA cache_size").fetchone()[0]
|
||||
temp_store = conn.execute("PRAGMA temp_store").fetchone()[0]
|
||||
mmap_size = conn.execute("PRAGMA mmap_size").fetchone()[0]
|
||||
user_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
index_names = {
|
||||
row[1]
|
||||
for row in conn.execute(
|
||||
"""
|
||||
SELECT type, name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
assert busy_timeout == 10_000
|
||||
assert journal_mode == "wal"
|
||||
assert synchronous == 1
|
||||
assert cache_size == -20_000
|
||||
assert temp_store == 2
|
||||
assert mmap_size == 256 * 1024 * 1024
|
||||
assert user_version == 1
|
||||
assert {
|
||||
"idx_accounts_created_at",
|
||||
"idx_domain_challenges_claim_hash",
|
||||
"idx_domain_challenges_unverified_created",
|
||||
"idx_rate_limits_window",
|
||||
"idx_update_events_hostname_created_at",
|
||||
"idx_update_event_rollups_hostname_last_seen_at",
|
||||
}.issubset(index_names)
|
||||
|
||||
|
||||
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
|
||||
|
||||
original_connect = store.connect
|
||||
|
||||
@contextmanager
|
||||
def fail_connect():
|
||||
raise RuntimeError("database temporarily unavailable")
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(store, "connect", fail_connect)
|
||||
with pytest.raises(RuntimeError, match="database temporarily unavailable"):
|
||||
store.flush_rate_limits()
|
||||
|
||||
monkeypatch.setattr(store, "connect", original_connect)
|
||||
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:/api", window),
|
||||
).fetchone()[0]
|
||||
assert count == 1
|
||||
|
||||
|
||||
def test_cleanup_prunes_old_raw_update_events(tmp_path: Path) -> None:
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
store.log_update_event("old.example.net", "203.0.113.1", None, "updated", "old", "198.51.100.1")
|
||||
store.log_update_event("new.example.net", "203.0.113.2", None, "updated", "new", "198.51.100.2")
|
||||
|
||||
with store.connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE update_events SET created_at = ? WHERE hostname = ?",
|
||||
("2000-01-01T00:00:00+00:00", "old.example.net"),
|
||||
)
|
||||
|
||||
result = store.cleanup(challenge_hours=1, unused_account_hours=1, event_history_hours=24)
|
||||
|
||||
assert result["update_events"] == 1
|
||||
events = store.list_update_events()
|
||||
assert [event["hostname"] for event in events] == ["new.example.net"]
|
||||
|
||||
|
||||
def test_background_cleanup_removes_abandoned_setup_flows(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
cleanup_challenge_hours=1,
|
||||
cleanup_unused_account_hours=1,
|
||||
cleanup_interval_seconds=1,
|
||||
)
|
||||
)
|
||||
store = DdnsStore(database_path)
|
||||
|
||||
with TestClient(app):
|
||||
abandoned_claim = store.create_domain_challenge("abandoned.example.net")
|
||||
verified_claim = store.create_domain_challenge("verified.example.net")
|
||||
abandoned_account = store.create_account("abandoned.ddns.example.net", "router")
|
||||
active_account = store.create_account("active.ddns.example.net", "router")
|
||||
store.upsert("active.ddns.example.net", "203.0.113.10", None)
|
||||
|
||||
old = "2000-01-01T00:00:00+00:00"
|
||||
with store.connect() as conn:
|
||||
conn.execute("UPDATE domain_challenges SET created_at = ? WHERE domain = ?", (old, abandoned_claim["domain"]))
|
||||
conn.execute(
|
||||
"UPDATE domain_challenges SET created_at = ?, verified_at = ? WHERE domain = ?",
|
||||
(old, old, verified_claim["domain"]),
|
||||
)
|
||||
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, abandoned_account["hostname"]))
|
||||
conn.execute("UPDATE accounts SET created_at = ? WHERE hostname = ?", (old, active_account["hostname"]))
|
||||
|
||||
time.sleep(1.3)
|
||||
|
||||
assert store.get_domain_challenge("abandoned.example.net") is None
|
||||
assert store.get_domain_challenge("verified.example.net") is not None
|
||||
assert store.get_account_by_slug(abandoned_account["update_slug"]) is None
|
||||
assert store.get_account_by_slug(active_account["update_slug"]) is not None
|
||||
scheduled_task = store.list_scheduled_tasks()[0]
|
||||
assert scheduled_task["name"] == "cleanup"
|
||||
assert scheduled_task["last_status"] == "ok"
|
||||
assert scheduled_task["last_finished_at"]
|
||||
|
||||
|
||||
def test_scheduled_cleanup_failure_is_logged(tmp_path: Path, monkeypatch, caplog) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
settings = DdnsSettings(database_path=database_path, admin_password="admin", cleanup_interval_seconds=60)
|
||||
store = DdnsStore(database_path)
|
||||
store.ensure_scheduled_task(app_module.CLEANUP_TASK_NAME, settings.cleanup_interval_seconds)
|
||||
|
||||
def fail_cleanup(challenge_hours, unused_account_hours, event_history_hours):
|
||||
raise RuntimeError("cleanup exploded")
|
||||
|
||||
caplog.set_level(logging.ERROR, logger="router_dyndns.app")
|
||||
monkeypatch.setattr(store, "cleanup", fail_cleanup)
|
||||
|
||||
asyncio.run(app_module._run_cleanup_if_due(store, settings))
|
||||
|
||||
scheduled_task = store.list_scheduled_tasks()[0]
|
||||
assert scheduled_task["last_status"] == "error"
|
||||
assert "RuntimeError: cleanup exploded" in str(scheduled_task["last_error"])
|
||||
assert "Scheduled cleanup failed" in caplog.text
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from router_dyndns.ddns import DdnsSettings, make_app
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
|
||||
|
||||
def run_script(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
|
||||
merged_env = os.environ.copy()
|
||||
if env:
|
||||
merged_env.update(env)
|
||||
return subprocess.run(
|
||||
args,
|
||||
check=True,
|
||||
cwd=ROOT,
|
||||
env=merged_env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def test_shell_scripts_parse() -> None:
|
||||
for script in ("install-linux.sh", "karldns-update.sh", "uninstall-linux.sh"):
|
||||
run_script("sh", "-n", str(ROOT / "scripts" / script))
|
||||
|
||||
|
||||
def test_updater_strips_fritzbox_placeholders_and_caches_ip(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="https://karldns.de",
|
||||
hostname_suffix="karldns.de",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
account = client.post("/api/v1/hostnames/magic", json={}).json()
|
||||
update_url = account["update_url"]
|
||||
script = tmp_path / "curl"
|
||||
log_file = tmp_path / "curl.log"
|
||||
cache_file = tmp_path / "last-ip"
|
||||
lock_file = tmp_path / "updater.lock"
|
||||
|
||||
script.write_text(
|
||||
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 ;;
|
||||
esac
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
script.chmod(0o700)
|
||||
|
||||
result = run_script(
|
||||
"sh",
|
||||
str(ROOT / "scripts" / "karldns-update.sh"),
|
||||
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_IPV6": "off",
|
||||
},
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,321 @@
|
||||
import logging
|
||||
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, make_app
|
||||
from router_dyndns.ddns_dns import validate_dns_provider_settings
|
||||
|
||||
|
||||
def test_generated_hostname_is_not_an_update_secret(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
store.create_account("public-target.ddns.example.net", "alice")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "public-target.ddns.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
def test_generated_account_cannot_update_other_hostname(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": "other.example.net",
|
||||
"myip": "203.0.113.9",
|
||||
"username": account["username"],
|
||||
"password": account["password"],
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_shared_secret_without_allowed_hosts_cannot_update_arbitrary_hostname(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
app = make_app(DdnsSettings(database_path=database_path, shared_secret="secret", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "other.example.net", "myip": "203.0.113.9", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.text == "badauth"
|
||||
assert store.list_records() == []
|
||||
|
||||
|
||||
def test_bearer_link_routes_share_rate_limit_bucket(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
first = client.get("/m/not-a-real-token")
|
||||
second = client.get("/m/another-token")
|
||||
|
||||
assert first.status_code == 404
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
def test_trusted_host_middleware_rejects_unlisted_host(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
trusted_hosts={"ddns.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/", headers={"host": "evil.example.net"})
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_secret_html_responses_are_not_cached(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post("/magic", data={"username": "router"})
|
||||
|
||||
assert created.status_code == 200
|
||||
assert created.headers["cache-control"] == "no-store"
|
||||
assert created.headers["pragma"] == "no-cache"
|
||||
|
||||
|
||||
def test_oversized_request_body_is_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
max_request_body_bytes=1024,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/magic", data={"username": "x" * 2000})
|
||||
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_invalid_utf8_form_body_is_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/magic",
|
||||
content=b"username=\xff",
|
||||
headers={"content-type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "form body must be valid UTF-8"
|
||||
|
||||
|
||||
def test_too_many_form_fields_are_rejected(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
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"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"] == "form must not contain more than 100 fields"
|
||||
|
||||
|
||||
def test_untrusted_forwarded_for_does_not_bypass_rate_limit(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
hostname_suffix="ddns.example.net",
|
||||
rate_limit_per_minute=1,
|
||||
)
|
||||
)
|
||||
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"})
|
||||
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 429
|
||||
|
||||
|
||||
def test_dns_delete_failure_is_logged_without_secret_management_slug(tmp_path: Path, monkeypatch, caplog) -> None:
|
||||
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)
|
||||
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"))
|
||||
|
||||
with pytest.raises(DnsProviderError):
|
||||
app.state.service.delete_account("home.example.net", "unit-test")
|
||||
|
||||
assert store.get_account_by_slug(account["update_slug"]) is not None
|
||||
assert store.list_update_events()[0]["status"] == "dns_delete_failed"
|
||||
assert "DNS delete failed for hostname=home.example.net source=unit-test" in caplog.text
|
||||
assert account["management_slug"] not in caplog.text
|
||||
assert "dns down" not in caplog.text
|
||||
|
||||
|
||||
def test_require_dns_provider_fails_startup_without_backend(tmp_path: Path) -> None:
|
||||
try:
|
||||
make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
require_dns_provider=True,
|
||||
)
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
assert "no DNS backend" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected startup failure")
|
||||
|
||||
|
||||
def test_invalid_dns_provider_is_rejected() -> None:
|
||||
settings = DdnsSettings(dns_provider="route53")
|
||||
|
||||
with pytest.raises(RuntimeError, match="Unsupported DDNS_DNS_PROVIDER"):
|
||||
validate_dns_provider_settings(settings)
|
||||
|
||||
|
||||
def test_required_cloudflare_provider_requires_token_and_zone(tmp_path: Path) -> None:
|
||||
with pytest.raises(RuntimeError, match="DDNS_CLOUDFLARE_API_TOKEN"):
|
||||
make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="a" * 16,
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
dns_provider="cloudflare",
|
||||
require_dns_provider=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_required_rfc2136_provider_requires_server_and_zone(tmp_path: Path) -> None:
|
||||
with pytest.raises(RuntimeError, match="DDNS_RFC2136_SERVER and DDNS_RFC2136_ZONE"):
|
||||
make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="a" * 16,
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
dns_provider="rfc2136",
|
||||
rfc2136_server="192.0.2.53",
|
||||
require_dns_provider=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_rfc2136_delete_checks_response_code(monkeypatch) -> None:
|
||||
import dns.query
|
||||
import dns.rcode
|
||||
|
||||
from router_dyndns.dns.rfc2136 import delete_rfc2136_records
|
||||
|
||||
class RefusedResponse:
|
||||
def rcode(self) -> int:
|
||||
return dns.rcode.REFUSED
|
||||
|
||||
monkeypatch.setattr(dns.query, "tcp", lambda *args, **kwargs: RefusedResponse())
|
||||
|
||||
with pytest.raises(DnsProviderError, match="RFC2136 delete failed"):
|
||||
delete_rfc2136_records(
|
||||
"home.example.net",
|
||||
DdnsSettings(rfc2136_server="192.0.2.53", rfc2136_zone="example.net"),
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_integer_env_value_fails_startup(monkeypatch) -> None:
|
||||
monkeypatch.setenv("DDNS_RATE_LIMIT_PER_MINUTE", "not-an-int")
|
||||
|
||||
with pytest.raises(ValueError, match="DDNS_RATE_LIMIT_PER_MINUTE must be an integer"):
|
||||
DdnsSettings.from_env()
|
||||
|
||||
|
||||
def test_invalid_admin_path_fails_startup() -> None:
|
||||
with pytest.raises(ValueError, match="admin_path"):
|
||||
DdnsSettings(admin_path="admin")
|
||||
|
||||
with pytest.raises(ValueError, match="admin_path"):
|
||||
DdnsSettings(admin_path="/")
|
||||
|
||||
with pytest.raises(ValueError, match="admin_path"):
|
||||
DdnsSettings(admin_path="/admin?")
|
||||
|
||||
with pytest.raises(ValueError, match="public route"):
|
||||
DdnsSettings(admin_path="/api")
|
||||
|
||||
|
||||
def test_launch_mode_requires_strong_admin_password(tmp_path: Path) -> None:
|
||||
try:
|
||||
make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
hostname_suffix="ddns.example.net",
|
||||
dns_provider="cloudflare",
|
||||
cloudflare_api_token="token",
|
||||
cloudflare_zone_id="zone",
|
||||
require_dns_provider=True,
|
||||
)
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
assert "DDNS_ADMIN_PASSWORD" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected startup failure")
|
||||
@@ -0,0 +1,683 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import router_dyndns.ddns_service as ddns_service
|
||||
from router_dyndns.ddns import DdnsSettings, DdnsStore, make_app
|
||||
|
||||
|
||||
def _csrf(html: str) -> str:
|
||||
match = re.search(r'name="csrf" value="([^"]+)"', html)
|
||||
assert match
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def test_self_service_managed_hostname_generates_random_account(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)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "managed", "username": "alice"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert "One-box update URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Copy complete FRITZ!Box fields" in response.text
|
||||
assert "Copy CNAME record" in response.text
|
||||
assert "Manual updates" in response.text
|
||||
assert "curl -sS" in response.text
|
||||
assert "203.0.113.10" in response.text
|
||||
assert "2001:db8::10" in response.text
|
||||
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 "/m/" in response.text
|
||||
|
||||
|
||||
def test_magic_hostname_without_login_generates_update_and_management_links(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)
|
||||
|
||||
response = client.post("/magic")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Update-URL:" in response.text
|
||||
assert "One-box update URL:" in response.text
|
||||
assert "CNAME target:" in response.text
|
||||
assert "Benutzername (optional):" in response.text
|
||||
assert "Kennwort (optional):" in response.text
|
||||
assert "Private status page:" in response.text
|
||||
assert "Copy complete FRITZ!Box fields" in response.text
|
||||
assert "Copy CNAME record" in response.text
|
||||
assert "FRITZ!Box replaces" in response.text
|
||||
assert "Copy curl examples" in response.text
|
||||
assert response.text.count("Secret") >= 3
|
||||
assert response.text.count("Public") >= 3
|
||||
assert ".ddns.example.net" in response.text
|
||||
|
||||
|
||||
def test_magic_get_redirects_to_home_form(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"
|
||||
|
||||
|
||||
def test_magic_management_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")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
management = client.get(f"/m/{account['management_slug']}")
|
||||
assert management.status_code == 200
|
||||
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 "Copy complete FRITZ!Box fields" in management.text
|
||||
assert "Copy CNAME record" in management.text
|
||||
assert "Manual updates" in management.text
|
||||
assert "curl -sS" in management.text
|
||||
assert "Public:" in management.text
|
||||
assert "Secret:" in management.text
|
||||
assert "Router updates" in management.text
|
||||
|
||||
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"})
|
||||
assert update.status_code == 404
|
||||
|
||||
|
||||
def test_management_dashboard_password_locks_dashboard_only(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "home")
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
dashboard = client.get(f"/m/{account['management_slug']}")
|
||||
set_password = client.post(
|
||||
f"/m/{account['management_slug']}/password",
|
||||
data={
|
||||
"csrf": _csrf(dashboard.text),
|
||||
"dashboard_password": "correct horse",
|
||||
"dashboard_password_confirm": "correct horse",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert set_password.status_code == 303
|
||||
assert "httponly" in set_password.headers["set-cookie"].lower()
|
||||
assert "samesite=lax" in set_password.headers["set-cookie"].lower()
|
||||
assert "secure" not in set_password.headers["set-cookie"].lower()
|
||||
|
||||
stored = app.state.store.get_account_by_management_slug(account["management_slug"])
|
||||
assert stored["dashboard_password_hash"].startswith("pbkdf2_sha256$")
|
||||
assert stored["dashboard_password_hash"] != "correct horse"
|
||||
|
||||
locked_client = TestClient(app)
|
||||
locked = locked_client.get(f"/m/{account['management_slug']}")
|
||||
assert locked.status_code == 200
|
||||
assert "Dashboard locked" in locked.text
|
||||
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"})
|
||||
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"},
|
||||
)
|
||||
assert bad_unlock.status_code == 401
|
||||
assert "Incorrect dashboard password." in bad_unlock.text
|
||||
assert "Update-URL:" not in bad_unlock.text
|
||||
|
||||
good_unlock = locked_client.post(
|
||||
f"/m/{account['management_slug']}/unlock",
|
||||
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert good_unlock.status_code == 303
|
||||
assert "max-age=86400" in good_unlock.headers["set-cookie"].lower()
|
||||
unlocked = locked_client.get(f"/m/{account['management_slug']}")
|
||||
assert "Update-URL:" in unlocked.text
|
||||
assert "Password protection" in unlocked.text
|
||||
|
||||
|
||||
def test_management_dashboard_password_cookie_is_secure_on_https_public_url(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "home")
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
public_base_url="https://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
dashboard = client.get(f"/m/{account['management_slug']}")
|
||||
response = client.post(
|
||||
f"/m/{account['management_slug']}/password",
|
||||
data={
|
||||
"csrf": _csrf(dashboard.text),
|
||||
"dashboard_password": "correct horse",
|
||||
"dashboard_password_confirm": "correct horse",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert "secure" in response.headers["set-cookie"].lower()
|
||||
|
||||
|
||||
def test_management_dashboard_password_can_be_removed(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "home")
|
||||
store.set_dashboard_password("home.ddns.example.net", "correct horse")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
unlock = client.post(
|
||||
f"/m/{account['management_slug']}/unlock",
|
||||
data={"csrf": _csrf(client.get(f"/m/{account['management_slug']}").text), "dashboard_password": "correct horse"},
|
||||
)
|
||||
assert unlock.status_code == 200
|
||||
|
||||
remove = client.post(
|
||||
f"/m/{account['management_slug']}/password/remove",
|
||||
data={"csrf": _csrf(unlock.text), "current_dashboard_password": "correct horse"},
|
||||
)
|
||||
assert remove.status_code == 200
|
||||
assert "Not enabled" in remove.text
|
||||
|
||||
fresh_client = TestClient(app)
|
||||
unlocked = fresh_client.get(f"/m/{account['management_slug']}")
|
||||
assert unlocked.status_code == 200
|
||||
assert "Update-URL:" in unlocked.text
|
||||
|
||||
|
||||
def test_dashboard_stay_unlocked_cookie_uses_longer_lifetime(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "home")
|
||||
store.set_dashboard_password("home.ddns.example.net", "correct horse")
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
dashboard_unlock_seconds=3600,
|
||||
dashboard_stay_unlocked_seconds=30 * 24 * 60 * 60,
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
locked = client.get(f"/m/{account['management_slug']}")
|
||||
|
||||
response = client.post(
|
||||
f"/m/{account['management_slug']}/unlock",
|
||||
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse", "stay_unlocked": "1"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert "max-age=2592000" in response.headers["set-cookie"].lower()
|
||||
|
||||
|
||||
def test_unlocked_dashboard_posts_require_csrf(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "home")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert store.get_account_by_hostname("home.ddns.example.net")["update_slug"] == account["update_slug"]
|
||||
|
||||
|
||||
def test_existing_dashboard_password_requires_current_password_to_change_or_remove(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "home")
|
||||
store.set_dashboard_password("home.ddns.example.net", "correct horse")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
locked = client.get(f"/m/{account['management_slug']}")
|
||||
unlocked = client.post(
|
||||
f"/m/{account['management_slug']}/unlock",
|
||||
data={"csrf": _csrf(locked.text), "dashboard_password": "correct horse"},
|
||||
)
|
||||
|
||||
change = client.post(
|
||||
f"/m/{account['management_slug']}/password",
|
||||
data={
|
||||
"csrf": _csrf(unlocked.text),
|
||||
"current_dashboard_password": "wrong",
|
||||
"dashboard_password": "new password",
|
||||
"dashboard_password_confirm": "new password",
|
||||
},
|
||||
)
|
||||
remove = client.post(
|
||||
f"/m/{account['management_slug']}/password/remove",
|
||||
data={"csrf": _csrf(unlocked.text), "current_dashboard_password": "wrong"},
|
||||
)
|
||||
|
||||
assert change.status_code == 400
|
||||
assert remove.status_code == 400
|
||||
assert "Current dashboard password is incorrect." in change.text
|
||||
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:
|
||||
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",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
dashboard = client.get(f"/m/{account['management_slug']}")
|
||||
assert dashboard.status_code == 200
|
||||
assert "Rotate Update-URL" in dashboard.text
|
||||
assert "Rotate router credentials" in dashboard.text
|
||||
assert "data-secret-toggle" in dashboard.text
|
||||
assert 'type="password"' in dashboard.text
|
||||
|
||||
rotated_update = client.post(f"/m/{account['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"})
|
||||
assert rotated_update.status_code == 200
|
||||
assert "Update-URL rotated." 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 credentials rotated" in rotated_password.text
|
||||
assert "One-box 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
|
||||
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
|
||||
|
||||
|
||||
def test_management_dashboard_actions_are_scoped_to_slug_owner(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
first = store.create_account("first.ddns.example.net", "first")
|
||||
second = store.create_account("second.ddns.example.net", "second")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
dashboard = client.get(f"/m/{first['management_slug']}")
|
||||
rotated = client.post(f"/m/{first['management_slug']}/rotate", data={"csrf": _csrf(dashboard.text), "action": "update"})
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_locked_dashboard_rejects_mutating_actions_without_unlock_cookie(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("locked.ddns.example.net", "router")
|
||||
store.set_dashboard_password("locked.ddns.example.net", "correct horse")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
rotate = client.post(f"/m/{account['management_slug']}/rotate", data={"action": "update"})
|
||||
delete = client.post(f"/m/{account['management_slug']}/delete")
|
||||
claim = client.post(f"/m/{account['management_slug']}/domains/request", data={"domain": "example.net"})
|
||||
|
||||
assert rotate.status_code == 401
|
||||
assert delete.status_code == 401
|
||||
assert claim.status_code == 401
|
||||
assert store.get_account_by_hostname("locked.ddns.example.net")["update_slug"] == account["update_slug"]
|
||||
assert store.get_account_by_slug(account["update_slug"]) is not None
|
||||
assert store.get_domain_challenge("example.net") is None
|
||||
|
||||
|
||||
def test_management_page_shows_ip_history(tmp_path: Path) -> None:
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.ddns.example.net", "router")
|
||||
store.log_update_event("home.ddns.example.net", "203.0.113.9", None, "updated", "ok", "198.51.100.1")
|
||||
store.log_update_event("home.ddns.example.net", "203.0.113.10", None, "updated", "ok", "198.51.100.1")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(f"/m/{account['management_slug']}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Router updates" in response.text
|
||||
assert "203.0.113.9" in response.text
|
||||
assert "203.0.113.10" in response.text
|
||||
|
||||
|
||||
def test_management_dashboard_custom_domain_claim_flow(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("base.ddns.example.net", "router")
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
dashboard = client.get(f"/m/{account['management_slug']}")
|
||||
challenge = client.post(f"/m/{account['management_slug']}/domains/request", data={"csrf": _csrf(dashboard.text), "domain": "example.net"})
|
||||
assert challenge.status_code == 200
|
||||
assert "Domain claim created" in challenge.text
|
||||
assert "TXT name" in challenge.text
|
||||
assert "TXT value" in challenge.text
|
||||
assert "Claim secret" in challenge.text
|
||||
assert "Private claim link" in challenge.text
|
||||
assert "This claim belongs to this dashboard" in challenge.text
|
||||
assert "data-secret-toggle" in challenge.text
|
||||
|
||||
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
|
||||
verified = client.post(
|
||||
f"/m/{account['management_slug']}/domains/verify",
|
||||
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
assert verified.status_code == 200
|
||||
assert "Domain verified" in verified.text
|
||||
assert "Create custom hostname" in verified.text
|
||||
|
||||
created = client.post(
|
||||
f"/m/{account['management_slug']}/domains/hostnames",
|
||||
data={"csrf": _csrf(verified.text), "hostname": "home.example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert "Custom hostname created" in created.text
|
||||
assert "home.example.net" in created.text
|
||||
assert "Update-URL:" in created.text
|
||||
assert "Private status page:" in created.text
|
||||
|
||||
|
||||
def test_dashboard_domain_claims_are_bound_to_creating_dashboard(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
owner = store.create_account("owner.ddns.example.net", "owner")
|
||||
other = store.create_account("other.ddns.example.net", "other")
|
||||
app = make_app(DdnsSettings(database_path=database_path, admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
owner_dashboard = client.get(f"/m/{owner['management_slug']}")
|
||||
challenge = client.post(f"/m/{owner['management_slug']}/domains/request", data={"csrf": _csrf(owner_dashboard.text), "domain": "example.net"})
|
||||
assert challenge.status_code == 200
|
||||
claim_secret = re.search(r'data-copy="([^"]+)" aria-label="Copy Claim secret"', challenge.text).group(1)
|
||||
|
||||
verify_other = client.post(
|
||||
f"/m/{other['management_slug']}/domains/verify",
|
||||
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "domain": "example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
create_other = client.post(
|
||||
f"/m/{other['management_slug']}/domains/hostnames",
|
||||
data={"csrf": _csrf(client.get(f"/m/{other['management_slug']}").text), "hostname": "home.example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
verify_owner = client.post(
|
||||
f"/m/{owner['management_slug']}/domains/verify",
|
||||
data={"csrf": _csrf(challenge.text), "domain": "example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
|
||||
assert verify_other.status_code == 404
|
||||
assert create_other.status_code == 403
|
||||
assert verify_owner.status_code == 200
|
||||
assert "Domain verified" in verify_owner.text
|
||||
assert store.get_account_by_hostname("home.example.net") is None
|
||||
|
||||
|
||||
def test_custom_hostname_requires_verified_parent_domain(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post("/accounts", data={"mode": "custom", "hostname": "home.example.net"})
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_custom_hostname_after_dns_verification(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
store = DdnsStore(tmp_path / "ddns.sqlite3")
|
||||
challenge_data = store.create_domain_challenge("example.net")
|
||||
verify = client.post(
|
||||
"/verify-domain",
|
||||
data={"domain": "example.net", "claim_secret": challenge_data["claim_secret"]},
|
||||
)
|
||||
assert verify.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
"/accounts",
|
||||
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": challenge_data["claim_secret"]},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "home.example.net" in response.text
|
||||
assert "Update-URL:" in response.text
|
||||
|
||||
|
||||
def test_custom_domain_flow_works_without_login(tmp_path: Path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(ddns_service, "dns_txt_contains", lambda name, expected: True)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
admin_password="admin",
|
||||
public_base_url="http://ddns.example.net",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
challenge = client.post("/request-domain", data={"domain": "example.net"})
|
||||
assert challenge.status_code == 200
|
||||
assert "Private claim link" in challenge.text
|
||||
assert "/d/" in challenge.text
|
||||
|
||||
claim_secret = re.search(r"/d/([A-Za-z0-9_-]+)", challenge.text).group(1)
|
||||
claim_page = client.get(f"/d/{claim_secret}")
|
||||
assert claim_page.status_code == 200
|
||||
assert "TXT value" in claim_page.text
|
||||
|
||||
verify = client.post("/verify-domain", data={"domain": "example.net", "claim_secret": claim_secret})
|
||||
assert verify.status_code == 200
|
||||
assert "Create credentials" in verify.text
|
||||
|
||||
created = client.post(
|
||||
"/accounts",
|
||||
data={"mode": "custom", "hostname": "home.example.net", "claim_secret": claim_secret},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert "home.example.net" in created.text
|
||||
assert "Update-URL:" in created.text
|
||||
|
||||
|
||||
def test_public_home_hides_admin_link_and_uses_automatic_router_login(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
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 "generates the hostname, secret update URL, and optional router compatibility credentials automatically" 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",
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "https://karldns.de/u/<secret>" in response.text
|
||||
assert "http://127.0.0.1" not in response.text
|
||||
assert "http://localhost" not 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)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert '<script src="/app.js" defer></script>' in response.text
|
||||
assert '<link href="/app.css" rel="stylesheet">' in response.text
|
||||
assert "<style>" not in response.text
|
||||
assert "script-src 'self'" in response.headers["content-security-policy"]
|
||||
assert "script-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
|
||||
assert "style-src 'self' https://cdn.jsdelivr.net" in response.headers["content-security-policy"]
|
||||
assert "style-src 'self' 'unsafe-inline'" not in response.headers["content-security-policy"]
|
||||
assert client.get("/app.js").status_code == 200
|
||||
app_css = client.get("/app.css")
|
||||
assert app_css.status_code == 200
|
||||
assert "text/css" in app_css.headers["content-type"]
|
||||
assert app_css.headers["cache-control"] == "public, max-age=3600"
|
||||
|
||||
|
||||
def test_footer_links_and_privacy_page_are_available(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Made by Karl" in response.text
|
||||
assert 'href="https://github.com/StasonJatham/karldns.de"' in response.text
|
||||
assert 'href="https://karl.fail"' in response.text
|
||||
assert 'href="https://karlcom.de"' in response.text
|
||||
assert 'href="/privacy"' in response.text
|
||||
|
||||
privacy = client.get("/privacy")
|
||||
assert privacy.status_code == 200
|
||||
assert "Legal and privacy" in privacy.text
|
||||
assert "Data processed" in privacy.text
|
||||
assert "hashed router passwords" in privacy.text
|
||||
assert "Made by Karl" in privacy.text
|
||||
|
||||
|
||||
def test_web_icon_assets_and_manifest_are_exposed(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", admin_password="admin"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert 'href="/favicon.ico"' in response.text
|
||||
assert 'href="/favicon-32x32.png"' in response.text
|
||||
assert 'href="/favicon-16x16.png"' in response.text
|
||||
assert 'href="/apple-touch-icon.png"' in response.text
|
||||
assert 'href="/site.webmanifest"' in response.text
|
||||
for path in [
|
||||
"/favicon.ico",
|
||||
"/favicon-32x32.png",
|
||||
"/favicon-16x16.png",
|
||||
"/apple-touch-icon.png",
|
||||
"/logo-192.png",
|
||||
"/logo-512.png",
|
||||
]:
|
||||
asset = client.get(path)
|
||||
assert asset.status_code == 200
|
||||
assert asset.content
|
||||
manifest = client.get("/site.webmanifest")
|
||||
assert manifest.status_code == 200
|
||||
assert manifest.json()["icons"] == [
|
||||
{"src": "/logo-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/logo-512.png", "sizes": "512x512", "type": "image/png"},
|
||||
]
|
||||
@@ -0,0 +1,321 @@
|
||||
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, make_app
|
||||
|
||||
|
||||
def test_update_accepts_fritzbox_placeholders(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
admin_password="admin",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={
|
||||
"hostname": "home.example.net",
|
||||
"myip": "203.0.113.8",
|
||||
"myipv6": "2001:db8::8",
|
||||
"password": "secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.8"
|
||||
records = client.get("/records", auth=("admin", "admin")).json()
|
||||
assert records["total"] == 1
|
||||
assert records["items"][0]["hostname"] == "home.example.net"
|
||||
assert records["items"][0]["ipv4"] == "203.0.113.8"
|
||||
assert records["items"][0]["ipv6"] == "2001:db8::8"
|
||||
|
||||
|
||||
def test_update_rejects_wrong_secret(tmp_path: Path) -> None:
|
||||
app = make_app(DdnsSettings(database_path=tmp_path / "ddns.sqlite3", shared_secret="secret"))
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "home.example.net", "myip": "203.0.113.8", "password": "wrong"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_rejects_unlisted_host(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "other.example.net", "myip": "203.0.113.8", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_generated_account_can_update_own_hostname(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"],
|
||||
},
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_one_box_update_url_can_authenticate_with_query_credentials(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)
|
||||
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()})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.11"
|
||||
assert response.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
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)
|
||||
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"})
|
||||
|
||||
assert response.status_code == 500
|
||||
assert store.list_records() == []
|
||||
events = store.list_update_events()
|
||||
assert events[0]["status"] == "dns_failed"
|
||||
assert "DNS publish failed for hostname=home.example.net" in caplog.text
|
||||
assert account["update_slug"] not in caplog.text
|
||||
assert "dns down" not in caplog.text
|
||||
|
||||
|
||||
def test_programming_error_during_publish_is_not_mapped_to_dns_failure(tmp_path: Path, monkeypatch) -> None:
|
||||
def fail_publish(result, settings):
|
||||
raise AssertionError("publish bug")
|
||||
|
||||
monkeypatch.setattr(ddns_service, "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)
|
||||
|
||||
with pytest.raises(AssertionError, match="publish bug"):
|
||||
client.get(f"/u/{account['update_slug']}", params={"myip": "203.0.113.9"})
|
||||
|
||||
assert store.list_records() == []
|
||||
assert store.list_update_events() == []
|
||||
|
||||
|
||||
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))
|
||||
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"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.9"
|
||||
assert len(published) == 1
|
||||
assert store.list_records()[0]["ipv4"] == "203.0.113.9"
|
||||
|
||||
|
||||
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))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
store.upsert("home.example.net", "203.0.113.9", None)
|
||||
before = store.list_records()[0]["updated_at"]
|
||||
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"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "nochg 203.0.113.9"
|
||||
assert second_response.status_code == 200
|
||||
assert store.list_records()[0]["updated_at"] == before
|
||||
events = store.list_update_events()
|
||||
assert events[0]["status"] == "unchanged"
|
||||
assert events[0]["count"] == 2
|
||||
assert published == []
|
||||
|
||||
|
||||
def test_shared_secret_still_updates_explicitly_allowed_hostname(tmp_path: Path) -> None:
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=tmp_path / "ddns.sqlite3",
|
||||
shared_secret="secret",
|
||||
admin_password="admin",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "home.example.net", "myip": "203.0.113.9", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 203.0.113.9"
|
||||
|
||||
|
||||
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))
|
||||
database_path = tmp_path / "ddns.sqlite3"
|
||||
store = DdnsStore(database_path)
|
||||
account = store.create_account("home.example.net", "alice")
|
||||
store.upsert("home.example.net", "203.0.113.8", "2001:db8::8")
|
||||
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"})
|
||||
|
||||
assert response.status_code == 200
|
||||
record = store.list_records()[0]
|
||||
assert record["ipv4"] == "203.0.113.9"
|
||||
assert record["ipv6"] == "2001:db8::8"
|
||||
assert published[0].update_ipv4 is True
|
||||
assert published[0].update_ipv6 is False
|
||||
|
||||
|
||||
def test_blank_ip_params_fall_back_to_source_ip(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, client=("198.51.100.44", 50000))
|
||||
|
||||
response = client.get(
|
||||
f"/u/{account['update_slug']}",
|
||||
params={"myip": "", "myipv6": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.text == "good 198.51.100.44"
|
||||
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:
|
||||
published = []
|
||||
monkeypatch.setattr(ddns_service, "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")
|
||||
store.upsert("home.example.net", "203.0.113.8", "2001:db8::8")
|
||||
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"})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.text == "badip"
|
||||
assert published == []
|
||||
record = store.list_records()[0]
|
||||
assert record["ipv4"] == "203.0.113.8"
|
||||
assert record["ipv6"] == "2001:db8::8"
|
||||
|
||||
|
||||
def test_wrong_family_legacy_ip_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")
|
||||
store.upsert("home.example.net", "203.0.113.8", "2001:db8::8")
|
||||
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"})
|
||||
|
||||
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)
|
||||
app = make_app(
|
||||
DdnsSettings(
|
||||
database_path=database_path,
|
||||
shared_secret="secret",
|
||||
admin_password="admin",
|
||||
allowed_hosts={"home.example.net"},
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get(
|
||||
"/nic/update",
|
||||
params={"hostname": "home.example.net", "myipv6": "203.0.113.9", "password": "secret"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.text == "badip"
|
||||
assert store.list_records() == []
|
||||
Reference in new issue
Block a user