v2.31.0-dev21 b59533b

service_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close