1package publictls
2
3import (
4 "context"
5 "crypto/tls"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/require"
11
12 "github.com/bnema/gordon/internal/boundaries/out"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16// ---------------------------------------------------------------------------
17// ShouldRenew
18// ---------------------------------------------------------------------------
19
20func TestShouldRenew(t *testing.T) {
21 // Fixed "now" for deterministic tests.
22 now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
23
24 tests := []struct {
25 name string
26 cert out.StoredCertificate
27 want bool
28 }{
29 {
30 name: "cert with NotAfter 45 days from now should not renew",
31 cert: out.StoredCertificate{NotAfter: now.Add(45 * 24 * time.Hour)},
32 want: false,
33 },
34 {
35 name: "cert with NotAfter 10 days from now should renew",
36 cert: out.StoredCertificate{NotAfter: now.Add(10 * 24 * time.Hour)},
37 want: true,
38 },
39 {
40 name: "expired cert should renew",
41 cert: out.StoredCertificate{NotAfter: now.Add(-1 * time.Hour)},
42 want: true,
43 },
44 {
45 name: "cert with zero NotAfter should renew",
46 cert: out.StoredCertificate{},
47 want: true,
48 },
49 }
50
51 for _, tt := range tests {
52 t.Run(tt.name, func(t *testing.T) {
53 got := ShouldRenew(tt.cert, now)
54 assert.Equal(t, tt.want, got)
55 })
56 }
57}
58
59func TestRenewDueCertificatesRequiresDependencies(t *testing.T) {
60 cfg := Config{Enabled: true}
61 svc := NewService(cfg, ServiceDeps{})
62
63 err := svc.renewDueCertificates(context.Background(), time.Now())
64 require.Error(t, err)
65 assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired)
66
67 svc.deps.Store, _ = newMockCertificateStore(t)
68 err = svc.renewDueCertificates(context.Background(), time.Now())
69 require.Error(t, err)
70 assert.ErrorIs(t, err, domain.ErrCertificateIssuerRequired)
71}
72
73// ---------------------------------------------------------------------------
74// RenewalLoopStops
75// ---------------------------------------------------------------------------
76
77func TestRenewalLoopStops(t *testing.T) {
78 ctx, cancel := context.WithCancel(context.Background())
79
80 cfg := Config{
81 Enabled: true,
82 Email: "admin@example.com",
83 Challenge: "http-01",
84 HTTPPort: 8088,
85 TLSPort: 8443,
86 }
87
88 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
89 store, _ := newMockCertificateStore(t)
90
91 svc := NewService(cfg, ServiceDeps{
92 Config: cfg,
93 Routes: &fakeRoutes{},
94 Issuer: issuer,
95 Store: store,
96 Challenges: NewHTTP01Challenges(),
97 Effective: EffectiveChallenge{
98 ConfiguredMode: domain.ACMEChallengeHTTP01,
99 Mode: domain.ACMEChallengeHTTP01,
100 TokenSource: domain.ACMETokenSourceNone,
101 Reason: "http-01 challenge selected",
102 },
103 })
104
105 done := svc.StartRenewalLoop(ctx, 10*time.Millisecond)
106 cancel()
107
108 require.Eventually(t, func() bool {
109 select {
110 case <-done:
111 return true
112 default:
113 return false
114 }
115 }, 5*time.Second, 10*time.Millisecond, "renewal loop should stop after context cancel")
116}
117
118// ---------------------------------------------------------------------------
119// RenewDueCertificates
120// ---------------------------------------------------------------------------
121
122func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) {
123 ctx := context.Background()
124
125 certID := "http01-app.example.com"
126 now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
127 notAfterSoon := now.Add(10 * 24 * time.Hour) // 10 days from now — due for renewal
128 notAfterRenewed := now.Add(90 * 24 * time.Hour) // renewed cert
129
130 // Generate a test certificate.
131 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
132 require.NoError(t, err)
133 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
134 require.NoError(t, err)
135
136 store, storeState := newMockCertificateStore(t, out.StoredCertificate{
137 ID: certID,
138 Names: []string{"app.example.com"},
139 Challenge: domain.ACMEChallengeHTTP01,
140 Certificate: tlsCert,
141 FullchainPEM: certPEM,
142 PrivateKeyPEM: keyPEM,
143 NotAfter: notAfterSoon,
144 })
145
146 issuer, _ := newMockPublicCertificateIssuer(t, nil, func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
147 renewed := cert
148 renewed.Certificate = tls.Certificate{}
149 renewed.LastError = "previous renewal error"
150 renewed.NotAfter = notAfterRenewed
151 return &renewed, nil
152 })
153
154 cfg := Config{
155 Enabled: true,
156 Email: "admin@example.com",
157 Challenge: "http-01",
158 HTTPPort: 8088,
159 TLSPort: 8443,
160 }
161
162 svc := NewService(cfg, ServiceDeps{
163 Config: cfg,
164 Routes: &fakeRoutes{},
165 Issuer: issuer,
166 Store: store,
167 Challenges: NewHTTP01Challenges(),
168 Effective: EffectiveChallenge{
169 ConfiguredMode: domain.ACMEChallengeHTTP01,
170 Mode: domain.ACMEChallengeHTTP01,
171 TokenSource: domain.ACMETokenSourceNone,
172 Reason: "http-01 challenge selected",
173 },
174 })
175
176 // Load certs into the cache from the store.
177 err = svc.Load(ctx)
178 require.NoError(t, err)
179
180 // Verify cert is in cache with original NotAfter via Status.
181 status := svc.Status(ctx)
182 require.Len(t, status.Certificates, 1, "expected 1 managed certificate after Load")
183 assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterSoon),
184 "cache should have original NotAfter (got %v, want %v)",
185 status.Certificates[0].NotAfter, notAfterSoon)
186
187 // Run renewDueCertificates.
188 err = svc.renewDueCertificates(ctx, now)
189 require.NoError(t, err)
190
191 // Verify the store has the updated certificate.
192 all := storeState.All()
193 require.Len(t, all, 1)
194 assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter")
195 assert.NotEmpty(t, all[0].Certificate.Certificate, "store should have normalized tls.Certificate")
196 assert.Empty(t, all[0].LastError, "store should not persist stale LastError")
197
198 // Verify the cache has the updated certificate via Status.
199 status = svc.Status(ctx)
200 require.Len(t, status.Certificates, 1, "expected 1 managed certificate after renewal")
201 assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterRenewed),
202 "cache should have renewed NotAfter (got %v, want %v)",
203 status.Certificates[0].NotAfter, notAfterRenewed)
204
205 // Verify lastErr is empty for the renewed certificate.
206 assert.Empty(t, status.Certificates[0].LastError,
207 "LastError should be empty for renewed cert")
208}