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")