11import socket
22import sys
3+ from contextlib import contextmanager
34from pathlib import Path
5+ from unittest .mock import patch
46
57import pytest
8+ from cryptography import x509
9+ from cryptography .hazmat .backends import default_backend
10+ from cryptography .x509 .oid import ExtensionOID
611from ops import CharmBase
712from scenario import Context , PeerRelation , Relation , State
813
1217
1318libs = str (Path (__file__ ).parent .parent .parent .parent / "lib" )
1419sys .path .append (libs )
20+ MOCK_HOSTNAME = "mock-hostname"
1521
1622
1723class MyCharm (CharmBase ):
@@ -22,8 +28,28 @@ class MyCharm(CharmBase):
2228
2329 def __init__ (self , fw ):
2430 super ().__init__ (fw )
31+ sans = [socket .getfqdn ()]
32+ if hostname := self ._mock_san :
33+ sans .append (hostname )
2534
26- self .ch = CertHandler (self , key = "ch" , sans = [socket .getfqdn ()])
35+ self .ch = CertHandler (self , key = "ch" , sans = sans , refresh_events = [self .on .config_changed ])
36+
37+ @property
38+ def _mock_san (self ):
39+ """This property is meant to be mocked to return a mock string hostname to be used as SAN.
40+
41+ By default, it returns None.
42+ """
43+ return None
44+
45+
46+ def get_csr_obj (csr : str ):
47+ return x509 .load_pem_x509_csr (csr .encode (), default_backend ())
48+
49+
50+ def get_sans_from_csr (csr ):
51+ san_extension = csr .extensions .get_extension_for_oid (ExtensionOID .SUBJECT_ALTERNATIVE_NAME )
52+ return set (san_extension .value .get_values_for_type (x509 .DNSName ))
2753
2854
2955@pytest .fixture
@@ -36,6 +62,20 @@ def certificates():
3662 return Relation ("certificates" )
3763
3864
65+ @contextmanager
66+ def _sans_patch (hostname = MOCK_HOSTNAME ):
67+ with patch .object (MyCharm , "_mock_san" , hostname ):
68+ yield
69+
70+
71+ @contextmanager
72+ def _cert_renew_patch ():
73+ with patch (
74+ "charms.tls_certificates_interface.v3.tls_certificates.TLSCertificatesRequiresV3.request_certificate_renewal"
75+ ) as patcher :
76+ yield patcher
77+
78+
3979@pytest .mark .parametrize ("leader" , (True , False ))
4080def test_cert_joins (ctx , certificates , leader ):
4181 with ctx .manager (
@@ -72,3 +112,65 @@ def test_cert_joins_peer_vault_backend(ctx_juju2, certificates, leader):
72112 ) as mgr :
73113 mgr .run ()
74114 assert mgr .charm .ch .private_key
115+
116+
117+ def test_renew_csr_on_sans_change (ctx , certificates ):
118+ # generate a CSR
119+ with ctx .manager (
120+ certificates .joined_event ,
121+ State (leader = True , relations = [certificates ]),
122+ ) as mgr :
123+ charm = mgr .charm
124+ state_out = mgr .run ()
125+ orig_csr = get_csr_obj (charm .ch ._csr )
126+ assert get_sans_from_csr (orig_csr ) == {socket .getfqdn ()}
127+
128+ # trigger a config_changed with a modified SAN
129+ with _sans_patch ():
130+ with ctx .manager ("config_changed" , state_out ) as mgr :
131+ charm = mgr .charm
132+ state_out = mgr .run ()
133+ csr = get_csr_obj (charm .ch ._csr )
134+ # assert CSR contains updated SAN
135+ assert get_sans_from_csr (csr ) == {socket .getfqdn (), MOCK_HOSTNAME }
136+
137+
138+ def test_csr_no_change_on_wrong_refresh_event (ctx , certificates ):
139+ with _cert_renew_patch () as renew_patch :
140+ with ctx .manager (
141+ "config_changed" ,
142+ State (leader = True , relations = [certificates ]),
143+ ) as mgr :
144+ charm = mgr .charm
145+ state_out = mgr .run ()
146+ orig_csr = get_csr_obj (charm .ch ._csr )
147+ assert get_sans_from_csr (orig_csr ) == {socket .getfqdn ()}
148+
149+ with _sans_patch ():
150+ with _cert_renew_patch () as renew_patch :
151+ with ctx .manager ("update_status" , state_out ) as mgr :
152+ charm = mgr .charm
153+ state_out = mgr .run ()
154+ csr = get_csr_obj (charm .ch ._csr )
155+ assert get_sans_from_csr (csr ) == {socket .getfqdn ()}
156+ assert renew_patch .call_count == 0
157+
158+
159+ def test_csr_no_change (ctx , certificates ):
160+
161+ with ctx .manager (
162+ "config_changed" ,
163+ State (leader = True , relations = [certificates ]),
164+ ) as mgr :
165+ charm = mgr .charm
166+ state_out = mgr .run ()
167+ orig_csr = get_csr_obj (charm .ch ._csr )
168+ assert get_sans_from_csr (orig_csr ) == {socket .getfqdn ()}
169+
170+ with _cert_renew_patch () as renew_patch :
171+ with ctx .manager ("config_changed" , state_out ) as mgr :
172+ charm = mgr .charm
173+ state_out = mgr .run ()
174+ csr = get_csr_obj (charm .ch ._csr )
175+ assert get_sans_from_csr (csr ) == {socket .getfqdn ()}
176+ assert renew_patch .call_count == 0
0 commit comments