Archived
352 lines
14 KiB
Python
352 lines
14 KiB
Python
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"
|