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 TestServiceSetAdditionalHosts_ImmediatelyRevokesRemovedManagementDomain(t *testing.T) {
352 ctx := t.Context()
353 routes := outmocks.NewMockRouteChecker(t)
354 routes.EXPECT().GetRoutes(mock.Anything).Return(nil).Maybe()
355 routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe()
356 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
357 store, _ := newMockCertificateStore(t)
358
359 svc := NewService(Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", ObtainBatchSize: 10}, ServiceDeps{
360 Routes: routes,
361 Issuer: issuer,
362 Store: store,
363 AdditionalHosts: []string{"old.example.com"},
364 Effective: EffectiveChallenge{
365 ConfiguredMode: domain.ACMEChallengeHTTP01,
366 Mode: domain.ACMEChallengeHTTP01,
367 },
368 })
369
370 require.NoError(t, svc.Load(ctx))
371 require.NoError(t, svc.Reconcile(ctx))
372 cert, err := svc.GetCertificateForHost("old.example.com")
373 require.NoError(t, err)
374 require.NotNil(t, cert)
375
376 svc.SetAdditionalHosts(ctx, []string{"new.example.com"})
377
378 cert, err = svc.GetCertificateForHost("old.example.com")
379 require.NoError(t, err)
380 assert.Nil(t, cert)
381}
382
383func TestServiceSetAdditionalHosts_ReconcileReplacesManagementDomain(t *testing.T) {
384 ctx := context.Background()
385 routes := &fakeRoutes{}
386 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
387 store, _ := newMockCertificateStore(t)
388
389 cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", ObtainBatchSize: 10}
390 svc := NewService(cfg, ServiceDeps{
391 Routes: routes,
392 Issuer: issuer,
393 Store: store,
394 AdditionalHosts: []string{"old.example.com"},
395 Effective: EffectiveChallenge{
396 ConfiguredMode: domain.ACMEChallengeHTTP01,
397 Mode: domain.ACMEChallengeHTTP01,
398 },
399 })
400
401 require.NoError(t, svc.Load(ctx))
402 require.NoError(t, svc.Reconcile(ctx))
403 svc.SetAdditionalHosts(ctx, []string{"NEW.EXAMPLE.COM."})
404 require.NoError(t, svc.Reconcile(ctx))
405
406 orders := recorder.Orders()
407 require.Len(t, orders, 2)
408 assert.Equal(t, "http01-old.example.com", orders[0].ID)
409 assert.Equal(t, "http01-new.example.com", orders[1].ID)
410
411 cert, err := svc.GetCertificateForHost("old.example.com")
412 require.NoError(t, err)
413 assert.Nil(t, cert)
414}
415
416func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
417 ctx := context.Background()
418
419 routes := &fakeRoutes{
420 routes: []domain.Route{
421 {Domain: "b.example.com"},
422 {Domain: "a.example.com"},
423 },
424 }
425 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
426 if order.ID == "http01-a.example.com" {
427 return nil, fmt.Errorf("acme validation failed")
428 }
429 return defaultStoredCert(order)
430 }, nil)
431 store, storeState := newMockCertificateStore(t)
432
433 cfg := Config{
434 Enabled: true,
435 Email: "admin@example.com",
436 Challenge: "http-01",
437 HTTPPort: 8088,
438 TLSPort: 8443,
439 ObtainBatchSize: 1,
440 }
441
442 svc := NewService(cfg, ServiceDeps{
443 Config: cfg,
444 Routes: routes,
445 Issuer: issuer,
446 Store: store,
447 Challenges: NewHTTP01Challenges(),
448 Effective: EffectiveChallenge{
449 ConfiguredMode: domain.ACMEChallengeHTTP01,
450 Mode: domain.ACMEChallengeHTTP01,
451 TokenSource: domain.ACMETokenSourceNone,
452 Reason: "http-01 challenge selected",
453 },
454 })
455
456 require.NoError(t, svc.Load(ctx))
457 require.NoError(t, svc.Reconcile(ctx))
458 require.NoError(t, svc.Reconcile(ctx))
459
460 orders := recorder.Orders()
461 require.Len(t, orders, 2)
462 assert.Equal(t, "http01-a.example.com", orders[0].ID)
463 assert.Equal(t, "http01-b.example.com", orders[1].ID, "second reconcile should rotate past the failed first target")
464
465 stored := storeState.All()
466 require.Len(t, stored, 1)
467 assert.Equal(t, "http01-b.example.com", stored[0].ID)
468}
469
470func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) {
471 ctx := context.Background()
472
473 routes := &fakeRoutes{
474 routes: []domain.Route{
475 {Domain: "b.example.com"},
476 {Domain: "a.example.com"},
477 },
478 }
479 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
480 store, storeState := newMockCertificateStore(t)
481 storeState.SetState(out.CertificateStoreState{ObtainCursor: 1})
482 storeState.SetLoadStateError(fmt.Errorf("state unreadable"))
483
484 cfg := Config{
485 Enabled: true,
486 Email: "admin@example.com",
487 Challenge: "http-01",
488 HTTPPort: 8088,
489 TLSPort: 8443,
490 ObtainBatchSize: 1,
491 }
492 svc := NewService(cfg, ServiceDeps{
493 Config: cfg,
494 Routes: routes,
495 Issuer: issuer,
496 Store: store,
497 Challenges: NewHTTP01Challenges(),
498 Effective: EffectiveChallenge{
499 ConfiguredMode: domain.ACMEChallengeHTTP01,
500 Mode: domain.ACMEChallengeHTTP01,
501 TokenSource: domain.ACMETokenSourceNone,
502 Reason: "http-01 challenge selected",
503 },
504 })
505
506 require.NoError(t, svc.Load(ctx))
507 require.NoError(t, svc.Reconcile(ctx))
508
509 orders := recorder.Orders()
510 require.Len(t, orders, 1)
511 assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor")
512}
513
514func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) {
515 ctx := context.Background()
516
517 routes := &fakeRoutes{
518 routes: []domain.Route{
519 {Domain: "b.example.com"},
520 {Domain: "a.example.com"},
521 },
522 }
523 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
524 if order.ID == "http01-a.example.com" {
525 return nil, fmt.Errorf("acme validation failed")
526 }
527 return defaultStoredCert(order)
528 }, nil)
529 store, storeState := newMockCertificateStore(t)
530
531 cfg := Config{
532 Enabled: true,
533 Email: "admin@example.com",
534 Challenge: "http-01",
535 HTTPPort: 8088,
536 TLSPort: 8443,
537 ObtainBatchSize: 1,
538 }
539 svc := NewService(cfg, ServiceDeps{
540 Config: cfg,
541 Routes: routes,
542 Issuer: issuer,
543 Store: store,
544 Challenges: NewHTTP01Challenges(),
545 Effective: EffectiveChallenge{
546 ConfiguredMode: domain.ACMEChallengeHTTP01,
547 Mode: domain.ACMEChallengeHTTP01,
548 TokenSource: domain.ACMETokenSourceNone,
549 Reason: "http-01 challenge selected",
550 },
551 })
552
553 storeState.SetSaveStateError(fmt.Errorf("disk full"))
554 require.NoError(t, svc.Load(ctx))
555 require.NoError(t, svc.Reconcile(ctx))
556 require.NoError(t, svc.Reconcile(ctx))
557 assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged")
558
559 orders := recorder.Orders()
560 require.Len(t, orders, 2)
561 assert.Equal(t, "http01-a.example.com", orders[0].ID)
562 assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor")
563
564 stored := storeState.All()
565 require.Len(t, stored, 1)
566 assert.Equal(t, "http01-b.example.com", stored[0].ID)
567}
568
569func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) {
570 ctx := context.Background()
571
572 routes := &fakeRoutes{
573 routes: []domain.Route{
574 {Domain: "b.example.com"},
575 {Domain: "a.example.com"},
576 },
577 }
578 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
579 if order.ID == "http01-a.example.com" {
580 return nil, fmt.Errorf("acme validation failed")
581 }
582 return defaultStoredCert(order)
583 }, nil)
584 store, storeState := newMockCertificateStore(t)
585
586 cfg := Config{
587 Enabled: true,
588 Email: "admin@example.com",
589 Challenge: "http-01",
590 HTTPPort: 8088,
591 TLSPort: 8443,
592 ObtainBatchSize: 1,
593 }
594 deps := ServiceDeps{
595 Config: cfg,
596 Routes: routes,
597 Issuer: issuer,
598 Store: store,
599 Challenges: NewHTTP01Challenges(),
600 Effective: EffectiveChallenge{
601 ConfiguredMode: domain.ACMEChallengeHTTP01,
602 Mode: domain.ACMEChallengeHTTP01,
603 TokenSource: domain.ACMETokenSourceNone,
604 Reason: "http-01 challenge selected",
605 },
606 }
607
608 firstSvc := NewService(cfg, deps)
609 require.NoError(t, firstSvc.Load(ctx))
610 require.NoError(t, firstSvc.Reconcile(ctx))
611 assert.Equal(t, out.CertificateStoreState{ObtainCursor: 1}, storeState.State())
612
613 secondSvc := NewService(cfg, deps)
614 require.NoError(t, secondSvc.Load(ctx))
615 require.NoError(t, secondSvc.Reconcile(ctx))
616
617 orders := recorder.Orders()
618 require.Len(t, orders, 2)
619 assert.Equal(t, "http01-a.example.com", orders[0].ID)
620 assert.Equal(t, "http01-b.example.com", orders[1].ID, "restart should resume from the persisted obtain cursor")
621
622 stored := storeState.All()
623 require.Len(t, stored, 1)
624 assert.Equal(t, "http01-b.example.com", stored[0].ID)
625}
626
627func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
628 ctx := context.Background()
629
630 routes := &fakeRoutes{
631 routes: []domain.Route{
632 {Domain: "app.example.com"},
633 },
634 }
635 issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
636 store, storeState := newMockCertificateStore(t)
637
638 cfg := Config{
639 Enabled: true,
640 Email: "admin@example.com",
641 Challenge: "http-01",
642 HTTPPort: 8088,
643 TLSPort: 8443,
644 }
645
646 svc := NewService(cfg, ServiceDeps{
647 Config: cfg,
648 Routes: routes,
649 Issuer: issuer,
650 Store: store,
651 Challenges: NewHTTP01Challenges(),
652 Effective: EffectiveChallenge{
653 ConfiguredMode: domain.ACMEChallengeHTTP01,
654 Mode: domain.ACMEChallengeHTTP01,
655 TokenSource: domain.ACMETokenSourceNone,
656 Reason: "http-01 challenge selected",
657 },
658 })
659
660 // Load (nothing stored yet).
661 err := svc.Load(ctx)
662 require.NoError(t, err)
663
664 // Reconcile — should obtain one certificate for app.example.com.
665 err = svc.Reconcile(ctx)
666 require.NoError(t, err)
667
668 // Verify the issuer received exactly one order.
669 orders := recorder.Orders()
670
671 require.Len(t, orders, 1, "expected one order to be placed")
672 assert.Equal(t, "http01-app.example.com", orders[0].ID)
673 assert.Equal(t, []string{"app.example.com"}, orders[0].Names)
674 assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge)
675
676 // Verify the store has the saved certificate.
677 stored := storeState.All()
678 require.Len(t, stored, 1)
679 assert.Equal(t, "http01-app.example.com", stored[0].ID)
680 assert.Equal(t, []string{"app.example.com"}, stored[0].Names)
681 assert.False(t, stored[0].NotAfter.IsZero())
682 assert.NotEmpty(t, stored[0].Certificate.Certificate)
683}
684
685func TestServiceReconcile_SerializesConcurrentRuns(t *testing.T) {
686 ctx := context.Background()
687 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
688 obtainStarted := make(chan struct{}, 2)
689 releaseObtain := make(chan struct{})
690
691 issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
692 obtainStarted <- struct{}{}
693 <-releaseObtain
694 return defaultStoredCert(order)
695 }, nil)
696 store, _ := newMockCertificateStore(t)
697
698 cfg := Config{
699 Enabled: true,
700 Email: "admin@example.com",
701 Challenge: "http-01",
702 HTTPPort: 8088,
703 TLSPort: 8443,
704 }
705
706 svc := NewService(cfg, ServiceDeps{
707 Config: cfg,
708 Routes: routes,
709 Issuer: issuer,
710 Store: store,
711 Challenges: NewHTTP01Challenges(),
712 Effective: EffectiveChallenge{
713 ConfiguredMode: domain.ACMEChallengeHTTP01,
714 Mode: domain.ACMEChallengeHTTP01,
715 TokenSource: domain.ACMETokenSourceNone,
716 Reason: "http-01 challenge selected",
717 },
718 })
719
720 require.NoError(t, svc.Load(ctx))
721
722 errCh := make(chan error, 2)
723 go func() { errCh <- svc.Reconcile(ctx) }()
724
725 select {
726 case <-obtainStarted:
727 case <-time.After(2 * time.Second):
728 t.Fatal("first reconcile did not start certificate obtain")
729 }
730
731 secondStarted := make(chan struct{})
732 go func() {
733 close(secondStarted)
734 errCh <- svc.Reconcile(ctx)
735 }()
736 <-secondStarted
737 close(releaseObtain)
738
739 require.NoError(t, <-errCh)
740 require.NoError(t, <-errCh)
741 assert.Len(t, recorder.Orders(), 1)
742}
743
744func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) {
745 ctx := context.Background()
746 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
747 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
748 store, _ := newMockCertificateStore(t)
749 cfg := Config{
750 Enabled: true,
751 Email: "admin@example.com",
752 Challenge: "http-01",
753 HTTPPort: 8088,
754 // Missing TLSPort should be caught by ResolveEffectiveChallenge rather
755 // than silently defaulting an effective mode.
756 }
757
758 svc := NewService(cfg, ServiceDeps{
759 Config: cfg,
760 Routes: routes,
761 Issuer: issuer,
762 Store: store,
763 })
764
765 err := svc.Reconcile(ctx)
766 require.Error(t, err)
767 assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
768}
769
770func TestServiceStatusReportsCoverage(t *testing.T) {
771 ctx := context.Background()
772
773 // Pre-populate the store with a valid certificate.
774 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
775 require.NoError(t, err)
776 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
777 require.NoError(t, err)
778
779 store, _ := newMockCertificateStore(t, out.StoredCertificate{
780 ID: "http01-app.example.com",
781 Names: []string{"app.example.com"},
782 Challenge: domain.ACMEChallengeHTTP01,
783 Certificate: tlsCert,
784 FullchainPEM: certPEM,
785 PrivateKeyPEM: keyPEM,
786 NotAfter: time.Now().Add(90 * 24 * time.Hour),
787 })
788
789 routes := &fakeRoutes{
790 routes: []domain.Route{
791 {Domain: "app.example.com"},
792 },
793 }
794 cfg := Config{
795 Enabled: true,
796 Email: "admin@example.com",
797 Challenge: "http-01",
798 HTTPPort: 8088,
799 TLSPort: 8443,
800 }
801
802 svc := NewService(cfg, ServiceDeps{
803 Config: cfg,
804 Routes: routes,
805 Store: store,
806 Effective: EffectiveChallenge{
807 ConfiguredMode: domain.ACMEChallengeHTTP01,
808 Mode: domain.ACMEChallengeHTTP01,
809 TokenSource: domain.ACMETokenSourceNone,
810 Reason: "http-01 challenge selected",
811 },
812 })
813
814 // Load the cert into the cache.
815 err = svc.Load(ctx)
816 require.NoError(t, err)
817
818 // Get status.
819 status := svc.Status(ctx)
820
821 assert.True(t, status.ACMEEnabled)
822 assert.Equal(t, domain.ACMEChallengeHTTP01, status.ConfiguredMode)
823 assert.Equal(t, domain.ACMEChallengeHTTP01, status.EffectiveMode)
824
825 require.Len(t, status.Certificates, 1, "expected one managed certificate")
826 assert.Equal(t, "http01-app.example.com", status.Certificates[0].ID)
827 assert.Equal(t, domain.TLSCertificateStatusValid, status.Certificates[0].Status)
828
829 require.Len(t, status.Routes, 1, "expected one route coverage")
830 assert.Equal(t, "app.example.com", status.Routes[0].Domain)
831 assert.True(t, status.Routes[0].Covered, "route should be covered")
832 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
833 assert.True(t, status.Routes[0].RequiredACME)
834}
835
836func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) {
837 ctx := context.Background()
838
839 routes := &fakeRoutes{
840 routes: []domain.Route{
841 {Domain: "app.example.com"},
842 },
843 }
844 // Make Obtain fail so no cert is cached.
845 issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
846 return nil, fmt.Errorf("acme provider unavailable")
847 }, nil)
848 store, _ := newMockCertificateStore(t)
849
850 cfg := Config{
851 Enabled: true,
852 Email: "admin@example.com",
853 Challenge: "http-01",
854 HTTPPort: 8088,
855 TLSPort: 8443,
856 }
857
858 svc := NewService(cfg, ServiceDeps{
859 Config: cfg,
860 Routes: routes,
861 Issuer: issuer,
862 Store: store,
863 Challenges: NewHTTP01Challenges(),
864 Effective: EffectiveChallenge{
865 ConfiguredMode: domain.ACMEChallengeHTTP01,
866 Mode: domain.ACMEChallengeHTTP01,
867 TokenSource: domain.ACMETokenSourceNone,
868 Reason: "http-01 challenge selected",
869 },
870 })
871
872 // Load (no certs stored).
873 err := svc.Load(ctx)
874 require.NoError(t, err)
875
876 // Reconcile — issuer fails, no cert cached but requiredHosts is set.
877 err = svc.Reconcile(ctx)
878 require.NoError(t, err) // Reconcile swallows per-target obtain errors
879
880 // GetCertificateForHost for app.example.com should return ErrTLSRouteNotCovered.
881 _, err = svc.GetCertificateForHost("app.example.com")
882 require.Error(t, err)
883 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
884 assert.Contains(t, err.Error(), "app.example.com")
885}
886
887func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) {
888 ctx := context.Background()
889
890 routes := &fakeRoutes{
891 routes: []domain.Route{
892 {Domain: "app.example.com"},
893 },
894 }
895 issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
896 store, _ := newMockCertificateStore(t)
897
898 // Use generated mock that always returns an error.
899 zoneResolver := outmocks.NewMockCloudflareZoneResolver(t)
900 zoneResolver.EXPECT().FindZone(mock.Anything, "app.example.com").Return(
901 out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for app.example.com"),
902 )
903
904 cfg := Config{
905 Enabled: true,
906 Email: "admin@example.com",
907 Challenge: "dns-01",
908 HTTPPort: 8088,
909 TLSPort: 8443,
910 }
911
912 svc := NewService(cfg, ServiceDeps{
913 Config: cfg,
914 Routes: routes,
915 Issuer: issuer,
916 Store: store,
917 ZoneResolver: zoneResolver, // generated mock that always fails
918 Challenges: NewHTTP01Challenges(),
919 Effective: EffectiveChallenge{
920 ConfiguredMode: domain.ACMEChallengeCloudflareDNS01,
921 Mode: domain.ACMEChallengeCloudflareDNS01,
922 TokenSource: domain.ACMETokenSourceNone,
923 Reason: "dns-01 challenge selected",
924 },
925 })
926
927 // Load (nothing stored).
928 err := svc.Load(ctx)
929 require.NoError(t, err)
930
931 // Reconcile — zone resolver is broken, so DeriveCertificateTargets fails.
932 err = svc.Reconcile(ctx)
933 require.Error(t, err)
934 assert.Contains(t, err.Error(), "derive certificate targets")
935
936 // GetCertificateForHost for the configured route should return ErrTLSRouteNotCovered,
937 // not nil,nil, because requiredHosts was set from route hosts before
938 // the failed DeriveCertificateTargets call.
939 _, err = svc.GetCertificateForHost("app.example.com")
940 require.Error(t, err)
941 assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered)
942 assert.Contains(t, err.Error(), "app.example.com")
943}
944
945func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) {
946 ctx := context.Background()
947
948 // Generate valid PEM cert data.
949 certPEM, keyPEM, err := generateTestCertPEM([]string{"test.example.com"})
950 require.NoError(t, err)
951
952 // Store a cert with PEM data but empty tls.Certificate.
953 store, _ := newMockCertificateStore(t, out.StoredCertificate{
954 ID: "http01-test.example.com",
955 Names: []string{"test.example.com"},
956 Challenge: domain.ACMEChallengeHTTP01,
957 FullchainPEM: certPEM,
958 PrivateKeyPEM: keyPEM,
959 NotAfter: time.Now().Add(90 * 24 * time.Hour),
960 // Certificate field is zero — will be populated by Load.
961 })
962
963 routes := &fakeRoutes{routes: []domain.Route{{Domain: "test.example.com"}}}
964 cfg := Config{Enabled: true}
965
966 svc := NewService(cfg, ServiceDeps{
967 Config: cfg,
968 Routes: routes,
969 Store: store,
970 })
971
972 // Load — should parse PEM into tls.Certificate.
973 err = svc.Load(ctx)
974 require.NoError(t, err)
975
976 // Verify the cached cert has a valid tls.Certificate via the accessor.
977 cached := svc.getStoredCertificate("http01-test.example.com")
978 require.NotNil(t, cached, "cert should be cached")
979 assert.NotEmpty(t, cached.Certificate.Certificate, "tls.Certificate should be populated")
980 assert.NotNil(t, cached.Certificate.PrivateKey, "private key should be populated")
981
982 // Verify it's a valid TLS cert by performing a parse round-trip.
983 leaf, err := x509.ParseCertificate(cached.Certificate.Certificate[0])
984 require.NoError(t, err)
985 assert.Contains(t, leaf.DNSNames, "test.example.com")
986
987 served, err := svc.GetCertificateForHost("test.example.com")
988 require.NoError(t, err)
989 require.NotNil(t, served, "loaded certificate should cover the configured host")
990}
991
992func TestServiceStatusReportsRouteErrorAfterObtainFailure(t *testing.T) {
993 ctx := context.Background()
994 routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
995 issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) {
996 return nil, fmt.Errorf("acme unavailable token=sk-secret")
997 }, nil)
998 store, _ := newMockCertificateStore(t)
999 cfg := Config{
1000 Enabled: true,
1001 Email: "admin@example.com",
1002 Challenge: "http-01",
1003 HTTPPort: 8088,
1004 TLSPort: 8443,
1005 }
1006 svc := NewService(cfg, ServiceDeps{
1007 Config: cfg,
1008 Routes: routes,
1009 Issuer: issuer,
1010 Store: store,
1011 Effective: EffectiveChallenge{
1012 ConfiguredMode: domain.ACMEChallengeHTTP01,
1013 Mode: domain.ACMEChallengeHTTP01,
1014 TokenSource: domain.ACMETokenSourceNone,
1015 },
1016 })
1017
1018 require.NoError(t, svc.Reconcile(ctx))
1019 status := svc.Status(ctx)
1020 require.Len(t, status.Routes, 1)
1021 assert.False(t, status.Routes[0].Covered)
1022 assert.Contains(t, status.Routes[0].Error, "token=redacted")
1023 assert.NotContains(t, status.Routes[0].Error, "sk-secret")
1024}
1025
1026func TestServiceStatusCoverageUsesServingCertDespiteTransientError(t *testing.T) {
1027 ctx := context.Background()
1028 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
1029 require.NoError(t, err)
1030 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
1031 require.NoError(t, err)
1032 store, _ := newMockCertificateStore(t, out.StoredCertificate{
1033 ID: "http01-app.example.com",
1034 Names: []string{"app.example.com"},
1035 Challenge: domain.ACMEChallengeHTTP01,
1036 Certificate: tlsCert,
1037 FullchainPEM: certPEM,
1038 PrivateKeyPEM: keyPEM,
1039 NotAfter: time.Now().Add(90 * 24 * time.Hour),
1040 LastError: "transient renewal error",
1041 })
1042 cfg := Config{Enabled: true}
1043 svc := NewService(cfg, ServiceDeps{
1044 Config: cfg,
1045 Routes: &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}},
1046 Store: store,
1047 })
1048 require.NoError(t, svc.Load(ctx))
1049
1050 status := svc.Status(ctx)
1051 require.Len(t, status.Routes, 1)
1052 assert.True(t, status.Routes[0].Covered)
1053 assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy)
1054}
1055
1056func TestServiceStatusRedactsSensitiveStrings(t *testing.T) {
1057 ctx := context.Background()
1058
1059 // Pre-populate the store with a certificate that has a LastError
1060 // containing sensitive data.
1061 certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
1062 require.NoError(t, err)
1063 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
1064 require.NoError(t, err)
1065
1066 store, _ := newMockCertificateStore(t, out.StoredCertificate{
1067 ID: "http01-app.example.com",
1068 Names: []string{"app.example.com"},
1069 Challenge: domain.ACMEChallengeHTTP01,
1070 Certificate: tlsCert,
1071 FullchainPEM: certPEM,
1072 PrivateKeyPEM: keyPEM,
1073 NotAfter: time.Now().Add(90 * 24 * time.Hour),
1074 LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid",
1075 })
1076
1077 routes := &fakeRoutes{
1078 routes: []domain.Route{
1079 {Domain: "app.example.com"},
1080 },
1081 }
1082 cfg := Config{
1083 Enabled: true,
1084 Email: "admin@example.com",
1085 Challenge: "http-01",
1086 HTTPPort: 8088,
1087 TLSPort: 8443,
1088 }
1089
1090 svc := NewService(cfg, ServiceDeps{
1091 Config: cfg,
1092 Routes: routes,
1093 Store: store,
1094 Effective: EffectiveChallenge{
1095 ConfiguredMode: domain.ACMEChallengeHTTP01,
1096 Mode: domain.ACMEChallengeHTTP01,
1097 TokenSource: domain.ACMETokenSourceNone,
1098 Reason: "http-01 challenge selected",
1099 },
1100 })
1101
1102 // Load the cert into the cache.
1103 err = svc.Load(ctx)
1104 require.NoError(t, err)
1105
1106 // Get status.
1107 status := svc.Status(ctx)
1108
1109 require.Len(t, status.Certificates, 1)
1110 lastErr := status.Certificates[0].LastError
1111 t.Logf("sanitized LastError: %q", lastErr)
1112
1113 // The original error contained "token=sk-secret-goes-here".
1114 // After sanitization, the secret value should be redacted.
1115 assert.NotContains(t, lastErr, "sk-secret-goes-here",
1116 "LastError must not contain the raw secret value")
1117 assert.Contains(t, lastErr, "token=redacted",
1118 "LastError should contain token=redacted")
1119}