Files
certctl/internal/connector/target/caddy/caddy_test.go
T
shankar0123 5dc698307b 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 bc6039a (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

785 lines
24 KiB
Go

package caddy_test
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"math/big"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/certctl-io/certctl/internal/connector/target"
"github.com/certctl-io/certctl/internal/connector/target/caddy"
)
// generateTestCertAndKey creates a self-signed cert + ECDSA key for tests
// that exercise the file-mode PEM-validation path added in Bundle 9 (the
// 2026-05-02 deployment-target audit). Pre-Bundle-9 the placeholder
// "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----" was
// enough because ValidateDeployment only checked file existence; Fix 2
// of Bundle 9 PEM-parses the file via certutil.ParseCertificatePEM, so
// real test certs are required wherever the test deploys-then-validates.
func generateTestCertAndKey(t *testing.T) (string, string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "caddy-test.example.com"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}))
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}))
return certPEM, keyPEM
}
func TestCaddyConnector_ValidateConfig_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
}
func TestCaddyConnector_ValidateConfig_InvalidJSON(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
connector := caddy.New(&caddy.Config{}, logger)
err := connector.ValidateConfig(ctx, json.RawMessage(`{invalid}`))
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestCaddyConnector_ValidateConfig_InvalidMode(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
Mode: "invalid",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("expected error for invalid mode")
}
}
func TestCaddyConnector_ValidateConfig_FileMode_MissingCertDir(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err == nil {
t.Fatal("expected error for missing cert_dir in file mode")
}
}
func TestCaddyConnector_ValidateConfig_DefaultsApplied(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
CertDir: tmpDir,
Mode: "file",
// Don't specify AdminAPI, CertFile, KeyFile - should use defaults
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
err := connector.ValidateConfig(ctx, rawConfig)
if err != nil {
t.Fatalf("ValidateConfig failed: %v", err)
}
}
func TestCaddyConnector_DeployViaAPI_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Create a mock Caddy admin API server.
//
// Bundle 9 Fix 3 of the 2026-05-02 deployment-target audit added an
// idempotency short-circuit: the connector now GETs the load endpoint
// first to compare the active cert hash with the deploy payload. The
// GET returns an empty array so the comparison falls through to the
// POST (which is what this test exercises).
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
switch r.Method {
case "GET":
// Idempotency probe — return empty so the connector falls
// through to the POST path that this test asserts on.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte("[]"))
return
case "POST":
body, _ := io.ReadAll(r.Body)
var payload map[string]string
json.Unmarshal(body, &payload)
if payload["cert"] == "" {
t.Fatal("cert field missing in payload")
}
if payload["key"] == "" {
t.Fatal("key field missing in payload")
}
w.WriteHeader(http.StatusOK)
return
}
t.Fatalf("unexpected method: %s", r.Method)
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := caddy.Config{
AdminAPI: server.URL,
Mode: "api",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed, got: %s", result.Message)
}
if !strings.Contains(result.Message, "API") {
t.Fatalf("expected API deployment message, got: %s", result.Message)
}
}
func TestCaddyConnector_DeployViaAPI_ServerError(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Create a mock Caddy admin API server that returns error
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid certificate"))
}))
defer server.Close()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: server.URL,
CertDir: tmpDir,
Mode: "api",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
// API fails and falls back to file mode - should succeed
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed via file fallback, got: %s", result.Message)
}
if !strings.Contains(result.Message, "file") {
t.Fatalf("expected file deployment message after API failure, got: %s", result.Message)
}
}
func TestCaddyConnector_DeployViaFile_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed, got: %s", result.Message)
}
// Verify files were created
certPath := filepath.Join(tmpDir, "cert.pem")
keyPath := filepath.Join(tmpDir, "key.pem")
if _, err := os.Stat(certPath); os.IsNotExist(err) {
t.Fatalf("certificate file was not created: %s", certPath)
}
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
t.Fatalf("key file was not created: %s", keyPath)
}
// Verify key file has correct permissions
keyInfo, _ := os.Stat(keyPath)
if keyInfo.Mode().Perm() != 0600 {
t.Fatalf("key file permissions are %o, expected 0600", keyInfo.Mode().Perm())
}
}
func TestCaddyConnector_DeployViaFile_WriteError(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: "/root/nonexistent",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
ChainPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
}
result, err := connector.DeployCertificate(ctx, request)
if err == nil {
t.Fatal("expected error for write failure")
}
if result.Success {
t.Fatal("deployment should fail")
}
}
func TestCaddyConnector_ValidateDeployment_Success(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
// Bundle 9 Fix 2: ValidateDeployment now PEM-parses the cert file, so
// the deploy-then-validate flow needs a real test cert (placeholder
// "MIIC..." would fail the new PEM-parse check).
certPEM, keyPEM := generateTestCertAndKey(t)
deployRequest := target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
}
connector.DeployCertificate(ctx, deployRequest)
// Validate deployment
validateRequest := target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
}
result, err := connector.ValidateDeployment(ctx, validateRequest)
if err != nil {
t.Fatalf("ValidateDeployment failed: %v", err)
}
if !result.Valid {
t.Fatalf("validation should succeed, got: %s", result.Message)
}
if result.Serial != "123456" {
t.Fatalf("serial mismatch: expected 123456, got %s", result.Serial)
}
}
func TestCaddyConnector_ValidateDeployment_FileNotFound(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
tmpDir := t.TempDir()
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
// Don't deploy, just validate
validateRequest := target.ValidationRequest{
CertificateID: "mc-test",
Serial: "123456",
}
result, err := connector.ValidateDeployment(ctx, validateRequest)
if err == nil {
t.Fatal("expected error for missing certificate file")
}
if result.Valid {
t.Fatal("validation should fail")
}
}
func TestCaddyConnector_APIMode_NoChain(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := caddy.Config{
AdminAPI: server.URL,
Mode: "api",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
request := target.DeploymentRequest{
CertPEM: "-----BEGIN CERTIFICATE-----\nMIIC...\n-----END CERTIFICATE-----",
KeyPEM: "-----BEGIN PRIVATE KEY-----\nMIIE...\n-----END PRIVATE KEY-----",
// No ChainPEM
}
result, err := connector.DeployCertificate(ctx, request)
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !result.Success {
t.Fatalf("deployment should succeed, got: %s", result.Message)
}
}
// --- Bundle 9: duration metric + file-mode PEM validate + api-mode idempotency ---
//
// Six tests pin the three independent fixes added in Bundle 9 of the
// 2026-05-02 deployment-target audit:
// - Fix 1 (duration metric L176): TestCaddy_API_DurationMetric_NonZero.
// - Fix 2 (file-mode PEM validate):
// TestCaddy_ValidateDeployment_FileMode_MalformedPEM_Rejected,
// TestCaddy_ValidateDeployment_FileMode_ValidPEM_Accepted.
// - Fix 3 (api-mode idempotency short-circuit):
// TestCaddy_API_Idempotent_SkipsPOSTWhenCertHashMatches,
// TestCaddy_API_Idempotent_RunsPOSTWhenCertHashDiffers,
// TestCaddy_API_Idempotent_GETFails_FallsThroughToPOST.
func TestCaddy_API_DurationMetric_NonZero(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Bundle 9 Fix 1: pre-fix the api-mode duration_ms metric was
// computed as time.Since(time.Now()).Milliseconds() which always
// rounded to ~0ms. Post-fix it uses the startTime captured in
// DeployCertificate. Add a small artificial delay in the handler so
// the asserted duration_ms is unambiguously non-zero.
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte("[]"))
case "POST":
// Simulate a slow Caddy admin reload — 10ms is enough to
// produce a measurable duration_ms.
time.Sleep(10 * time.Millisecond)
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}))
defer server.Close()
cfg := caddy.Config{AdminAPI: server.URL, Mode: "api"}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
certPEM, keyPEM := generateTestCertAndKey(t)
result, err := connector.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
})
if err != nil {
t.Fatalf("deploy failed: %v", err)
}
if !result.Success {
t.Fatalf("expected success, got: %s", result.Message)
}
// duration_ms must parse as int >= 5 (we slept 10ms in the handler;
// allow some headroom for clock granularity on slow CI hosts).
durationStr := result.Metadata["duration_ms"]
if durationStr == "" {
t.Fatal("expected duration_ms in metadata")
}
durationMs, err := strconv.Atoi(durationStr)
if err != nil {
t.Fatalf("duration_ms is not int-parseable: %q (%v)", durationStr, err)
}
if durationMs < 5 {
t.Errorf("duration_ms = %d, expected >= 5 (handler slept 10ms; pre-Bundle-9 bug rounded this to 0)", durationMs)
}
}
func TestCaddy_ValidateDeployment_FileMode_MalformedPEM_Rejected(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Bundle 9 Fix 2: ValidateDeployment now PEM-parses the cert file
// (was only os.Stat existence check). A cert file containing garbage
// passes existence-check but fails PEM-decode → Valid=false.
tmpDir := t.TempDir()
certPath := filepath.Join(tmpDir, "cert.pem")
keyPath := filepath.Join(tmpDir, "key.pem")
if err := os.WriteFile(certPath, []byte("this is not a PEM cert"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, []byte("this is not a key either"), 0600); err != nil {
t.Fatal(err)
}
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
CertificateID: "mc-test",
Serial: "0xDEADBEEF",
})
if err == nil {
t.Fatal("expected error when cert file is malformed PEM")
}
if result.Valid {
t.Fatal("expected Valid=false for malformed PEM")
}
// Error message must reference the PEM/x509 failure so operators see
// what's wrong rather than a confusing downstream symptom.
if !strings.Contains(result.Message, "PEM") && !strings.Contains(result.Message, "x509") {
t.Errorf("expected error message to mention PEM/x509, got: %s", result.Message)
}
}
func TestCaddy_ValidateDeployment_FileMode_ValidPEM_Accepted(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Bundle 9 Fix 2: a real test cert + key passes both the os.Stat
// existence check and the new certutil.ParseCertificatePEM check.
tmpDir := t.TempDir()
certPath := filepath.Join(tmpDir, "cert.pem")
keyPath := filepath.Join(tmpDir, "key.pem")
certPEM, keyPEM := generateTestCertAndKey(t)
if err := os.WriteFile(certPath, []byte(certPEM), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, []byte(keyPEM), 0600); err != nil {
t.Fatal(err)
}
cfg := caddy.Config{
AdminAPI: "http://localhost:2019",
CertDir: tmpDir,
CertFile: "cert.pem",
KeyFile: "key.pem",
Mode: "file",
}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
result, err := connector.ValidateDeployment(ctx, target.ValidationRequest{
CertificateID: "mc-test",
Serial: "1",
})
if err != nil {
t.Fatalf("ValidateDeployment failed unexpectedly: %v (msg: %s)", err, result.Message)
}
if !result.Valid {
t.Errorf("expected Valid=true for a real PEM cert, got: %s", result.Message)
}
}
func TestCaddy_API_Idempotent_SkipsPOSTWhenCertHashMatches(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Bundle 9 Fix 3: the connector GETs first, hashes the active cert,
// and skips the POST when SHA-256 matches. Build the deploy payload
// (cert + trailing newline; no chain in this test) and seed the mock
// GET response with an identical cert string so the hash matches and
// the POST counter stays at 0.
certPEM, keyPEM := generateTestCertAndKey(t)
expectedCertField := certPEM + "\n" // matches deployViaAPI's "request.CertPEM + \"\\n\"" build step
var postCount, getCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Method {
case "GET":
getCount.Add(1)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
payload, _ := json.Marshal([]map[string]string{
{"cert": expectedCertField, "key": keyPEM},
})
w.Write(payload)
case "POST":
postCount.Add(1)
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}))
defer server.Close()
cfg := caddy.Config{AdminAPI: server.URL, Mode: "api"}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
result, err := connector.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
})
if err != nil {
t.Fatalf("deploy failed: %v", err)
}
if !result.Success {
t.Fatalf("expected success, got: %s", result.Message)
}
if got := postCount.Load(); got != 0 {
t.Errorf("expected 0 POST calls (idempotent skip), got %d", got)
}
if got := getCount.Load(); got != 1 {
t.Errorf("expected exactly 1 GET call (idempotency probe), got %d", got)
}
if result.Metadata["idempotent"] != "true" {
t.Errorf("expected metadata.idempotent=true on the skip path, got: %q", result.Metadata["idempotent"])
}
}
func TestCaddy_API_Idempotent_RunsPOSTWhenCertHashDiffers(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Bundle 9 Fix 3: when the GET response's cert hash differs from
// the deploy payload, fall through to the POST. metadata.idempotent
// must NOT be set on the POST path (only on the skip path).
certPEM, keyPEM := generateTestCertAndKey(t)
differentCert, _ := generateTestCertAndKey(t) // a DIFFERENT cert in the GET response
var postCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Method {
case "GET":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
payload, _ := json.Marshal([]map[string]string{
{"cert": differentCert, "key": "different-key"},
})
w.Write(payload)
case "POST":
postCount.Add(1)
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()
cfg := caddy.Config{AdminAPI: server.URL, Mode: "api"}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
result, err := connector.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
})
if err != nil {
t.Fatalf("deploy failed: %v", err)
}
if !result.Success {
t.Fatalf("expected success, got: %s", result.Message)
}
if got := postCount.Load(); got != 1 {
t.Errorf("expected exactly 1 POST call (cert-hash mismatch fell through), got %d", got)
}
if result.Metadata["idempotent"] == "true" {
t.Error("expected metadata.idempotent absent or false on the non-idempotent POST path")
}
}
func TestCaddy_API_Idempotent_GETFails_FallsThroughToPOST(t *testing.T) {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
ctx := context.Background()
// Bundle 9 Fix 3: idempotency is best-effort. A GET that returns 500
// (or 404, or a malformed JSON body, or a network error) silently
// falls through to the POST so deploys never get blocked by a
// misbehaving admin endpoint.
certPEM, keyPEM := generateTestCertAndKey(t)
var postCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.URL.Path, "/config/apps/tls/certificates/load") {
w.WriteHeader(http.StatusNotFound)
return
}
switch r.Method {
case "GET":
// Return 500 — connector should fall through to POST.
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprint(w, "internal error")
case "POST":
postCount.Add(1)
w.WriteHeader(http.StatusOK)
}
}))
defer server.Close()
cfg := caddy.Config{AdminAPI: server.URL, Mode: "api"}
connector := caddy.New(&cfg, logger)
rawConfig, _ := json.Marshal(cfg)
_ = connector.ValidateConfig(ctx, rawConfig)
result, err := connector.DeployCertificate(ctx, target.DeploymentRequest{
CertPEM: certPEM,
KeyPEM: keyPEM,
})
if err != nil {
t.Fatalf("deploy failed: %v", err)
}
if !result.Success {
t.Fatalf("expected success, got: %s", result.Message)
}
if got := postCount.Load(); got != 1 {
t.Errorf("expected exactly 1 POST call (GET failure fell through), got %d", got)
}
if result.Metadata["idempotent"] == "true" {
t.Error("expected metadata.idempotent absent on the fallthrough POST path")
}
}