v2.31.0-dev23 58a92f3

renewal_test.go RawHistoryFolder

1package publictls
2
3import (
4	"context"
5	"crypto/tls"
6	"testing"
7	"time"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/require"
11
12	"github.com/bnema/gordon/internal/boundaries/out"
13	"github.com/bnema/gordon/internal/domain"
14)
15
16// ---------------------------------------------------------------------------
17// ShouldRenew
18// ---------------------------------------------------------------------------
19
20func TestShouldRenew(t *testing.T) {
21	// Fixed "now" for deterministic tests.
22	now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
23
24	tests := []struct {
25		name string
26		cert out.StoredCertificate
27		want bool
28	}{
29		{
30			name: "cert with NotAfter 45 days from now should not renew",
31			cert: out.StoredCertificate{NotAfter: now.Add(45 * 24 * time.Hour)},
32			want: false,
33		},
34		{
35			name: "cert with NotAfter 10 days from now should renew",
36			cert: out.StoredCertificate{NotAfter: now.Add(10 * 24 * time.Hour)},
37			want: true,
38		},
39		{
40			name: "expired cert should renew",
41			cert: out.StoredCertificate{NotAfter: now.Add(-1 * time.Hour)},
42			want: true,
43		},
44		{
45			name: "cert with zero NotAfter should renew",
46			cert: out.StoredCertificate{},
47			want: true,
48		},
49	}
50
51	for _, tt := range tests {
52		t.Run(tt.name, func(t *testing.T) {
53			got := ShouldRenew(tt.cert, now)
54			assert.Equal(t, tt.want, got)
55		})
56	}
57}
58
59func TestRenewDueCertificatesRequiresDependencies(t *testing.T) {
60	cfg := Config{Enabled: true}
61	svc := NewService(cfg, ServiceDeps{})
62
63	err := svc.renewDueCertificates(context.Background(), time.Now())
64	require.Error(t, err)
65	assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired)
66
67	svc.deps.Store, _ = newMockCertificateStore(t)
68	err = svc.renewDueCertificates(context.Background(), time.Now())
69	require.Error(t, err)
70	assert.ErrorIs(t, err, domain.ErrCertificateIssuerRequired)
71}
72
73// ---------------------------------------------------------------------------
74// RenewalLoopStops
75// ---------------------------------------------------------------------------
76
77func TestRenewalLoopStops(t *testing.T) {
78	ctx, cancel := context.WithCancel(context.Background())
79
80	cfg := Config{
81		Enabled:   true,
82		Email:     "admin@example.com",
83		Challenge: "http-01",
84		HTTPPort:  8088,
85		TLSPort:   8443,
86	}
87
88	issuer, _ := newMockPublicCertificateIssuer(t, nil, nil)
89	store, _ := newMockCertificateStore(t)
90
91	svc := NewService(cfg, ServiceDeps{
92		Config:     cfg,
93		Routes:     &fakeRoutes{},
94		Issuer:     issuer,
95		Store:      store,
96		Challenges: NewHTTP01Challenges(),
97		Effective: EffectiveChallenge{
98			ConfiguredMode: domain.ACMEChallengeHTTP01,
99			Mode:           domain.ACMEChallengeHTTP01,
100			TokenSource:    domain.ACMETokenSourceNone,
101			Reason:         "http-01 challenge selected",
102		},
103	})
104
105	done := svc.StartRenewalLoop(ctx, 10*time.Millisecond)
106	cancel()
107
108	require.Eventually(t, func() bool {
109		select {
110		case <-done:
111			return true
112		default:
113			return false
114		}
115	}, 5*time.Second, 10*time.Millisecond, "renewal loop should stop after context cancel")
116}
117
118func TestRenewalLoop_ReconcilesMissingCertificatesOnTick(t *testing.T) {
119	ctx, cancel := context.WithCancel(context.Background())
120	defer cancel()
121
122	cfg := Config{
123		Enabled:   true,
124		Email:     "admin@example.com",
125		Challenge: "http-01",
126		HTTPPort:  8088,
127		TLSPort:   8443,
128	}
129
130	routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}
131	issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
132	store, _ := newMockCertificateStore(t)
133
134	svc := NewService(cfg, ServiceDeps{
135		Config:     cfg,
136		Routes:     routes,
137		Issuer:     issuer,
138		Store:      store,
139		Challenges: NewHTTP01Challenges(),
140		Effective: EffectiveChallenge{
141			ConfiguredMode: domain.ACMEChallengeHTTP01,
142			Mode:           domain.ACMEChallengeHTTP01,
143			TokenSource:    domain.ACMETokenSourceNone,
144			Reason:         "http-01 challenge selected",
145		},
146	})
147
148	require.NoError(t, svc.Load(ctx))
149	done := svc.StartRenewalLoop(ctx, 10*time.Millisecond)
150	defer func() {
151		cancel()
152		<-done
153	}()
154
155	require.Eventually(t, func() bool {
156		return len(recorder.Orders()) > 0
157	}, 5*time.Second, 10*time.Millisecond, "renewal loop should retry missing certificate obtains")
158}
159
160// ---------------------------------------------------------------------------
161// RenewDueCertificates
162// ---------------------------------------------------------------------------
163
164func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) {
165	ctx := context.Background()
166
167	certID := "http01-app.example.com"
168	now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC)
169	notAfterSoon := now.Add(10 * 24 * time.Hour)    // 10 days from now — due for renewal
170	notAfterRenewed := now.Add(90 * 24 * time.Hour) // renewed cert
171
172	// Generate a test certificate.
173	certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"})
174	require.NoError(t, err)
175	tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
176	require.NoError(t, err)
177
178	store, storeState := newMockCertificateStore(t, out.StoredCertificate{
179		ID:            certID,
180		Names:         []string{"app.example.com"},
181		Challenge:     domain.ACMEChallengeHTTP01,
182		Certificate:   tlsCert,
183		FullchainPEM:  certPEM,
184		PrivateKeyPEM: keyPEM,
185		NotAfter:      notAfterSoon,
186	})
187
188	issuer, _ := newMockPublicCertificateIssuer(t, nil, func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) {
189		renewed := cert
190		renewed.Certificate = tls.Certificate{}
191		renewed.LastError = "previous renewal error"
192		renewed.NotAfter = notAfterRenewed
193		return &renewed, nil
194	})
195
196	cfg := Config{
197		Enabled:   true,
198		Email:     "admin@example.com",
199		Challenge: "http-01",
200		HTTPPort:  8088,
201		TLSPort:   8443,
202	}
203
204	svc := NewService(cfg, ServiceDeps{
205		Config:     cfg,
206		Routes:     &fakeRoutes{},
207		Issuer:     issuer,
208		Store:      store,
209		Challenges: NewHTTP01Challenges(),
210		Effective: EffectiveChallenge{
211			ConfiguredMode: domain.ACMEChallengeHTTP01,
212			Mode:           domain.ACMEChallengeHTTP01,
213			TokenSource:    domain.ACMETokenSourceNone,
214			Reason:         "http-01 challenge selected",
215		},
216	})
217
218	// Load certs into the cache from the store.
219	err = svc.Load(ctx)
220	require.NoError(t, err)
221
222	// Verify cert is in cache with original NotAfter via Status.
223	status := svc.Status(ctx)
224	require.Len(t, status.Certificates, 1, "expected 1 managed certificate after Load")
225	assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterSoon),
226		"cache should have original NotAfter (got %v, want %v)",
227		status.Certificates[0].NotAfter, notAfterSoon)
228
229	// Run renewDueCertificates.
230	err = svc.renewDueCertificates(ctx, now)
231	require.NoError(t, err)
232
233	// Verify the store has the updated certificate.
234	all := storeState.All()
235	require.Len(t, all, 1)
236	assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter")
237	assert.NotEmpty(t, all[0].Certificate.Certificate, "store should have normalized tls.Certificate")
238	assert.Empty(t, all[0].LastError, "store should not persist stale LastError")
239
240	// Verify the cache has the updated certificate via Status.
241	status = svc.Status(ctx)
242	require.Len(t, status.Certificates, 1, "expected 1 managed certificate after renewal")
243	assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterRenewed),
244		"cache should have renewed NotAfter (got %v, want %v)",
245		status.Certificates[0].NotAfter, notAfterRenewed)
246
247	// Verify lastErr is empty for the renewed certificate.
248	assert.Empty(t, status.Certificates[0].LastError,
249		"LastError should be empty for renewed cert")
250}

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