Skip to content

Commit 6c94eeb

Browse files
[DPE-10609] Unit tests for the operator-cert manager + handler (4/4) (#175)
* feat(tls): add TLS state and workload file primitives Introduce the low-level building blocks that the operator-certificate TLS flow composes on top of, so they can be reviewed on their own, ahead of the manager, events handler and charm wiring that consume them. This covers the peer-relation databag accessors for CA rotation (current-ca/old-ca), the client-facing database-address, and the K8s-shaped peer address set that omits the ip key for parity with the pre-migration K8s charm; the workload file-ownership/mode primitives (user/group and the substrate-specific tls_file_mode, 0o600 on VM and 0o400 on K8s) plus user/group forwarding through write_text so TLS material is chowned correctly on both substrates; a per-substrate tls path; the TLS relation-name constants; and the unit-test harness fixture the later branches' TLS tests depend on. The change is purely additive: the existing charm still constructs unchanged and the current unit suite is unaffected, which keeps this branch a safe, self-contained foundation for the stack. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * fix(tls): keep K8s patroni_conf at the data dir (match #172) The TLS stack had overridden the K8s patroni_conf path to /etc/patroni, a vestige of an earlier TLS-hardening lineage. #172's Patroni port renders patroni.yaml at patroni_conf, so it must stay the data storage root (/var/lib/pg/data); the override made a consuming charm run 'patroni /etc/patroni/patroni.yaml' against a config rendered elsewhere. TLS writes its .pem files to the separate 'tls' path (also the data dir) and does not read patroni_conf, so this revert is TLS-safe. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * feat(tls): add substrate-aware CharmState TLS SAN/CN accessors Layer the certificate-SAN and common-name policy onto CharmState, on top of the raw peer-databag accessors from the previous branch, so the substrate-specific certificate identity is reviewable as a unit with its own tests before any manager or handler consumes it. K8s must regain the parity the migration had dropped: common_hosts has to advertise the primary/replicas Service FQDNs and the resolved pod FQDN, and the operator-cert common name has to be the endpoints FQDN (wildcarded past the 64-char CN limit) rather than the VM-style host/address; the peer SAN set must exclude the ip key the original K8s charm never emitted. VM behaviour is left host/address-derived as before. The CharmState charm parameter is also widened to ops.CharmBase so the state object no longer depends on the concrete charm type. These accessors are additive and only read state, so the existing charm keeps constructing unchanged. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * docs(tls): drop migration-history prose from _k8s_cert_common_name docstring The 'migration had switched this to VM-style ...; restore the endpoints-FQDN CN for K8s parity' sentence narrates a completed regression fix and is redundant with the preceding 'Matches the original K8s charm' line. The format, wildcard rule, and parity rationale already carry the load-bearing context; the migration history belongs in the original commit message, not the docstring. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * feat(tls): operator-cert manager, events handler, and wiring Wire the operator-certificate TLSManager and TLS events handler into the lib charm and bump the library version. The manager fetches operator cert/key live from the tls_certificates V4 requirers (constructor-injected by the handler); only the peer CA is tracked in state for rotation. Unit tests for this layer land in the stacked tests PR. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * 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. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * docs(tls): trim cross-repo refs and overclaim from operator-cert tests The 4/4 test docstrings pointed at postgresql-operator/src/relations/tls.py file/line positions and a '16/edge secrets, kept' track label — both stale-prone the moment that repo refactors or the track shifts, with no test catching the drift. Replace with conceptual references to the pre-port charm's defer guards and the state-tracked rotation, which survive refactors; git blame recovers the exact position when needed. Also soften test_relation_broken_client_wired's docstring: it asserts only that removal doesn't crash and the live getter reports absent, not that the event routes to _on_certificate_available (the peer variant verifies routing via the CA side-effect; the client route has no state side-effect to diff on). No test logic changes; suite stays 56/56 green on both substrates. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test(tls): de-duplicate helpers, stub crypto, strengthen three weak tests Lift the requirer-return-shape fakes (FakePrivateKey + fake_assigned) out of the two TLS test files into a shared tests/unit/tls_helpers.py, and add a patch_crypto conftest fixture that stubs generate_private_key/generate_ca with sentinels. The incidental set_leader paths were running real RSA keygen purely to populate internal-ca; the dedicated generate_* tests keep their own sentinels to assert call args, so this only touches paths that need the CA present, not its content. Suite runtime drops 4.2s -> 1.9s. Three tests sat below the patroni/config bar and are tightened, none tautological after the change (each mutation-confirmed to fail when the pinned behavior breaks): - test_tls_manager_wired_to_handler_requirers (was constructor_injects_requirers): drop the two asserts that only verified `self.x = x` plumbing; keep the real wiring asserts (charm injects the handler's requirers into the manager). - test_peer_relation_changed_emits_refresh (was refresh_event_defined_and_wired): drop the hasattr tautology + assertion-less relation_changed smoke; patch TLS.refresh_tls_certificates_event and assert emit() was called once. - test_relation_broken_client_routes_to_live_push (was relation_broken_client_wired): the client route has no CA side-effect, so the old test only proved it didn't crash; now set leader + seed a peer CA, remove the client relation, and assert the push was reached while peer CA rotation stayed untouched — proving the route is the client path, not the peer path. No source or other test changes; the two pre-existing test_patroni_manager.py daemon_reload failures are unrelated (charmlibs.systemd symbol not resolving in this env) and untouched here. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test(tls): drop redundant wiring test, cover the TlsError guards The previous round left test_tls_manager_wired_to_handler_requirers asserting only `manager.{client,peer}_certificate is tls.{client,peer}_certificate` — the exact two checks test_handler_is_wired in the events file already makes (plus the not-None construction asserts). The charm-wiring concern has one home (the events file, since wiring is a charm __init__ concern); the manager-side copy added nothing. Delete it. generate_internal_peer_cert had two raise TlsError guards — "No CA key content" and "No CA cert content" — exercised by no test (the stores_material test seeds both secrets). Add pytest.raises coverage for each: the CA-key guard raises before any crypto runs (no leader, no secrets); the CA-cert guard runs after PrivateKey.from_string, so it stubs that and drops only the CA cert. Both mutation-confirmed (removing the guard makes each test fail with DID-NOT-RAISE). Also give test_generate_internal_peer_ca_stores_secrets the patch_crypto fixture and drop its now-redundant inner generate_* patches (patch_crypto stubs the same symbols with the same sentinels for both the set_leader side-effect and the explicit call) — it had been running real RSA during set_leader, then overwriting it with sentinels. No source changes; suite is 58 TLS tests on both substrates. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> * test(tls): drop five tests that re-prove already-covered behavior Each dropped test was a second layer over a code path another test already pins; keeping both is the two-layers-that-sort-of-do smell. None of the five guarded a behavior the surviving suite doesn't. - test_rotate_peer_ca_sets_current: first-set + old-ca-None. Covered by no_rotation_when_unchanged's first call (sets current, leaves old None) and the else-branch remove_secret in clears_stale_old_ca_on_reenable. The rotation state machine stays fully covered (rotate, no-op, clear, re-enable-clear). - test_get_client_tls_files_none_when_absent: the absent getter path. Covered by test_certificate_available_pushes_on_empty, which asserts get_client_tls_files == (None, None, None) on an empty requirer. - test_client_and_peer_requesters_have_distinct_common_names: the CN values live in test_tls_state.py (three tests); requester construction is in test_handler_is_wired. - test_peer_certificate_available_defers_when_internal_ca_absent and test_peer_certificate_available_defers_on_workload_file_error: the defer guards live in the shared _push_tls_files. The client variant proves each guard; the peer handler's routing through _push_tls_files is proven by test_peer_certificate_available_rotates_ca_and_pushes. The two surviving defer tests now note they cover the peer path too (shared guard). Suite: 58 -> 48 TLS tests on both substrates. Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com> --------- Signed-off-by: Marcelo Henrique Neppel <marcelo.neppel@canonical.com>
1 parent 834c90e commit 6c94eeb

4 files changed

Lines changed: 528 additions & 1 deletion

File tree

tests/unit/conftest.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,36 @@
11
# Copyright 2026 Canonical Ltd.
22
# See LICENSE file for licensing details.
33

4-
from unittest.mock import patch
4+
from unittest.mock import patch, sentinel
55

66
import pytest
77
from ops.testing import Harness
88
from single_kernel_postgresql.charms import k8s_charm, vm_charm
99
from single_kernel_postgresql.config.literals import PEER_RELATION
1010

1111

12+
@pytest.fixture
13+
def patch_crypto():
14+
"""Stub the tls-crypto generators so leader-elected paths skip real RSA keygen.
15+
16+
set_leader() fires _on_leader_elected -> configure_internal_peer_ca, which would
17+
otherwise run real generate_private_key/generate_ca. The dedicated generate_* tests
18+
patch with their own sentinels to assert call args; this fixture is for the
19+
incidental paths that only need internal-ca to be present, not its content.
20+
"""
21+
with (
22+
patch(
23+
"single_kernel_postgresql.managers.tls.generate_private_key",
24+
return_value=sentinel.ca_key,
25+
),
26+
patch(
27+
"single_kernel_postgresql.managers.tls.generate_ca",
28+
return_value=sentinel.ca,
29+
),
30+
):
31+
yield
32+
33+
1234
@pytest.fixture
1335
def harness(substrate, test_charm_path):
1436
"""A begun Harness for the substrate's test charm, with the peer relation added."""

tests/unit/test_tls_events.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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, matching the pre-port charm's peer-cert handler
8+
(which stashes current-ca/old-ca and otherwise reads live).
9+
"""
10+
11+
from unittest.mock import MagicMock, patch
12+
13+
from single_kernel_postgresql.config.exceptions import PostgreSQLFileOperationError
14+
from tls_helpers import fake_assigned
15+
16+
17+
def test_handler_is_wired(harness):
18+
tls = harness.charm.tls
19+
assert tls.client_certificate is not None
20+
assert tls.peer_certificate is not None
21+
# the handler wires its requirers onto the manager so the live-fetch getters work
22+
assert harness.charm.tls_manager.client_certificate is tls.client_certificate
23+
assert harness.charm.tls_manager.peer_certificate is tls.peer_certificate
24+
25+
26+
def test_client_certificate_available_pushes(harness, patch_crypto):
27+
charm = harness.charm
28+
with (
29+
patch.object(charm.cluster_manager, "configure_system_passwords"),
30+
patch.object(charm.config_manager, "update_config"),
31+
):
32+
harness.set_leader(True)
33+
34+
tls = charm.tls
35+
tls.client_certificate.get_assigned_certificates = MagicMock(
36+
return_value=fake_assigned("CC", "CA", "CK")
37+
)
38+
charm.tls_manager.push_tls_files = MagicMock()
39+
40+
tls._on_certificate_available(MagicMock())
41+
42+
# no operator client cert/key is persisted; the push reads live
43+
charm.tls_manager.push_tls_files.assert_called_once()
44+
assert charm.tls_manager.get_client_tls_files() == ("CK", "CA", "CC")
45+
46+
47+
def test_peer_certificate_available_rotates_ca_and_pushes(harness, patch_crypto):
48+
charm = harness.charm
49+
with (
50+
patch.object(charm.cluster_manager, "configure_system_passwords"),
51+
patch.object(charm.config_manager, "update_config"),
52+
):
53+
harness.set_leader(True)
54+
55+
tls = charm.tls
56+
tls.peer_certificate.get_assigned_certificates = MagicMock(
57+
return_value=fake_assigned("PC", "PCA", "PK")
58+
)
59+
charm.tls_manager.push_tls_files = MagicMock()
60+
61+
tls._on_peer_certificate_available(MagicMock())
62+
63+
peer = charm.state.peer
64+
# only the CA is tracked in state for rotation; cert/key stay live
65+
assert peer.current_ca == "PCA"
66+
charm.tls_manager.push_tls_files.assert_called_once()
67+
key, _, cert = charm.tls_manager.get_peer_tls_files()
68+
assert (key, cert) == ("PK", "PC")
69+
70+
71+
def test_certificate_available_pushes_on_empty(harness, patch_crypto):
72+
charm = harness.charm
73+
with (
74+
patch.object(charm.cluster_manager, "configure_system_passwords"),
75+
patch.object(charm.config_manager, "update_config"),
76+
):
77+
harness.set_leader(True)
78+
79+
tls = charm.tls
80+
# requirer reports no assigned cert (relation gone) -> getters return nothing
81+
tls.client_certificate.get_assigned_certificates = MagicMock(return_value=([], None))
82+
charm.tls_manager.push_tls_files = MagicMock()
83+
84+
tls._on_certificate_available(MagicMock())
85+
86+
assert charm.tls_manager.get_client_tls_files() == (None, None, None)
87+
charm.tls_manager.push_tls_files.assert_called_once()
88+
89+
90+
def test_peer_certificate_available_clears_ca_on_empty(harness, patch_crypto):
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+
# seed a current CA via a prior rotation, then the relation goes empty
100+
charm.tls_manager.rotate_peer_ca("PCA")
101+
tls.peer_certificate.get_assigned_certificates = MagicMock(return_value=([], None))
102+
charm.tls_manager.push_tls_files = MagicMock()
103+
104+
tls._on_peer_certificate_available(MagicMock())
105+
106+
peer = charm.state.peer
107+
assert peer.old_ca == "PCA"
108+
assert peer.current_ca is None
109+
charm.tls_manager.push_tls_files.assert_called_once()
110+
111+
112+
def test_relation_broken_client_routes_to_live_push(harness, patch_crypto):
113+
"""relation_broken on TLS_CLIENT_RELATION routes to the live-push path.
114+
115+
Distinct from the peer variant (which retires the CA): the client route only pushes,
116+
so routing is verified by the push being reached (internal-ca present via leadership)
117+
while peer CA rotation is left untouched.
118+
"""
119+
charm = harness.charm
120+
# seed a peer CA so we can prove the client route does not touch peer rotation
121+
charm.tls_manager.rotate_peer_ca("PCA")
122+
with (
123+
patch.object(charm.cluster_manager, "configure_system_passwords"),
124+
patch.object(charm.config_manager, "update_config"),
125+
):
126+
harness.set_leader(True) # internal-ca present so the route reaches push
127+
charm.tls_manager.push_tls_files = MagicMock()
128+
129+
client_rel_id = harness.add_relation("client-certificates", "tls-provider")
130+
harness.remove_relation(client_rel_id)
131+
132+
# the client broken route reached the live push; peer CA rotation was not touched
133+
charm.tls_manager.push_tls_files.assert_called_once()
134+
assert charm.state.peer.current_ca == "PCA"
135+
assert charm.state.peer.old_ca is None
136+
# with the relation gone, the live getter reports nothing
137+
assert charm.tls_manager.get_client_tls_files() == (None, None, None)
138+
139+
140+
def test_relation_broken_peer_wired(harness):
141+
"""relation_broken on TLS_PEER_RELATION routes to _on_peer_certificate_available (clears CA)."""
142+
charm = harness.charm
143+
# seed a current CA so the broken path has something to retire
144+
charm.tls_manager.rotate_peer_ca("PCA")
145+
146+
peer_rel_id = harness.add_relation("peer-certificates", "tls-provider")
147+
charm.tls_manager.push_tls_files = MagicMock()
148+
harness.remove_relation(peer_rel_id)
149+
150+
# The broken handler retired the current CA into old-ca and cleared current.
151+
assert charm.state.peer.current_ca is None
152+
assert charm.state.peer.old_ca == "PCA"
153+
154+
155+
def test_peer_relation_changed_emits_refresh(harness):
156+
"""Peer relation_changed emits refresh_tls_certificates_event to re-request certs with updated SANs."""
157+
from single_kernel_postgresql.events.tls import TLS
158+
159+
charm = harness.charm
160+
with patch.object(TLS, "refresh_tls_certificates_event") as _refresh:
161+
charm.tls._on_peer_relation_changed(MagicMock())
162+
163+
_refresh.emit.assert_called_once()
164+
165+
166+
def test_certificate_available_defers_when_internal_ca_absent(harness):
167+
"""When internal-ca is not yet set, _on_certificate_available defers and skips push.
168+
169+
The handler must not attempt file writes before the CA is present (K8s Pebble
170+
may not be ready), matching the pre-port charm's defer-before-CA-present guard.
171+
The defer guard lives in the shared _push_tls_files, so this also covers the peer
172+
handler's path (both route through it).
173+
"""
174+
tls = harness.charm.tls
175+
tls.client_certificate.get_assigned_certificates = MagicMock(
176+
return_value=fake_assigned("CC", "CA", "CK")
177+
)
178+
harness.charm.tls_manager.push_tls_files = MagicMock()
179+
180+
event = MagicMock()
181+
# internal_ca is None because no leader has set it yet
182+
assert harness.charm.state.application.internal_ca is None
183+
tls._on_certificate_available(event)
184+
185+
event.defer.assert_called_once()
186+
harness.charm.tls_manager.push_tls_files.assert_not_called()
187+
188+
189+
def test_certificate_available_defers_on_workload_file_error(harness, patch_crypto):
190+
"""When push_tls_files raises PostgreSQLFileOperationError, the handler defers.
191+
192+
Workload file-write failures (e.g. Pebble not yet ready on K8s) must defer
193+
rather than crash the hook, matching the pre-port charm's defer-on-write-failure guard.
194+
The guard lives in the shared _push_tls_files, so this also covers the peer handler's path.
195+
"""
196+
charm = harness.charm
197+
with (
198+
patch.object(charm.cluster_manager, "configure_system_passwords"),
199+
patch.object(charm.config_manager, "update_config"),
200+
):
201+
harness.set_leader(True)
202+
203+
tls = charm.tls
204+
tls.client_certificate.get_assigned_certificates = MagicMock(
205+
return_value=fake_assigned("CC", "CA", "CK")
206+
)
207+
charm.tls_manager.push_tls_files = MagicMock(
208+
side_effect=PostgreSQLFileOperationError("disk full")
209+
)
210+
211+
event = MagicMock()
212+
tls._on_certificate_available(event)
213+
214+
event.defer.assert_called_once()

0 commit comments

Comments
 (0)