1662 lines
50 KiB
Python
1662 lines
50 KiB
Python
import os
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
from dns_sync.adapters import (
|
|
CERTIFICATES,
|
|
PROXY_HOSTS,
|
|
Capability,
|
|
Endpoint,
|
|
CapabilityOperation,
|
|
NormalizedObject,
|
|
ObjectReference,
|
|
PlanAction,
|
|
PlanOperation,
|
|
)
|
|
from dns_sync.certificates.material import CertificateMaterial
|
|
from dns_sync.config import AppConfig
|
|
from dns_sync.object_normalizer import normalize_discovered_object
|
|
from dns_sync.registry import (
|
|
configured_adapter_endpoints,
|
|
load_adapter,
|
|
)
|
|
|
|
|
|
class NpmAdapterTests(unittest.TestCase):
|
|
@staticmethod
|
|
def npm_env(
|
|
endpoints="192.168.1.3,192.168.1.4",
|
|
):
|
|
return patch.dict(
|
|
os.environ,
|
|
{
|
|
"NPM_ENDPOINTS": endpoints,
|
|
"DNS_TARGET_IP": "192.168.1.101",
|
|
},
|
|
clear=True,
|
|
)
|
|
|
|
def config(self):
|
|
with self.npm_env():
|
|
return AppConfig.from_env()
|
|
|
|
def adapter(self, client):
|
|
with self.npm_env():
|
|
config = AppConfig.from_env()
|
|
endpoints = configured_adapter_endpoints(
|
|
"npm",
|
|
config,
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
return_value=client,
|
|
):
|
|
return load_adapter(
|
|
"npm",
|
|
config,
|
|
endpoints[0],
|
|
)
|
|
|
|
def target_adapter_with_sources(
|
|
self,
|
|
target_client,
|
|
source_clients,
|
|
):
|
|
with self.npm_env():
|
|
config = AppConfig.from_env()
|
|
endpoints = configured_adapter_endpoints(
|
|
"npm",
|
|
config,
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
return_value=target_client,
|
|
):
|
|
adapter = load_adapter(
|
|
"npm",
|
|
config,
|
|
endpoints[1],
|
|
)
|
|
|
|
client_by_identity = {
|
|
endpoint_id: client
|
|
for endpoint_id, client in source_clients.items()
|
|
}
|
|
|
|
def client_factory(endpoint):
|
|
return client_by_identity[endpoint.identity]
|
|
|
|
return adapter, client_factory
|
|
|
|
def test_registry_creates_one_adapter_per_endpoint(self):
|
|
first_client = Mock()
|
|
second_client = Mock()
|
|
|
|
with self.npm_env():
|
|
config = AppConfig.from_env()
|
|
endpoints = configured_adapter_endpoints(
|
|
"npm",
|
|
config,
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=[first_client, second_client],
|
|
) as client_class:
|
|
adapters = [
|
|
load_adapter(
|
|
"npm",
|
|
config,
|
|
endpoint,
|
|
)
|
|
for endpoint in endpoints
|
|
]
|
|
|
|
self.assertEqual(
|
|
tuple(adapter.endpoint.identity for adapter in adapters),
|
|
(
|
|
"npm://192.168.1.3:81",
|
|
"npm://192.168.1.4:81",
|
|
),
|
|
)
|
|
self.assertEqual(client_class.call_count, 2)
|
|
self.assertIs(adapters[0].client, first_client)
|
|
self.assertIs(adapters[1].client, second_client)
|
|
|
|
def test_adapter_rejects_endpoint_for_other_plugin(self):
|
|
config = self.config()
|
|
endpoint = Endpoint(
|
|
"verizon",
|
|
"192.168.1.1:443",
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient"
|
|
) as client_class:
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"cannot use endpoint",
|
|
):
|
|
load_adapter(
|
|
"npm",
|
|
config,
|
|
endpoint,
|
|
)
|
|
|
|
client_class.assert_not_called()
|
|
|
|
|
|
def test_capabilities_cover_discovery_apply_and_verify(self):
|
|
adapter = self.adapter(Mock())
|
|
|
|
self.assertEqual(
|
|
adapter.capabilities,
|
|
frozenset(
|
|
{
|
|
Capability(
|
|
PROXY_HOSTS,
|
|
CapabilityOperation.DISCOVER,
|
|
),
|
|
Capability(
|
|
PROXY_HOSTS,
|
|
CapabilityOperation.APPLY,
|
|
),
|
|
Capability(
|
|
PROXY_HOSTS,
|
|
CapabilityOperation.VERIFY,
|
|
),
|
|
Capability(
|
|
CERTIFICATES,
|
|
CapabilityOperation.DISCOVER,
|
|
),
|
|
Capability(
|
|
CERTIFICATES,
|
|
CapabilityOperation.APPLY,
|
|
),
|
|
Capability(
|
|
CERTIFICATES,
|
|
CapabilityOperation.VERIFY,
|
|
),
|
|
}
|
|
),
|
|
)
|
|
|
|
def test_discovers_proxy_hosts_with_endpoint_local_key(self):
|
|
client = Mock()
|
|
client.proxy_hosts.return_value = [
|
|
{
|
|
"id": 12,
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "192.168.1.20",
|
|
"forward_port": 8080,
|
|
"certificate_id": 67,
|
|
"enabled": 1,
|
|
"unexpected_secret": "must-not-be-copied",
|
|
}
|
|
]
|
|
adapter = self.adapter(client)
|
|
|
|
discovered = adapter.discover(PROXY_HOSTS)
|
|
|
|
self.assertEqual(len(discovered), 1)
|
|
self.assertEqual(discovered[0].object_type, PROXY_HOSTS)
|
|
self.assertEqual(discovered[0].local_key, "12")
|
|
self.assertEqual(
|
|
discovered[0].payload["domain_names"],
|
|
["app.example.com"],
|
|
)
|
|
self.assertNotIn(
|
|
"unexpected_secret",
|
|
discovered[0].payload,
|
|
)
|
|
|
|
self.assertEqual(
|
|
discovered[0].references,
|
|
(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
local_key="67",
|
|
),
|
|
),
|
|
)
|
|
|
|
def test_proxy_host_without_certificate_has_no_reference(self):
|
|
client = Mock()
|
|
client.proxy_hosts.return_value = [
|
|
{
|
|
"id": 13,
|
|
"domain_names": ["plain.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "192.168.1.20",
|
|
"forward_port": 8080,
|
|
"certificate_id": 0,
|
|
"enabled": 1,
|
|
}
|
|
]
|
|
adapter = self.adapter(client)
|
|
|
|
discovered = adapter.discover(PROXY_HOSTS)
|
|
|
|
self.assertEqual(len(discovered), 1)
|
|
self.assertEqual(discovered[0].references, ())
|
|
|
|
|
|
def test_certificate_discovery_excludes_secret_material(self):
|
|
client = Mock()
|
|
client.certificates.return_value = [
|
|
{
|
|
"id": 67,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"nice_name": "cluster copy",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"private_key": "must-not-be-copied",
|
|
"certificate": "must-not-be-copied",
|
|
"meta": {
|
|
"cluster_managed": True,
|
|
"acme_renew": True,
|
|
"dns_challenge": True,
|
|
"dns_provider_credentials": "must-not-be-copied",
|
|
},
|
|
}
|
|
]
|
|
adapter = self.adapter(client)
|
|
|
|
discovered = adapter.discover(CERTIFICATES)
|
|
|
|
self.assertEqual(len(discovered), 1)
|
|
certificate = discovered[0]
|
|
self.assertEqual(certificate.local_key, "67")
|
|
self.assertTrue(certificate.payload["cluster_managed"])
|
|
self.assertTrue(certificate.payload["acme_renew"])
|
|
self.assertNotIn("private_key", certificate.payload)
|
|
self.assertNotIn("certificate", certificate.payload)
|
|
self.assertNotIn(
|
|
"dns_provider_credentials",
|
|
certificate.payload,
|
|
)
|
|
|
|
def test_verifies_proxy_host_exact_normalized_state(self):
|
|
client = Mock()
|
|
client.proxy_host.return_value = {
|
|
"id": 12,
|
|
"domain_names": ["APP.EXAMPLE.COM."],
|
|
"forward_scheme": "HTTP",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"certificate_id": 67,
|
|
"ssl_forced": True,
|
|
"caching_enabled": False,
|
|
"block_exploits": True,
|
|
"allow_websocket_upgrade": True,
|
|
"http2_support": True,
|
|
"hsts_enabled": True,
|
|
"hsts_subdomains": False,
|
|
"enabled": True,
|
|
}
|
|
adapter = self.adapter(client)
|
|
|
|
expected = normalize_discovered_object(
|
|
adapter._proxy_host_from_row(
|
|
client.proxy_host.return_value
|
|
)
|
|
)
|
|
|
|
self.assertTrue(adapter.verify(expected))
|
|
client.proxy_host.assert_called_once_with("12")
|
|
|
|
def test_proxy_host_verify_detects_reference_change(self):
|
|
client = Mock()
|
|
client.proxy_host.return_value = {
|
|
"id": 12,
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"certificate_id": 68,
|
|
"enabled": True,
|
|
}
|
|
adapter = self.adapter(client)
|
|
|
|
expected = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
local_key="12",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"ssl_forced": False,
|
|
"caching_enabled": False,
|
|
"block_exploits": False,
|
|
"allow_websocket_upgrade": False,
|
|
"http2_support": False,
|
|
"hsts_enabled": False,
|
|
"hsts_subdomains": False,
|
|
"enabled": True,
|
|
},
|
|
references=(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
local_key="67",
|
|
),
|
|
),
|
|
)
|
|
|
|
self.assertFalse(adapter.verify(expected))
|
|
|
|
def test_certificate_verify_uses_portable_certificate_state(self):
|
|
client = Mock()
|
|
client.certificate.return_value = {
|
|
"id": 84,
|
|
"domain_names": ["APP.EXAMPLE.COM."],
|
|
# Receiving NPM owns this as a custom/imported cert.
|
|
"provider": "other",
|
|
"nice_name": "local imported copy",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {
|
|
"cluster_managed": False,
|
|
"acme_renew": False,
|
|
"dns_challenge": False,
|
|
"certificate": "must-not-enter-normalized-state",
|
|
"certificate_key": "must-not-enter-normalized-state",
|
|
},
|
|
}
|
|
adapter = self.adapter(client)
|
|
|
|
expected = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
local_key="84",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
# Source may have been Let's Encrypt.
|
|
"provider": "letsencrypt",
|
|
"nice_name": "source certificate",
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
"cluster_managed": True,
|
|
"acme_renew": True,
|
|
"dns_challenge": True,
|
|
},
|
|
)
|
|
|
|
self.assertTrue(adapter.verify(expected))
|
|
client.certificate.assert_called_once_with("84")
|
|
|
|
def test_certificate_verify_detects_expiry_change(self):
|
|
client = Mock()
|
|
client.certificate.return_value = {
|
|
"id": 84,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2027-01-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
adapter = self.adapter(client)
|
|
|
|
expected = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
local_key="84",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "letsencrypt",
|
|
"nice_name": "",
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
"cluster_managed": False,
|
|
"acme_renew": False,
|
|
"dns_challenge": False,
|
|
},
|
|
)
|
|
|
|
self.assertFalse(adapter.verify(expected))
|
|
|
|
def test_verify_requires_endpoint_local_key(self):
|
|
adapter = self.adapter(Mock())
|
|
|
|
object = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
},
|
|
)
|
|
|
|
self.assertFalse(adapter.verify(object))
|
|
|
|
def test_resolves_certificate_material_from_other_npm_endpoint(self):
|
|
target_client = Mock()
|
|
source_client = Mock()
|
|
|
|
source_client.certificates.return_value = [
|
|
{
|
|
"id": 10,
|
|
"domain_names": ["APP.EXAMPLE.COM."],
|
|
"provider": "letsencrypt",
|
|
"nice_name": "source",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {
|
|
"cluster_managed": True,
|
|
"acme_renew": True,
|
|
},
|
|
}
|
|
]
|
|
|
|
material = CertificateMaterial(
|
|
certificate=b"CERT",
|
|
certificate_key=b"KEY",
|
|
intermediate_certificate=b"CHAIN",
|
|
)
|
|
source_client.certificate_material.return_value = material
|
|
|
|
adapter, client_factory = (
|
|
self.target_adapter_with_sources(
|
|
target_client,
|
|
{
|
|
"npm://192.168.1.3:81": source_client,
|
|
},
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "letsencrypt",
|
|
"nice_name": "source",
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
"cluster_managed": True,
|
|
"acme_renew": True,
|
|
"dns_challenge": False,
|
|
},
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
resolved = adapter._certificate_material_source(
|
|
desired
|
|
)
|
|
|
|
self.assertEqual(resolved, material)
|
|
source_client.certificate_material.assert_called_once_with(
|
|
"10"
|
|
)
|
|
target_client.certificates.assert_not_called()
|
|
target_client.certificate_material.assert_not_called()
|
|
|
|
def test_certificate_material_source_ignores_wrong_expiry(self):
|
|
target_client = Mock()
|
|
source_client = Mock()
|
|
|
|
source_client.certificates.return_value = [
|
|
{
|
|
"id": 10,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "letsencrypt",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2027-01-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
]
|
|
|
|
adapter, client_factory = (
|
|
self.target_adapter_with_sources(
|
|
target_client,
|
|
{
|
|
"npm://192.168.1.3:81": source_client,
|
|
},
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"No NPM source certificate material found",
|
|
):
|
|
adapter._certificate_material_source(desired)
|
|
|
|
source_client.certificate_material.assert_not_called()
|
|
|
|
def test_certificate_material_source_rejects_conflicting_material(self):
|
|
with self.npm_env(
|
|
"192.168.1.3,192.168.1.4,192.168.1.5"
|
|
):
|
|
config = AppConfig.from_env()
|
|
endpoints = configured_adapter_endpoints(
|
|
"npm",
|
|
config,
|
|
)
|
|
|
|
target_client = Mock()
|
|
source_a = Mock()
|
|
source_b = Mock()
|
|
|
|
row_a = {
|
|
"id": 10,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "letsencrypt",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
row_b = dict(row_a)
|
|
row_b["id"] = 44
|
|
|
|
source_a.certificates.return_value = [row_a]
|
|
source_b.certificates.return_value = [row_b]
|
|
|
|
source_a.certificate_material.return_value = (
|
|
CertificateMaterial(
|
|
certificate=b"CERT-A",
|
|
certificate_key=b"KEY-A",
|
|
)
|
|
)
|
|
source_b.certificate_material.return_value = (
|
|
CertificateMaterial(
|
|
certificate=b"CERT-B",
|
|
certificate_key=b"KEY-B",
|
|
)
|
|
)
|
|
|
|
with self.npm_env(
|
|
"192.168.1.3,192.168.1.4,192.168.1.5"
|
|
):
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
return_value=target_client,
|
|
):
|
|
adapter = load_adapter(
|
|
"npm",
|
|
config,
|
|
endpoints[1],
|
|
)
|
|
|
|
clients = {
|
|
"npm://192.168.1.3:81": source_a,
|
|
"npm://192.168.1.5:81": source_b,
|
|
}
|
|
|
|
def client_factory(endpoint):
|
|
return clients[endpoint.identity]
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"Conflicting NPM certificate material",
|
|
):
|
|
adapter._certificate_material_source(desired)
|
|
|
|
def test_certificate_material_source_accepts_identical_replicas(self):
|
|
with self.npm_env(
|
|
"192.168.1.3,192.168.1.4,192.168.1.5"
|
|
):
|
|
config = AppConfig.from_env()
|
|
endpoints = configured_adapter_endpoints(
|
|
"npm",
|
|
config,
|
|
)
|
|
|
|
target_client = Mock()
|
|
source_a = Mock()
|
|
source_b = Mock()
|
|
|
|
common_material = CertificateMaterial(
|
|
certificate=b"CERT",
|
|
certificate_key=b"KEY",
|
|
intermediate_certificate=b"CHAIN",
|
|
)
|
|
|
|
source_a.certificates.return_value = [
|
|
{
|
|
"id": 10,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "letsencrypt",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
]
|
|
source_b.certificates.return_value = [
|
|
{
|
|
"id": 44,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
]
|
|
|
|
source_a.certificate_material.return_value = common_material
|
|
source_b.certificate_material.return_value = common_material
|
|
|
|
with self.npm_env(
|
|
"192.168.1.3,192.168.1.4,192.168.1.5"
|
|
):
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
return_value=target_client,
|
|
):
|
|
adapter = load_adapter(
|
|
"npm",
|
|
config,
|
|
endpoints[1],
|
|
)
|
|
|
|
clients = {
|
|
"npm://192.168.1.3:81": source_a,
|
|
"npm://192.168.1.5:81": source_b,
|
|
}
|
|
|
|
def client_factory(endpoint):
|
|
return clients[endpoint.identity]
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
resolved = adapter._certificate_material_source(
|
|
desired
|
|
)
|
|
|
|
self.assertEqual(resolved, common_material)
|
|
|
|
def test_certificate_material_source_tolerates_unavailable_unrelated_source(self):
|
|
with self.npm_env(
|
|
"192.168.1.3,192.168.1.4,192.168.1.5"
|
|
):
|
|
config = AppConfig.from_env()
|
|
endpoints = configured_adapter_endpoints(
|
|
"npm",
|
|
config,
|
|
)
|
|
|
|
target_client = Mock()
|
|
unavailable = Mock()
|
|
available = Mock()
|
|
|
|
unavailable.certificates.side_effect = RuntimeError(
|
|
"offline"
|
|
)
|
|
|
|
available.certificates.return_value = [
|
|
{
|
|
"id": 44,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
]
|
|
|
|
material = CertificateMaterial(
|
|
certificate=b"CERT",
|
|
certificate_key=b"KEY",
|
|
)
|
|
available.certificate_material.return_value = material
|
|
|
|
with self.npm_env(
|
|
"192.168.1.3,192.168.1.4,192.168.1.5"
|
|
):
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
return_value=target_client,
|
|
):
|
|
adapter = load_adapter(
|
|
"npm",
|
|
config,
|
|
endpoints[1],
|
|
)
|
|
|
|
clients = {
|
|
"npm://192.168.1.3:81": unavailable,
|
|
"npm://192.168.1.5:81": available,
|
|
}
|
|
|
|
def client_factory(endpoint):
|
|
return clients[endpoint.identity]
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
resolved = adapter._certificate_material_source(
|
|
desired
|
|
)
|
|
|
|
self.assertEqual(resolved, material)
|
|
|
|
def test_certificate_create_uploads_verifies_and_returns_target_id(self):
|
|
target_client = Mock()
|
|
source_client = Mock()
|
|
|
|
source_client.certificates.return_value = [
|
|
{
|
|
"id": 10,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "letsencrypt",
|
|
"nice_name": "source",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
]
|
|
|
|
source_client.certificate_material.return_value = (
|
|
CertificateMaterial(
|
|
certificate=b"CERT",
|
|
certificate_key=b"KEY",
|
|
intermediate_certificate=b"CHAIN",
|
|
)
|
|
)
|
|
|
|
target_client.create_certificate.return_value = {
|
|
"id": 84,
|
|
}
|
|
|
|
target_client.certificate.return_value = {
|
|
"id": 84,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"nice_name": "source",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {
|
|
"cluster_managed": True,
|
|
"acme_renew": False,
|
|
"dns_challenge": False,
|
|
},
|
|
}
|
|
|
|
adapter, client_factory = (
|
|
self.target_adapter_with_sources(
|
|
target_client,
|
|
{
|
|
"npm://192.168.1.3:81": source_client,
|
|
},
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "letsencrypt",
|
|
"nice_name": "source",
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
"cluster_managed": True,
|
|
"acme_renew": True,
|
|
"dns_challenge": False,
|
|
},
|
|
)
|
|
|
|
action = PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
result = adapter.apply(action)
|
|
|
|
self.assertEqual(
|
|
result.object.local_key,
|
|
"84",
|
|
)
|
|
self.assertEqual(
|
|
result.object.logical_key,
|
|
"app.example.com",
|
|
)
|
|
|
|
target_client.create_certificate.assert_called_once_with(
|
|
{
|
|
"provider": "other",
|
|
"domain_names": ["app.example.com"],
|
|
"nice_name": "source",
|
|
"meta": {
|
|
"cluster_managed": True,
|
|
"acme_renew": False,
|
|
"dns_challenge": False,
|
|
},
|
|
}
|
|
)
|
|
|
|
target_client.upload_certificate.assert_called_once_with(
|
|
"84",
|
|
certificate=b"CERT",
|
|
certificate_key=b"KEY",
|
|
intermediate_certificate=b"CHAIN",
|
|
)
|
|
|
|
target_client.certificate.assert_called_once_with("84")
|
|
target_client.delete_certificate.assert_not_called()
|
|
|
|
def test_certificate_create_rolls_back_when_upload_fails(self):
|
|
target_client = Mock()
|
|
source_client = Mock()
|
|
|
|
source_client.certificates.return_value = [
|
|
{
|
|
"id": 10,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
]
|
|
|
|
source_client.certificate_material.return_value = (
|
|
CertificateMaterial(
|
|
certificate=b"CERT",
|
|
certificate_key=b"KEY",
|
|
)
|
|
)
|
|
|
|
target_client.create_certificate.return_value = {
|
|
"id": 84,
|
|
}
|
|
target_client.upload_certificate.side_effect = RuntimeError(
|
|
"upload failed"
|
|
)
|
|
|
|
adapter, client_factory = (
|
|
self.target_adapter_with_sources(
|
|
target_client,
|
|
{
|
|
"npm://192.168.1.3:81": source_client,
|
|
},
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
action = PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"upload failed",
|
|
):
|
|
adapter.apply(action)
|
|
|
|
target_client.delete_certificate.assert_called_once_with(
|
|
"84"
|
|
)
|
|
target_client.certificate.assert_not_called()
|
|
|
|
def test_certificate_create_rolls_back_when_created_state_is_wrong(self):
|
|
target_client = Mock()
|
|
source_client = Mock()
|
|
|
|
source_client.certificates.return_value = [
|
|
{
|
|
"id": 10,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
]
|
|
|
|
source_client.certificate_material.return_value = (
|
|
CertificateMaterial(
|
|
certificate=b"CERT",
|
|
certificate_key=b"KEY",
|
|
)
|
|
)
|
|
|
|
target_client.create_certificate.return_value = {
|
|
"id": 84,
|
|
}
|
|
|
|
target_client.certificate.return_value = {
|
|
"id": 84,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
# Wrong target certificate state.
|
|
"expires_on": "2027-01-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
|
|
adapter, client_factory = (
|
|
self.target_adapter_with_sources(
|
|
target_client,
|
|
{
|
|
"npm://192.168.1.3:81": source_client,
|
|
},
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
action = PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"does not match desired",
|
|
):
|
|
adapter.apply(action)
|
|
|
|
target_client.delete_certificate.assert_called_once_with(
|
|
"84"
|
|
)
|
|
|
|
def test_certificate_create_reports_rollback_failure_without_secrets(self):
|
|
target_client = Mock()
|
|
source_client = Mock()
|
|
|
|
source_client.certificates.return_value = [
|
|
{
|
|
"id": 10,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
]
|
|
|
|
source_client.certificate_material.return_value = (
|
|
CertificateMaterial(
|
|
certificate=b"DO-NOT-PRINT-CERT",
|
|
certificate_key=b"DO-NOT-PRINT-KEY",
|
|
)
|
|
)
|
|
|
|
target_client.create_certificate.return_value = {
|
|
"id": 84,
|
|
}
|
|
target_client.upload_certificate.side_effect = RuntimeError(
|
|
"upload failed"
|
|
)
|
|
target_client.delete_certificate.side_effect = RuntimeError(
|
|
"delete failed"
|
|
)
|
|
|
|
adapter, client_factory = (
|
|
self.target_adapter_with_sources(
|
|
target_client,
|
|
{
|
|
"npm://192.168.1.3:81": source_client,
|
|
},
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
action = PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
with self.assertRaises(RuntimeError) as caught:
|
|
adapter.apply(action)
|
|
|
|
rendered = str(caught.exception)
|
|
|
|
self.assertIn(
|
|
"rollback also failed",
|
|
rendered,
|
|
)
|
|
self.assertNotIn(
|
|
"DO-NOT-PRINT-CERT",
|
|
rendered,
|
|
)
|
|
self.assertNotIn(
|
|
"DO-NOT-PRINT-KEY",
|
|
rendered,
|
|
)
|
|
|
|
def test_certificate_create_rejects_invalid_created_id(self):
|
|
target_client = Mock()
|
|
source_client = Mock()
|
|
|
|
source_client.certificates.return_value = [
|
|
{
|
|
"id": 10,
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"valid_from": "2026-09-01T00:00:00Z",
|
|
"expires_on": "2026-12-01T00:00:00Z",
|
|
"meta": {},
|
|
}
|
|
]
|
|
|
|
source_client.certificate_material.return_value = (
|
|
CertificateMaterial(
|
|
certificate=b"CERT",
|
|
certificate_key=b"KEY",
|
|
)
|
|
)
|
|
|
|
target_client.create_certificate.return_value = {
|
|
"id": None,
|
|
}
|
|
|
|
adapter, client_factory = (
|
|
self.target_adapter_with_sources(
|
|
target_client,
|
|
{
|
|
"npm://192.168.1.3:81": source_client,
|
|
},
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
action = PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
|
|
with patch(
|
|
"dns_sync.plugins.adapters.npm.NpmClient",
|
|
side_effect=client_factory,
|
|
):
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"invalid id",
|
|
):
|
|
adapter.apply(action)
|
|
|
|
target_client.upload_certificate.assert_not_called()
|
|
target_client.delete_certificate.assert_not_called()
|
|
|
|
def test_proxy_host_create_uses_resolved_target_certificate_id(self):
|
|
client = Mock()
|
|
|
|
client.create_proxy_host.return_value = {
|
|
"id": 120,
|
|
}
|
|
|
|
client.proxy_host.return_value = {
|
|
"id": 120,
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"certificate_id": 84,
|
|
"ssl_forced": True,
|
|
"caching_enabled": False,
|
|
"block_exploits": True,
|
|
"allow_websocket_upgrade": True,
|
|
"http2_support": True,
|
|
"hsts_enabled": True,
|
|
"hsts_subdomains": False,
|
|
"enabled": True,
|
|
}
|
|
|
|
adapter = self.adapter(client)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"ssl_forced": True,
|
|
"caching_enabled": False,
|
|
"block_exploits": True,
|
|
"allow_websocket_upgrade": True,
|
|
"http2_support": True,
|
|
"hsts_enabled": True,
|
|
"hsts_subdomains": False,
|
|
"enabled": True,
|
|
},
|
|
references=(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
local_key="84",
|
|
),
|
|
),
|
|
)
|
|
|
|
result = adapter.apply(
|
|
PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
)
|
|
|
|
client.create_proxy_host.assert_called_once_with(
|
|
{
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"certificate_id": 84,
|
|
"ssl_forced": True,
|
|
"caching_enabled": False,
|
|
"block_exploits": True,
|
|
"allow_websocket_upgrade": True,
|
|
"http2_support": True,
|
|
"hsts_enabled": True,
|
|
"hsts_subdomains": False,
|
|
"enabled": True,
|
|
}
|
|
)
|
|
|
|
self.assertEqual(
|
|
result.object.local_key,
|
|
"120",
|
|
)
|
|
self.assertEqual(
|
|
result.object.references,
|
|
desired.references,
|
|
)
|
|
client.delete_proxy_host.assert_not_called()
|
|
|
|
def test_proxy_host_create_without_certificate_uses_zero(self):
|
|
client = Mock()
|
|
|
|
client.create_proxy_host.return_value = {
|
|
"id": 120,
|
|
}
|
|
|
|
client.proxy_host.return_value = {
|
|
"id": 120,
|
|
"domain_names": ["plain.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"certificate_id": 0,
|
|
"enabled": True,
|
|
}
|
|
|
|
adapter = self.adapter(client)
|
|
|
|
desired = normalize_discovered_object(
|
|
adapter._proxy_host_from_row(
|
|
client.proxy_host.return_value
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=desired.object_type,
|
|
logical_key=desired.logical_key,
|
|
payload=desired.payload,
|
|
references=desired.references,
|
|
)
|
|
|
|
result = adapter.apply(
|
|
PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
)
|
|
|
|
payload = client.create_proxy_host.call_args.kwargs
|
|
if not payload:
|
|
payload = client.create_proxy_host.call_args.args[0]
|
|
|
|
self.assertEqual(
|
|
payload["certificate_id"],
|
|
0,
|
|
)
|
|
self.assertEqual(
|
|
result.object.references,
|
|
(),
|
|
)
|
|
|
|
def test_proxy_host_create_rolls_back_when_refetch_mismatches(self):
|
|
client = Mock()
|
|
|
|
client.create_proxy_host.return_value = {
|
|
"id": 120,
|
|
}
|
|
|
|
client.proxy_host.return_value = {
|
|
"id": 120,
|
|
"domain_names": ["wrong.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"certificate_id": 0,
|
|
"enabled": True,
|
|
}
|
|
|
|
adapter = self.adapter(client)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"ssl_forced": False,
|
|
"caching_enabled": False,
|
|
"block_exploits": False,
|
|
"allow_websocket_upgrade": False,
|
|
"http2_support": False,
|
|
"hsts_enabled": False,
|
|
"hsts_subdomains": False,
|
|
"enabled": True,
|
|
},
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"does not match desired",
|
|
):
|
|
adapter.apply(
|
|
PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
)
|
|
|
|
client.delete_proxy_host.assert_called_once_with(
|
|
"120"
|
|
)
|
|
|
|
def test_proxy_host_create_reports_rollback_failure(self):
|
|
client = Mock()
|
|
|
|
client.create_proxy_host.return_value = {
|
|
"id": 120,
|
|
}
|
|
client.proxy_host.side_effect = RuntimeError(
|
|
"refetch failed"
|
|
)
|
|
client.delete_proxy_host.side_effect = RuntimeError(
|
|
"delete failed"
|
|
)
|
|
|
|
adapter = self.adapter(client)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
},
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"rollback also failed",
|
|
):
|
|
adapter.apply(
|
|
PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
)
|
|
|
|
def test_proxy_host_update_uses_current_local_id_and_resolved_reference(self):
|
|
client = Mock()
|
|
|
|
client.proxy_host.return_value = {
|
|
"id": 12,
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "https",
|
|
"forward_host": "new-backend.internal",
|
|
"forward_port": 8443,
|
|
"certificate_id": 84,
|
|
"ssl_forced": True,
|
|
"enabled": True,
|
|
}
|
|
|
|
adapter = self.adapter(client)
|
|
|
|
current = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
local_key="12",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "old-backend.internal",
|
|
"forward_port": 8080,
|
|
"ssl_forced": False,
|
|
"caching_enabled": False,
|
|
"block_exploits": False,
|
|
"allow_websocket_upgrade": False,
|
|
"http2_support": False,
|
|
"hsts_enabled": False,
|
|
"hsts_subdomains": False,
|
|
"enabled": True,
|
|
},
|
|
)
|
|
|
|
actual = normalize_discovered_object(
|
|
adapter._proxy_host_from_row(
|
|
client.proxy_host.return_value
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=actual.object_type,
|
|
logical_key=actual.logical_key,
|
|
payload=actual.payload,
|
|
references=actual.references,
|
|
)
|
|
|
|
result = adapter.apply(
|
|
PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.UPDATE,
|
|
desired=desired,
|
|
current=current,
|
|
)
|
|
)
|
|
|
|
client.update_proxy_host.assert_called_once()
|
|
|
|
args = client.update_proxy_host.call_args.args
|
|
self.assertEqual(args[0], "12")
|
|
self.assertEqual(
|
|
args[1]["certificate_id"],
|
|
84,
|
|
)
|
|
|
|
self.assertEqual(
|
|
result.object.local_key,
|
|
"12",
|
|
)
|
|
self.assertEqual(
|
|
result.object.references,
|
|
desired.references,
|
|
)
|
|
client.delete_proxy_host.assert_not_called()
|
|
|
|
def test_proxy_host_update_fails_when_target_does_not_converge(self):
|
|
client = Mock()
|
|
|
|
client.proxy_host.return_value = {
|
|
"id": 12,
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "still-old.internal",
|
|
"forward_port": 8080,
|
|
"certificate_id": 0,
|
|
"enabled": True,
|
|
}
|
|
|
|
adapter = self.adapter(client)
|
|
|
|
current = normalize_discovered_object(
|
|
adapter._proxy_host_from_row(
|
|
client.proxy_host.return_value
|
|
)
|
|
)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "new.internal",
|
|
"forward_port": 8080,
|
|
"ssl_forced": False,
|
|
"caching_enabled": False,
|
|
"block_exploits": False,
|
|
"allow_websocket_upgrade": False,
|
|
"http2_support": False,
|
|
"hsts_enabled": False,
|
|
"hsts_subdomains": False,
|
|
"enabled": True,
|
|
},
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"does not match desired",
|
|
):
|
|
adapter.apply(
|
|
PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.UPDATE,
|
|
desired=desired,
|
|
current=current,
|
|
)
|
|
)
|
|
|
|
client.delete_proxy_host.assert_not_called()
|
|
|
|
def test_proxy_host_apply_rejects_unresolved_certificate_reference(self):
|
|
client = Mock()
|
|
adapter = self.adapter(client)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
},
|
|
references=(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
),
|
|
),
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"not resolved",
|
|
):
|
|
adapter.apply(
|
|
PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
)
|
|
|
|
client.create_proxy_host.assert_not_called()
|
|
client.update_proxy_host.assert_not_called()
|
|
|
|
def test_proxy_host_apply_rejects_unsupported_reference(self):
|
|
client = Mock()
|
|
adapter = self.adapter(client)
|
|
|
|
desired = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
},
|
|
references=(
|
|
ObjectReference(
|
|
relation="other",
|
|
object_type=CERTIFICATES,
|
|
local_key="84",
|
|
),
|
|
),
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"unsupported object reference",
|
|
):
|
|
adapter.apply(
|
|
PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=desired,
|
|
)
|
|
)
|
|
|
|
client.create_proxy_host.assert_not_called()
|
|
|
|
def test_unsupported_operations_fail_explicitly(self):
|
|
adapter = self.adapter(Mock())
|
|
|
|
with self.assertRaisesRegex(RuntimeError, "cannot discover"):
|
|
adapter.discover("unsupported_objects")
|
|
|
|
unsupported_action = PlanAction(
|
|
endpoint_id=adapter.endpoint.identity,
|
|
operation=PlanOperation.CREATE,
|
|
desired=NormalizedObject(
|
|
object_type="unsupported_objects",
|
|
logical_key="unsupported",
|
|
payload={},
|
|
),
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"does not support",
|
|
):
|
|
adapter.apply(unsupported_action)
|
|
|
|
unsupported = NormalizedObject(
|
|
object_type="unsupported_objects",
|
|
logical_key="unsupported",
|
|
local_key="1",
|
|
payload={},
|
|
)
|
|
|
|
with self.assertRaisesRegex(RuntimeError, "cannot verify"):
|
|
adapter.verify(unsupported)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|