Resolve NPM certificate material sources
This commit is contained in:
@@ -9,6 +9,7 @@ from ...adapters import (
|
|||||||
NormalizedObject,
|
NormalizedObject,
|
||||||
ObjectReference,
|
ObjectReference,
|
||||||
)
|
)
|
||||||
|
from ...certificates.material import CertificateMaterial
|
||||||
from ...npm_client import NpmClient
|
from ...npm_client import NpmClient
|
||||||
from ...object_normalizer import normalize_discovered_object
|
from ...object_normalizer import normalize_discovered_object
|
||||||
|
|
||||||
@@ -180,6 +181,137 @@ class Plugin:
|
|||||||
for row in self.client.certificates()
|
for row in self.client.certificates()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _portable_certificate_matches(
|
||||||
|
actual: NormalizedObject,
|
||||||
|
desired: NormalizedObject,
|
||||||
|
) -> bool:
|
||||||
|
if actual.object_type != CERTIFICATES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if desired.object_type != CERTIFICATES:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if actual.logical_key != desired.logical_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for field in (
|
||||||
|
"domain_names",
|
||||||
|
"valid_from",
|
||||||
|
"expires_on",
|
||||||
|
):
|
||||||
|
if (
|
||||||
|
actual.payload.get(field)
|
||||||
|
!= desired.payload.get(field)
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _certificate_material_source(
|
||||||
|
self,
|
||||||
|
desired: NormalizedObject,
|
||||||
|
) -> CertificateMaterial:
|
||||||
|
if desired.object_type != CERTIFICATES:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Certificate material source requires "
|
||||||
|
"a certificate object"
|
||||||
|
)
|
||||||
|
|
||||||
|
matches: list[
|
||||||
|
tuple[str, str, CertificateMaterial]
|
||||||
|
] = []
|
||||||
|
source_errors: list[str] = []
|
||||||
|
|
||||||
|
for endpoint in sorted(
|
||||||
|
self.config.npm_endpoints,
|
||||||
|
key=lambda item: item.identity,
|
||||||
|
):
|
||||||
|
if endpoint.identity == self.endpoint.identity:
|
||||||
|
continue
|
||||||
|
|
||||||
|
client = NpmClient(
|
||||||
|
self.config,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = client.certificates()
|
||||||
|
except Exception as exc:
|
||||||
|
source_errors.append(
|
||||||
|
f"{endpoint.identity}: "
|
||||||
|
f"{type(exc).__name__}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
candidate = normalize_discovered_object(
|
||||||
|
self._certificate_from_row(row)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._portable_certificate_matches(
|
||||||
|
candidate,
|
||||||
|
desired,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if candidate.local_key is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Matching NPM certificate has no "
|
||||||
|
"endpoint-local key"
|
||||||
|
)
|
||||||
|
|
||||||
|
material = client.certificate_material(
|
||||||
|
candidate.local_key
|
||||||
|
)
|
||||||
|
|
||||||
|
matches.append(
|
||||||
|
(
|
||||||
|
endpoint.identity,
|
||||||
|
candidate.local_key,
|
||||||
|
material,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
detail = ""
|
||||||
|
|
||||||
|
if source_errors:
|
||||||
|
detail = (
|
||||||
|
"; unavailable sources: "
|
||||||
|
+ ", ".join(source_errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"No NPM source certificate material found for "
|
||||||
|
f"{desired.logical_key!r}"
|
||||||
|
f"{detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
baseline = matches[0][2]
|
||||||
|
|
||||||
|
for _, _, material in matches[1:]:
|
||||||
|
if material != baseline:
|
||||||
|
endpoints = ", ".join(
|
||||||
|
sorted(
|
||||||
|
{
|
||||||
|
endpoint_id
|
||||||
|
for (
|
||||||
|
endpoint_id,
|
||||||
|
_,
|
||||||
|
_,
|
||||||
|
) in matches
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
raise RuntimeError(
|
||||||
|
"Conflicting NPM certificate material "
|
||||||
|
f"sources for {desired.logical_key!r}: "
|
||||||
|
f"{endpoints}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return baseline
|
||||||
|
|
||||||
def apply(self, action):
|
def apply(self, action):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"{self.endpoint.identity} does not support apply yet"
|
f"{self.endpoint.identity} does not support apply yet"
|
||||||
@@ -210,28 +342,14 @@ class Plugin:
|
|||||||
if actual.local_key != object.local_key:
|
if actual.local_key != object.local_key:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if actual.logical_key != object.logical_key:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Provider, display name, and renewal flags describe how
|
# Provider, display name, and renewal flags describe how
|
||||||
# this NPM endpoint manages the certificate. They are not
|
# this NPM endpoint manages the certificate. They are not
|
||||||
# portable certificate identity. A copied Let's Encrypt
|
# portable certificate identity. A copied Let's Encrypt
|
||||||
# certificate becomes provider="other" on the recipient.
|
# certificate becomes provider="other" on the recipient.
|
||||||
#
|
return self._portable_certificate_matches(
|
||||||
# The portable verification state is the domain identity
|
actual,
|
||||||
# plus the certificate validity period exposed by NPM.
|
object,
|
||||||
for field in (
|
)
|
||||||
"domain_names",
|
|
||||||
"valid_from",
|
|
||||||
"expires_on",
|
|
||||||
):
|
|
||||||
if (
|
|
||||||
actual.payload.get(field)
|
|
||||||
!= object.payload.get(field)
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"{self.endpoint.identity} cannot verify "
|
f"{self.endpoint.identity} cannot verify "
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from dns_sync.adapters import (
|
|||||||
NormalizedObject,
|
NormalizedObject,
|
||||||
ObjectReference,
|
ObjectReference,
|
||||||
)
|
)
|
||||||
|
from dns_sync.certificates.material import CertificateMaterial
|
||||||
from dns_sync.config import AppConfig
|
from dns_sync.config import AppConfig
|
||||||
from dns_sync.object_normalizer import normalize_discovered_object
|
from dns_sync.object_normalizer import normalize_discovered_object
|
||||||
from dns_sync.registry import load_adapter
|
from dns_sync.registry import load_adapter
|
||||||
@@ -39,6 +40,33 @@ class NpmAdapterTests(unittest.TestCase):
|
|||||||
config.npm_endpoints[0],
|
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):
|
def test_registry_creates_one_adapter_per_endpoint(self):
|
||||||
config = self.config()
|
config = self.config()
|
||||||
first_client = Mock()
|
first_client = Mock()
|
||||||
@@ -358,6 +386,382 @@ class NpmAdapterTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertFalse(adapter.verify(object))
|
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):
|
def test_unsupported_operations_fail_explicitly(self):
|
||||||
adapter = self.adapter(Mock())
|
adapter = self.adapter(Mock())
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user