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}