v3.0.0-alpha.1 6fdddb8

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

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