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"] == [""] assert params["myipv6"] == [""] 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"