1package acmelego
2
3import (
4 "crypto/ecdsa"
5 "crypto/elliptic"
6 "crypto/rand"
7 "crypto/rsa"
8 "crypto/tls"
9 "crypto/x509"
10 "crypto/x509/pkix"
11 "encoding/json"
12 "encoding/pem"
13 "errors"
14 "math/big"
15 "testing"
16 "time"
17
18 "github.com/go-acme/lego/v4/acme"
19 "github.com/go-acme/lego/v4/certcrypto"
20 "github.com/go-acme/lego/v4/certificate"
21 "github.com/stretchr/testify/assert"
22 "github.com/stretchr/testify/require"
23
24 "github.com/bnema/gordon/internal/boundaries/out"
25 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
26 "github.com/bnema/gordon/internal/domain"
27)
28
29func TestNewIssuerValidatesEmail(t *testing.T) {
30 _, err := NewIssuer(Config{})
31 assert.Error(t, err)
32 assert.ErrorIs(t, err, domain.ErrACMEEmailRequired)
33}
34
35func TestNewIssuerValidatesStore(t *testing.T) {
36 _, err := NewIssuer(Config{Email: "test@example.com"})
37 assert.Error(t, err)
38 assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired)
39}
40
41func TestNewIssuerValidatesHTTPChallengeSink(t *testing.T) {
42 _, err := NewIssuer(Config{
43 Email: "test@example.com",
44 Challenge: domain.ACMEChallengeHTTP01,
45 Store: outmocks.NewMockCertificateStore(t),
46 })
47 assert.Error(t, err)
48 assert.ErrorIs(t, err, domain.ErrHTTPChallengeSinkRequired)
49}
50
51func TestNewIssuerValidatesChallengeMode(t *testing.T) {
52 _, err := NewIssuer(Config{
53 Email: "test@example.com",
54 Challenge: domain.ACMEChallengeMode("tls-alpn-01"),
55 Store: outmocks.NewMockCertificateStore(t),
56 HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
57 })
58 assert.Error(t, err)
59 assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid)
60}
61
62func TestNewIssuerValidatesCloudflareToken(t *testing.T) {
63 _, err := NewIssuer(Config{
64 Email: "test@example.com",
65 Challenge: domain.ACMEChallengeCloudflareDNS01,
66 Store: outmocks.NewMockCertificateStore(t),
67 })
68 assert.Error(t, err)
69 assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing)
70}
71
72func TestNewIssuerValidatesCloudflareDNSConfig(t *testing.T) {
73 _, err := NewIssuer(Config{
74 Email: "test@example.com",
75 Challenge: domain.ACMEChallengeCloudflareDNS01,
76 Token: "token",
77 Store: outmocks.NewMockCertificateStore(t),
78 DNSResolvers: nil,
79 DNSPropagationTimeout: 5 * time.Minute,
80 DNSPollingInterval: 5 * time.Second,
81 })
82 require.Error(t, err)
83 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
84 assert.Contains(t, err.Error(), "DNSResolvers")
85
86 _, err = NewIssuer(Config{
87 Email: "test@example.com",
88 Challenge: domain.ACMEChallengeCloudflareDNS01,
89 Token: "token",
90 Store: outmocks.NewMockCertificateStore(t),
91 DNSResolvers: []string{"1.1.1.1:abc"},
92 DNSPropagationTimeout: 5 * time.Minute,
93 DNSPollingInterval: 5 * time.Second,
94 })
95 require.Error(t, err)
96 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
97 assert.Contains(t, err.Error(), "invalid DNSResolvers entry")
98 assert.Contains(t, err.Error(), "index 0")
99 assert.Contains(t, err.Error(), "1.1.1.1:abc")
100
101 _, err = NewIssuer(Config{
102 Email: "test@example.com",
103 Challenge: domain.ACMEChallengeCloudflareDNS01,
104 Token: "token",
105 Store: outmocks.NewMockCertificateStore(t),
106 DNSResolvers: []string{"1.1.1.1:53"},
107 DNSPropagationTimeout: 5 * time.Second,
108 DNSPollingInterval: 5 * time.Second,
109 })
110 require.Error(t, err)
111 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
112 assert.Contains(t, err.Error(), "DNSPollingInterval")
113
114 issuer, err := NewIssuer(Config{
115 Email: "test@example.com",
116 Challenge: domain.ACMEChallengeCloudflareDNS01,
117 Token: "token",
118 Store: outmocks.NewMockCertificateStore(t),
119 DNSResolvers: []string{" 1.1.1.1:53 ", "\t8.8.8.8:53\n"},
120 DNSPropagationTimeout: 5 * time.Minute,
121 DNSPollingInterval: 5 * time.Second,
122 })
123 require.NoError(t, err)
124 assert.Equal(t, []string{"1.1.1.1:53", "8.8.8.8:53"}, issuer.cfg.DNSResolvers)
125}
126
127func TestNormalizeDNSResolversErrorsIncludeEntryContext(t *testing.T) {
128 tests := []struct {
129 name string
130 resolver string
131 want string
132 }{
133 {name: "empty after trimming", resolver: " \t ", want: "value \"\""},
134 {name: "missing port", resolver: "1.1.1.1", want: "value \"1.1.1.1\""},
135 {name: "empty host", resolver: ":53", want: "value \":53\""},
136 {name: "invalid port", resolver: "1.1.1.1:abc", want: "value \"1.1.1.1:abc\""},
137 }
138
139 for _, tt := range tests {
140 t.Run(tt.name, func(t *testing.T) {
141 _, err := normalizeDNSResolvers([]string{"8.8.8.8:53", tt.resolver})
142 require.Error(t, err)
143 assert.ErrorIs(t, err, domain.ErrDNSConfigInvalid)
144 assert.Contains(t, err.Error(), "invalid DNSResolvers entry")
145 assert.Contains(t, err.Error(), "index 1")
146 assert.Contains(t, err.Error(), tt.want)
147 })
148 }
149}
150
151func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) {
152 _, err := NewIssuer(Config{
153 Email: "test@example.com",
154 Challenge: domain.ACMEChallengeHTTP01,
155 Store: outmocks.NewMockCertificateStore(t),
156 HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
157 CADirectoryURL: "http://acme.test/directory",
158 })
159 require.Error(t, err)
160 assert.Contains(t, err.Error(), "must use HTTPS")
161
162 // HTTPS should be accepted
163 _, err = NewIssuer(Config{
164 Email: "test@example.com",
165 Challenge: domain.ACMEChallengeHTTP01,
166 Store: outmocks.NewMockCertificateStore(t),
167 HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
168 CADirectoryURL: "https://acme.test/directory",
169 })
170 require.NoError(t, err)
171}
172
173func TestNewIssuerValidWithDefaults(t *testing.T) {
174 issuer, err := NewIssuer(Config{
175 Email: "test@example.com",
176 Store: outmocks.NewMockCertificateStore(t),
177 HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t),
178 })
179 require.NoError(t, err)
180 require.NotNil(t, issuer)
181 assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge)
182}
183
184func TestCloudflareDNSProviderConfigUsesDNSSettings(t *testing.T) {
185 cfg := Config{
186 Token: "token",
187 DNSPropagationTimeout: 7 * time.Minute,
188 DNSPollingInterval: 11 * time.Second,
189 }
190
191 cfCfg := newCloudflareDNSProviderConfig(cfg)
192
193 assert.Equal(t, "token", cfCfg.AuthToken)
194 assert.Equal(t, 7*time.Minute, cfCfg.PropagationTimeout)
195 assert.Equal(t, 11*time.Second, cfCfg.PollingInterval)
196}
197
198func TestPrivateKeyRoundTrip(t *testing.T) {
199 // Generate an ECDSA P-256 private key
200 privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
201 require.NoError(t, err)
202 require.NotNil(t, privateKey)
203
204 // PEM encode
205 pemBytes := certcrypto.PEMEncode(privateKey)
206 require.NotEmpty(t, pemBytes)
207
208 // Parse back
209 parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes)
210 require.NoError(t, err)
211 require.NotNil(t, parsedKey)
212
213 // Verify it's the same curve (ECDSA P-256)
214 ecKey, ok := parsedKey.(*ecdsa.PrivateKey)
215 require.True(t, ok, "expected *ecdsa.PrivateKey")
216 assert.Equal(t, elliptic.P256(), ecKey.Curve)
217
218 // Verify we can sign with the original and verify with the parsed
219 digest := []byte("test message")
220 sig, err := ecdsa.SignASN1(rand.Reader, privateKey.(*ecdsa.PrivateKey), digest[:])
221 require.NoError(t, err)
222 assert.True(t, ecdsa.VerifyASN1(&ecKey.PublicKey, digest[:], sig))
223}
224
225func TestPrivateKeyRSARoundTrip(t *testing.T) {
226 // Test RSA private key round-trip via certcrypto
227 privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
228 require.NoError(t, err)
229
230 pemBytes := certcrypto.PEMEncode(privateKey)
231 require.NotEmpty(t, pemBytes)
232
233 parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes)
234 require.NoError(t, err)
235 require.NotNil(t, parsedKey)
236
237 _, ok := parsedKey.(*rsa.PrivateKey)
238 require.True(t, ok)
239}
240
241func TestResourceConversionToStoredCertificate(t *testing.T) {
242 // Generate a self-signed certificate for testing
243 certPEM, keyPEM, err := generateSelfSignedCert("example.com")
244 require.NoError(t, err)
245
246 // Generate a fake issuer certificate
247 issuerPEM, _, err := generateSelfSignedCert("fake-ca.example.com")
248 require.NoError(t, err)
249
250 resource := &certificate.Resource{
251 Domain: "example.com",
252 CertURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123",
253 CertStableURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123",
254 PrivateKey: keyPEM,
255 Certificate: certPEM,
256 IssuerCertificate: issuerPEM,
257 }
258
259 order := out.CertificateOrder{
260 ID: "test-cert",
261 Names: []string{"example.com"},
262 Challenge: domain.ACMEChallengeHTTP01,
263 }
264
265 stored, err := resourceToStored(order, resource)
266 require.NoError(t, err)
267 require.NotNil(t, stored)
268 assert.Equal(t, "test-cert", stored.ID)
269 assert.Equal(t, []string{"example.com"}, stored.Names)
270 assert.Equal(t, domain.ACMEChallengeHTTP01, stored.Challenge)
271 assert.Equal(t, certPEM, stored.CertPEM)
272 assert.Equal(t, issuerPEM, stored.ChainPEM)
273
274 // FullchainPEM should be cert + issuer
275 expectedFullchain := append(append([]byte{}, certPEM...), issuerPEM...)
276 assert.Equal(t, expectedFullchain, stored.FullchainPEM)
277 assert.Equal(t, keyPEM, stored.PrivateKeyPEM)
278
279 // NotAfter should be set
280 assert.False(t, stored.NotAfter.IsZero(), "NotAfter should be set")
281
282 // Verify tls.Certificate is populated from the PEM data
283 assert.NotEmpty(t, stored.Certificate.Certificate, "tls.Certificate should be populated from PEM")
284 assert.NotNil(t, stored.Certificate.PrivateKey, "private key should be populated")
285}
286
287func TestResourceConversionNoIssuer(t *testing.T) {
288 certPEM, keyPEM, err := generateSelfSignedCert("example.com")
289 require.NoError(t, err)
290
291 resource := &certificate.Resource{
292 Domain: "example.com",
293 PrivateKey: keyPEM,
294 Certificate: certPEM,
295 }
296
297 order := out.CertificateOrder{
298 ID: "test-cert-no-issuer",
299 Names: []string{"example.com"},
300 }
301
302 stored, err := resourceToStored(order, resource)
303 require.NoError(t, err)
304 require.NotNil(t, stored)
305 assert.Equal(t, certPEM, stored.FullchainPEM, "fullchain should equal cert when no issuer")
306 assert.Empty(t, stored.ChainPEM)
307}
308
309func TestAccountJSONRoundTrip(t *testing.T) {
310 // Simulate an ACME account with BodyJSON containing the serialized acme.Account
311 body := acme.Account{
312 Status: "valid",
313 Contact: []string{"mailto:test@example.com"},
314 TermsOfServiceAgreed: true,
315 Orders: "https://acme-v02.api.letsencrypt.org/acme/orders/abc123",
316 }
317
318 bodyJSON, err := json.Marshal(body)
319 require.NoError(t, err)
320
321 var restored acme.Account
322 err = json.Unmarshal(bodyJSON, &restored)
323 require.NoError(t, err)
324 assert.Equal(t, body.Status, restored.Status)
325 assert.Equal(t, body.Contact, restored.Contact)
326 assert.Equal(t, body.TermsOfServiceAgreed, restored.TermsOfServiceAgreed)
327 assert.Equal(t, body.Orders, restored.Orders)
328}
329
330// generateSelfSignedCert creates a self-signed certificate and its private key
331// for testing purposes.
332func generateSelfSignedCert(domain string) (certPEM, keyPEM []byte, err error) {
333 privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
334 if err != nil {
335 return nil, nil, err
336 }
337
338 serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
339 if err != nil {
340 return nil, nil, err
341 }
342
343 template := &x509.Certificate{
344 SerialNumber: serialNumber,
345 Subject: pkix.Name{
346 CommonName: domain,
347 Organization: []string{"Test"},
348 },
349 DNSNames: []string{domain},
350 NotBefore: time.Now().Add(-1 * time.Hour),
351 NotAfter: time.Now().Add(24 * time.Hour),
352 KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
353 ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
354 BasicConstraintsValid: true,
355 }
356
357 certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
358 if err != nil {
359 return nil, nil, err
360 }
361
362 certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
363 if certPEM == nil {
364 return nil, nil, errors.New("pem encode certificate returned nil")
365 }
366
367 keyDER, err := x509.MarshalECPrivateKey(privateKey)
368 if err != nil {
369 return nil, nil, err
370 }
371
372 keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
373 if keyPEM == nil {
374 return nil, nil, errors.New("pem encode private key returned nil")
375 }
376
377 return certPEM, keyPEM, nil
378}
379
380// Ensure TestResourceConversion also validates the tls.X509KeyPair parse works.
381func TestParsedCertificateNotAfter(t *testing.T) {
382 certPEM, keyPEM, err := generateSelfSignedCert("test.example.com")
383 require.NoError(t, err)
384
385 resource := &certificate.Resource{
386 Domain: "test.example.com",
387 PrivateKey: keyPEM,
388 Certificate: certPEM,
389 }
390
391 order := out.CertificateOrder{
392 ID: "parse-test",
393 Names: []string{"test.example.com"},
394 }
395
396 stored, err := resourceToStored(order, resource)
397 require.NoError(t, err)
398 require.NotNil(t, stored)
399 assert.False(t, stored.NotAfter.IsZero())
400 assert.True(t, stored.NotAfter.After(time.Now()))
401
402 // Confirm tls.X509KeyPair succeeds on the stored PEMs
403 _, parseErr := tls.X509KeyPair(stored.FullchainPEM, stored.PrivateKeyPEM)
404 assert.NoError(t, parseErr, "tls.X509KeyPair should succeed")
405}