Resolve NPM certificate material sources
This commit is contained in:
@@ -10,6 +10,7 @@ from dns_sync.adapters import (
|
||||
NormalizedObject,
|
||||
ObjectReference,
|
||||
)
|
||||
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 load_adapter
|
||||
@@ -39,6 +40,33 @@ class NpmAdapterTests(unittest.TestCase):
|
||||
config.npm_endpoints[0],
|
||||
)
|
||||
|
||||
def target_adapter_with_sources(
|
||||
self,
|
||||
target_client,
|
||||
source_clients,
|
||||
):
|
||||
config = self.config()
|
||||
|
||||
with patch(
|
||||
"dns_sync.plugins.adapters.npm.NpmClient",
|
||||
return_value=target_client,
|
||||
):
|
||||
adapter = load_adapter(
|
||||
"npm",
|
||||
config,
|
||||
config.npm_endpoints[1],
|
||||
)
|
||||
|
||||
client_by_identity = {
|
||||
endpoint_id: client
|
||||
for endpoint_id, client in source_clients.items()
|
||||
}
|
||||
|
||||
def client_factory(config, endpoint):
|
||||
return client_by_identity[endpoint.identity]
|
||||
|
||||
return adapter, client_factory
|
||||
|
||||
def test_registry_creates_one_adapter_per_endpoint(self):
|
||||
config = self.config()
|
||||
first_client = Mock()
|
||||
@@ -358,6 +386,382 @@ class NpmAdapterTests(unittest.TestCase):
|
||||
|
||||
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):
|
||||
config = self.config()
|
||||
|
||||
# Add a third endpoint for this ambiguity test.
|
||||
endpoint_type = type(config.npm_endpoints[0])
|
||||
third_endpoint = endpoint_type(
|
||||
"npm",
|
||||
"192.168.1.5:81",
|
||||
)
|
||||
|
||||
config = __import__(
|
||||
"dataclasses"
|
||||
).replace(
|
||||
config,
|
||||
npm_endpoints=(
|
||||
config.npm_endpoints[0],
|
||||
config.npm_endpoints[1],
|
||||
third_endpoint,
|
||||
),
|
||||
)
|
||||
|
||||
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 patch(
|
||||
"dns_sync.plugins.adapters.npm.NpmClient",
|
||||
return_value=target_client,
|
||||
):
|
||||
adapter = load_adapter(
|
||||
"npm",
|
||||
config,
|
||||
config.npm_endpoints[1],
|
||||
)
|
||||
|
||||
clients = {
|
||||
"npm://192.168.1.3:81": source_a,
|
||||
"npm://192.168.1.5:81": source_b,
|
||||
}
|
||||
|
||||
def client_factory(config, 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):
|
||||
config = self.config()
|
||||
|
||||
endpoint_type = type(config.npm_endpoints[0])
|
||||
third_endpoint = endpoint_type(
|
||||
"npm",
|
||||
"192.168.1.5:81",
|
||||
)
|
||||
|
||||
config = __import__(
|
||||
"dataclasses"
|
||||
).replace(
|
||||
config,
|
||||
npm_endpoints=(
|
||||
config.npm_endpoints[0],
|
||||
config.npm_endpoints[1],
|
||||
third_endpoint,
|
||||
),
|
||||
)
|
||||
|
||||
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 patch(
|
||||
"dns_sync.plugins.adapters.npm.NpmClient",
|
||||
return_value=target_client,
|
||||
):
|
||||
adapter = load_adapter(
|
||||
"npm",
|
||||
config,
|
||||
config.npm_endpoints[1],
|
||||
)
|
||||
|
||||
clients = {
|
||||
"npm://192.168.1.3:81": source_a,
|
||||
"npm://192.168.1.5:81": source_b,
|
||||
}
|
||||
|
||||
def client_factory(config, 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):
|
||||
config = self.config()
|
||||
|
||||
endpoint_type = type(config.npm_endpoints[0])
|
||||
third_endpoint = endpoint_type(
|
||||
"npm",
|
||||
"192.168.1.5:81",
|
||||
)
|
||||
|
||||
config = __import__(
|
||||
"dataclasses"
|
||||
).replace(
|
||||
config,
|
||||
npm_endpoints=(
|
||||
config.npm_endpoints[0],
|
||||
config.npm_endpoints[1],
|
||||
third_endpoint,
|
||||
),
|
||||
)
|
||||
|
||||
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 patch(
|
||||
"dns_sync.plugins.adapters.npm.NpmClient",
|
||||
return_value=target_client,
|
||||
):
|
||||
adapter = load_adapter(
|
||||
"npm",
|
||||
config,
|
||||
config.npm_endpoints[1],
|
||||
)
|
||||
|
||||
clients = {
|
||||
"npm://192.168.1.3:81": unavailable,
|
||||
"npm://192.168.1.5:81": available,
|
||||
}
|
||||
|
||||
def client_factory(config, 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_unsupported_operations_fail_explicitly(self):
|
||||
adapter = self.adapter(Mock())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user