1package publictls
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/rsa"
7 "crypto/tls"
8 "crypto/x509"
9 "crypto/x509/pkix"
10 "encoding/pem"
11 "fmt"
12 "maps"
13 "math/big"
14 "sync"
15 "testing"
16 "time"
17
18 "github.com/stretchr/testify/assert"
19 "github.com/stretchr/testify/mock"
20 "github.com/stretchr/testify/require"
21
22 "github.com/bnema/gordon/internal/boundaries/out"
23 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
24 "github.com/bnema/gordon/internal/domain"
25)
26
27// ---------------------------------------------------------------------------
28// Fakes
29// ---------------------------------------------------------------------------
30
31// fakeRoutes models a stateful RouteSource using an internal mutex and
32// copy-on-read semantics. Generated mocks cannot easily replicate this
33// pattern because the interface methods return slices/maps by value and
34// callers may modify the returned data; a hand-rolled fake ensures each
35// GetRoutes/GetExternalRoutes call returns a defensive copy.
36type fakeRoutes struct {
37 mu sync.Mutex
38 routes []domain.Route
39 external map[string]string
40}
41
42func (f *fakeRoutes) GetRoutes(_ context.Context) []domain.Route {
43 f.mu.Lock()
44 defer f.mu.Unlock()
45 routesCopy := make([]domain.Route, len(f.routes))
46 copy(routesCopy, f.routes)
47 return routesCopy
48}
49
50func (f *fakeRoutes) GetExternalRoutes() map[string]string {
51 f.mu.Lock()
52 defer f.mu.Unlock()
53 externalCopy := make(map[string]string, len(f.external))
54 maps.Copy(externalCopy, f.external)
55 return externalCopy
56}
57
58type mockIssuerRecorder struct {
59 mu sync.Mutex
60 orders []out.CertificateOrder
61}
62
63func (r *mockIssuerRecorder) record(order out.CertificateOrder) {
64 r.mu.Lock()
65 defer r.mu.Unlock()
66 r.orders = append(r.orders, order)
67}
68
69func (r *mockIssuerRecorder) Orders() []out.CertificateOrder {
70 r.mu.Lock()
71 defer r.mu.Unlock()
72 orders := make([]out.CertificateOrder, len(r.orders))
73 copy(orders, r.orders)
74 return orders
75}
76
77func newMockPublicCertificateIssuer(
78 t *testing.T,
79 obtain func(context.Context, out.CertificateOrder) (*out.StoredCertificate, error),
80 renew func(context.Context, out.StoredCertificate) (*out.StoredCertificate, error),
81) (*outmocks.MockPublicCertificateIssuer, *mockIssuerRecorder) {
82 t.Helper()
83 recorder := &mockIssuerRecorder{}
84 issuer := outmocks.NewMockPublicCertificateIssuer(t)
85
86 obtainCall := issuer.EXPECT().Obtain(mock.Anything, mock.Anything).RunAndReturn(
87 func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
88 recorder.record(order)
89 if obtain != nil {
90 return obtain(ctx, order)
91 }
92 return defaultStoredCert(order)
93 },
94 )
95 obtainCall.Maybe()
96
97 renewCall := issuer.EXPECT().Renew(mock.Anything, mock.Anything).RunAndReturn(
98 func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
99 if renew != nil {
100 return renew(ctx, cert)
101 }
102 return &cert, nil
103 },
104 )
105 renewCall.Maybe()
106
107 return issuer, recorder
108}
109
110// defaultStoredCert creates a StoredCertificate with a self-signed TLS cert
111// for the given order.
112func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, error) {
113 certPEM, keyPEM, err := generateTestCertPEM(order.Names)
114 if err != nil {
115 return nil, err
116 }
117 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
118 if err != nil {
119 return nil, err
120 }
121 return &out.StoredCertificate{
122 ID: order.ID,
123 Names: order.Names,
124 Challenge: order.Challenge,
125 Certificate: tlsCert,
126 FullchainPEM: certPEM,
127 PrivateKeyPEM: keyPEM,
128 NotAfter: time.Now().Add(90 * 24 * time.Hour),
129 }, nil
130}
131
132type mockCertificateStoreState struct {
133 mu sync.Mutex
134 account *out.ACMEAccount
135 certs []out.StoredCertificate
136 state out.CertificateStoreState
137 loadStateErr error
138 saveStateErr error
139}
140
141func (s *mockCertificateStoreState) All() []out.StoredCertificate {
142 s.mu.Lock()
143 defer s.mu.Unlock()
144 certs := make([]out.StoredCertificate, len(s.certs))
145 copy(certs, s.certs)
146 return certs
147}
148
149func (s *mockCertificateStoreState) State() out.CertificateStoreState {
150 s.mu.Lock()
151 defer s.mu.Unlock()
152 return s.state
153}
154
155func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) {
156 s.mu.Lock()
157 defer s.mu.Unlock()
158 s.state = state
159}
160
161func (s *mockCertificateStoreState) SetLoadStateError(err error) {
162 s.mu.Lock()
163 defer s.mu.Unlock()
164 s.loadStateErr = err
165}
166
167func (s *mockCertificateStoreState) SetSaveStateError(err error) {
168 s.mu.Lock()
169 defer s.mu.Unlock()
170 s.saveStateErr = err
171}
172
173func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {
174 t.Helper()
175 state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}
176 store := outmocks.NewMockCertificateStore(t)
177
178 loadAccountCall := store.EXPECT().LoadAccount(mock.Anything).RunAndReturn(func(context.Context) (*out.ACMEAccount, error) {
179 state.mu.Lock()
180 defer state.mu.Unlock()
181 if state.account == nil {
182 return nil, nil
183 }
184 account := *state.account
185 return &account, nil
186 })
187 loadAccountCall.Maybe()
188
189 saveAccountCall := store.EXPECT().SaveAccount(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, account out.ACMEAccount) error {
190 state.mu.Lock()
191 defer state.mu.Unlock()
192 accountCopy := account
193 state.account = &accountCopy
194 return nil
195 })
196 saveAccountCall.Maybe()
197
198 loadAllCall := store.EXPECT().LoadAll(mock.Anything).RunAndReturn(func(context.Context) ([]out.StoredCertificate, error) {
199 return state.All(), nil
200 })
201 loadAllCall.Maybe()
202
203 loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) {
204 state.mu.Lock()
205 defer state.mu.Unlock()
206 if state.loadStateErr != nil {
207 return out.CertificateStoreState{}, state.loadStateErr
208 }
209 return state.state, nil
210 })
211 loadStateCall.Maybe()
212
213 saveStateCall := store.EXPECT().SaveState(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, storeState out.CertificateStoreState) error {
214 state.mu.Lock()
215 defer state.mu.Unlock()
216 if state.saveStateErr != nil {
217 return state.saveStateErr
218 }
219 state.state = storeState
220 return nil
221 })
222 saveStateCall.Maybe()
223
224 saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {
225 state.mu.Lock()
226 defer state.mu.Unlock()
227 for i, existing := range state.certs {
228 if existing.ID == cert.ID {
229 state.certs[i] = cert
230 return nil
231 }
232 }
233 state.certs = append(state.certs, cert)
234 return nil
235 })
236 saveCall.Maybe()
237
238 lockCall := store.EXPECT().Lock(mock.Anything).Return(func() error { return nil }, nil)
239 lockCall.Maybe()
240
241 return store, state
242}
243
244// ---------------------------------------------------------------------------
245// Test certificate helpers
246// ---------------------------------------------------------------------------
247
248// generateTestCertPEM generates a self-signed certificate for the given names.
249// Returns PEM-encoded fullchain and private key.
250func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err error) {
251 if len(names) == 0 {
252 names = []string{"localhost"}
253 }
254
255 key, err := rsa.GenerateKey(rand.Reader, 2048)
256 if err != nil {
257 return nil, nil, fmt.Errorf("generate key: %w", err)
258 }
259
260 serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
261 if err != nil {
262 return nil, nil, fmt.Errorf("generate serial: %w", err)
263 }
264
265 now := time.Now()
266 tmpl := &x509.Certificate{
267 SerialNumber: serial,
268 Subject: pkix.Name{
269 CommonName: names[0],
270 },
271 NotBefore: now.Add(-1 * time.Hour),
272 NotAfter: now.Add(90 * 24 * time.Hour),
273 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
274 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
275 BasicConstraintsValid: true,
276 DNSNames: names,
277 }
278
279 certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
280 if err != nil {
281 return nil, nil, fmt.Errorf("create certificate: %w", err)
282 }
283
284 certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
285 if certPEM == nil {
286 return nil, nil, fmt.Errorf("encode cert PEM")
287 }
288
289 keyDER := x509.MarshalPKCS1PrivateKey(key)
290 keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER})
291 if keyPEM == nil {
292 return nil, nil, fmt.Errorf("encode key PEM")
293 }
294
295 return certPEM, keyPEM, nil
296}
297
298// ---------------------------------------------------------------------------
299// Tests
300// ---------------------------------------------------------------------------
301
302func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) {
303 ctx := context.Background()
304
305 routes := &fakeRoutes{
306 routes: []domain.Route{
307 {Domain: "one.example.com"},
308 {Domain: "two.example.com"},
309 {Domain: "three.example.com"},
310 },
311 }
312 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
313 store, storeState := newMockCertificateStore(t)
314
315 cfg := Config{
316 Enabled: true,
317 Email: "admin@example.com",
318 Challenge: "http-01",
319 HTTPPort: 8088,
320 TLSPort: 8443,
321 ObtainBatchSize: 2,
322 }
323
324 svc := NewService(cfg, ServiceDeps{
325 Config: cfg,
326 Routes: routes,
327 Issuer: issuer,
328 Store: store,
329 Challenges: NewHTTP01Challenges(),
330 Effective: EffectiveChallenge{
331 ConfiguredMode: domain.ACMEChallengeHTTP01,
332 Mode: domain.ACMEChallengeHTTP01,
333 TokenSource: domain.ACMETokenSourceNone,
334 Reason: "http-01 challenge selected",
335 },
336 })
337
338 require.NoError(t, svc.Load(ctx))
339 require.NoError(t, svc.Reconcile(ctx))
340
341 orders := recorder.Orders()
342 require.Len(t, orders, 2, "first reconcile should respect configured obtain batch size")
343 assert.Len(t, storeState.All(), 2)
344
345 require.NoError(t, svc.Reconcile(ctx))
346 orders = recorder.Orders()
347 require.Len(t, orders, 3, "second reconcile should continue remaining missing targets")
348 assert.Len(t, storeState.All(), 3)
349}
350
351func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
352 ctx := context.Background()
353
354 routes := &fakeRoutes{
355 routes: []domain.Route{
356 {Domain: "b.example.com"},
357 {Domain: "a.example.com"},
358 },
359 }
360 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
361 if order.ID == "http01-a.example.com" {
362 return nil, fmt.Errorf("acme validation failed")
363 }
364 return defaultStoredCert(order)
365 }, nil)
366 store, storeState := newMockCertificateStore(t)
367
368 cfg := Config{
369 Enabled: true,
370 Email: "admin@example.com",
371 Challenge: "http-01",
372 HTTPPort: 8088,
373 TLSPort: 8443,
374 ObtainBatchSize: 1,
375 }
376
377 svc := NewService(cfg, ServiceDeps{
378 Config: cfg,
379 Routes: routes,
380 Issuer: issuer,
381 Store: store,
382 Challenges: NewHTTP01Challenges(),
383 Effective: EffectiveChallenge{
384 ConfiguredMode: domain.ACMEChallengeHTTP01,
385 Mode: domain.ACMEChallengeHTTP01,
386 TokenSource: domain.ACMETokenSourceNone,
387 Reason: "http-01 challenge selected",
388 },
389 })
390
391 require.NoError(t, svc.Load(ctx))
392 require.NoError(t, svc.Reconcile(ctx))
393 require.NoError(t, svc.Reconcile(ctx))
394
395 orders := recorder.Orders()
396 require.Len(t, orders, 2)
397 assert.Equal(t, "http01-a.example.com", orders[0].ID)
398 assert.Equal(t, "http01-b.example.com", orders[1].ID, "second reconcile should rotate past the failed first target")
399
400 stored := storeState.All()
401 require.Len(t, stored, 1)
402 assert.Equal(t, "http01-b.example.com", stored[0].ID)
403}
404
405func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) {
406 ctx := context.Background()
407
408 routes := &fakeRoutes{
409 routes: []domain.Route{
410 {Domain: "b.example.com"},
411 {Domain: "a.example.com"},
412 },
413 }
414 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
415 store, storeState := newMockCertificateStore(t)
416 storeState.SetState(out.CertificateStoreState{ObtainCursor: 1})
417 storeState.SetLoadStateError(fmt.Errorf("state unreadable"))
418
419 cfg := Config{
420 Enabled: true,
421 Email: "admin@example.com",
422 Challenge: "http-01",
423 HTTPPort: 8088,
424 TLSPort: 8443,
425 ObtainBatchSize: 1,
426 }
427 svc := NewService(cfg, ServiceDeps{
428 Config: cfg,
429 Routes: routes,
430 Issuer: issuer,
431 Store: store,
432 Challenges: NewHTTP01Challenges(),
433 Effective: EffectiveChallenge{
434 ConfiguredMode: domain.ACMEChallengeHTTP01,
435 Mode: domain.ACMEChallengeHTTP01,
436 TokenSource: domain.ACMETokenSourceNone,
437 Reason: "http-01 challenge selected",
438 },
439 })
440
441 require.NoError(t, svc.Load(ctx))
442 require.NoError(t, svc.Reconcile(ctx))
443
444 orders := recorder.Orders()
445 require.Len(t, orders, 1)
446 assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor")
447}
448
449func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) {
450 ctx := context.Background()
451
452 routes := &fakeRoutes{
453 routes: []domain.Route{
454 {Domain: "b.example.com"},
455 {Domain: "a.example.com"},
456 },
457 }
458 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
459 if order.ID == "http01-a.example.com" {
460 return nil, fmt.Errorf("acme validation failed")
461 }
462 return defaultStoredCert(order)
463 }, nil)
464 store, storeState := newMockCertificateStore(t)
465
466 cfg := Config{
467 Enabled: true,
468 Email: "admin@example.com",
469 Challenge: "http-01",
470 HTTPPort: 8088,
471 TLSPort: 8443,
472 ObtainBatchSize: 1,
473 }
474 svc := NewService(cfg, ServiceDeps{
475 Config: cfg,
476 Routes: routes,
477 Issuer: issuer,
478 Store: store,
479 Challenges: NewHTTP01Challenges(),
480 Effective: EffectiveChallenge{
481 ConfiguredMode: domain.ACMEChallengeHTTP01,
482 Mode: domain.ACMEChallengeHTTP01,
483 TokenSource: domain.ACMETokenSourceNone,
484 Reason: "http-01 challenge selected",
485 },
486 })
487
488 storeState.SetSaveStateError(fmt.Errorf("disk full"))
489 require.NoError(t, svc.Load(ctx))
490 require.NoError(t, svc.Reconcile(ctx))
491 require.NoError(t, svc.Reconcile(ctx))
492 assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged")
493
494 orders := recorder.Orders()
495 require.Len(t, orders, 2)
496 assert.Equal(t, "http01-a.example.com", orders[0].ID)
497 assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor")
498
499 stored := storeState.All()
500 require.Len(t, stored, 1)
501 assert.Equal(t, "http01-b.example.com", stored[0].ID)
502}
503
504func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) {
505 ctx := context.Background()
506
507 routes := &fakeRoutes{
508 routes: []domain.Route{
509 {Domain: "b.example.com"},
510 {Domain: "a.example.com"},
511 },
512 }
513 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
514 if order.ID == "http01-a.example.com" {
515 return nil, fmt.Errorf("acme validation failed")
516 }
517 return defaultStoredCert(order)
518 }, nil)
519 store, storeState := newMockCertificateStore(t)
520
521 cfg := Config{
522 Enabled: true,
523 Email: "admin@example.com",
524 Challenge: "http-01",
525 HTTPPort: 8088,
526 TLSPort: 8443,
527 ObtainBatchSize: 1,
528 }
529 deps := ServiceDeps{
530 Config: cfg,
531 Routes: routes,
532 Issuer: issuer,
533 Store: store,
534 Challenges: NewHTTP01Challenges(),
535 Effective: EffectiveChallenge{
536 ConfiguredMode: domain.ACMEChallengeHTTP01,
537 Mode: domain.ACMEChallengeHTTP01,
538 TokenSource: domain.ACMETokenSourceNone,
539 Reason: "http-01 challenge selected",
540 },
541 }
542
543 firstSvc := NewService(cfg, deps)
544 require.NoError(t, firstSvc.Load(ctx))
545 require.NoError(t, firstSvc.Reconcile(ctx))
546 assert.Equal(t, out.CertificateStoreState{ObtainCursor: 1}, storeState.State())
547
548 secondSvc := NewService(cfg, deps)
549 require.NoError(t, secondSvc.Load(ctx))
550 require.NoError(t, secondSvc.Reconcile(ctx))
551
552 orders := recorder.Orders()
553 require.Len(t, orders, 2)
554 assert.Equal(t, "http01-a.example.com", orders[0].ID)
555 assert.Equal(t, "http01-b.example.com", orders[1].ID, "restart should resume from the persisted obtain cursor")
556
557 stored := storeState.All()
558 require.Len(t, stored, 1)
559 assert.Equal(t, "http01-b.example.com", stored[0].ID)
560}
561
562func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
563 ctx := context.Background()
564
565 routes := &fakeRoutes{
566 routes: []domain.Route{
567 {Domain: "app.example.com"},
568 },
569 }
570 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
571 store, storeState := newMockCertificateStore(t)
572
573 cfg := Config{
574 Enabled: true,
575 Email: "admin@example.com",
576 Challenge: "http-01",
577 HTTPPort: 8088,
578 TLSPort: 8443,
579 }
580
581 svc := NewService(cfg, ServiceDeps{
582 Config: cfg,
583 Routes: routes,
584 Issuer: issuer,
585 Store: store,
586 Challenges: NewHTTP01Challenges(),
587 Effective: EffectiveChallenge{
588 ConfiguredMode: domain.ACMEChallengeHTTP01,
589 Mode: domain.ACMEChallengeHTTP01,
590 TokenSource: domain.ACMETokenSourceNone,
591 Reason: "http-01 challenge selected",
592 },
593 })
594
595 // Load (nothing stored yet).
596 err := svc.Load(ctx)
597 require.NoError(t, err)
598
599 // Reconcile — should obtain one certificate for app.example.com.
600 err = svc.Reconcile(ctx)
601 require.NoError(t, err)
602
603 // Verify the issuer received exactly one order.
604 orders := recorder.Orders()
605
606 require.Len(t, orders, 1, "expected one order to be placed")
607 assert.Equal(t, "http01-app.example.com", orders[0].ID)
608 assert.Equal(t, []string{"app.example.com"}, orders[0].Names)
609 assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)
610
611 // Verify the store has the saved certificate.
612 stored := storeState.All()
613 require.Len(t, stored, 1)
614 assert.Equal(t, "http01-app.example.com", stored[0].ID)
615 assert.Equal(t, []string{"app.example.com"}, stored[0].Names)
616 assert.False(t, stored[0].NotAfter.IsZero())
617 assert.NotEmpty(t, stored[0].Certificate.Certificate)
618}
619
620func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) {
621 ctx := context.Background()
622 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
623 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
624 store, _ := newMockCertificateStore(t)
625 cfg := Config{
626 Enabled: true,
627 Email: "admin@example.com",
628 Challenge: "http-01",
629 HTTPPort: 8088,
630 // Missing TLSPort should be caught by ResolveEffectiveChallenge rather
631 // than silently defaulting an effective mode.
632 }
633
634 svc := NewService(cfg, ServiceDeps{
635 Config: cfg,
636 Routes: routes,
637 Issuer: issuer,
638 Store: store,
639 })
640
641 err := svc.Reconcile(ctx)
642 require.Error(t, err)
643 assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
644}
645
646func TestServiceStatusReportsCoverage(t *testing.T) {
647 ctx := context.Background()
648
649 // Pre-populate the store with a valid certificate.
650 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
651 require.NoError(t, err)
652 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
653 require.NoError(t, err)
654
655 store, _ := newMockCertificateStore(t, out.StoredCertificate{
656 ID: "http01-app.example.com",
657 Names: []string{"app.example.com"},
658 Challenge: domain.ACMEChallengeHTTP01,
659 Certificate: tlsCert,
660 FullchainPEM: certPEM,
661 PrivateKeyPEM: keyPEM,
662 NotAfter: time.Now().Add(90 * 24 * time.Hour),
663 })
664
665 routes := &fakeRoutes{
666 routes: []domain.Route{
667 {Domain: "app.example.com"},
668 },
669 }
670 cfg := Config{
671 Enabled: true,
672 Email: "admin@example.com",
673 Challenge: "http-01",
674 HTTPPort: 8088,
675 TLSPort: 8443,
676 }
677
678 svc := NewService(cfg, ServiceDeps{
679 Config: cfg,
680 Routes: routes,
681 Store: store,
682 Effective: EffectiveChallenge{
683 ConfiguredMode: domain.ACMEChallengeHTTP01,
684 Mode: domain.ACMEChallengeHTTP01,
685 TokenSource: domain.ACMETokenSourceNone,
686 Reason: "http-01 challenge selected",
687 },
688 })
689
690 // Load the cert into the cache.
691 err = svc.Load(ctx)
692 require.NoError(t, err)
693
694 // Get status.
695 status := svc.Status(ctx)
696
697 assert.True(t, status.ACMEEnabled)
698 assert.Equal(t, domain.ACMEChallengeHTTP01, status.ConfiguredMode)
699 assert.Equal(t, domain.ACMEChallengeHTTP01, status.EffectiveMode)
700
701 require.Len(t, status.Certificates, 1, "expected one managed certificate")
702 assert.Equal(t, "http01-app.example.com", status.Certificates[0].ID)
703 assert.Equal(t, domain.TLSCertificateStatusValid, status.Certificates[0].Status)
704
705 require.Len(t, status.Routes, 1, "expected one route coverage")
706 assert.Equal(t, "app.example.com", status.Routes[0].Domain)
707 assert.True(t, status.Routes[0].Covered, "route should be covered")
708 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
709 assert.True(t, status.Routes[0].RequiredACME)
710}
711
712func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
713 ctx := context.Background()
714
715 routes := &fakeRoutes{
716 routes: []domain.Route{
717 {Domain: "app.example.com"},
718 },
719 }
720 // Make Obtain fail so no cert is cached.
721 issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
722 return nil, fmt.Errorf("acme provider unavailable")
723 }, nil)
724 store, _ := newMockCertificateStore(t)
725
726 cfg := Config{
727 Enabled: true,
728 Email: "admin@example.com",
729 Challenge: "http-01",
730 HTTPPort: 8088,
731 TLSPort: 8443,
732 }
733
734 svc := NewService(cfg, ServiceDeps{
735 Config: cfg,
736 Routes: routes,
737 Issuer: issuer,
738 Store: store,
739 Challenges: NewHTTP01Challenges(),
740 Effective: EffectiveChallenge{
741 ConfiguredMode: domain.ACMEChallengeHTTP01,
742 Mode: domain.ACMEChallengeHTTP01,
743 TokenSource: domain.ACMETokenSourceNone,
744 Reason: "http-01 challenge selected",
745 },
746 })
747
748 // Load (no certs stored).
749 err := svc.Load(ctx)
750 require.NoError(t, err)
751
752 // Reconcile — issuer fails, no cert cached but requiredHosts is set.
753 err = svc.Reconcile(ctx)
754 require.NoError(t, err) // Reconcile swallows per-target obtain errors
755
756 // GetCertificateForHost for app.example.com should return ErrTLSRouteNotCovered.
757 _, err = svc.GetCertificateForHost("app.example.com")
758 require.Error(t, err)
759 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
760 assert.Contains(t, err.Error(), "app.example.com")
761}
762
763func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) {
764 ctx := context.Background()
765
766 routes := &fakeRoutes{
767 routes: []domain.Route{
768 {Domain: "app.example.com"},
769 },
770 }
771 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
772 store, _ := newMockCertificateStore(t)
773
774 // Use generated mock that always returns an error.
775 zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
776 zoneResolver.EXPECT().FindZone(mock.Anything, "app.example.com").Return(
777 out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for app.example.com"),
778 )
779
780 cfg := Config{
781 Enabled: true,
782 Email: "admin@example.com",
783 Challenge: "dns-01",
784 HTTPPort: 8088,
785 TLSPort: 8443,
786 }
787
788 svc := NewService(cfg, ServiceDeps{
789 Config: cfg,
790 Routes: routes,
791 Issuer: issuer,
792 Store: store,
793 ZoneResolver: zoneResolver, // generated mock that always fails
794 Challenges: NewHTTP01Challenges(),
795 Effective: EffectiveChallenge{
796 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
797 Mode: domain.ACMEChallengeCloudflareDNS01,
798 TokenSource: domain.ACMETokenSourceNone,
799 Reason: "dns-01 challenge selected",
800 },
801 })
802
803 // Load (nothing stored).
804 err := svc.Load(ctx)
805 require.NoError(t, err)
806
807 // Reconcile — zone resolver is broken, so DeriveCertificateTargets fails.
808 err = svc.Reconcile(ctx)
809 require.Error(t, err)
810 assert.Contains(t, err.Error(), "derive certificate targets")
811
812 // GetCertificateForHost for the configured route should return ErrTLSRouteNotCovered,
813 // not nil,nil, because requiredHosts was set from route hosts before
814 // the failed DeriveCertificateTargets call.
815 _, err = svc.GetCertificateForHost("app.example.com")
816 require.Error(t, err)
817 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
818 assert.Contains(t, err.Error(), "app.example.com")
819}
820
821func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
822 ctx := context.Background()
823
824 // Generate valid PEM cert data.
825 certPEM, keyPEM, err := generateTestCertPEM([]string{"test.example.com"})
826 require.NoError(t, err)
827
828 // Store a cert with PEM data but empty tls.Certificate.
829 store, _ := newMockCertificateStore(t, out.StoredCertificate{
830 ID: "http01-test.example.com",
831 Names: []string{"test.example.com"},
832 Challenge: domain.ACMEChallengeHTTP01,
833 FullchainPEM: certPEM,
834 PrivateKeyPEM: keyPEM,
835 NotAfter: time.Now().Add(90 * 24 * time.Hour),
836 // Certificate field is zero — will be populated by Load.
837 })
838
839 routes := &fakeRoutes{}
840 cfg := Config{Enabled: true}
841
842 svc := NewService(cfg, ServiceDeps{
843 Config: cfg,
844 Routes: routes,
845 Store: store,
846 })
847
848 // Load — should parse PEM into tls.Certificate.
849 err = svc.Load(ctx)
850 require.NoError(t, err)
851
852 // Verify the cached cert has a valid tls.Certificate via the accessor.
853 cached := svc.getStoredCertificate("http01-test.example.com")
854 require.NotNil(t, cached, "cert should be cached")
855 assert.NotEmpty(t, cached.Certificate.Certificate, "tls.Certificate should be populated")
856 assert.NotNil(t, cached.Certificate.PrivateKey, "private key should be populated")
857
858 // Verify it's a valid TLS cert by performing a parse round-trip.
859 leaf, err := x509.ParseCertificate(cached.Certificate.Certificate[0])
860 require.NoError(t, err)
861 assert.Contains(t, leaf.DNSNames, "test.example.com")
862
863 served, err := svc.GetCertificateForHost("test.example.com")
864 require.NoError(t, err)
865 require.NotNil(t, served, "loaded certificates should seed required hosts")
866}
867
868func TestServiceStatusReportsRouteErrorAfterObtainFailure(t *testing.T) {
869 ctx := context.Background()
870 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
871 issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
872 return nil, fmt.Errorf("acme unavailable token=sk-secret")
873 }, nil)
874 store, _ := newMockCertificateStore(t)
875 cfg := Config{
876 Enabled: true,
877 Email: "admin@example.com",
878 Challenge: "http-01",
879 HTTPPort: 8088,
880 TLSPort: 8443,
881 }
882 svc := NewService(cfg, ServiceDeps{
883 Config: cfg,
884 Routes: routes,
885 Issuer: issuer,
886 Store: store,
887 Effective: EffectiveChallenge{
888 ConfiguredMode: domain.ACMEChallengeHTTP01,
889 Mode: domain.ACMEChallengeHTTP01,
890 TokenSource: domain.ACMETokenSourceNone,
891 },
892 })
893
894 require.NoError(t, svc.Reconcile(ctx))
895 status := svc.Status(ctx)
896 require.Len(t, status.Routes, 1)
897 assert.False(t, status.Routes[0].Covered)
898 assert.Contains(t, status.Routes[0].Error, "token=redacted")
899 assert.NotContains(t, status.Routes[0].Error, "sk-secret")
900}
901
902func TestServiceStatusCoverageUsesServingCertDespiteTransientError(t *testing.T) {
903 ctx := context.Background()
904 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
905 require.NoError(t, err)
906 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
907 require.NoError(t, err)
908 store, _ := newMockCertificateStore(t, out.StoredCertificate{
909 ID: "http01-app.example.com",
910 Names: []string{"app.example.com"},
911 Challenge: domain.ACMEChallengeHTTP01,
912 Certificate: tlsCert,
913 FullchainPEM: certPEM,
914 PrivateKeyPEM: keyPEM,
915 NotAfter: time.Now().Add(90 * 24 * time.Hour),
916 LastError: "transient renewal error",
917 })
918 cfg := Config{Enabled: true}
919 svc := NewService(cfg, ServiceDeps{
920 Config: cfg,
921 Routes: &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}},
922 Store: store,
923 })
924 require.NoError(t, svc.Load(ctx))
925
926 status := svc.Status(ctx)
927 require.Len(t, status.Routes, 1)
928 assert.True(t, status.Routes[0].Covered)
929 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
930}
931
932func TestServiceStatusRedactsSensitiveStrings(t *testing.T) {
933 ctx := context.Background()
934
935 // Pre-populate the store with a certificate that has a LastError
936 // containing sensitive data.
937 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
938 require.NoError(t, err)
939 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
940 require.NoError(t, err)
941
942 store, _ := newMockCertificateStore(t, out.StoredCertificate{
943 ID: "http01-app.example.com",
944 Names: []string{"app.example.com"},
945 Challenge: domain.ACMEChallengeHTTP01,
946 Certificate: tlsCert,
947 FullchainPEM: certPEM,
948 PrivateKeyPEM: keyPEM,
949 NotAfter: time.Now().Add(90 * 24 * time.Hour),
950 LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid",
951 })
952
953 routes := &fakeRoutes{
954 routes: []domain.Route{
955 {Domain: "app.example.com"},
956 },
957 }
958 cfg := Config{
959 Enabled: true,
960 Email: "admin@example.com",
961 Challenge: "http-01",
962 HTTPPort: 8088,
963 TLSPort: 8443,
964 }
965
966 svc := NewService(cfg, ServiceDeps{
967 Config: cfg,
968 Routes: routes,
969 Store: store,
970 Effective: EffectiveChallenge{
971 ConfiguredMode: domain.ACMEChallengeHTTP01,
972 Mode: domain.ACMEChallengeHTTP01,
973 TokenSource: domain.ACMETokenSourceNone,
974 Reason: "http-01 challenge selected",
975 },
976 })
977
978 // Load the cert into the cache.
979 err = svc.Load(ctx)
980 require.NoError(t, err)
981
982 // Get status.
983 status := svc.Status(ctx)
984
985 require.Len(t, status.Certificates, 1)
986 lastErr := status.Certificates[0].LastError
987 t.Logf("sanitized LastError: %q", lastErr)
988
989 // The original error contained "token=sk-secret-goes-here".
990 // After sanitization, the secret value should be redacted.
991 assert.NotContains(t, lastErr, "sk-secret-goes-here",
992 "LastError must not contain the raw secret value")
993 assert.Contains(t, lastErr, "token=redacted",
994 "LastError should contain token=redacted")
995}