Skip to content
File

Blob: acme/handler_test.go

go365 lines
1package acme
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 "net/http"
14 "net/http/httptest"
15 "testing"
16 "time"
17 
18 "go.miragespace.co/specter/kv/memory"
19 "go.miragespace.co/specter/spec/chord"
20 
21 "github.com/caddyserver/certmagic"
22 "github.com/stretchr/testify/require"
23 "go.uber.org/zap/zaptest"
24)
25 
26func generateTestCert(t *testing.T, domain string) (certPEM, keyPEM []byte) {
27 t.Helper()
28 
29 priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
30 require.NoError(t, err)
31 
32 template := &x509.Certificate{
33 SerialNumber: big.NewInt(1),
34 Subject: pkix.Name{
35 CommonName: domain,
36 },
37 NotBefore: time.Now(),
38 NotAfter: time.Now().Add(24 * time.Hour),
39 DNSNames: []string{domain},
40 }
41 
42 certDER, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv)
43 require.NoError(t, err)
44 
45 certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
46 
47 keyDER, err := x509.MarshalECPrivateKey(priv)
48 require.NoError(t, err)
49 keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
50 
51 return certPEM, keyPEM
52}
53 
54func setupTestStorage(t *testing.T) *ChordStorage {
55 t.Helper()
56 logger := zaptest.NewLogger(t)
57 kv := memory.WithHashFn(chord.Hash)
58 
59 storage, err := NewChordStorage(logger, kv, StorageConfig{
60 RetryInterval: time.Millisecond * 100,
61 LeaseTTL: time.Minute,
62 })
63 require.NoError(t, err)
64 
65 return storage
66}
67 
68func TestHandlerListIssuers(t *testing.T) {
69 as := require.New(t)
70 ctx := context.Background()
71 
72 storage := setupTestStorage(t)
73 
74 // Store test certificates
75 issuer1 := "acme-v02.api.letsencrypt.org-directory"
76 issuer2 := "acme.zerossl.com-v2-dv90"
77 
78 certPEM, keyPEM := generateTestCert(t, "example.com")
79 meta := []byte(`{"url":"https://acme.example.com/order/123"}`)
80 
81 // Store certs for issuer1
82 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuer1, "example.com"), certPEM))
83 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuer1, "example.com"), keyPEM))
84 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteMeta(issuer1, "example.com"), meta))
85 
86 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuer1, "test.com"), certPEM))
87 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuer1, "test.com"), keyPEM))
88 
89 // Store certs for issuer2
90 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuer2, "other.com"), certPEM))
91 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuer2, "other.com"), keyPEM))
92 
93 // Create manager with minimal config (just to get the handler)
94 m := &Manager{chordStorage: storage}
95 handler := AcmeManagerHandler(m)
96 
97 // Test GET /certs
98 req := httptest.NewRequest("GET", "/certs", nil)
99 w := httptest.NewRecorder()
100 handler.ServeHTTP(w, req)
101 
102 as.Equal(http.StatusOK, w.Result().StatusCode)
103 
104 var resp issuersResponse
105 as.NoError(json.NewDecoder(w.Body).Decode(&resp))
106 
107 as.Len(resp.Issuers, 2)
108 
109 // Check counts
110 issuerMap := make(map[string]int)
111 for _, i := range resp.Issuers {
112 issuerMap[i.Issuer] = i.Count
113 }
114 as.Equal(2, issuerMap[issuer1])
115 as.Equal(1, issuerMap[issuer2])
116}
117 
118func TestHandlerListCerts(t *testing.T) {
119 as := require.New(t)
120 ctx := context.Background()
121 
122 storage := setupTestStorage(t)
123 
124 issuer := "acme-v02.api.letsencrypt.org-directory"
125 certPEM, keyPEM := generateTestCert(t, "example.com")
126 
127 // Store regular cert
128 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuer, "example.com"), certPEM))
129 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuer, "example.com"), keyPEM))
130 
131 // Store wildcard cert
132 wildcardCert, wildcardKey := generateTestCert(t, "*.example.com")
133 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuer, "*.example.com"), wildcardCert))
134 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuer, "*.example.com"), wildcardKey))
135 
136 m := &Manager{chordStorage: storage}
137 handler := AcmeManagerHandler(m)
138 
139 // Test GET /certs/{issuer}
140 req := httptest.NewRequest("GET", "/certs/"+issuer, nil)
141 w := httptest.NewRecorder()
142 handler.ServeHTTP(w, req)
143 
144 as.Equal(http.StatusOK, w.Result().StatusCode)
145 
146 var resp certsListResponse
147 as.NoError(json.NewDecoder(w.Body).Decode(&resp))
148 
149 as.Equal(issuer, resp.Issuer)
150 as.Equal(2, resp.Count)
151 as.Len(resp.Certs, 2)
152 
153 // Check that wildcard is properly displayed
154 certMap := make(map[string]string)
155 for _, c := range resp.Certs {
156 certMap[c.Name] = c.Domain
157 }
158 
159 as.Equal("example.com", certMap["example.com"])
160 as.Equal("*.example.com", certMap["wildcard_.example.com"])
161}
162 
163func TestHandlerInspectCert(t *testing.T) {
164 as := require.New(t)
165 ctx := context.Background()
166 
167 storage := setupTestStorage(t)
168 
169 issuer := "acme-v02.api.letsencrypt.org-directory"
170 certPEM, keyPEM := generateTestCert(t, "example.com")
171 meta := []byte(`{"url":"https://acme.example.com/order/123","domain":"example.com"}`)
172 
173 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuer, "example.com"), certPEM))
174 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuer, "example.com"), keyPEM))
175 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteMeta(issuer, "example.com"), meta))
176 
177 m := &Manager{chordStorage: storage}
178 handler := AcmeManagerHandler(m)
179 
180 // Test GET /certs/{issuer}/{name}
181 req := httptest.NewRequest("GET", "/certs/"+issuer+"/example.com", nil)
182 w := httptest.NewRecorder()
183 handler.ServeHTTP(w, req)
184 
185 as.Equal(http.StatusOK, w.Result().StatusCode)
186 
187 var resp certInspectResponse
188 as.NoError(json.NewDecoder(w.Body).Decode(&resp))
189 
190 as.Equal(issuer, resp.IssuerKey)
191 as.Equal("example.com", resp.Name)
192 as.Equal("example.com", resp.Domain)
193 as.Contains(resp.DNSNames, "example.com")
194 as.False(resp.NotBefore.IsZero())
195 as.False(resp.NotAfter.IsZero())
196 as.NotEmpty(resp.SerialNumber)
197 as.NotEmpty(resp.SignatureAlgorithm)
198 as.Equal("example.com", resp.Subject.CommonName)
199 as.Equal("ECDSA (P-256)", resp.PublicKey.Algorithm)
200 as.Equal(256, resp.PublicKey.Size)
201 as.Equal(3, resp.Version)
202 as.NotNil(resp.Metadata)
203}
204 
205func TestHandlerInspectWildcardCert(t *testing.T) {
206 as := require.New(t)
207 ctx := context.Background()
208 
209 storage := setupTestStorage(t)
210 
211 issuer := "acme-v02.api.letsencrypt.org-directory"
212 certPEM, keyPEM := generateTestCert(t, "*.example.com")
213 
214 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuer, "*.example.com"), certPEM))
215 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuer, "*.example.com"), keyPEM))
216 
217 m := &Manager{chordStorage: storage}
218 handler := AcmeManagerHandler(m)
219 
220 // Test with safe name in URL
221 req := httptest.NewRequest("GET", "/certs/"+issuer+"/wildcard_.example.com", nil)
222 w := httptest.NewRecorder()
223 handler.ServeHTTP(w, req)
224 
225 as.Equal(http.StatusOK, w.Result().StatusCode)
226 
227 var resp certInspectResponse
228 as.NoError(json.NewDecoder(w.Body).Decode(&resp))
229 
230 as.Equal("wildcard_.example.com", resp.Name)
231 as.Equal("*.example.com", resp.Domain)
232 as.Contains(resp.DNSNames, "*.example.com")
233}
234 
235func TestHandlerInspectCertNotFound(t *testing.T) {
236 as := require.New(t)
237 
238 storage := setupTestStorage(t)
239 
240 m := &Manager{chordStorage: storage}
241 handler := AcmeManagerHandler(m)
242 
243 req := httptest.NewRequest("GET", "/certs/some-issuer/nonexistent.com", nil)
244 w := httptest.NewRecorder()
245 handler.ServeHTTP(w, req)
246 
247 as.Equal(http.StatusNotFound, w.Result().StatusCode)
248}
249 
250func TestHandlerDeleteCert(t *testing.T) {
251 as := require.New(t)
252 ctx := context.Background()
253 
254 storage := setupTestStorage(t)
255 
256 issuer := "acme-v02.api.letsencrypt.org-directory"
257 certPEM, keyPEM := generateTestCert(t, "example.com")
258 meta := []byte(`{"url":"https://acme.example.com/order/123"}`)
259 
260 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuer, "example.com"), certPEM))
261 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuer, "example.com"), keyPEM))
262 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteMeta(issuer, "example.com"), meta))
263 
264 // Verify files exist
265 as.True(storage.Exists(ctx, certmagic.StorageKeys.SiteCert(issuer, "example.com")))
266 as.True(storage.Exists(ctx, certmagic.StorageKeys.SitePrivateKey(issuer, "example.com")))
267 as.True(storage.Exists(ctx, certmagic.StorageKeys.SiteMeta(issuer, "example.com")))
268 
269 m := &Manager{chordStorage: storage}
270 handler := AcmeManagerHandler(m)
271 
272 // Test DELETE /certs/{issuer}/{name}
273 req := httptest.NewRequest("DELETE", "/certs/"+issuer+"/example.com", nil)
274 w := httptest.NewRecorder()
275 handler.ServeHTTP(w, req)
276 
277 as.Equal(http.StatusNoContent, w.Result().StatusCode)
278 
279 // Verify files are deleted
280 as.False(storage.Exists(ctx, certmagic.StorageKeys.SiteCert(issuer, "example.com")))
281 as.False(storage.Exists(ctx, certmagic.StorageKeys.SitePrivateKey(issuer, "example.com")))
282 as.False(storage.Exists(ctx, certmagic.StorageKeys.SiteMeta(issuer, "example.com")))
283}
284 
285func TestHandlerDeleteWildcardCert(t *testing.T) {
286 as := require.New(t)
287 ctx := context.Background()
288 
289 storage := setupTestStorage(t)
290 
291 issuer := "acme-v02.api.letsencrypt.org-directory"
292 certPEM, keyPEM := generateTestCert(t, "*.example.com")
293 
294 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SiteCert(issuer, "*.example.com"), certPEM))
295 as.NoError(storage.Store(ctx, certmagic.StorageKeys.SitePrivateKey(issuer, "*.example.com"), keyPEM))
296 
297 m := &Manager{chordStorage: storage}
298 handler := AcmeManagerHandler(m)
299 
300 // Test DELETE with safe name
301 req := httptest.NewRequest("DELETE", "/certs/"+issuer+"/wildcard_.example.com", nil)
302 w := httptest.NewRecorder()
303 handler.ServeHTTP(w, req)
304 
305 as.Equal(http.StatusNoContent, w.Result().StatusCode)
306 
307 // Verify files are deleted
308 as.False(storage.Exists(ctx, certmagic.StorageKeys.SiteCert(issuer, "*.example.com")))
309 as.False(storage.Exists(ctx, certmagic.StorageKeys.SitePrivateKey(issuer, "*.example.com")))
310}
311 
312func TestHandlerDeleteNonexistent(t *testing.T) {
313 as := require.New(t)
314 
315 storage := setupTestStorage(t)
316 
317 m := &Manager{chordStorage: storage}
318 handler := AcmeManagerHandler(m)
319 
320 // Deleting nonexistent cert should still return 204
321 req := httptest.NewRequest("DELETE", "/certs/some-issuer/nonexistent.com", nil)
322 w := httptest.NewRecorder()
323 handler.ServeHTTP(w, req)
324 
325 as.Equal(http.StatusNoContent, w.Result().StatusCode)
326}
327 
328func TestHandlerEmptyIssuers(t *testing.T) {
329 as := require.New(t)
330 
331 storage := setupTestStorage(t)
332 
333 m := &Manager{chordStorage: storage}
334 handler := AcmeManagerHandler(m)
335 
336 req := httptest.NewRequest("GET", "/certs", nil)
337 w := httptest.NewRecorder()
338 handler.ServeHTTP(w, req)
339 
340 as.Equal(http.StatusOK, w.Result().StatusCode)
341 
342 var resp issuersResponse
343 as.NoError(json.NewDecoder(w.Body).Decode(&resp))
344 as.Empty(resp.Issuers)
345}
346 
347func TestUnsafeName(t *testing.T) {
348 tests := []struct {
349 input string
350 expected string
351 }{
352 {"example.com", "example.com"},
353 {"wildcard_.example.com", "*.example.com"},
354 {"wildcard_.test.example.com", "*.test.example.com"},
355 {"sub.example.com", "sub.example.com"},
356 }
357 
358 for _, tc := range tests {
359 t.Run(tc.input, func(t *testing.T) {
360 result := unsafeName(tc.input)
361 require.Equal(t, tc.expected, result)
362 })
363 }
364}