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