Skip to content

Commit 4a6ab71

Browse files
committed
test(tls): unit tests for the operator-cert manager and events handler
Cover TLSManager live-fetch getters, CA rotation, push, and the TLS events handler (certificate_available / relation_broken / defer paths). Stacked on the manager+events PR whose code these exercise.
1 parent 90e7728 commit 4a6ab71

2 files changed

Lines changed: 594 additions & 0 deletions

File tree

tests/unit/test_tls_events.py

Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
# Copyright 2026 Canonical Ltd.
2+
# See LICENSE file for licensing details.
3+
"""Tests for the TLS events handler (single_kernel_postgresql/events/tls.py).
4+
5+
The handler is live-fetch: operator cert/key are read from the requirer on demand
6+
(get_assigned_certificates), never persisted. Only the peer CA is tracked in state
7+
(current-ca / old-ca) for rotation, mirroring postgresql-operator/src/relations/tls.py
8+
_on_peer_certificate_available (which stashes current-ca/old-ca and otherwise reads live).
9+
"""
10+
11+
from types import SimpleNamespace
12+
from unittest.mock import MagicMock, patch
13+
14+
from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError
15+
16+
17+
class _FakePrivateKey:
18+
"""Minimal stand-in for PrivateKey: str(key) returns the raw PEM string."""
19+
20+
def __init__(self, raw: str) -> None:
21+
self._raw = raw
22+
23+
def __str__(self) -> str:
24+
return self._raw
25+
26+
27+
def _fake_assigned(cert, ca, key):
28+
"""Mimic TLSCertificatesRequiresV4.get_assigned_certificates() return shape.
29+
30+
Returns (list[ProviderCertificate], PrivateKey | None).
31+
"""
32+
provider_cert = SimpleNamespace(certificate=cert, ca=ca)
33+
return [provider_cert], _FakePrivateKey(key)
34+
35+
36+
def test_handler_is_wired(harness):
37+
tls = harness.charm.tls
38+
assert tls.client_certificate is not None
39+
assert tls.peer_certificate is not None
40+
# the handler wires its requirers onto the manager so the live-fetch getters work
41+
assert harness.charm.tls_manager.client_certificate is tls.client_certificate
42+
assert harness.charm.tls_manager.peer_certificate is tls.peer_certificate
43+
44+
45+
def test_client_certificate_available_pushes(harness):
46+
charm = harness.charm
47+
with (
48+
patch.object(charm.cluster_manager, "configure_system_passwords"),
49+
patch.object(charm.config_manager, "update_config"),
50+
):
51+
harness.set_leader(True)
52+
53+
tls = charm.tls
54+
tls.client_certificate.get_assigned_certificates = MagicMock(
55+
return_value=_fake_assigned("CC", "CA", "CK")
56+
)
57+
charm.tls_manager.push_tls_files = MagicMock()
58+
59+
tls._on_certificate_available(MagicMock())
60+
61+
# no operator client cert/key is persisted; the push reads live
62+
charm.tls_manager.push_tls_files.assert_called_once()
63+
assert charm.tls_manager.get_client_tls_files() == ("CK", "CA", "CC")
64+
65+
66+
def test_peer_certificate_available_rotates_ca_and_pushes(harness):
67+
charm = harness.charm
68+
with (
69+
patch.object(charm.cluster_manager, "configure_system_passwords"),
70+
patch.object(charm.config_manager, "update_config"),
71+
):
72+
harness.set_leader(True)
73+
74+
tls = charm.tls
75+
tls.peer_certificate.get_assigned_certificates = MagicMock(
76+
return_value=_fake_assigned("PC", "PCA", "PK")
77+
)
78+
charm.tls_manager.push_tls_files = MagicMock()
79+
80+
tls._on_peer_certificate_available(MagicMock())
81+
82+
peer = charm.state.peer
83+
# only the CA is tracked in state for rotation; cert/key stay live
84+
assert peer.current_ca == "PCA"
85+
charm.tls_manager.push_tls_files.assert_called_once()
86+
key, _, cert = charm.tls_manager.get_peer_tls_files()
87+
assert (key, cert) == ("PK", "PC")
88+
89+
90+
def test_certificate_available_pushes_on_empty(harness):
91+
charm = harness.charm
92+
with (
93+
patch.object(charm.cluster_manager, "configure_system_passwords"),
94+
patch.object(charm.config_manager, "update_config"),
95+
):
96+
harness.set_leader(True)
97+
98+
tls = charm.tls
99+
# requirer reports no assigned cert (relation gone) -> getters return nothing
100+
tls.client_certificate.get_assigned_certificates = MagicMock(return_value=([], None))
101+
charm.tls_manager.push_tls_files = MagicMock()
102+
103+
tls._on_certificate_available(MagicMock())
104+
105+
assert charm.tls_manager.get_client_tls_files() == (None, None, None)
106+
charm.tls_manager.push_tls_files.assert_called_once()
107+
108+
109+
def test_peer_certificate_available_clears_ca_on_empty(harness):
110+
charm = harness.charm
111+
with (
112+
patch.object(charm.cluster_manager, "configure_system_passwords"),
113+
patch.object(charm.config_manager, "update_config"),
114+
):
115+
harness.set_leader(True)
116+
117+
tls = charm.tls
118+
# seed a current CA via a prior rotation, then the relation goes empty
119+
charm.tls_manager.rotate_peer_ca("PCA")
120+
tls.peer_certificate.get_assigned_certificates = MagicMock(return_value=([], None))
121+
charm.tls_manager.push_tls_files = MagicMock()
122+
123+
tls._on_peer_certificate_available(MagicMock())
124+
125+
peer = charm.state.peer
126+
assert peer.old_ca == "PCA"
127+
assert peer.current_ca is None
128+
charm.tls_manager.push_tls_files.assert_called_once()
129+
130+
131+
def test_relation_broken_client_wired(harness):
132+
"""relation_broken on TLS_CLIENT_RELATION routes to _on_certificate_available (live push)."""
133+
charm = harness.charm
134+
client_rel_id = harness.add_relation("client-certificates", "tls-provider")
135+
charm.tls_manager.push_tls_files = MagicMock()
136+
harness.remove_relation(client_rel_id)
137+
138+
# With the relation gone, the live getter reports nothing.
139+
assert charm.tls_manager.get_client_tls_files() == (None, None, None)
140+
141+
142+
def test_relation_broken_peer_wired(harness):
143+
"""relation_broken on TLS_PEER_RELATION routes to _on_peer_certificate_available (clears CA)."""
144+
charm = harness.charm
145+
# seed a current CA so the broken path has something to retire
146+
charm.tls_manager.rotate_peer_ca("PCA")
147+
148+
peer_rel_id = harness.add_relation("peer-certificates", "tls-provider")
149+
charm.tls_manager.push_tls_files = MagicMock()
150+
harness.remove_relation(peer_rel_id)
151+
152+
# The broken handler retired the current CA into old-ca and cleared current.
153+
assert charm.state.peer.current_ca is None
154+
assert charm.state.peer.old_ca == "PCA"
155+
156+
157+
def _set_unit_db(harness, key, value):
158+
rel_id = harness.model.get_relation("database-peers").id
159+
harness.update_relation_data(rel_id, harness.charm.unit.name, {key: value})
160+
161+
162+
def test_client_and_peer_requesters_have_distinct_common_names(substrate, harness):
163+
"""Client and peer requesters use distinct CNs drawn from different databag keys.
164+
165+
certificate_requests are baked at init time (before the test updates the databag), so
166+
we verify distinctness via the live state properties (which read directly from the
167+
databag) and confirm both requester objects were constructed.
168+
169+
On VM the CNs are host-derived (database-address / database-peers-address). On K8s
170+
both CNs collapse to the endpoints FQDN (original charm parity) — still distinct from
171+
each other is not required there, only that both are the endpoints FQDN.
172+
"""
173+
_set_unit_db(harness, "database-address", "10.1.2.3")
174+
_set_unit_db(harness, "database-peers-address", "10.4.5.6")
175+
176+
state = harness.charm.state
177+
if substrate == "vm":
178+
# VM: client CN reads database-address, peer CN reads database-peers-address.
179+
assert state.client_common_name == "10.1.2.3"
180+
assert state.peer_common_name == "10.4.5.6"
181+
assert state.client_common_name != state.peer_common_name
182+
else:
183+
# K8s: both CNs are the unit endpoints FQDN (operator-cert parity).
184+
app = state.model.app.name
185+
expected = f"{app}-0.{app}-endpoints"
186+
assert state.client_common_name == expected
187+
assert state.peer_common_name == expected
188+
189+
tls = harness.charm.tls
190+
assert tls.client_certificate is not None
191+
assert tls.peer_certificate is not None
192+
193+
194+
def test_refresh_event_defined_and_wired(harness):
195+
"""refresh_tls_certificates_event exists; peer relation_changed emits it without error."""
196+
tls = harness.charm.tls
197+
assert hasattr(tls, "refresh_tls_certificates_event")
198+
199+
charm = harness.charm
200+
peer_rel_id = harness.model.get_relation("database-peers").id
201+
with (
202+
patch.object(charm.cluster_manager, "configure_system_passwords", return_value=None),
203+
patch.object(charm.config_manager, "update_config", return_value=None),
204+
):
205+
harness.update_relation_data(
206+
peer_rel_id, charm.unit.name, {"database-address": "10.9.8.7"}
207+
)
208+
209+
210+
def test_certificate_available_defers_when_internal_ca_absent(harness):
211+
"""When internal-ca is not yet set, _on_certificate_available defers and skips push.
212+
213+
Mirrors postgresql-operator/src/relations/tls.py lines 157-161: the handler must
214+
not attempt file writes before the CA is present (K8s Pebble may not be ready).
215+
"""
216+
tls = harness.charm.tls
217+
tls.client_certificate.get_assigned_certificates = MagicMock(
218+
return_value=_fake_assigned("CC", "CA", "CK")
219+
)
220+
harness.charm.tls_manager.push_tls_files = MagicMock()
221+
222+
event = MagicMock()
223+
# internal_ca is None because no leader has set it yet
224+
assert harness.charm.state.application.internal_ca is None
225+
tls._on_certificate_available(event)
226+
227+
event.defer.assert_called_once()
228+
harness.charm.tls_manager.push_tls_files.assert_not_called()
229+
230+
231+
def test_peer_certificate_available_defers_when_internal_ca_absent(harness):
232+
"""When internal-ca is not yet set, _on_peer_certificate_available defers and skips push."""
233+
tls = harness.charm.tls
234+
tls.peer_certificate.get_assigned_certificates = MagicMock(
235+
return_value=_fake_assigned("PC", "PCA", "PK")
236+
)
237+
harness.charm.tls_manager.push_tls_files = MagicMock()
238+
239+
event = MagicMock()
240+
assert harness.charm.state.application.internal_ca is None
241+
tls._on_peer_certificate_available(event)
242+
243+
event.defer.assert_called_once()
244+
harness.charm.tls_manager.push_tls_files.assert_not_called()
245+
246+
247+
def test_certificate_available_defers_on_workload_file_error(harness):
248+
"""When push_tls_files raises PostgreSQLFileOperationError, the handler defers.
249+
250+
Mirrors postgresql-operator/src/relations/tls.py lines 162-170: workload
251+
file-write failures (e.g. Pebble not yet ready on K8s) must defer rather
252+
than crash the hook.
253+
"""
254+
charm = harness.charm
255+
with (
256+
patch.object(charm.cluster_manager, "configure_system_passwords"),
257+
patch.object(charm.config_manager, "update_config"),
258+
):
259+
harness.set_leader(True)
260+
261+
tls = charm.tls
262+
tls.client_certificate.get_assigned_certificates = MagicMock(
263+
return_value=_fake_assigned("CC", "CA", "CK")
264+
)
265+
charm.tls_manager.push_tls_files = MagicMock(
266+
side_effect=PostgreSQLFileOperationError("disk full")
267+
)
268+
269+
event = MagicMock()
270+
tls._on_certificate_available(event)
271+
272+
event.defer.assert_called_once()
273+
274+
275+
def test_peer_certificate_available_defers_on_workload_file_error(harness):
276+
"""When push_tls_files raises PostgreSQLFileOperationError, the peer handler defers."""
277+
charm = harness.charm
278+
with (
279+
patch.object(charm.cluster_manager, "configure_system_passwords"),
280+
patch.object(charm.config_manager, "update_config"),
281+
):
282+
harness.set_leader(True)
283+
284+
tls = charm.tls
285+
tls.peer_certificate.get_assigned_certificates = MagicMock(
286+
return_value=_fake_assigned("PC", "PCA", "PK")
287+
)
288+
charm.tls_manager.push_tls_files = MagicMock(
289+
side_effect=PostgreSQLFileOperationError("pebble not ready")
290+
)
291+
292+
event = MagicMock()
293+
tls._on_peer_certificate_available(event)
294+
295+
event.defer.assert_called_once()

0 commit comments

Comments
 (0)