v2.31.0 e02f654

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 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}

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