mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 18:18:51 +00:00
43e1c89623
Close coverage gaps identified by dual-audit (qualitative + quantitative). New test files for config (0%→98%), router (0%→100%), handler validation, health, audit, response helpers, webhook notifier (0%→88%), email notifier, middleware (recovery, rate limiter), domain profile, service nil-safety, config helpers, issuer bootstrap, and server bootstrap wiring. Expanded existing tests for ACME (34%→42%), step-ca (42%→52%), F5, SSH, agent (43%→63%), scheduler (88%→99%), renewal service, and issuerfactory. All tests pass: go test -short, go vet, go test -race clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1047 lines
32 KiB
Go
1047 lines
32 KiB
Go
package acme
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"log/slog"
|
|
"math/big"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/issuer"
|
|
)
|
|
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
|
|
}
|
|
|
|
func TestValidateConfig_MissingDirectoryURL(t *testing.T) {
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{"email": "test@example.com"})
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err == nil || !strings.Contains(err.Error(), "directory_url is required") {
|
|
t.Fatalf("expected directory_url error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_MissingEmail(t *testing.T) {
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{"directory_url": "https://example.com/directory"})
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err == nil || !strings.Contains(err.Error(), "email is required") {
|
|
t.Fatalf("expected email error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_InvalidChallengeType(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{
|
|
"directory_url": srv.URL,
|
|
"email": "test@example.com",
|
|
"challenge_type": "invalid-challenge",
|
|
})
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err == nil || !strings.Contains(err.Error(), "invalid challenge_type") {
|
|
t.Fatalf("expected invalid challenge_type error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_Success(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{
|
|
"directory_url": srv.URL,
|
|
"email": "test@example.com",
|
|
})
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_EABFieldsPreserved(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{
|
|
"directory_url": srv.URL,
|
|
"email": "test@example.com",
|
|
"eab_kid": "kid-12345",
|
|
"eab_hmac": base64.RawURLEncoding.EncodeToString([]byte("test-hmac-key")),
|
|
})
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if c.config.EABKid != "kid-12345" {
|
|
t.Fatalf("expected EABKid to be preserved, got: %s", c.config.EABKid)
|
|
}
|
|
if c.config.EABHmac == "" {
|
|
t.Fatal("expected EABHmac to be preserved")
|
|
}
|
|
}
|
|
|
|
func TestEnsureClient_EABDecodeError(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.example.com/directory",
|
|
Email: "test@example.com",
|
|
EABKid: "kid-12345",
|
|
EABHmac: "!!!not-valid-base64url!!!",
|
|
}, testLogger())
|
|
|
|
err := c.ensureClient(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "decode EAB HMAC") {
|
|
t.Fatalf("expected EAB decode error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureClient_EABBindingSet(t *testing.T) {
|
|
// We can't fully mock the ACME protocol (JWS nonce exchange), but we can
|
|
// verify that valid EAB credentials are decoded and attached to the account
|
|
// without panicking. The ensureClient call will fail at the network level
|
|
// (no real ACME server), but it must NOT fail at EAB decoding.
|
|
hmacKey := base64.RawURLEncoding.EncodeToString([]byte("test-hmac-secret-key"))
|
|
c := New(&Config{
|
|
DirectoryURL: "https://127.0.0.1:1/directory", // unreachable — that's fine
|
|
Email: "test@example.com",
|
|
EABKid: "kid-zerossl-12345",
|
|
EABHmac: hmacKey,
|
|
}, testLogger())
|
|
|
|
err := c.ensureClient(context.Background())
|
|
// Expected: network error (unreachable server), NOT an EAB decode error
|
|
if err != nil && strings.Contains(err.Error(), "decode EAB HMAC") {
|
|
t.Fatalf("EAB decode should not fail with valid base64url key, got: %v", err)
|
|
}
|
|
// We expect some error (network unreachable) — that's correct
|
|
if err == nil {
|
|
t.Log("ensureClient succeeded (unexpected but not a failure for this test)")
|
|
}
|
|
}
|
|
|
|
// --- ZeroSSL auto-EAB tests ---
|
|
|
|
func TestIsZeroSSL(t *testing.T) {
|
|
tests := []struct {
|
|
url string
|
|
expect bool
|
|
}{
|
|
{"https://acme.zerossl.com/v2/DV90", true},
|
|
{"https://ACME.ZEROSSL.COM/v2/DV90", true},
|
|
{"https://acme-v02.api.letsencrypt.org/directory", false},
|
|
{"https://acme.example.com/directory", false},
|
|
{"", false},
|
|
}
|
|
for _, tt := range tests {
|
|
if got := isZeroSSL(tt.url); got != tt.expect {
|
|
t.Errorf("isZeroSSL(%q) = %v, want %v", tt.url, got, tt.expect)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFetchZeroSSLEAB_Success(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Errorf("expected POST, got %s", r.Method)
|
|
}
|
|
if ct := r.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" {
|
|
t.Errorf("expected form content-type, got %s", ct)
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if email := r.FormValue("email"); email != "test@example.com" {
|
|
t.Errorf("expected email test@example.com, got %s", email)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"success":true,"eab_kid":"kid_abc123","eab_hmac_key":"dGVzdC1obWFjLWtleQ"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
// Override the endpoint for testing
|
|
origEndpoint := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = origEndpoint }()
|
|
zeroSSLEABEndpoint = srv.URL
|
|
|
|
kid, hmac, err := fetchZeroSSLEAB(context.Background(), "test@example.com")
|
|
if err != nil {
|
|
t.Fatalf("expected success, got: %v", err)
|
|
}
|
|
if kid != "kid_abc123" {
|
|
t.Errorf("expected kid_abc123, got %s", kid)
|
|
}
|
|
if hmac != "dGVzdC1obWFjLWtleQ" {
|
|
t.Errorf("expected dGVzdC1obWFjLWtleQ, got %s", hmac)
|
|
}
|
|
}
|
|
|
|
func TestFetchZeroSSLEAB_EmptyEmail(t *testing.T) {
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "")
|
|
if err == nil || !strings.Contains(err.Error(), "email is required") {
|
|
t.Fatalf("expected email required error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchZeroSSLEAB_APIError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
fmt.Fprint(w, `{"success":false,"error":"invalid email"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
origEndpoint := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = origEndpoint }()
|
|
zeroSSLEABEndpoint = srv.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "bad@example.com")
|
|
if err == nil || !strings.Contains(err.Error(), "status 400") {
|
|
t.Fatalf("expected API error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFetchZeroSSLEAB_MissingCredentials(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"success":false,"error":"rate limited"}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
origEndpoint := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = origEndpoint }()
|
|
zeroSSLEABEndpoint = srv.URL
|
|
|
|
_, _, err := fetchZeroSSLEAB(context.Background(), "test@example.com")
|
|
if err == nil || !strings.Contains(err.Error(), "EAB generation failed") {
|
|
t.Fatalf("expected EAB generation failed error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnsureClient_ZeroSSLAutoEAB(t *testing.T) {
|
|
// Mock ZeroSSL EAB endpoint
|
|
eabSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"success":true,"eab_kid":"auto-kid-123","eab_hmac_key":"dGVzdC1obWFjLWtleQ"}`)
|
|
}))
|
|
defer eabSrv.Close()
|
|
|
|
origEndpoint := zeroSSLEABEndpoint
|
|
defer func() { zeroSSLEABEndpoint = origEndpoint }()
|
|
zeroSSLEABEndpoint = eabSrv.URL
|
|
|
|
// Use an unreachable ACME directory — we only care that auto-EAB fetch happens
|
|
c := New(&Config{
|
|
DirectoryURL: "https://acme.zerossl.com/v2/DV90",
|
|
Email: "test@example.com",
|
|
// EABKid and EABHmac intentionally empty — should auto-fetch
|
|
}, testLogger())
|
|
|
|
err := c.ensureClient(context.Background())
|
|
// Will fail at ACME protocol level (unreachable ZeroSSL directory), but
|
|
// EAB credentials should have been auto-fetched and set on config
|
|
if c.config.EABKid != "auto-kid-123" {
|
|
t.Errorf("expected auto-fetched EABKid, got: %s (err: %v)", c.config.EABKid, err)
|
|
}
|
|
if c.config.EABHmac != "dGVzdC1obWFjLWtleQ" {
|
|
t.Errorf("expected auto-fetched EABHmac, got: %s", c.config.EABHmac)
|
|
}
|
|
}
|
|
|
|
// --- parseCSRPEM tests ---
|
|
|
|
func TestParseCSRPEM_ValidPEM(t *testing.T) {
|
|
// Generate a real ECDSA P-256 CSR using crypto/x509
|
|
key, err := generateTestKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate test key: %v", err)
|
|
}
|
|
|
|
csrTemplate := x509.CertificateRequest{
|
|
Subject: generateTestName("test.example.com"),
|
|
DNSNames: []string{"test.example.com", "www.test.example.com"},
|
|
PublicKey: &key.PublicKey,
|
|
}
|
|
|
|
csrDER, err := x509.CreateCertificateRequest(nil, &csrTemplate, key)
|
|
if err != nil {
|
|
t.Fatalf("failed to create CSR: %v", err)
|
|
}
|
|
|
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE REQUEST",
|
|
Bytes: csrDER,
|
|
}))
|
|
|
|
// Test parseCSRPEM
|
|
result, err := parseCSRPEM(csrPEM)
|
|
if err != nil {
|
|
t.Fatalf("parseCSRPEM failed: %v", err)
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
t.Fatal("expected non-empty DER bytes")
|
|
}
|
|
|
|
// Verify it's valid DER by parsing it
|
|
parsed, err := x509.ParseCertificateRequest(result)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse result as valid CSR: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(parsed.Subject.String(), "test.example.com") {
|
|
t.Errorf("expected CN in parsed CSR, got: %s", parsed.Subject.String())
|
|
}
|
|
}
|
|
|
|
func TestParseCSRPEM_InvalidPEM(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
pem string
|
|
wantErr bool
|
|
}{
|
|
{"empty string", "", true},
|
|
{"not PEM format", "not-a-pem", true},
|
|
{"valid PEM but wrong type", "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", true},
|
|
{"invalid base64", "-----BEGIN CERTIFICATE REQUEST-----\n!!!not-valid-base64!!!\n-----END CERTIFICATE REQUEST-----", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := parseCSRPEM(tt.pem)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("parseCSRPEM() error = %v, wantErr = %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- parseDERChain tests ---
|
|
|
|
func TestParseDERChain_ValidChain(t *testing.T) {
|
|
// Generate a root and leaf certificate for testing
|
|
rootKey, err := generateTestKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate root key: %v", err)
|
|
}
|
|
|
|
leafKey, err := generateTestKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate leaf key: %v", err)
|
|
}
|
|
|
|
// Root cert (self-signed)
|
|
rootTemplate := x509.Certificate{
|
|
Subject: generateTestName("Root CA"),
|
|
SerialNumber: big.NewInt(1),
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().AddDate(10, 0, 0),
|
|
KeyUsage: x509.KeyUsageCertSign,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}
|
|
|
|
rootDER, err := x509.CreateCertificate(nil, &rootTemplate, &rootTemplate, &rootKey.PublicKey, rootKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to create root cert: %v", err)
|
|
}
|
|
|
|
// Leaf cert (signed by root)
|
|
leafTemplate := x509.Certificate{
|
|
Subject: generateTestName("test.example.com"),
|
|
SerialNumber: big.NewInt(100),
|
|
DNSNames: []string{"test.example.com", "www.test.example.com"},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().AddDate(1, 0, 0),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
PublicKey: &leafKey.PublicKey,
|
|
}
|
|
|
|
leafDER, err := x509.CreateCertificate(nil, &leafTemplate, &rootTemplate, &leafKey.PublicKey, rootKey)
|
|
if err != nil {
|
|
t.Fatalf("failed to create leaf cert: %v", err)
|
|
}
|
|
|
|
// Parse the chain
|
|
certPEM, chainPEM, serial, notBefore, notAfter, err := parseDERChain([][]byte{leafDER, rootDER})
|
|
if err != nil {
|
|
t.Fatalf("parseDERChain failed: %v", err)
|
|
}
|
|
|
|
// Verify leaf cert PEM
|
|
if !strings.Contains(certPEM, "BEGIN CERTIFICATE") {
|
|
t.Errorf("certPEM should contain PEM header, got: %s", certPEM)
|
|
}
|
|
|
|
// Verify chain PEM contains root
|
|
if !strings.Contains(chainPEM, "BEGIN CERTIFICATE") {
|
|
t.Errorf("chainPEM should contain root cert PEM, got: %s", chainPEM)
|
|
}
|
|
|
|
// Verify serial is correctly extracted
|
|
if serial != "100" {
|
|
t.Errorf("expected serial '100', got: %s", serial)
|
|
}
|
|
|
|
// Verify timestamps are set
|
|
if notBefore.IsZero() {
|
|
t.Error("notBefore should not be zero")
|
|
}
|
|
if notAfter.IsZero() {
|
|
t.Error("notAfter should not be zero")
|
|
}
|
|
|
|
// Verify we can parse the returned PEM
|
|
block, _ := pem.Decode([]byte(certPEM))
|
|
if block == nil {
|
|
t.Fatal("failed to decode returned certPEM")
|
|
}
|
|
|
|
parsedLeaf, err := x509.ParseCertificate(block.Bytes)
|
|
if err != nil {
|
|
t.Fatalf("failed to parse returned certPEM: %v", err)
|
|
}
|
|
|
|
if parsedLeaf.SerialNumber.Cmp(big.NewInt(100)) != 0 {
|
|
t.Errorf("parsed leaf serial mismatch: got %v, expected 100", parsedLeaf.SerialNumber)
|
|
}
|
|
}
|
|
|
|
func TestParseDERChain_SingleCert(t *testing.T) {
|
|
// Generate a single certificate
|
|
key, err := generateTestKey()
|
|
if err != nil {
|
|
t.Fatalf("failed to generate key: %v", err)
|
|
}
|
|
|
|
template := x509.Certificate{
|
|
Subject: generateTestName("test.example.com"),
|
|
SerialNumber: big.NewInt(42),
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().AddDate(1, 0, 0),
|
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
|
PublicKey: &key.PublicKey,
|
|
}
|
|
|
|
certDER, err := x509.CreateCertificate(nil, &template, &template, &key.PublicKey, key)
|
|
if err != nil {
|
|
t.Fatalf("failed to create cert: %v", err)
|
|
}
|
|
|
|
certPEM, chainPEM, serial, notBefore, notAfter, err := parseDERChain([][]byte{certDER})
|
|
if err != nil {
|
|
t.Fatalf("parseDERChain failed: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(certPEM, "BEGIN CERTIFICATE") {
|
|
t.Error("certPEM should contain PEM header")
|
|
}
|
|
|
|
if chainPEM != "" {
|
|
t.Errorf("chainPEM should be empty for single cert, got: %s", chainPEM)
|
|
}
|
|
|
|
if serial != "42" {
|
|
t.Errorf("expected serial '42', got: %s", serial)
|
|
}
|
|
|
|
if notBefore.IsZero() || notAfter.IsZero() {
|
|
t.Error("timestamps should be set")
|
|
}
|
|
}
|
|
|
|
func TestParseDERChain_EmptyChain(t *testing.T) {
|
|
_, _, _, _, _, err := parseDERChain([][]byte{})
|
|
if err == nil {
|
|
t.Fatal("expected error for empty chain")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty") {
|
|
t.Errorf("expected 'empty' in error message, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestParseDERChain_InvalidDER(t *testing.T) {
|
|
// Invalid DER bytes
|
|
invalidDER := []byte{0xFF, 0xFF, 0xFF}
|
|
_, _, _, _, _, err := parseDERChain([][]byte{invalidDER})
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid DER")
|
|
}
|
|
}
|
|
|
|
// --- IssueCertificate / RenewCertificate error path tests ---
|
|
// Note: Full IssueCertificate/RenewCertificate testing requires an ACME server.
|
|
// We test the CSR parsing logic which is the first step.
|
|
|
|
func TestIssueCertificateCSRParsing(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
csrPEM string
|
|
wantErr bool
|
|
}{
|
|
{"invalid PEM", "not-a-valid-csr-pem", true},
|
|
{"empty PEM", "", true},
|
|
{"wrong PEM type", "-----BEGIN CERTIFICATE-----\nMIID\n-----END CERTIFICATE-----", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := parseCSRPEM(tt.csrPEM)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("parseCSRPEM() error = %v, wantErr = %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- RevokeCertificate behavior test ---
|
|
// ACME revocation is not fully supported in V1 — it requires certificate DER, not just the serial.
|
|
// Full testing would require an ACME server; we verify the basic interface behavior.
|
|
// Skipped here because it requires network access for ACME client initialization.
|
|
|
|
// --- GenerateCRL and SignOCSPResponse error path tests ---
|
|
|
|
func TestGenerateCRL_NotSupported(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://example.com/acme/directory",
|
|
Email: "test@example.com",
|
|
}, testLogger())
|
|
|
|
_, err := c.GenerateCRL(context.Background(), nil)
|
|
if err == nil {
|
|
t.Fatal("expected error for CRL generation")
|
|
}
|
|
if !strings.Contains(err.Error(), "not support") {
|
|
t.Errorf("expected 'not support' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSignOCSPResponse_NotSupported(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://example.com/acme/directory",
|
|
Email: "test@example.com",
|
|
}, testLogger())
|
|
|
|
req := issuer.OCSPSignRequest{
|
|
CertSerial: big.NewInt(123),
|
|
}
|
|
|
|
_, err := c.SignOCSPResponse(context.Background(), req)
|
|
if err == nil {
|
|
t.Fatal("expected error for OCSP signing")
|
|
}
|
|
if !strings.Contains(err.Error(), "not support") {
|
|
t.Errorf("expected 'not support' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestGetCACertPEM_NotSupported(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://example.com/acme/directory",
|
|
Email: "test@example.com",
|
|
}, testLogger())
|
|
|
|
_, err := c.GetCACertPEM(context.Background())
|
|
if err == nil {
|
|
t.Fatal("expected error for GetCACertPEM")
|
|
}
|
|
if !strings.Contains(err.Error(), "not") {
|
|
t.Errorf("expected error message, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- httpClient behavior tests ---
|
|
|
|
func TestHttpClient_DefaultTimeout(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://example.com/acme/directory",
|
|
Email: "test@example.com",
|
|
Insecure: false,
|
|
}, testLogger())
|
|
|
|
client := c.httpClient()
|
|
if client == nil {
|
|
t.Fatal("httpClient should not be nil")
|
|
}
|
|
if client.Timeout == 0 {
|
|
t.Error("httpClient should have a non-zero timeout")
|
|
}
|
|
}
|
|
|
|
func TestHttpClient_InsecureSkipVerify(t *testing.T) {
|
|
c := New(&Config{
|
|
DirectoryURL: "https://example.com/acme/directory",
|
|
Email: "test@example.com",
|
|
Insecure: true,
|
|
}, testLogger())
|
|
|
|
client := c.httpClient()
|
|
if client == nil {
|
|
t.Fatal("httpClient should not be nil")
|
|
}
|
|
|
|
// Verify that the transport has InsecureSkipVerify enabled
|
|
if client.Transport == nil {
|
|
t.Error("client transport should be set for insecure mode")
|
|
} else {
|
|
transport := client.Transport.(*http.Transport)
|
|
if transport.TLSClientConfig == nil || !transport.TLSClientConfig.InsecureSkipVerify {
|
|
t.Error("TLS config should have InsecureSkipVerify=true")
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- buildIdentifiers tests ---
|
|
|
|
func TestBuildIdentifiers_CommonNameOnly(t *testing.T) {
|
|
identifiers := buildIdentifiers("example.com", nil)
|
|
if len(identifiers) != 1 {
|
|
t.Fatalf("expected 1 identifier, got %d", len(identifiers))
|
|
}
|
|
if identifiers[0].Value != "example.com" {
|
|
t.Errorf("expected 'example.com', got %s", identifiers[0].Value)
|
|
}
|
|
}
|
|
|
|
func TestBuildIdentifiers_CommonNameAndSANs(t *testing.T) {
|
|
identifiers := buildIdentifiers("example.com", []string{"www.example.com", "api.example.com"})
|
|
if len(identifiers) != 3 {
|
|
t.Fatalf("expected 3 identifiers, got %d", len(identifiers))
|
|
}
|
|
|
|
expected := map[string]bool{
|
|
"example.com": true,
|
|
"www.example.com": true,
|
|
"api.example.com": true,
|
|
}
|
|
|
|
for _, id := range identifiers {
|
|
if !expected[id.Value] {
|
|
t.Errorf("unexpected identifier: %s", id.Value)
|
|
}
|
|
if id.Type != "dns" {
|
|
t.Errorf("expected type 'dns', got %s", id.Type)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildIdentifiers_DeduplicatesCommonName(t *testing.T) {
|
|
// If CommonName is also in SANs, it should only appear once
|
|
identifiers := buildIdentifiers("example.com", []string{"example.com", "www.example.com"})
|
|
if len(identifiers) != 2 {
|
|
t.Fatalf("expected 2 identifiers (deduplicated), got %d", len(identifiers))
|
|
}
|
|
}
|
|
|
|
func TestBuildIdentifiers_EmptyCommonName(t *testing.T) {
|
|
identifiers := buildIdentifiers("", []string{"www.example.com"})
|
|
if len(identifiers) != 1 {
|
|
t.Fatalf("expected 1 identifier, got %d", len(identifiers))
|
|
}
|
|
if identifiers[0].Value != "www.example.com" {
|
|
t.Errorf("expected 'www.example.com', got %s", identifiers[0].Value)
|
|
}
|
|
}
|
|
|
|
// --- New constructor tests ---
|
|
|
|
func TestNew_WithNilConfig(t *testing.T) {
|
|
c := New(nil, testLogger())
|
|
if c == nil {
|
|
t.Fatal("New should return a non-nil Connector")
|
|
}
|
|
if c.config != nil {
|
|
t.Error("config should be nil when initialized with nil")
|
|
}
|
|
if len(c.challengeTokens) != 0 {
|
|
t.Error("challengeTokens should be initialized as empty map")
|
|
}
|
|
}
|
|
|
|
func TestNew_WithHTTPPort0DefaultsTo80(t *testing.T) {
|
|
cfg := &Config{
|
|
DirectoryURL: "https://example.com/acme",
|
|
Email: "test@example.com",
|
|
HTTPPort: 0, // Should default to 80
|
|
ChallengeType: "http-01",
|
|
}
|
|
c := New(cfg, testLogger())
|
|
if c.config.HTTPPort != 80 {
|
|
t.Errorf("expected HTTPPort to default to 80, got %d", c.config.HTTPPort)
|
|
}
|
|
}
|
|
|
|
func TestNew_WithChallengeTypeDefaultsToHTTP01(t *testing.T) {
|
|
cfg := &Config{
|
|
DirectoryURL: "https://example.com/acme",
|
|
Email: "test@example.com",
|
|
HTTPPort: 8080,
|
|
// ChallengeType intentionally empty
|
|
}
|
|
c := New(cfg, testLogger())
|
|
if c.config.ChallengeType != "http-01" {
|
|
t.Errorf("expected ChallengeType to default to http-01, got %s", c.config.ChallengeType)
|
|
}
|
|
}
|
|
|
|
func TestNew_WithDNSPropagationWaitDefaultsTo30(t *testing.T) {
|
|
cfg := &Config{
|
|
DirectoryURL: "https://example.com/acme",
|
|
Email: "test@example.com",
|
|
ChallengeType: "dns-01",
|
|
// DNSPropagationWait intentionally 0
|
|
}
|
|
c := New(cfg, testLogger())
|
|
if c.config.DNSPropagationWait != 30 {
|
|
t.Errorf("expected DNSPropagationWait to default to 30, got %d", c.config.DNSPropagationWait)
|
|
}
|
|
}
|
|
|
|
func TestNew_InitializesDNSSolverForDNS01(t *testing.T) {
|
|
cfg := &Config{
|
|
DirectoryURL: "https://example.com/acme",
|
|
Email: "test@example.com",
|
|
ChallengeType: "dns-01",
|
|
DNSPresentScript: "/bin/sh", // Use a real script that exists
|
|
}
|
|
c := New(cfg, testLogger())
|
|
// DNS solver should be initialized for dns-01
|
|
if c.dnsSolver == nil && cfg.DNSPresentScript != "" {
|
|
// Note: it only initializes if the script path is not empty
|
|
t.Error("dnsSolver should be initialized for dns-01 with present script")
|
|
}
|
|
}
|
|
|
|
func TestNew_InitializesDNSSolverForDNSPersist01(t *testing.T) {
|
|
cfg := &Config{
|
|
DirectoryURL: "https://example.com/acme",
|
|
Email: "test@example.com",
|
|
ChallengeType: "dns-persist-01",
|
|
DNSPresentScript: "/bin/sh", // Use a real script path
|
|
}
|
|
c := New(cfg, testLogger())
|
|
if c.dnsSolver == nil && cfg.DNSPresentScript != "" {
|
|
t.Error("dnsSolver should be initialized for dns-persist-01 with present script")
|
|
}
|
|
}
|
|
|
|
func TestNew_NooDNSSolverForHTTP01(t *testing.T) {
|
|
cfg := &Config{
|
|
DirectoryURL: "https://example.com/acme",
|
|
Email: "test@example.com",
|
|
ChallengeType: "http-01",
|
|
DNSPresentScript: "/nonexistent/path", // Intentionally not initialized
|
|
}
|
|
c := New(cfg, testLogger())
|
|
if c.dnsSolver != nil {
|
|
t.Error("dnsSolver should not be initialized for http-01")
|
|
}
|
|
}
|
|
|
|
// --- ValidateConfig additional coverage tests ---
|
|
|
|
func TestValidateConfig_DNSPresentScriptRequired(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{
|
|
"directory_url": srv.URL,
|
|
"email": "test@example.com",
|
|
"challenge_type": "dns-01",
|
|
// Missing dns_present_script
|
|
})
|
|
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error when dns_present_script is missing for dns-01")
|
|
}
|
|
if !strings.Contains(err.Error(), "dns_present_script") {
|
|
t.Errorf("expected 'dns_present_script' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_DNSPersistIssuerDomainRequired(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{
|
|
"directory_url": srv.URL,
|
|
"email": "test@example.com",
|
|
"challenge_type": "dns-persist-01",
|
|
"dns_present_script": "/tmp/script.sh",
|
|
// Missing dns_persist_issuer_domain
|
|
})
|
|
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error when dns_persist_issuer_domain is missing for dns-persist-01")
|
|
}
|
|
if !strings.Contains(err.Error(), "dns_persist_issuer_domain") {
|
|
t.Errorf("expected 'dns_persist_issuer_domain' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_InvalidJSON(t *testing.T) {
|
|
c := New(nil, testLogger())
|
|
err := c.ValidateConfig(context.Background(), []byte("{invalid json}"))
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid JSON")
|
|
}
|
|
if !strings.Contains(err.Error(), "invalid") {
|
|
t.Errorf("expected 'invalid' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Note: Profile validation tests are in profile_test.go
|
|
|
|
func TestValidateConfig_ACMEDirectoryUnreachable(t *testing.T) {
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{
|
|
"directory_url": "https://127.0.0.1:1/directory", // Unreachable
|
|
"email": "test@example.com",
|
|
})
|
|
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error for unreachable ACME directory")
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_HTTPStatusError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{
|
|
"directory_url": srv.URL,
|
|
"email": "test@example.com",
|
|
})
|
|
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-2xx status")
|
|
}
|
|
if !strings.Contains(err.Error(), "404") {
|
|
t.Errorf("expected '404' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_DNS01WithPresentScript(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{
|
|
"directory_url": srv.URL,
|
|
"email": "test@example.com",
|
|
"challenge_type": "dns-01",
|
|
"dns_present_script": "/bin/sh",
|
|
"dns_cleanup_script": "/bin/sh",
|
|
})
|
|
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err != nil {
|
|
t.Fatalf("expected DNS-01 with present script to succeed, got: %v", err)
|
|
}
|
|
|
|
// Verify config was updated
|
|
if c.config.ChallengeType != "dns-01" {
|
|
t.Errorf("expected ChallengeType=dns-01, got %s", c.config.ChallengeType)
|
|
}
|
|
}
|
|
|
|
func TestValidateConfig_DNSPersist01WithAllFields(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprint(w, `{"newNonce":"","newAccount":"","newOrder":""}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
c := New(nil, testLogger())
|
|
cfg, _ := json.Marshal(map[string]string{
|
|
"directory_url": srv.URL,
|
|
"email": "test@example.com",
|
|
"challenge_type": "dns-persist-01",
|
|
"dns_present_script": "/bin/sh",
|
|
"dns_persist_issuer_domain": "letsencrypt.org",
|
|
})
|
|
|
|
err := c.ValidateConfig(context.Background(), cfg)
|
|
if err != nil {
|
|
t.Fatalf("expected DNS-PERSIST-01 to succeed, got: %v", err)
|
|
}
|
|
|
|
if c.config.DNSPersistIssuerDomain != "letsencrypt.org" {
|
|
t.Errorf("expected issuer domain to be set, got %s", c.config.DNSPersistIssuerDomain)
|
|
}
|
|
}
|
|
|
|
// --- Additional comprehensive tests ---
|
|
|
|
func TestParseDERChain_MultipleChainCerts(t *testing.T) {
|
|
// Generate a complete chain: leaf -> intermediate -> root
|
|
rootKey, _ := generateTestKey()
|
|
intermediateKey, _ := generateTestKey()
|
|
leafKey, _ := generateTestKey()
|
|
|
|
// Root certificate (self-signed)
|
|
rootTemplate := x509.Certificate{
|
|
Subject: generateTestName("Root CA"),
|
|
SerialNumber: big.NewInt(1),
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().AddDate(20, 0, 0),
|
|
KeyUsage: x509.KeyUsageCertSign,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
}
|
|
rootDER, _ := x509.CreateCertificate(nil, &rootTemplate, &rootTemplate, &rootKey.PublicKey, rootKey)
|
|
|
|
// Intermediate certificate (signed by root)
|
|
intermediateTemplate := x509.Certificate{
|
|
Subject: generateTestName("Intermediate CA"),
|
|
SerialNumber: big.NewInt(2),
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().AddDate(10, 0, 0),
|
|
KeyUsage: x509.KeyUsageCertSign,
|
|
BasicConstraintsValid: true,
|
|
IsCA: true,
|
|
PublicKey: &intermediateKey.PublicKey,
|
|
}
|
|
intermediateDER, _ := x509.CreateCertificate(nil, &intermediateTemplate, &rootTemplate, &intermediateKey.PublicKey, rootKey)
|
|
|
|
// Leaf certificate (signed by intermediate)
|
|
leafTemplate := x509.Certificate{
|
|
Subject: generateTestName("leaf.example.com"),
|
|
SerialNumber: big.NewInt(100),
|
|
DNSNames: []string{"leaf.example.com"},
|
|
NotBefore: time.Now(),
|
|
NotAfter: time.Now().AddDate(1, 0, 0),
|
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
|
PublicKey: &leafKey.PublicKey,
|
|
}
|
|
leafDER, _ := x509.CreateCertificate(nil, &leafTemplate, &intermediateTemplate, &leafKey.PublicKey, intermediateKey)
|
|
|
|
certPEM, chainPEM, serial, _, _, err := parseDERChain([][]byte{leafDER, intermediateDER, rootDER})
|
|
if err != nil {
|
|
t.Fatalf("parseDERChain failed: %v", err)
|
|
}
|
|
|
|
// Verify serial from leaf
|
|
if serial != "100" {
|
|
t.Errorf("expected serial '100', got: %s", serial)
|
|
}
|
|
|
|
// Verify chainPEM contains both intermediate and root
|
|
chainCount := strings.Count(chainPEM, "BEGIN CERTIFICATE")
|
|
if chainCount != 2 {
|
|
t.Errorf("expected 2 certs in chain, found %d", chainCount)
|
|
}
|
|
|
|
// Verify certPEM contains only the leaf
|
|
if !strings.Contains(certPEM, "BEGIN CERTIFICATE") {
|
|
t.Error("certPEM should contain certificate header")
|
|
}
|
|
}
|
|
|
|
func TestParseCSRPEM_WithTrailingWhitespace(t *testing.T) {
|
|
key, _ := generateTestKey()
|
|
csrTemplate := x509.CertificateRequest{
|
|
Subject: generateTestName("test.example.com"),
|
|
PublicKey: &key.PublicKey,
|
|
}
|
|
csrDER, _ := x509.CreateCertificateRequest(nil, &csrTemplate, key)
|
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE REQUEST",
|
|
Bytes: csrDER,
|
|
}))
|
|
|
|
// Add trailing whitespace and newlines
|
|
csrWithWhitespace := csrPEM + "\n\n \n"
|
|
|
|
result, err := parseCSRPEM(csrWithWhitespace)
|
|
if err != nil {
|
|
t.Fatalf("parseCSRPEM should handle trailing whitespace, got: %v", err)
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
t.Fatal("expected non-empty result")
|
|
}
|
|
}
|
|
|
|
func TestParseCSRPEM_MultipleCSRsInPEM(t *testing.T) {
|
|
key, _ := generateTestKey()
|
|
csrTemplate := x509.CertificateRequest{
|
|
Subject: generateTestName("test.example.com"),
|
|
PublicKey: &key.PublicKey,
|
|
}
|
|
csrDER, _ := x509.CreateCertificateRequest(nil, &csrTemplate, key)
|
|
csrPEM := string(pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE REQUEST",
|
|
Bytes: csrDER,
|
|
}))
|
|
|
|
// pem.Decode only returns the first PEM block, so this tests that behavior
|
|
multiCSRPEM := csrPEM + "\n" + csrPEM
|
|
|
|
result, err := parseCSRPEM(multiCSRPEM)
|
|
if err != nil {
|
|
t.Fatalf("parseCSRPEM should handle multiple PEMs by decoding the first, got: %v", err)
|
|
}
|
|
|
|
if len(result) == 0 {
|
|
t.Fatal("expected non-empty result")
|
|
}
|
|
}
|
|
|
|
// --- Helper functions for tests ---
|
|
|
|
func generateTestKey() (*ecdsa.PrivateKey, error) {
|
|
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
}
|
|
|
|
func generateTestName(cn string) pkix.Name {
|
|
return pkix.Name{
|
|
CommonName: cn,
|
|
Organization: []string{"Test Org"},
|
|
Country: []string{"US"},
|
|
}
|
|
}
|