Files
certctl/internal/connector/issuer/acme/acme_test.go
T
shankar0123 8b75e0311b chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.

Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.

Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).

Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.

Diff shape:
  361 *.go files  — import path replacement only
    2 go.mod     — module declaration replacement only
    1 binary     — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
                   so embedded build-info reflects the new path (8618965 vs
                   8618933 bytes; 32-byte diff is the build-info change)

  Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
  mechanical substitution.

Verification:
  gofmt: 17 files needed re-alignment after sed (the new path is one char
    shorter than the old, so column-aligned import groups drifted). Applied
    `gofmt -w` to fix.
  go mod tidy: clean exit on both modules.
  go vet ./...: clean exit.
  go build ./...: clean exit.
  go test -short -count=1 on representative packages: all green
    (internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
    cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
    confirming the module path resolves correctly.
  binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
    nothing; `strings | grep certctl-io/certctl` shows the new module path
    embedded in build-info.

Files intentionally NOT touched in this commit:
  README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
    URLs in commit 0729ee4 (the post-transfer URL refresh). This commit is
    purely the Go-tooling layer.
  Scarf pixels (`shankar0123.docker.scarf.sh/...`) — Scarf-account
    namespace, not a Go import or GitHub repo URL. Stays.

This is a non-blocking, non-customer-impacting change. Operators pulling
container images, running `make verify`, hitting the API, or installing the
agent see no functional difference. Only Go-tooling consumers (none today)
are affected, and they're enabled — not broken — by this commit.
2026-05-04 00:30:29 +00:00

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/certctl-io/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"},
}
}