279 lines
9.7 KiB
Python
279 lines
9.7 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import app.integrations.cloudflare_client as cloudflare_module
|
|
from app.core.errors import ExternalServiceError, ValidationAppError
|
|
from app.integrations.cloudflare_client import CloudflareClient
|
|
|
|
|
|
class FakeHttpxClient:
|
|
responses: list[httpx.Response | Exception] = []
|
|
requests: list[tuple[str, str, dict[str, Any]]] = []
|
|
|
|
def __init__(self, **_kwargs: Any) -> None:
|
|
pass
|
|
|
|
def __enter__(self) -> FakeHttpxClient:
|
|
return self
|
|
|
|
def __exit__(self, *_args: object) -> None:
|
|
return None
|
|
|
|
def request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
self.requests.append((method, path, kwargs))
|
|
item = self.responses.pop(0)
|
|
if isinstance(item, Exception):
|
|
raise item
|
|
return item
|
|
|
|
|
|
def json_response(status: int, payload: dict[str, object]) -> httpx.Response:
|
|
return httpx.Response(
|
|
status,
|
|
json=payload,
|
|
request=httpx.Request("GET", "https://api.cloudflare.test/resource"),
|
|
)
|
|
|
|
|
|
def test_cloudflare_request_maps_success_and_upstream_failures(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(cloudflare_module.httpx, "Client", FakeHttpxClient)
|
|
FakeHttpxClient.requests = []
|
|
client = CloudflareClient(
|
|
token="token", zone_name="example.com", record_name="socks.example.com"
|
|
)
|
|
|
|
FakeHttpxClient.responses = [json_response(200, {"success": True, "result": {"id": "1"}})]
|
|
assert client._request("GET", "/ok") == {"id": "1"}
|
|
assert FakeHttpxClient.requests[-1][:2] == ("GET", "/ok")
|
|
|
|
FakeHttpxClient.responses = [
|
|
json_response(
|
|
403,
|
|
{"success": False, "errors": [{"code": 9109, "message": "invalid token"}]},
|
|
)
|
|
]
|
|
with pytest.raises(ExternalServiceError, match="invalid token") as api_error:
|
|
client._request("GET", "/denied")
|
|
assert api_error.value.code == "9109"
|
|
|
|
FakeHttpxClient.responses = [
|
|
httpx.Response(
|
|
200,
|
|
content=b"not-json",
|
|
request=httpx.Request("GET", "https://api.cloudflare.test/invalid"),
|
|
)
|
|
]
|
|
with pytest.raises(ExternalServiceError) as invalid_response:
|
|
client._request("GET", "/invalid")
|
|
assert invalid_response.value.code == "CLOUDFLARE_INVALID_RESPONSE"
|
|
|
|
FakeHttpxClient.responses = [
|
|
httpx.ConnectError(
|
|
"offline", request=httpx.Request("GET", "https://api.cloudflare.test/offline")
|
|
)
|
|
]
|
|
with pytest.raises(ExternalServiceError) as connection_error:
|
|
client._request("GET", "/offline")
|
|
assert connection_error.value.code == "CLOUDFLARE_CONNECTION_FAILED"
|
|
|
|
|
|
def test_cloudflare_dns_contract_rejects_conflicts_and_upserts_dns_only(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
records: list[dict[str, object]] = [
|
|
{
|
|
"id": "record-id",
|
|
"name": "socks.example.com",
|
|
"type": "A",
|
|
"content": "198.51.100.10",
|
|
"proxied": False,
|
|
"ttl": 60,
|
|
}
|
|
]
|
|
calls: list[tuple[str, str, dict[str, Any]]] = []
|
|
|
|
def fake_request(method: str, path: str, **kwargs: Any) -> object:
|
|
calls.append((method, path, kwargs))
|
|
if path == "/zones":
|
|
return [{"id": "resolved-zone", "name": "example.com"}]
|
|
if path == "/zones/resolved-zone":
|
|
return {"id": "resolved-zone", "name": "example.com"}
|
|
if path.endswith("/dns_records") and method == "GET":
|
|
return [*records, {"id": "ignored", "name": "other.example.com", "type": "A"}]
|
|
if "/dns_records/record-id" in path and method == "PATCH":
|
|
records[0] = {"id": "record-id", **kwargs["json"]}
|
|
return dict(records[0])
|
|
if path.endswith("/dns_records") and method == "POST":
|
|
created = {"id": "created-id", **kwargs["json"]}
|
|
records.append(created)
|
|
return created
|
|
raise AssertionError(f"unexpected Cloudflare request: {method} {path}")
|
|
|
|
client = CloudflareClient(
|
|
token="token", zone_name="example.com", record_name="socks.example.com"
|
|
)
|
|
monkeypatch.setattr(client, "_request", fake_request)
|
|
status = client.test_connection()
|
|
assert status["zone_id"] == "resolved-zone"
|
|
assert status["record_ip"] == "198.51.100.10"
|
|
assert status["proxied"] is False
|
|
assert status["expected_proxied"] is False
|
|
|
|
updated = client.upsert_a_record("198.51.100.20", expected_current="198.51.100.10")
|
|
assert updated["content"] == "198.51.100.20"
|
|
assert updated["proxied"] is False
|
|
assert updated["ttl"] == 60
|
|
assert any(method == "PATCH" for method, _path, _kwargs in calls)
|
|
|
|
with pytest.raises(ValidationAppError) as externally_modified:
|
|
client.upsert_a_record("198.51.100.30", expected_current="198.51.100.10")
|
|
assert externally_modified.value.code == "DNS_EXTERNALLY_MODIFIED"
|
|
|
|
with pytest.raises(ValidationAppError) as unexpectedly_present:
|
|
client.upsert_a_record("198.51.100.30", expected_current=None)
|
|
assert unexpectedly_present.value.code == "DNS_EXTERNALLY_MODIFIED"
|
|
|
|
unconstrained = client.upsert_a_record("198.51.100.30")
|
|
assert unconstrained["content"] == "198.51.100.30"
|
|
idempotent = client.upsert_a_record("198.51.100.30", expected_current=None)
|
|
assert idempotent["content"] == "198.51.100.30"
|
|
|
|
records.clear()
|
|
with pytest.raises(ValidationAppError) as externally_deleted:
|
|
client.upsert_a_record("198.51.100.40", expected_current="198.51.100.30")
|
|
assert externally_deleted.value.code == "DNS_EXTERNALLY_MODIFIED"
|
|
|
|
created = client.upsert_a_record("198.51.100.40", expected_current=None)
|
|
assert created["id"] == "created-id"
|
|
|
|
with pytest.raises(ValidationAppError) as conflict:
|
|
CloudflareClient._validate_records(
|
|
[{"type": "AAAA", "name": "socks.example.com", "content": "::1"}]
|
|
)
|
|
assert conflict.value.code == "CONFLICTING_DNS_RECORD"
|
|
with pytest.raises(ValidationAppError) as duplicates:
|
|
CloudflareClient._validate_records(
|
|
[
|
|
{"type": "A", "content": "198.51.100.1"},
|
|
{"type": "A", "content": "198.51.100.2"},
|
|
]
|
|
)
|
|
assert duplicates.value.code == "MULTIPLE_A_RECORDS"
|
|
assert any(method == "POST" for method, _path, _kwargs in calls)
|
|
|
|
|
|
def test_cloudflare_upsert_supports_proxied_records_with_automatic_ttl(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
record = {
|
|
"id": "record-id",
|
|
"name": "socks.example.com",
|
|
"type": "A",
|
|
"content": "198.51.100.10",
|
|
"proxied": False,
|
|
"ttl": 60,
|
|
}
|
|
written_bodies: list[dict[str, object]] = []
|
|
|
|
def fake_request(method: str, path: str, **kwargs: Any) -> object:
|
|
if path == "/zones/zone-id":
|
|
return {"id": "zone-id", "name": "example.com"}
|
|
if path.endswith("/dns_records") and method == "GET":
|
|
return [dict(record)]
|
|
if path.endswith("/dns_records/record-id") and method == "PATCH":
|
|
written_bodies.append(dict(kwargs["json"]))
|
|
written = {"id": "record-id", **kwargs["json"]}
|
|
record.update(written)
|
|
return written
|
|
raise AssertionError(f"unexpected Cloudflare request: {method} {path}")
|
|
|
|
client = CloudflareClient(
|
|
token="token",
|
|
zone_name="example.com",
|
|
zone_id="zone-id",
|
|
record_name="socks.example.com",
|
|
proxied=True,
|
|
)
|
|
monkeypatch.setattr(client, "_request", fake_request)
|
|
|
|
before = client.test_connection()
|
|
assert before["proxied"] is False
|
|
assert before["expected_proxied"] is True
|
|
|
|
updated = client.upsert_a_record(
|
|
"198.51.100.20",
|
|
expected_current="198.51.100.10",
|
|
)
|
|
|
|
assert written_bodies == [
|
|
{
|
|
"type": "A",
|
|
"name": "socks.example.com",
|
|
"content": "198.51.100.20",
|
|
"ttl": 1,
|
|
"proxied": True,
|
|
}
|
|
]
|
|
assert updated["content"] == "198.51.100.20"
|
|
assert updated["proxied"] is True
|
|
assert updated["ttl"] == 1
|
|
after = client.test_connection()
|
|
assert after["proxied"] is True
|
|
assert after["expected_proxied"] is True
|
|
assert after["ttl"] == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("mutated_field", "mutated_value"),
|
|
[
|
|
("content", "198.51.100.99"),
|
|
("proxied", False),
|
|
("ttl", 60),
|
|
],
|
|
)
|
|
def test_cloudflare_upsert_rejects_failed_post_write_verification(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
mutated_field: str,
|
|
mutated_value: object,
|
|
) -> None:
|
|
record = {
|
|
"id": "record-id",
|
|
"name": "socks.example.com",
|
|
"type": "A",
|
|
"content": "198.51.100.10",
|
|
"proxied": True,
|
|
"ttl": 1,
|
|
}
|
|
|
|
def fake_request(method: str, path: str, **kwargs: Any) -> object:
|
|
if path == "/zones/zone-id":
|
|
return {"id": "zone-id", "name": "example.com"}
|
|
if path.endswith("/dns_records") and method == "GET":
|
|
return [dict(record)]
|
|
if path.endswith("/dns_records/record-id") and method == "PATCH":
|
|
written = {"id": "record-id", **kwargs["json"]}
|
|
record.update(written)
|
|
record[mutated_field] = mutated_value
|
|
return written
|
|
raise AssertionError(f"unexpected Cloudflare request: {method} {path}")
|
|
|
|
client = CloudflareClient(
|
|
token="token",
|
|
zone_name="example.com",
|
|
zone_id="zone-id",
|
|
record_name="socks.example.com",
|
|
proxied=True,
|
|
)
|
|
monkeypatch.setattr(client, "_request", fake_request)
|
|
|
|
with pytest.raises(ValidationAppError) as verification_error:
|
|
client.upsert_a_record("198.51.100.20", expected_current="198.51.100.10")
|
|
|
|
assert verification_error.value.code == "DNS_WRITE_NOT_VERIFIED"
|