Skip to content

Commit 0d5017f

Browse files
committed
test: add unit tests for external CA support
Add 13 new tests covering reconcileExternalCA, buildExternalCAHTTPClient, and external CA phase acceptance in the certificate controller. - testutil_test.go: add caOption type with withExternal, withExternalCASecret, withExternalTLSSecret, withExternalInsecureSkipVerify options - certificateauthority_controller_test.go: 6 tests for external CA reconciler (basic flow, custom CA secret, missing secret, missing key, no PVC/Job, no Config) - certificate_controller_test.go: 1 test for External phase acceptance - certificate_signing_test.go: 6 tests for buildExternalCAHTTPClient (minimal, insecure skip verify, CA secret, missing secret, mTLS, missing key)
1 parent f51478e commit 0d5017f

4 files changed

Lines changed: 378 additions & 1 deletion

File tree

internal/controller/certificate_controller_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,35 @@ func TestCertReconcile_PhasePending(t *testing.T) {
129129
t.Errorf("expected phase %q, got %q", openvoxv1alpha1.CertificatePhasePending, updated.Status.Phase)
130130
}
131131
}
132+
133+
func TestCertReconcile_CAExternalPhase_Accepted(t *testing.T) {
134+
cert := newCertificate("my-cert", "ext-ca", "")
135+
ca := newCertificateAuthority("ext-ca", withExternal("https://puppet-ca.example.com:8140"))
136+
ca.Status.Phase = openvoxv1alpha1.CertificateAuthorityPhaseExternal
137+
// Pre-create the TLS secret so reconcile completes without HTTP calls
138+
tlsSecret := newSecret("my-cert-tls", map[string][]byte{
139+
"cert.pem": []byte("signed-cert"),
140+
"key.pem": []byte("private-key"),
141+
})
142+
143+
c := setupTestClient(cert, ca, tlsSecret)
144+
r := newCertificateReconciler(c)
145+
146+
res, err := r.Reconcile(testCtx(), testRequest("my-cert"))
147+
if err != nil {
148+
t.Fatalf("unexpected error: %v", err)
149+
}
150+
151+
// Should NOT requeue with 10s (that would mean CA was treated as not-ready)
152+
if res.RequeueAfter == 10*time.Second {
153+
t.Error("certificate should not wait for CA when phase is External")
154+
}
155+
156+
updated := &openvoxv1alpha1.Certificate{}
157+
if err := c.Get(testCtx(), types.NamespacedName{Name: "my-cert", Namespace: testNamespace}, updated); err != nil {
158+
t.Fatalf("failed to get Certificate: %v", err)
159+
}
160+
if updated.Status.Phase != openvoxv1alpha1.CertificatePhaseSigned {
161+
t.Errorf("expected phase %q, got %q", openvoxv1alpha1.CertificatePhaseSigned, updated.Status.Phase)
162+
}
163+
}

internal/controller/certificate_signing_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
package controller
22

33
import (
4+
"crypto/rand"
5+
"crypto/rsa"
6+
"crypto/x509"
7+
"crypto/x509/pkix"
8+
"encoding/pem"
9+
"math/big"
10+
"net/http"
411
"testing"
512
"time"
13+
14+
openvoxv1alpha1 "github.com/slauger/openvox-operator/api/v1alpha1"
615
)
716

817
func TestCSRPollBackoff(t *testing.T) {
@@ -32,3 +41,145 @@ func TestCSRPollBackoff(t *testing.T) {
3241
}
3342
}
3443
}
44+
45+
// generateTestCert creates a self-signed CA certificate and key pair for testing.
46+
// Returns PEM-encoded certificate, PEM-encoded private key.
47+
func generateTestCert(t *testing.T) ([]byte, []byte) {
48+
t.Helper()
49+
key, err := rsa.GenerateKey(rand.Reader, 2048)
50+
if err != nil {
51+
t.Fatalf("generating test key: %v", err)
52+
}
53+
tmpl := &x509.Certificate{
54+
SerialNumber: big.NewInt(1),
55+
Subject: pkix.Name{CommonName: "test-ca"},
56+
NotBefore: time.Now().Add(-time.Hour),
57+
NotAfter: time.Now().Add(24 * time.Hour),
58+
IsCA: true,
59+
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
60+
}
61+
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
62+
if err != nil {
63+
t.Fatalf("creating test certificate: %v", err)
64+
}
65+
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
66+
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
67+
return certPEM, keyPEM
68+
}
69+
70+
func TestBuildExternalCAHTTPClient_Minimal(t *testing.T) {
71+
ext := &openvoxv1alpha1.ExternalCASpec{
72+
URL: "https://puppet-ca.example.com:8140",
73+
}
74+
c := setupTestClient()
75+
76+
httpClient, err := buildExternalCAHTTPClient(testCtx(), c, ext, testNamespace)
77+
if err != nil {
78+
t.Fatalf("unexpected error: %v", err)
79+
}
80+
if httpClient == nil {
81+
t.Fatal("expected non-nil HTTP client")
82+
}
83+
84+
transport := httpClient.Transport.(*http.Transport)
85+
if transport.TLSClientConfig.InsecureSkipVerify {
86+
t.Error("expected InsecureSkipVerify=false")
87+
}
88+
if transport.TLSClientConfig.RootCAs != nil {
89+
t.Error("expected no custom RootCAs")
90+
}
91+
}
92+
93+
func TestBuildExternalCAHTTPClient_InsecureSkipVerify(t *testing.T) {
94+
ext := &openvoxv1alpha1.ExternalCASpec{
95+
URL: "https://puppet-ca.example.com:8140",
96+
InsecureSkipVerify: true,
97+
}
98+
c := setupTestClient()
99+
100+
httpClient, err := buildExternalCAHTTPClient(testCtx(), c, ext, testNamespace)
101+
if err != nil {
102+
t.Fatalf("unexpected error: %v", err)
103+
}
104+
105+
transport := httpClient.Transport.(*http.Transport)
106+
if !transport.TLSClientConfig.InsecureSkipVerify {
107+
t.Error("expected InsecureSkipVerify=true")
108+
}
109+
}
110+
111+
func TestBuildExternalCAHTTPClient_WithCASecret(t *testing.T) {
112+
certPEM, _ := generateTestCert(t)
113+
caSecret := newSecret("ca-secret", map[string][]byte{
114+
"ca_crt.pem": certPEM,
115+
})
116+
ext := &openvoxv1alpha1.ExternalCASpec{
117+
URL: "https://puppet-ca.example.com:8140",
118+
CASecretRef: "ca-secret",
119+
}
120+
c := setupTestClient(caSecret)
121+
122+
httpClient, err := buildExternalCAHTTPClient(testCtx(), c, ext, testNamespace)
123+
if err != nil {
124+
t.Fatalf("unexpected error: %v", err)
125+
}
126+
127+
transport := httpClient.Transport.(*http.Transport)
128+
if transport.TLSClientConfig.RootCAs == nil {
129+
t.Error("expected custom RootCAs pool")
130+
}
131+
}
132+
133+
func TestBuildExternalCAHTTPClient_CASecretNotFound(t *testing.T) {
134+
ext := &openvoxv1alpha1.ExternalCASpec{
135+
URL: "https://puppet-ca.example.com:8140",
136+
CASecretRef: "missing-secret",
137+
}
138+
c := setupTestClient()
139+
140+
_, err := buildExternalCAHTTPClient(testCtx(), c, ext, testNamespace)
141+
if err == nil {
142+
t.Fatal("expected error when CA secret is missing")
143+
}
144+
}
145+
146+
func TestBuildExternalCAHTTPClient_WithTLSSecret(t *testing.T) {
147+
certPEM, keyPEM := generateTestCert(t)
148+
tlsSecret := newSecret("tls-secret", map[string][]byte{
149+
"tls.crt": certPEM,
150+
"tls.key": keyPEM,
151+
})
152+
ext := &openvoxv1alpha1.ExternalCASpec{
153+
URL: "https://puppet-ca.example.com:8140",
154+
TLSSecretRef: "tls-secret",
155+
}
156+
c := setupTestClient(tlsSecret)
157+
158+
httpClient, err := buildExternalCAHTTPClient(testCtx(), c, ext, testNamespace)
159+
if err != nil {
160+
t.Fatalf("unexpected error: %v", err)
161+
}
162+
163+
transport := httpClient.Transport.(*http.Transport)
164+
if len(transport.TLSClientConfig.Certificates) != 1 {
165+
t.Errorf("expected 1 client certificate, got %d", len(transport.TLSClientConfig.Certificates))
166+
}
167+
}
168+
169+
func TestBuildExternalCAHTTPClient_TLSSecretMissingKey(t *testing.T) {
170+
certPEM, _ := generateTestCert(t)
171+
// Secret has tls.crt but missing tls.key
172+
tlsSecret := newSecret("tls-secret", map[string][]byte{
173+
"tls.crt": certPEM,
174+
})
175+
ext := &openvoxv1alpha1.ExternalCASpec{
176+
URL: "https://puppet-ca.example.com:8140",
177+
TLSSecretRef: "tls-secret",
178+
}
179+
c := setupTestClient(tlsSecret)
180+
181+
_, err := buildExternalCAHTTPClient(testCtx(), c, ext, testNamespace)
182+
if err == nil {
183+
t.Fatal("expected error when TLS secret is missing tls.key")
184+
}
185+
}

internal/controller/certificateauthority_controller_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,3 +351,156 @@ func TestResolveCAJobResources_Custom(t *testing.T) {
351351
t.Errorf("expected memory limit 2Gi, got %s", res.Limits.Memory().String())
352352
}
353353
}
354+
355+
// --- External CA tests ---
356+
357+
func TestCAReconcile_ExternalCA_Basic(t *testing.T) {
358+
ca := newCertificateAuthority("ext-ca", withExternal("https://puppet-ca.example.com:8140"))
359+
ca.Status.Phase = "" // reset to trigger initial phase
360+
c := setupTestClient(ca)
361+
r := newCertificateAuthorityReconciler(c)
362+
363+
if _, err := r.Reconcile(testCtx(), testRequest("ext-ca")); err != nil {
364+
t.Fatalf("reconcile error: %v", err)
365+
}
366+
367+
updated := &openvoxv1alpha1.CertificateAuthority{}
368+
if err := c.Get(testCtx(), types.NamespacedName{Name: "ext-ca", Namespace: testNamespace}, updated); err != nil {
369+
t.Fatalf("failed to get CA: %v", err)
370+
}
371+
372+
if updated.Status.Phase != openvoxv1alpha1.CertificateAuthorityPhaseExternal {
373+
t.Errorf("expected phase %q, got %q", openvoxv1alpha1.CertificateAuthorityPhaseExternal, updated.Status.Phase)
374+
}
375+
if updated.Status.CASecretName != "ext-ca-ca" {
376+
t.Errorf("expected CASecretName %q, got %q", "ext-ca-ca", updated.Status.CASecretName)
377+
}
378+
379+
// Verify CAReady condition with ExternalCA reason
380+
found := false
381+
for _, cond := range updated.Status.Conditions {
382+
if cond.Type == openvoxv1alpha1.ConditionCAReady {
383+
found = true
384+
if cond.Status != "True" {
385+
t.Errorf("expected condition status True, got %q", cond.Status)
386+
}
387+
if cond.Reason != "ExternalCA" {
388+
t.Errorf("expected condition reason ExternalCA, got %q", cond.Reason)
389+
}
390+
}
391+
}
392+
if !found {
393+
t.Error("CAReady condition not set")
394+
}
395+
}
396+
397+
func TestCAReconcile_ExternalCA_WithCASecretRef(t *testing.T) {
398+
ca := newCertificateAuthority("ext-ca",
399+
withExternal("https://puppet-ca.example.com:8140"),
400+
withExternalCASecret("my-custom-ca-secret"),
401+
)
402+
ca.Status.Phase = ""
403+
caSecret := newSecret("my-custom-ca-secret", map[string][]byte{
404+
"ca_crt.pem": []byte("ca-cert-data"),
405+
})
406+
c := setupTestClient(ca, caSecret)
407+
r := newCertificateAuthorityReconciler(c)
408+
409+
if _, err := r.Reconcile(testCtx(), testRequest("ext-ca")); err != nil {
410+
t.Fatalf("reconcile error: %v", err)
411+
}
412+
413+
updated := &openvoxv1alpha1.CertificateAuthority{}
414+
if err := c.Get(testCtx(), types.NamespacedName{Name: "ext-ca", Namespace: testNamespace}, updated); err != nil {
415+
t.Fatalf("failed to get CA: %v", err)
416+
}
417+
418+
if updated.Status.CASecretName != "my-custom-ca-secret" {
419+
t.Errorf("expected CASecretName %q, got %q", "my-custom-ca-secret", updated.Status.CASecretName)
420+
}
421+
}
422+
423+
func TestCAReconcile_ExternalCA_CASecretNotFound(t *testing.T) {
424+
ca := newCertificateAuthority("ext-ca",
425+
withExternal("https://puppet-ca.example.com:8140"),
426+
withExternalCASecret("missing-secret"),
427+
)
428+
ca.Status.Phase = ""
429+
c := setupTestClient(ca)
430+
r := newCertificateAuthorityReconciler(c)
431+
432+
res, err := r.Reconcile(testCtx(), testRequest("ext-ca"))
433+
if err != nil {
434+
t.Fatalf("unexpected error: %v", err)
435+
}
436+
if res.RequeueAfter != 5*time.Second {
437+
t.Errorf("expected requeue after 5s, got %v", res.RequeueAfter)
438+
}
439+
}
440+
441+
func TestCAReconcile_ExternalCA_CASecretMissingKey(t *testing.T) {
442+
ca := newCertificateAuthority("ext-ca",
443+
withExternal("https://puppet-ca.example.com:8140"),
444+
withExternalCASecret("bad-secret"),
445+
)
446+
ca.Status.Phase = ""
447+
// Secret exists but lacks the ca_crt.pem key
448+
badSecret := newSecret("bad-secret", map[string][]byte{
449+
"wrong-key": []byte("data"),
450+
})
451+
c := setupTestClient(ca, badSecret)
452+
r := newCertificateAuthorityReconciler(c)
453+
454+
res, err := r.Reconcile(testCtx(), testRequest("ext-ca"))
455+
if err != nil {
456+
t.Fatalf("unexpected error: %v", err)
457+
}
458+
if res.RequeueAfter != 5*time.Second {
459+
t.Errorf("expected requeue after 5s, got %v", res.RequeueAfter)
460+
}
461+
}
462+
463+
func TestCAReconcile_ExternalCA_SkipsPVCAndJob(t *testing.T) {
464+
ca := newCertificateAuthority("ext-ca", withExternal("https://puppet-ca.example.com:8140"))
465+
ca.Status.Phase = ""
466+
c := setupTestClient(ca)
467+
r := newCertificateAuthorityReconciler(c)
468+
469+
if _, err := r.Reconcile(testCtx(), testRequest("ext-ca")); err != nil {
470+
t.Fatalf("reconcile error: %v", err)
471+
}
472+
473+
// No PVC should be created
474+
pvc := &corev1.PersistentVolumeClaim{}
475+
if err := c.Get(testCtx(), types.NamespacedName{Name: "ext-ca-data", Namespace: testNamespace}, pvc); err == nil {
476+
t.Error("expected no PVC for external CA, but one was created")
477+
}
478+
479+
// No Job should be created
480+
job := &batchv1.Job{}
481+
if err := c.Get(testCtx(), types.NamespacedName{Name: "ext-ca-ca-setup", Namespace: testNamespace}, job); err == nil {
482+
t.Error("expected no Job for external CA, but one was created")
483+
}
484+
}
485+
486+
func TestCAReconcile_ExternalCA_NoConfigRequired(t *testing.T) {
487+
// External CA should work without any Config object (unlike internal CA which requires it)
488+
ca := newCertificateAuthority("ext-ca", withExternal("https://puppet-ca.example.com:8140"))
489+
ca.Status.Phase = ""
490+
c := setupTestClient(ca) // no Config object
491+
r := newCertificateAuthorityReconciler(c)
492+
493+
if _, err := r.Reconcile(testCtx(), testRequest("ext-ca")); err != nil {
494+
t.Fatalf("reconcile error: %v", err)
495+
}
496+
497+
updated := &openvoxv1alpha1.CertificateAuthority{}
498+
if err := c.Get(testCtx(), types.NamespacedName{Name: "ext-ca", Namespace: testNamespace}, updated); err != nil {
499+
t.Fatalf("failed to get CA: %v", err)
500+
}
501+
502+
// Should reach External phase without a Config
503+
if updated.Status.Phase != openvoxv1alpha1.CertificateAuthorityPhaseExternal {
504+
t.Errorf("expected phase %q, got %q", openvoxv1alpha1.CertificateAuthorityPhaseExternal, updated.Status.Phase)
505+
}
506+
}

0 commit comments

Comments
 (0)