v3.0.0 f19f453

store_test.go RawHistoryFolder

1package acmestore
2
3import (
4	"context"
5	"crypto/ecdsa"
6	"crypto/elliptic"
7	"crypto/rand"
8	"crypto/x509"
9	"crypto/x509/pkix"
10	"encoding/json"
11	"encoding/pem"
12	"math/big"
13	"os"
14	"path/filepath"
15	"testing"
16	"time"
17
18	"github.com/stretchr/testify/assert"
19	"github.com/stretchr/testify/require"
20
21	"github.com/bnema/gordon/internal/boundaries/out"
22	"github.com/bnema/gordon/internal/domain"
23)
24
25// generateTestCertPEM creates a self-signed ECDSA P256 certificate and its
26// private key, both PEM-encoded, for use in tests that require a valid
27// tls.Certificate.
28func generateTestCertPEM(t *testing.T) (certPEM, keyPEM []byte) {
29	t.Helper()
30
31	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
32	require.NoError(t, err)
33
34	keyDER, err := x509.MarshalECPrivateKey(key)
35	require.NoError(t, err)
36
37	keyPEM = pem.EncodeToMemory(&pem.Block{
38		Type:  "EC PRIVATE KEY",
39		Bytes: keyDER,
40	})
41
42	template := &x509.Certificate{
43		SerialNumber: big.NewInt(1),
44		Subject: pkix.Name{
45			CommonName: "test.example.com",
46		},
47		NotBefore: time.Now().Add(-1 * time.Hour),
48		NotAfter:  time.Now().Add(24 * time.Hour),
49		KeyUsage:  x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
50	}
51
52	certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
53	require.NoError(t, err)
54
55	certPEM = pem.EncodeToMemory(&pem.Block{
56		Type:  "CERTIFICATE",
57		Bytes: certDER,
58	})
59
60	return certPEM, keyPEM
61}
62
63func TestStoreSaveLoadAll(t *testing.T) {
64	root := t.TempDir()
65	ctx := context.Background()
66
67	store, err := New(root)
68	require.NoError(t, err)
69
70	now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
71
72	fullchainPEM, privKeyPEM := generateTestCertPEM(t)
73
74	cert := out.StoredCertificate{
75		ID:            "dns01-example.com",
76		Names:         []string{"example.com", "*.example.com"},
77		Challenge:     domain.ACMEChallengeCloudflareDNS01,
78		CertPEM:       []byte("dummy-cert"),
79		ChainPEM:      []byte("dummy-chain"),
80		FullchainPEM:  fullchainPEM,
81		PrivateKeyPEM: privKeyPEM,
82		NotAfter:      now,
83	}
84
85	err = store.Save(ctx, cert)
86	require.NoError(t, err)
87
88	certs, err := store.LoadAll(ctx)
89	require.NoError(t, err)
90	require.Len(t, certs, 1)
91
92	assert.Equal(t, "dns01-example.com", certs[0].ID)
93	assert.Equal(t, []string{"example.com", "*.example.com"}, certs[0].Names)
94	assert.Equal(t, privKeyPEM, certs[0].PrivateKeyPEM)
95	// Verify tls.Certificate is populated from valid PEM
96	assert.NotEmpty(t, certs[0].Certificate.Certificate, "tls.Certificate.Certificate should be populated")
97}
98
99func TestStorePrivateKeyMode(t *testing.T) {
100	root := t.TempDir()
101	ctx := context.Background()
102
103	store, err := New(root)
104	require.NoError(t, err)
105
106	cert := out.StoredCertificate{
107		ID:            "http01-app.example.com",
108		Names:         []string{"app.example.com"},
109		Challenge:     domain.ACMEChallengeHTTP01,
110		PrivateKeyPEM: []byte("key"),
111	}
112
113	err = store.Save(ctx, cert)
114	require.NoError(t, err)
115
116	privkeyPath := filepath.Join(root, certDir, "http01-app.example.com", privkeyFile)
117	info, err := os.Stat(privkeyPath)
118	require.NoError(t, err)
119
120	// Mode 0600 (owner read+write only)
121	assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
122}
123
124func TestStoreRejectsUnsafeID(t *testing.T) {
125	root := t.TempDir()
126	ctx := context.Background()
127
128	store, err := New(root)
129	require.NoError(t, err)
130
131	for _, id := range []string{"../escape", ".", ".hidden", "tenant.old", "tenant.tmp-123"} {
132		t.Run(id, func(t *testing.T) {
133			cert := out.StoredCertificate{
134				ID:            id,
135				PrivateKeyPEM: []byte("key"),
136			}
137
138			err = store.Save(ctx, cert)
139			require.Error(t, err)
140			assert.Contains(t, err.Error(), "unsafe certificate id")
141			assert.ErrorIs(t, err, domain.ErrPathTraversal)
142		})
143	}
144}
145
146func TestStoreLockAcquireRelease(t *testing.T) {
147	root := t.TempDir()
148	ctx := context.Background()
149
150	store, err := New(root)
151	require.NoError(t, err)
152
153	unlock, err := store.Lock(ctx)
154	require.NoError(t, err)
155	assert.FileExists(t, filepath.Join(root, lockFile))
156
157	_, err = store.Lock(ctx)
158	require.Error(t, err)
159	assert.Contains(t, err.Error(), "lock already held")
160
161	require.NoError(t, unlock())
162	assert.FileExists(t, filepath.Join(root, lockFile))
163
164	unlock, err = store.Lock(ctx)
165	require.NoError(t, err)
166	require.NoError(t, unlock())
167}
168
169func TestStoreLockContextCanceled(t *testing.T) {
170	root := t.TempDir()
171	store, err := New(root)
172	require.NoError(t, err)
173
174	ctx, cancel := context.WithCancel(context.Background())
175	cancel()
176
177	_, err = store.Lock(ctx)
178	require.ErrorIs(t, err, context.Canceled)
179	assert.NoFileExists(t, filepath.Join(root, lockFile))
180}
181
182func TestStoreNewRestoresBackupDir(t *testing.T) {
183	root := t.TempDir()
184	ctx := context.Background()
185
186	store, err := New(root)
187	require.NoError(t, err)
188
189	fullchainPEM, privKeyPEM := generateTestCertPEM(t)
190	cert := out.StoredCertificate{
191		ID:            "restore.example.com",
192		Names:         []string{"restore.example.com"},
193		Challenge:     domain.ACMEChallengeHTTP01,
194		CertPEM:       fullchainPEM,
195		FullchainPEM:  fullchainPEM,
196		PrivateKeyPEM: privKeyPEM,
197	}
198	require.NoError(t, store.Save(ctx, cert))
199
200	certPath := filepath.Join(root, certDir, cert.ID)
201	backupPath := certPath + ".old"
202	require.NoError(t, os.Rename(certPath, backupPath))
203	require.NoDirExists(t, certPath)
204
205	store, err = New(root)
206	require.NoError(t, err)
207	assert.DirExists(t, certPath)
208
209	certs, err := store.LoadAll(ctx)
210	require.NoError(t, err)
211	require.Len(t, certs, 1)
212	assert.Equal(t, cert.ID, certs[0].ID)
213}
214
215func TestStoreSaveLoadAccount(t *testing.T) {
216	root := t.TempDir()
217	ctx := context.Background()
218
219	store, err := New(root)
220	require.NoError(t, err)
221
222	acct := out.ACMEAccount{
223		Email:           "admin@example.com",
224		PrivateKeyPEM:   []byte("key"),
225		RegistrationURI: "https://acme.test/acct/1",
226		BodyJSON:        []byte(`{"status":"valid"}`),
227	}
228
229	err = store.SaveAccount(ctx, acct)
230	require.NoError(t, err)
231
232	loaded, err := store.LoadAccount(ctx)
233	require.NoError(t, err)
234	require.NotNil(t, loaded)
235
236	assert.Equal(t, "admin@example.com", loaded.Email)
237	assert.Equal(t, []byte("key"), loaded.PrivateKeyPEM)
238	assert.Equal(t, "https://acme.test/acct/1", loaded.RegistrationURI)
239	assert.Equal(t, []byte(`{"status":"valid"}`), loaded.BodyJSON)
240}
241
242func TestStoreAccountFileMode(t *testing.T) {
243	root := t.TempDir()
244	ctx := context.Background()
245
246	store, err := New(root)
247	require.NoError(t, err)
248
249	acct := out.ACMEAccount{
250		Email:           "test@example.com",
251		PrivateKeyPEM:   []byte("test-key"),
252		RegistrationURI: "https://acme.test/acct/99",
253		BodyJSON:        []byte(`{"status":"valid"}`),
254	}
255
256	err = store.SaveAccount(ctx, acct)
257	require.NoError(t, err)
258
259	accountPath := filepath.Join(root, accountFile)
260	info, err := os.Stat(accountPath)
261	require.NoError(t, err)
262
263	assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
264}
265
266func TestStoreSaveLoadState(t *testing.T) {
267	root := t.TempDir()
268	ctx := context.Background()
269
270	store, err := New(root)
271	require.NoError(t, err)
272
273	state, err := store.LoadState(ctx)
274	require.NoError(t, err)
275	assert.Equal(t, 0, state.ObtainCursor)
276
277	err = store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: 3})
278	require.NoError(t, err)
279
280	state, err = store.LoadState(ctx)
281	require.NoError(t, err)
282	assert.Equal(t, 3, state.ObtainCursor)
283
284	info, err := os.Stat(filepath.Join(root, stateFile))
285	require.NoError(t, err)
286	assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
287}
288
289func TestStoreLoadAllInvalidChallenge(t *testing.T) {
290	root := t.TempDir()
291	ctx := context.Background()
292
293	store, err := New(root)
294	require.NoError(t, err)
295
296	// Create a cert dir manually with invalid challenge in metadata
297	certDirPath := filepath.Join(root, certDir, "bad-challenge.example.com")
298	err = os.MkdirAll(certDirPath, dirMode)
299	require.NoError(t, err)
300
301	meta := certMetadata{
302		ID:        "bad-challenge.example.com",
303		Names:     []string{"bad-challenge.example.com"},
304		Challenge: "invalid-challenge-mode",
305	}
306	metaData, err := json.MarshalIndent(meta, "", "  ")
307	require.NoError(t, err)
308	err = os.WriteFile(filepath.Join(certDirPath, metaFile), metaData, metaMode)
309	require.NoError(t, err)
310
311	// LoadAll should return an error wrapping domain.ErrACMEChallengeInvalid
312	_, err = store.LoadAll(ctx)
313	require.Error(t, err)
314	assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
315}
316
317func TestStoreLoadAllCorruptPrivkey(t *testing.T) {
318	root := t.TempDir()
319	ctx := context.Background()
320
321	store, err := New(root)
322	require.NoError(t, err)
323
324	// Save a valid cert first
325	cert := out.StoredCertificate{
326		ID:            "corrupt-privkey.example.com",
327		Names:         []string{"corrupt-privkey.example.com"},
328		Challenge:     domain.ACMEChallengeHTTP01,
329		FullchainPEM:  []byte("fullchain-data"),
330		PrivateKeyPEM: []byte("privkey-data"),
331	}
332	err = store.Save(ctx, cert)
333	require.NoError(t, err)
334
335	// Replace privkey.pem with an unreadable directory (simulate corruption)
336	privkeyPath := filepath.Join(root, certDir, "corrupt-privkey.example.com", privkeyFile)
337	err = os.Remove(privkeyPath)
338	require.NoError(t, err)
339	// Create a directory in its place to cause a read error
340	err = os.MkdirAll(privkeyPath, 0700)
341	require.NoError(t, err)
342
343	// LoadAll should return an error
344	_, err = store.LoadAll(ctx)
345	require.Error(t, err)
346	assert.Contains(t, err.Error(), "privkey.pem")
347}
348
349func TestStoreLoadAllCorruptFullchain(t *testing.T) {
350	root := t.TempDir()
351	ctx := context.Background()
352
353	store, err := New(root)
354	require.NoError(t, err)
355
356	// Save a valid cert first
357	cert := out.StoredCertificate{
358		ID:            "corrupt-fullchain.example.com",
359		Names:         []string{"corrupt-fullchain.example.com"},
360		Challenge:     domain.ACMEChallengeHTTP01,
361		FullchainPEM:  []byte("fullchain-data"),
362		PrivateKeyPEM: []byte("privkey-data"),
363	}
364	err = store.Save(ctx, cert)
365	require.NoError(t, err)
366
367	// Replace fullchain.pem with an unreadable directory
368	fullchainPath := filepath.Join(root, certDir, "corrupt-fullchain.example.com", fullchainFile)
369	err = os.Remove(fullchainPath)
370	require.NoError(t, err)
371	err = os.MkdirAll(fullchainPath, 0700)
372	require.NoError(t, err)
373
374	// LoadAll should return an error
375	_, err = store.LoadAll(ctx)
376	require.Error(t, err)
377	assert.Contains(t, err.Error(), "fullchain.pem")
378}
379
380func TestStoreLoadAllSkipsTempAndBackupDirs(t *testing.T) {
381	root := t.TempDir()
382	ctx := context.Background()
383
384	store, err := New(root)
385	require.NoError(t, err)
386
387	fullchainPEM, privKeyPEM := generateTestCertPEM(t)
388
389	// Save a valid certificate first.
390	cert := out.StoredCertificate{
391		ID:            "valid.example.com",
392		Names:         []string{"valid.example.com"},
393		Challenge:     domain.ACMEChallengeHTTP01,
394		CertPEM:       fullchainPEM,
395		FullchainPEM:  fullchainPEM,
396		PrivateKeyPEM: privKeyPEM,
397	}
398	err = store.Save(ctx, cert)
399	require.NoError(t, err)
400
401	// Create a tmp-* directory that Save would have left behind.
402	tmpDir := filepath.Join(root, certDir, "orphan.tmp-abc123")
403	err = os.MkdirAll(tmpDir, dirMode)
404	require.NoError(t, err)
405
406	// Create a .old directory that Save would have left behind.
407	oldDir := filepath.Join(root, certDir, "old-cert.example.com.old")
408	err = os.MkdirAll(oldDir, dirMode)
409	require.NoError(t, err)
410
411	// LoadAll should return only the valid cert, skipping both temp and backup dirs.
412	certs, err := store.LoadAll(ctx)
413	require.NoError(t, err)
414	require.Len(t, certs, 1, "should skip tmp-* and *.old directories")
415	assert.Equal(t, "valid.example.com", certs[0].ID)
416}
417
418func TestStoreLoadAllInvalidPEM(t *testing.T) {
419	root := t.TempDir()
420	ctx := context.Background()
421
422	store, err := New(root)
423	require.NoError(t, err)
424
425	// Save a cert with non-empty but invalid PEM bytes (mismatched key)
426	cert := out.StoredCertificate{
427		ID:            "invalid-pem.example.com",
428		Names:         []string{"invalid-pem.example.com"},
429		Challenge:     domain.ACMEChallengeHTTP01,
430		FullchainPEM:  []byte("invalid-fullchain"),
431		PrivateKeyPEM: []byte("invalid-privkey"),
432	}
433	err = store.Save(ctx, cert)
434	require.NoError(t, err)
435
436	// LoadAll should return an error because tls.X509KeyPair fails
437	_, err = store.LoadAll(ctx)
438	require.Error(t, err)
439	assert.Contains(t, err.Error(), "parse key pair")
440}

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