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
118func TestRenewalLoop_ReconcilesMissingCertificatesOnTick(t *testing.T) {
119 ctx, cancel := context.WithCancel(context.Background())
120 defer cancel()
121
122 cfg := Config{
123 Enabled: true,
124 Email: "admin@example.com",
125 Challenge: "http-01",
126 HTTPPort: 8088,
127 TLSPort: 8443,
128 }
129
130 routes := &fakeRoutes{hosts: []out.AppHost{{Host: "app.example.com"}}}
131 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
132 store, _ := newMockCertificateStore(t)
133
134 svc := NewService(cfg, ServiceDeps{
135 Config: cfg,
136 Routes: routes,
137 Issuer: issuer,
138 Store: store,
139 Challenges: NewHTTP01Challenges(),
140 Effective: EffectiveChallenge{
141 ConfiguredMode: domain.ACMEChallengeHTTP01,
142 Mode: domain.ACMEChallengeHTTP01,
143 TokenSource: domain.ACMETokenSourceNone,
144 Reason: "http-01 challenge selected",
145 },
146 })
147
148 require.NoError(t, svc.Load(ctx))
149 done := svc.StartRenewalLoop(ctx, 10*time.Millisecond)
150 defer func() {
151 cancel()
152 <-done
153 }()
154
155 require.Eventually(t, func() bool {
156 return len(recorder.Orders()) > 0
157 }, 5*time.Second, 10*time.Millisecond, "renewal loop should retry missing certificate obtains")
158}
159
160// ---------------------------------------------------------------------------
161// RenewDueCertificates
162// ---------------------------------------------------------------------------
163
164func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) {
165 ctx := context.Background()
166
167 certID := "http01-app.example.com"
168 now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
169 notAfterSoon := now.Add(10 * 24 * time.Hour) // 10 days from now — due for renewal
170 notAfterRenewed := now.Add(90 * 24 * time.Hour) // renewed cert
171
172 // Generate a test certificate.
173 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
174 require.NoError(t, err)
175 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
176 require.NoError(t, err)
177
178 store, storeState := newMockCertificateStore(t, out.StoredCertificate{
179 ID: certID,
180 Names: []string{"app.example.com"},
181 Challenge: domain.ACMEChallengeHTTP01,
182 Certificate: tlsCert,
183 FullchainPEM: certPEM,
184 PrivateKeyPEM: keyPEM,
185 NotAfter: notAfterSoon,
186 })
187
188 issuer, _ := newMockPublicCertificateIssuer(t, nil, func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
189 renewed := cert
190 renewed.Certificate = tls.Certificate{}
191 renewed.LastError = "previous renewal error"
192 renewed.NotAfter = notAfterRenewed
193 return &renewed, nil
194 })
195
196 cfg := Config{
197 Enabled: true,
198 Email: "admin@example.com",
199 Challenge: "http-01",
200 HTTPPort: 8088,
201 TLSPort: 8443,
202 }
203
204 svc := NewService(cfg, ServiceDeps{
205 Config: cfg,
206 Routes: &fakeRoutes{},
207 Issuer: issuer,
208 Store: store,
209 Challenges: NewHTTP01Challenges(),
210 Effective: EffectiveChallenge{
211 ConfiguredMode: domain.ACMEChallengeHTTP01,
212 Mode: domain.ACMEChallengeHTTP01,
213 TokenSource: domain.ACMETokenSourceNone,
214 Reason: "http-01 challenge selected",
215 },
216 })
217
218 // Load certs into the cache from the store.
219 err = svc.Load(ctx)
220 require.NoError(t, err)
221
222 // Verify cert is in cache with original NotAfter via Status.
223 status := svc.Status(ctx)
224 require.Len(t, status.Certificates, 1, "expected 1 managed certificate after Load")
225 assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterSoon),
226 "cache should have original NotAfter (got %v, want %v)",
227 status.Certificates[0].NotAfter, notAfterSoon)
228
229 // Run renewDueCertificates.
230 err = svc.renewDueCertificates(ctx, now)
231 require.NoError(t, err)
232
233 // Verify the store has the updated certificate.
234 all := storeState.All()
235 require.Len(t, all, 1)
236 assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter")
237 assert.NotEmpty(t, all[0].Certificate.Certificate, "store should have normalized tls.Certificate")
238 assert.Empty(t, all[0].LastError, "store should not persist stale LastError")
239
240 // Verify the cache has the updated certificate via Status.
241 status = svc.Status(ctx)
242 require.Len(t, status.Certificates, 1, "expected 1 managed certificate after renewal")
243 assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterRenewed),
244 "cache should have renewed NotAfter (got %v, want %v)",
245 status.Certificates[0].NotAfter, notAfterRenewed)
246
247 // Verify lastErr is empty for the renewed certificate.
248 assert.Empty(t, status.Certificates[0].LastError,
249 "LastError should be empty for renewed cert")
250}