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 TestServiceReconcile_SerializesConcurrentRuns(t *testing.T) {
621 ctx := context.Background()
622 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
623 obtainStarted := make(chan struct{}, 2)
624 releaseObtain := make(chan struct{})
625
626 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
627 obtainStarted <- struct{}{}
628 <-releaseObtain
629 return defaultStoredCert(order)
630 }, nil)
631 store, _ := newMockCertificateStore(t)
632
633 cfg := Config{
634 Enabled: true,
635 Email: "admin@example.com",
636 Challenge: "http-01",
637 HTTPPort: 8088,
638 TLSPort: 8443,
639 }
640
641 svc := NewService(cfg, ServiceDeps{
642 Config: cfg,
643 Routes: routes,
644 Issuer: issuer,
645 Store: store,
646 Challenges: NewHTTP01Challenges(),
647 Effective: EffectiveChallenge{
648 ConfiguredMode: domain.ACMEChallengeHTTP01,
649 Mode: domain.ACMEChallengeHTTP01,
650 TokenSource: domain.ACMETokenSourceNone,
651 Reason: "http-01 challenge selected",
652 },
653 })
654
655 require.NoError(t, svc.Load(ctx))
656
657 errCh := make(chan error, 2)
658 go func() { errCh <- svc.Reconcile(ctx) }()
659
660 select {
661 case <-obtainStarted:
662 case <-time.After(2 * time.Second):
663 t.Fatal("first reconcile did not start certificate obtain")
664 }
665
666 secondStarted := make(chan struct{})
667 go func() {
668 close(secondStarted)
669 errCh <- svc.Reconcile(ctx)
670 }()
671 <-secondStarted
672 close(releaseObtain)
673
674 require.NoError(t, <-errCh)
675 require.NoError(t, <-errCh)
676 assert.Len(t, recorder.Orders(), 1)
677}
678
679func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) {
680 ctx := context.Background()
681 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
682 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
683 store, _ := newMockCertificateStore(t)
684 cfg := Config{
685 Enabled: true,
686 Email: "admin@example.com",
687 Challenge: "http-01",
688 HTTPPort: 8088,
689 // Missing TLSPort should be caught by ResolveEffectiveChallenge rather
690 // than silently defaulting an effective mode.
691 }
692
693 svc := NewService(cfg, ServiceDeps{
694 Config: cfg,
695 Routes: routes,
696 Issuer: issuer,
697 Store: store,
698 })
699
700 err := svc.Reconcile(ctx)
701 require.Error(t, err)
702 assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
703}
704
705func TestServiceStatusReportsCoverage(t *testing.T) {
706 ctx := context.Background()
707
708 // Pre-populate the store with a valid certificate.
709 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
710 require.NoError(t, err)
711 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
712 require.NoError(t, err)
713
714 store, _ := newMockCertificateStore(t, out.StoredCertificate{
715 ID: "http01-app.example.com",
716 Names: []string{"app.example.com"},
717 Challenge: domain.ACMEChallengeHTTP01,
718 Certificate: tlsCert,
719 FullchainPEM: certPEM,
720 PrivateKeyPEM: keyPEM,
721 NotAfter: time.Now().Add(90 * 24 * time.Hour),
722 })
723
724 routes := &fakeRoutes{
725 routes: []domain.Route{
726 {Domain: "app.example.com"},
727 },
728 }
729 cfg := Config{
730 Enabled: true,
731 Email: "admin@example.com",
732 Challenge: "http-01",
733 HTTPPort: 8088,
734 TLSPort: 8443,
735 }
736
737 svc := NewService(cfg, ServiceDeps{
738 Config: cfg,
739 Routes: routes,
740 Store: store,
741 Effective: EffectiveChallenge{
742 ConfiguredMode: domain.ACMEChallengeHTTP01,
743 Mode: domain.ACMEChallengeHTTP01,
744 TokenSource: domain.ACMETokenSourceNone,
745 Reason: "http-01 challenge selected",
746 },
747 })
748
749 // Load the cert into the cache.
750 err = svc.Load(ctx)
751 require.NoError(t, err)
752
753 // Get status.
754 status := svc.Status(ctx)
755
756 assert.True(t, status.ACMEEnabled)
757 assert.Equal(t, domain.ACMEChallengeHTTP01, status.ConfiguredMode)
758 assert.Equal(t, domain.ACMEChallengeHTTP01, status.EffectiveMode)
759
760 require.Len(t, status.Certificates, 1, "expected one managed certificate")
761 assert.Equal(t, "http01-app.example.com", status.Certificates[0].ID)
762 assert.Equal(t, domain.TLSCertificateStatusValid, status.Certificates[0].Status)
763
764 require.Len(t, status.Routes, 1, "expected one route coverage")
765 assert.Equal(t, "app.example.com", status.Routes[0].Domain)
766 assert.True(t, status.Routes[0].Covered, "route should be covered")
767 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
768 assert.True(t, status.Routes[0].RequiredACME)
769}
770
771func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
772 ctx := context.Background()
773
774 routes := &fakeRoutes{
775 routes: []domain.Route{
776 {Domain: "app.example.com"},
777 },
778 }
779 // Make Obtain fail so no cert is cached.
780 issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
781 return nil, fmt.Errorf("acme provider unavailable")
782 }, nil)
783 store, _ := newMockCertificateStore(t)
784
785 cfg := Config{
786 Enabled: true,
787 Email: "admin@example.com",
788 Challenge: "http-01",
789 HTTPPort: 8088,
790 TLSPort: 8443,
791 }
792
793 svc := NewService(cfg, ServiceDeps{
794 Config: cfg,
795 Routes: routes,
796 Issuer: issuer,
797 Store: store,
798 Challenges: NewHTTP01Challenges(),
799 Effective: EffectiveChallenge{
800 ConfiguredMode: domain.ACMEChallengeHTTP01,
801 Mode: domain.ACMEChallengeHTTP01,
802 TokenSource: domain.ACMETokenSourceNone,
803 Reason: "http-01 challenge selected",
804 },
805 })
806
807 // Load (no certs stored).
808 err := svc.Load(ctx)
809 require.NoError(t, err)
810
811 // Reconcile — issuer fails, no cert cached but requiredHosts is set.
812 err = svc.Reconcile(ctx)
813 require.NoError(t, err) // Reconcile swallows per-target obtain errors
814
815 // GetCertificateForHost for app.example.com should return ErrTLSRouteNotCovered.
816 _, err = svc.GetCertificateForHost("app.example.com")
817 require.Error(t, err)
818 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
819 assert.Contains(t, err.Error(), "app.example.com")
820}
821
822func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) {
823 ctx := context.Background()
824
825 routes := &fakeRoutes{
826 routes: []domain.Route{
827 {Domain: "app.example.com"},
828 },
829 }
830 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
831 store, _ := newMockCertificateStore(t)
832
833 // Use generated mock that always returns an error.
834 zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
835 zoneResolver.EXPECT().FindZone(mock.Anything, "app.example.com").Return(
836 out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for app.example.com"),
837 )
838
839 cfg := Config{
840 Enabled: true,
841 Email: "admin@example.com",
842 Challenge: "dns-01",
843 HTTPPort: 8088,
844 TLSPort: 8443,
845 }
846
847 svc := NewService(cfg, ServiceDeps{
848 Config: cfg,
849 Routes: routes,
850 Issuer: issuer,
851 Store: store,
852 ZoneResolver: zoneResolver, // generated mock that always fails
853 Challenges: NewHTTP01Challenges(),
854 Effective: EffectiveChallenge{
855 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
856 Mode: domain.ACMEChallengeCloudflareDNS01,
857 TokenSource: domain.ACMETokenSourceNone,
858 Reason: "dns-01 challenge selected",
859 },
860 })
861
862 // Load (nothing stored).
863 err := svc.Load(ctx)
864 require.NoError(t, err)
865
866 // Reconcile — zone resolver is broken, so DeriveCertificateTargets fails.
867 err = svc.Reconcile(ctx)
868 require.Error(t, err)
869 assert.Contains(t, err.Error(), "derive certificate targets")
870
871 // GetCertificateForHost for the configured route should return ErrTLSRouteNotCovered,
872 // not nil,nil, because requiredHosts was set from route hosts before
873 // the failed DeriveCertificateTargets call.
874 _, err = svc.GetCertificateForHost("app.example.com")
875 require.Error(t, err)
876 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
877 assert.Contains(t, err.Error(), "app.example.com")
878}
879
880func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
881 ctx := context.Background()
882
883 // Generate valid PEM cert data.
884 certPEM, keyPEM, err := generateTestCertPEM([]string{"test.example.com"})
885 require.NoError(t, err)
886
887 // Store a cert with PEM data but empty tls.Certificate.
888 store, _ := newMockCertificateStore(t, out.StoredCertificate{
889 ID: "http01-test.example.com",
890 Names: []string{"test.example.com"},
891 Challenge: domain.ACMEChallengeHTTP01,
892 FullchainPEM: certPEM,
893 PrivateKeyPEM: keyPEM,
894 NotAfter: time.Now().Add(90 * 24 * time.Hour),
895 // Certificate field is zero — will be populated by Load.
896 })
897
898 routes := &fakeRoutes{}
899 cfg := Config{Enabled: true}
900
901 svc := NewService(cfg, ServiceDeps{
902 Config: cfg,
903 Routes: routes,
904 Store: store,
905 })
906
907 // Load — should parse PEM into tls.Certificate.
908 err = svc.Load(ctx)
909 require.NoError(t, err)
910
911 // Verify the cached cert has a valid tls.Certificate via the accessor.
912 cached := svc.getStoredCertificate("http01-test.example.com")
913 require.NotNil(t, cached, "cert should be cached")
914 assert.NotEmpty(t, cached.Certificate.Certificate, "tls.Certificate should be populated")
915 assert.NotNil(t, cached.Certificate.PrivateKey, "private key should be populated")
916
917 // Verify it's a valid TLS cert by performing a parse round-trip.
918 leaf, err := x509.ParseCertificate(cached.Certificate.Certificate[0])
919 require.NoError(t, err)
920 assert.Contains(t, leaf.DNSNames, "test.example.com")
921
922 served, err := svc.GetCertificateForHost("test.example.com")
923 require.NoError(t, err)
924 require.NotNil(t, served, "loaded certificates should seed required hosts")
925}
926
927func TestServiceStatusReportsRouteErrorAfterObtainFailure(t *testing.T) {
928 ctx := context.Background()
929 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
930 issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
931 return nil, fmt.Errorf("acme unavailable token=sk-secret")
932 }, nil)
933 store, _ := newMockCertificateStore(t)
934 cfg := Config{
935 Enabled: true,
936 Email: "admin@example.com",
937 Challenge: "http-01",
938 HTTPPort: 8088,
939 TLSPort: 8443,
940 }
941 svc := NewService(cfg, ServiceDeps{
942 Config: cfg,
943 Routes: routes,
944 Issuer: issuer,
945 Store: store,
946 Effective: EffectiveChallenge{
947 ConfiguredMode: domain.ACMEChallengeHTTP01,
948 Mode: domain.ACMEChallengeHTTP01,
949 TokenSource: domain.ACMETokenSourceNone,
950 },
951 })
952
953 require.NoError(t, svc.Reconcile(ctx))
954 status := svc.Status(ctx)
955 require.Len(t, status.Routes, 1)
956 assert.False(t, status.Routes[0].Covered)
957 assert.Contains(t, status.Routes[0].Error, "token=redacted")
958 assert.NotContains(t, status.Routes[0].Error, "sk-secret")
959}
960
961func TestServiceStatusCoverageUsesServingCertDespiteTransientError(t *testing.T) {
962 ctx := context.Background()
963 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
964 require.NoError(t, err)
965 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
966 require.NoError(t, err)
967 store, _ := newMockCertificateStore(t, out.StoredCertificate{
968 ID: "http01-app.example.com",
969 Names: []string{"app.example.com"},
970 Challenge: domain.ACMEChallengeHTTP01,
971 Certificate: tlsCert,
972 FullchainPEM: certPEM,
973 PrivateKeyPEM: keyPEM,
974 NotAfter: time.Now().Add(90 * 24 * time.Hour),
975 LastError: "transient renewal error",
976 })
977 cfg := Config{Enabled: true}
978 svc := NewService(cfg, ServiceDeps{
979 Config: cfg,
980 Routes: &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}},
981 Store: store,
982 })
983 require.NoError(t, svc.Load(ctx))
984
985 status := svc.Status(ctx)
986 require.Len(t, status.Routes, 1)
987 assert.True(t, status.Routes[0].Covered)
988 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
989}
990
991func TestServiceStatusRedactsSensitiveStrings(t *testing.T) {
992 ctx := context.Background()
993
994 // Pre-populate the store with a certificate that has a LastError
995 // containing sensitive data.
996 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
997 require.NoError(t, err)
998 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
999 require.NoError(t, err)
1000
1001 store, _ := newMockCertificateStore(t, out.StoredCertificate{
1002 ID: "http01-app.example.com",
1003 Names: []string{"app.example.com"},
1004 Challenge: domain.ACMEChallengeHTTP01,
1005 Certificate: tlsCert,
1006 FullchainPEM: certPEM,
1007 PrivateKeyPEM: keyPEM,
1008 NotAfter: time.Now().Add(90 * 24 * time.Hour),
1009 LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid",
1010 })
1011
1012 routes := &fakeRoutes{
1013 routes: []domain.Route{
1014 {Domain: "app.example.com"},
1015 },
1016 }
1017 cfg := Config{
1018 Enabled: true,
1019 Email: "admin@example.com",
1020 Challenge: "http-01",
1021 HTTPPort: 8088,
1022 TLSPort: 8443,
1023 }
1024
1025 svc := NewService(cfg, ServiceDeps{
1026 Config: cfg,
1027 Routes: routes,
1028 Store: store,
1029 Effective: EffectiveChallenge{
1030 ConfiguredMode: domain.ACMEChallengeHTTP01,
1031 Mode: domain.ACMEChallengeHTTP01,
1032 TokenSource: domain.ACMETokenSourceNone,
1033 Reason: "http-01 challenge selected",
1034 },
1035 })
1036
1037 // Load the cert into the cache.
1038 err = svc.Load(ctx)
1039 require.NoError(t, err)
1040
1041 // Get status.
1042 status := svc.Status(ctx)
1043
1044 require.Len(t, status.Certificates, 1)
1045 lastErr := status.Certificates[0].LastError
1046 t.Logf("sanitized LastError: %q", lastErr)
1047
1048 // The original error contained "token=sk-secret-goes-here".
1049 // After sanitization, the secret value should be redacted.
1050 assert.NotContains(t, lastErr, "sk-secret-goes-here",
1051 "LastError must not contain the raw secret value")
1052 assert.Contains(t, lastErr, "token=redacted",
1053 "LastError should contain token=redacted")
1054}