v2.31.0-dev16 8d0a092

issuer_test.go RawHistoryFolder

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}

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