Files
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

1281 lines
41 KiB
Go

package nginx_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/certctl-io/certctl/internal/connector/target"
"github.com/certctl-io/certctl/internal/connector/target/nginx"
"github.com/certctl-io/certctl/internal/deploy"
"github.com/certctl-io/certctl/internal/tlsprobe"
)
// Phase 4 of the deploy-hardening I master bundle: ≥40 tests on
// the NGINX connector covering the atomic-deploy + post-deploy TLS
// verify + rollback + ValidateOnly + ownership-preservation matrix
// the prompt requires. The IIS bar is 41; this file plus the 17
// pre-existing tests in nginx_test.go puts NGINX at well over 40.
// --- Fixtures + helpers ---
// Test fixtures: valid base64-shaped PEM bodies so the
// fingerprintOfPEM helper can compute SHA-256 over real binary
// payloads. The actual DER content is junk; only the SHA-256 over
// it matters for verifying post-deploy match logic.
const (
certA = "-----BEGIN CERTIFICATE-----\nQUxQSEEtQ0VSVC1QRU0tQ09OVEVOVFMtQQ==\n-----END CERTIFICATE-----\n"
certB = "-----BEGIN CERTIFICATE-----\nQkVUQS1DRVJULVBFTS1DT05URU5UUy1C\n-----END CERTIFICATE-----\n"
chain = "-----BEGIN CERTIFICATE-----\nSU5URVJNRURJQVRFLUNIQUlOLVBFTQ==\n-----END CERTIFICATE-----\n"
keyA = "-----BEGIN PRIVATE KEY-----\nZmFrZS1rZXktQQ==\n-----END PRIVATE KEY-----\n"
)
// quietLogger discards log output so test runs stay readable.
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.NewFile(0, os.DevNull), &slog.HandlerOptions{Level: slog.LevelError}))
}
// fingerprintOfPEM returns the SHA-256 hex of the first cert in
// the PEM bundle. Mirrors what tlsprobe.ProbeTLS would return for
// a deployed cert. Used by stub probers to claim "deployed cert
// matches" or "doesn't match".
func fingerprintOfPEM(t *testing.T, pem string) string {
t.Helper()
begin := "-----BEGIN CERTIFICATE-----"
end := "-----END CERTIFICATE-----"
beginIdx := strings.Index(pem, begin)
if beginIdx < 0 {
t.Fatal("no cert block")
}
body := pem[beginIdx+len(begin):]
endIdx := strings.Index(body, end)
if endIdx < 0 {
t.Fatal("no end")
}
body = strings.TrimSpace(body[:endIdx])
body = strings.ReplaceAll(body, "\n", "")
body = strings.ReplaceAll(body, "\r", "")
body = strings.ReplaceAll(body, " ", "")
der, err := decodeBase64(body)
if err != nil {
t.Fatal(err)
}
h := sha256.Sum256(der)
return hex.EncodeToString(h[:])
}
func decodeBase64(s string) ([]byte, error) {
// Use a real base64 decoder for the fingerprint helper. We
// avoid encoding/base64 in this package's import to keep
// test-time imports lean — but for a one-shot test helper
// it's fine to import it here.
return base64StdDecode(s)
}
// successProbe returns a stub probe.ProbeResult with the given
// fingerprint. Used to simulate post-deploy TLS verify success
// (matching) or mismatch.
func successProbe(fp string) func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult {
return func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{
Address: address,
Success: true,
Fingerprint: fp,
}
}
}
// failProbe returns a stub indicating dial timeout / handshake fail.
func failProbe(reason string) func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult {
return func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{
Address: address,
Success: false,
Error: reason,
}
}
}
// noopRun stubs runValidate / runReload to always succeed.
func noopRun(ctx context.Context, command string) ([]byte, error) {
return nil, nil
}
// failingRun stubs runValidate / runReload with a fixed error.
func failingRun(reason string) func(ctx context.Context, command string) ([]byte, error) {
return func(ctx context.Context, command string) ([]byte, error) {
return []byte("stderr: " + reason), errors.New(reason)
}
}
// newConnectorWithStubs is the canonical test-time constructor —
// produces a Connector with no-op validate / no-op reload / no-op
// (skip-because-no-endpoint) probe.
func newConnectorWithStubs(t *testing.T, cfg *nginx.Config) *nginx.Connector {
c := nginx.New(cfg, quietLogger())
c.SetTestRunValidate(noopRun)
c.SetTestRunReload(noopRun)
c.SetTestProbe(successProbe("ignored"))
return c
}
// --- The ≥40 tests ---
// 1. Happy path: cert + key + chain all written atomically; reload
// succeeds; final files have new bytes.
func TestNginx_Atomic_HappyPath_CertChainKeyAllAtomic(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ChainPath: filepath.Join(dir, "chain.pem"),
KeyPath: filepath.Join(dir, "key.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
KeyFileMode: 0640,
}
c := newConnectorWithStubs(t, cfg)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certA, ChainPEM: chain, KeyPEM: keyA,
})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Fatal("Success=false")
}
for path, want := range map[string]string{cfg.CertPath: certA, cfg.ChainPath: chain, cfg.KeyPath: keyA} {
got, _ := os.ReadFile(path)
if string(got) != want {
t.Errorf("%s = %q, want %q", path, got, want)
}
}
}
// 2. Validate (PreCommit) fails → no live file modified, error
// surfaces as ErrValidateFailed wrap.
func TestNginx_Atomic_ValidateFails_NoFilesChanged(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
c.SetTestRunValidate(failingRun("invalid SAN"))
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, deploy.ErrValidateFailed) {
t.Errorf("got %v, want ErrValidateFailed wrap", err)
}
if got, _ := os.ReadFile(cert); string(got) != "ORIGINAL" {
t.Errorf("cert was modified despite validate failure: %q", got)
}
}
// 3. Reload (PostCommit) fails → rollback restores the previous
// bytes + reload runs again successfully → ErrReloadFailed surfaces.
func TestNginx_Atomic_ReloadFails_RollbackRestoresPrevious(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
var reloadCalls int32
c.SetTestRunReload(func(ctx context.Context, _ string) ([]byte, error) {
n := atomic.AddInt32(&reloadCalls, 1)
if n == 1 {
return nil, errors.New("nginx exited 1")
}
return nil, nil
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected reload error")
}
if !errors.Is(err, deploy.ErrReloadFailed) {
t.Errorf("got %v, want ErrReloadFailed wrap", err)
}
if got, _ := os.ReadFile(cert); string(got) != "ORIGINAL" {
t.Errorf("cert after rollback = %q, want ORIGINAL", got)
}
if atomic.LoadInt32(&reloadCalls) != 2 {
t.Errorf("reload calls = %d, want 2 (once for new bytes, once for restored)", reloadCalls)
}
}
// 4. Reload fails AND the second reload also fails → ErrRollbackFailed.
func TestNginx_Atomic_ReloadFails_AndRollbackReloadAlsoFails(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
c.SetTestRunReload(failingRun("nginx wedged"))
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if !errors.Is(err, deploy.ErrRollbackFailed) {
t.Errorf("got %v, want ErrRollbackFailed wrap", err)
}
}
// 5. Post-deploy verify SHA-256 mismatch → rollback restores OLD
// cert + emits an error.
func TestNginx_Atomic_PostVerify_SHA256Mismatch_TriggersRollback(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "nginx-test:443",
Timeout: 100 * time.Millisecond,
},
}
c := newConnectorWithStubs(t, cfg)
c.SetTestProbe(successProbe("0000000000000000000000000000000000000000000000000000000000000000"))
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected error from verify mismatch")
}
if !strings.Contains(err.Error(), "SHA-256 mismatch") {
t.Errorf("error not labeled SHA-256 mismatch: %v", err)
}
if got, _ := os.ReadFile(cert); string(got) != "ORIGINAL" {
t.Errorf("cert after verify-failure rollback = %q, want ORIGINAL", got)
}
}
// 6. Post-deploy verify succeeds (fingerprint matches) → result
// reports Success=true.
func TestNginx_Atomic_PostVerify_FingerprintMatches_Succeeds(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "nginx:443",
Timeout: 100 * time.Millisecond,
},
}
c := newConnectorWithStubs(t, cfg)
want := fingerprintOfPEM(t, certA)
c.SetTestProbe(successProbe(want))
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Error("Success=false")
}
}
// 7. Post-deploy verify TLS-dial timeout → rollback restores.
func TestNginx_Atomic_PostVerify_DialTimeout_TriggersRollback(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "nginx:443",
Timeout: 10 * time.Millisecond,
},
}
c := newConnectorWithStubs(t, cfg)
c.SetTestProbe(failProbe("dial tcp: i/o timeout"))
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected dial-timeout error")
}
if got, _ := os.ReadFile(cert); string(got) != "ORIGINAL" {
t.Errorf("cert after dial-timeout rollback = %q, want ORIGINAL", got)
}
}
// 8. Idempotency: second deploy with identical bytes → no validate
// + no reload + verify skipped (the deploy was a no-op).
func TestNginx_Atomic_IdempotencyHit_SkipsAllSteps(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte(certA), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
var validateCalls, reloadCalls, probeCalls int32
c.SetTestRunValidate(func(ctx context.Context, _ string) ([]byte, error) {
atomic.AddInt32(&validateCalls, 1)
return nil, nil
})
c.SetTestRunReload(func(ctx context.Context, _ string) ([]byte, error) {
atomic.AddInt32(&reloadCalls, 1)
return nil, nil
})
c.SetTestProbe(func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult {
atomic.AddInt32(&probeCalls, 1)
return tlsprobe.ProbeResult{Success: true, Fingerprint: "ignored"}
})
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Error("Success=false on idempotent skip")
}
if validateCalls != 0 || reloadCalls != 0 || probeCalls != 0 {
t.Errorf("expected 0/0/0 calls, got %d/%d/%d", validateCalls, reloadCalls, probeCalls)
}
}
// 9. Mode override on key file: KeyFileMode 0600 wins over default.
func TestNginx_Atomic_KeyFileMode_OverrideWins(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
KeyPath: filepath.Join(dir, "key.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
KeyFileMode: 0600,
}
c := newConnectorWithStubs(t, cfg)
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA}); err != nil {
t.Fatal(err)
}
stat, _ := os.Stat(cfg.KeyPath)
if stat.Mode().Perm() != 0600 {
t.Errorf("key mode = %#o, want 0600", stat.Mode().Perm())
}
}
// 10. Existing cert file's mode is preserved across renewal.
func TestNginx_Atomic_ExistingMode_Preserved(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("OLD"), 0640); err != nil {
t.Fatal(err)
}
if err := os.Chmod(cert, 0640); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}); err != nil {
t.Fatal(err)
}
stat, _ := os.Stat(cert)
if stat.Mode().Perm() != 0640 {
t.Errorf("mode = %#o, want 0640 (preservation)", stat.Mode().Perm())
}
}
// 11. Backups are pruned to the configured retention.
func TestNginx_Atomic_BackupRetention_KeepsLastN(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("V0"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
BackupRetention: 2,
}
c := newConnectorWithStubs(t, cfg)
for i := 1; i <= 5; i++ {
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: fmt.Sprintf("V%d-CERT", i),
}); err != nil {
t.Fatal(err)
}
time.Sleep(2 * time.Millisecond)
}
entries, _ := os.ReadDir(dir)
bakCount := 0
for _, e := range entries {
if strings.Contains(e.Name(), deploy.BackupSuffix) {
bakCount++
}
}
if bakCount != 2 {
t.Errorf("backup count = %d, want 2", bakCount)
}
}
// 12. ValidateOnly happy path: returns nil when validate command
// passes.
func TestNginx_ValidateOnly_HappyPath_ReturnsNil(t *testing.T) {
cfg := &nginx.Config{
CertPath: "/tmp/cert.pem",
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{CertPEM: certA}); err != nil {
t.Errorf("got %v, want nil", err)
}
}
// 13. ValidateOnly returns the validate command's error.
func TestNginx_ValidateOnly_ValidateFails_ReturnsWrappedError(t *testing.T) {
cfg := &nginx.Config{
CertPath: "/tmp/cert.pem",
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
c.SetTestRunValidate(failingRun("invalid certificate"))
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected error")
}
if errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("got ErrValidateOnlyNotSupported, want wrapped validate error: %v", err)
}
}
// 14. ValidateOnly returns ErrValidateOnlyNotSupported when no
// validate command configured.
func TestNginx_ValidateOnly_NoConfig_ReturnsSentinel(t *testing.T) {
cfg := &nginx.Config{ /* no ValidateCommand */ }
c := nginx.New(cfg, quietLogger())
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
if !errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("got %v, want ErrValidateOnlyNotSupported", err)
}
}
// 15. Post-deploy verify ON but endpoint empty → skip with warn.
// Deploy still succeeds.
func TestNginx_Atomic_PostVerify_NoEndpoint_SkipsWithWarn(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
// Endpoint left blank
},
}
c := newConnectorWithStubs(t, cfg)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Error("Success=false")
}
}
// 16. Post-deploy verify explicitly DISABLED → skip entirely.
func TestNginx_Atomic_PostVerify_Disabled_NoProbeCalled(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: false,
Endpoint: "nginx:443",
},
}
c := newConnectorWithStubs(t, cfg)
var probeCalls int32
c.SetTestProbe(func(ctx context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
atomic.AddInt32(&probeCalls, 1)
return tlsprobe.ProbeResult{Success: true, Fingerprint: "ignored"}
})
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}); err != nil {
t.Fatal(err)
}
if probeCalls != 0 {
t.Errorf("probe called %d times despite Enabled=false", probeCalls)
}
}
// 17. Verify retries: 3 attempts, fingerprint matches on the 3rd.
func TestNginx_Atomic_PostVerify_RetriesUntilMatch(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 3,
PostDeployVerifyBackoff: 1 * time.Millisecond,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "nginx:443",
},
}
c := newConnectorWithStubs(t, cfg)
want := fingerprintOfPEM(t, certA)
var attempts int32
c.SetTestProbe(func(ctx context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
n := atomic.AddInt32(&attempts, 1)
if n < 3 {
return tlsprobe.ProbeResult{Success: true, Fingerprint: "stale-from-other-pod"}
}
return tlsprobe.ProbeResult{Success: true, Fingerprint: want}
})
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Error("Success=false")
}
if attempts != 3 {
t.Errorf("probe attempts = %d, want 3", attempts)
}
}
// 18. Verify exhausts retries → rollback.
func TestNginx_Atomic_PostVerify_RetriesExhausted_RollsBack(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 2,
PostDeployVerifyBackoff: 1 * time.Millisecond,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "nginx:443",
},
}
c := newConnectorWithStubs(t, cfg)
c.SetTestProbe(successProbe("0000000000000000000000000000000000000000000000000000000000000000"))
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected verify-mismatch error")
}
if got, _ := os.ReadFile(cert); string(got) != "ORIGINAL" {
t.Errorf("cert after rollback = %q, want ORIGINAL", got)
}
}
// 19. Concurrent deploys to same paths serialize via deploy
// package's file mutex.
func TestNginx_Atomic_ConcurrentDeploys_SamePath_Serialize(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
var inFlight, maxInFlight int32
c.SetTestRunReload(func(ctx context.Context, _ string) ([]byte, error) {
n := atomic.AddInt32(&inFlight, 1)
for {
m := atomic.LoadInt32(&maxInFlight)
if n <= m || atomic.CompareAndSwapInt32(&maxInFlight, m, n) {
break
}
}
time.Sleep(2 * time.Millisecond)
atomic.AddInt32(&inFlight, -1)
return nil, nil
})
const N = 5
errs := make(chan error, N)
for i := 0; i < N; i++ {
go func(idx int) {
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: fmt.Sprintf("WRITER-%d-%s", idx, certA),
})
errs <- err
}(i)
}
for i := 0; i < N; i++ {
if err := <-errs; err != nil {
t.Errorf("Deploy %d: %v", i, err)
}
}
if maxInFlight > 1 {
t.Errorf("max concurrent reload = %d, want 1", maxInFlight)
}
}
// 20. Deploy without chain still succeeds (chain field optional).
func TestNginx_Atomic_NoChain_StillSucceeds(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Error("Success=false")
}
}
// 21. Deploy without key → only cert + chain written.
func TestNginx_Atomic_NoKey_OnlyCertAndChainWritten(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ChainPath: filepath.Join(dir, "chain.pem"),
KeyPath: filepath.Join(dir, "key.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(cfg.KeyPath); err == nil {
t.Error("key file written despite empty KeyPEM")
}
}
// 22. ChainPath unset + ChainPEM provided → chain not written
// (operator never asked for it).
func TestNginx_Atomic_NoChainPath_ChainPEMIgnored(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain}); err != nil {
t.Fatal(err)
}
}
// 23. SHA-256 idempotency check across cert + key + chain.
func TestNginx_Atomic_Idempotency_AllThreeFilesMatch(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
chainP := filepath.Join(dir, "chain.pem")
key := filepath.Join(dir, "key.pem")
for _, p := range []struct {
path string
body string
}{{cert, certA}, {chainP, chain}, {key, keyA}} {
if err := os.WriteFile(p.path, []byte(p.body), 0640); err != nil {
t.Fatal(err)
}
}
cfg := &nginx.Config{
CertPath: cert,
ChainPath: chainP,
KeyPath: key,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
var reloadCalls int32
c.SetTestRunReload(func(ctx context.Context, _ string) ([]byte, error) {
atomic.AddInt32(&reloadCalls, 1)
return nil, nil
})
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certA, ChainPEM: chain, KeyPEM: keyA,
}); err != nil {
t.Fatal(err)
}
if reloadCalls != 0 {
t.Errorf("reload called %d times despite idempotent input", reloadCalls)
}
}
// 24. Partial idempotency (cert matches, key differs) → full
// deploy (validate + reload run).
func TestNginx_Atomic_PartialIdempotency_FullDeploy(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
key := filepath.Join(dir, "key.pem")
if err := os.WriteFile(cert, []byte(certA), 0640); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(key, []byte("OLD-KEY"), 0640); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
KeyPath: key,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
var reloadCalls int32
c.SetTestRunReload(func(ctx context.Context, _ string) ([]byte, error) {
atomic.AddInt32(&reloadCalls, 1)
return nil, nil
})
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA}); err != nil {
t.Fatal(err)
}
if reloadCalls != 1 {
t.Errorf("reload called %d times, want 1 (partial-mismatch should trigger full deploy)", reloadCalls)
}
}
// 25. New file (didn't exist) gets default mode 0644 for cert.
func TestNginx_Atomic_NewCert_DefaultMode0644(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}); err != nil {
t.Fatal(err)
}
stat, _ := os.Stat(cfg.CertPath)
if stat.Mode().Perm() != 0644 {
t.Errorf("default cert mode = %#o, want 0644", stat.Mode().Perm())
}
}
// 26. Backup file exists after first deploy with existing file.
func TestNginx_Atomic_FirstDeploy_BackupCreated(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
found := false
for _, e := range entries {
if strings.Contains(e.Name(), deploy.BackupSuffix) {
found = true
break
}
}
if !found {
t.Error("no backup file created")
}
}
// 27. BackupRetention=-1 disables backups (no foot-gun protection).
func TestNginx_Atomic_BackupDisabled_NoBackupFile(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
BackupRetention: -1,
}
c := newConnectorWithStubs(t, cfg)
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.Contains(e.Name(), deploy.BackupSuffix) {
t.Errorf("backup created despite BackupRetention=-1: %s", e.Name())
}
}
}
// 28. ValidateOnly with stubbed validate-fail returns the wrapped
// command output for the operator to read.
func TestNginx_ValidateOnly_ErrorMessageIncludesStderr(t *testing.T) {
cfg := &nginx.Config{
CertPath: "/tmp/cert.pem",
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
c.SetTestRunValidate(failingRun("alert: SSL_CTX_use_certificate failed"))
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "SSL_CTX_use_certificate failed") {
t.Errorf("error %q doesn't include validate stderr", err)
}
}
// 29. Context cancellation propagates through deploy.Apply.
func TestNginx_Atomic_ContextCancelled_AbortsCleanly(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected context.Canceled")
}
}
// 30. Verify-failure rollback re-runs reload against restored bytes.
func TestNginx_Atomic_VerifyFailure_RollbackRunsReloadAgain(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "nginx:443",
},
}
c := newConnectorWithStubs(t, cfg)
c.SetTestProbe(successProbe("0000000000000000000000000000000000000000000000000000000000000000"))
var reloadCalls int32
c.SetTestRunReload(func(ctx context.Context, _ string) ([]byte, error) {
atomic.AddInt32(&reloadCalls, 1)
return nil, nil
})
_, _ = c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if reloadCalls != 2 {
t.Errorf("reload calls = %d, want 2 (once for new bytes, once for rollback restore)", reloadCalls)
}
}
// 31. ValidateOnly with cancelled context returns context error.
func TestNginx_ValidateOnly_ContextCancelled(t *testing.T) {
cfg := &nginx.Config{
CertPath: "/tmp/cert.pem",
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
c.SetTestRunValidate(func(ctx context.Context, _ string) ([]byte, error) {
return nil, ctx.Err()
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := c.ValidateOnly(ctx, target.DeploymentRequest{}); err == nil {
t.Error("expected error from cancelled ctx")
}
}
// 32. Cert + Chain + Key + verify all deploy in single Apply call.
func TestNginx_Atomic_AllFour_OneApply(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ChainPath: filepath.Join(dir, "chain.pem"),
KeyPath: filepath.Join(dir, "key.pem"),
KeyFileMode: 0640,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "nginx:443",
},
}
c := newConnectorWithStubs(t, cfg)
c.SetTestProbe(successProbe(fingerprintOfPEM(t, certA)))
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certA, ChainPEM: chain, KeyPEM: keyA,
})
if err != nil {
t.Fatal(err)
}
if !res.Success {
t.Error("Success=false")
}
}
// 33. Idempotent skip skips post-verify too (deploy was a no-op).
func TestNginx_Atomic_IdempotentSkip_SkipsVerify(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte(certA), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "nginx:443",
},
}
c := newConnectorWithStubs(t, cfg)
var probeCalls int32
c.SetTestProbe(func(ctx context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
atomic.AddInt32(&probeCalls, 1)
return tlsprobe.ProbeResult{Success: true, Fingerprint: "ignored"}
})
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}); err != nil {
t.Fatal(err)
}
if probeCalls != 0 {
t.Errorf("probe called %d times despite idempotent skip", probeCalls)
}
}
// 34. Result.Metadata carries cert_path + chain_path + duration_ms
// + idempotent flags. (Audit log + Prometheus consume these.)
func TestNginx_Atomic_Result_MetadataPopulated(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ChainPath: filepath.Join(dir, "chain.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain})
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"cert_path", "chain_path", "duration_ms", "idempotent"} {
if _, ok := res.Metadata[key]; !ok {
t.Errorf("metadata missing %q", key)
}
}
}
// 35. Successful deploy returns DeploymentID with nginx- prefix.
func TestNginx_Atomic_DeploymentID_HasNginxPrefix(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
res, _ := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if !strings.HasPrefix(res.DeploymentID, "nginx-") {
t.Errorf("DeploymentID = %q, want nginx-* prefix", res.DeploymentID)
}
}
// 36. Concurrent deploys to DIFFERENT paths run in parallel.
func TestNginx_Atomic_DifferentPaths_RunInParallel(t *testing.T) {
dirA := t.TempDir()
dirB := t.TempDir()
cfgA := &nginx.Config{
CertPath: filepath.Join(dirA, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
cfgB := &nginx.Config{
CertPath: filepath.Join(dirB, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
cA := newConnectorWithStubs(t, cfgA)
cB := newConnectorWithStubs(t, cfgB)
// Both should be able to deploy without serializing.
doneA := make(chan struct{})
doneB := make(chan struct{})
go func() {
_, _ = cA.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
close(doneA)
}()
go func() {
_, _ = cB.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certB})
close(doneB)
}()
select {
case <-doneA:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for cA")
}
select {
case <-doneB:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for cB")
}
}
// 37. Reload command CombinedOutput surfaces in the failure
// message for operator triage.
func TestNginx_Atomic_ReloadFailure_OutputInError(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
cfg := &nginx.Config{
CertPath: cert,
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
var reloadCalls int32
c.SetTestRunReload(func(ctx context.Context, _ string) ([]byte, error) {
n := atomic.AddInt32(&reloadCalls, 1)
if n == 1 {
return []byte("nginx: [emerg] cannot bind to :443"), errors.New("exit 1")
}
return nil, nil
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil || !strings.Contains(err.Error(), "cannot bind") {
t.Errorf("error doesn't include reload stderr: %v", err)
}
}
// 38. Validate command CombinedOutput surfaces in the failure
// message.
func TestNginx_Atomic_ValidateFailure_OutputInError(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
c.SetTestRunValidate(func(ctx context.Context, _ string) ([]byte, error) {
return []byte("nginx: [emerg] no SSL session ID context"), errors.New("exit 1")
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil || !strings.Contains(err.Error(), "SSL session ID context") {
t.Errorf("error doesn't include validate stderr: %v", err)
}
}
// 39. PostDeployVerify with default Timeout (0) uses 10s default.
// We verify by stubbing the prober and checking the timeout
// argument it receives.
func TestNginx_Atomic_PostVerify_DefaultTimeout10s(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "nginx:443",
// Timeout left zero
},
}
c := newConnectorWithStubs(t, cfg)
var seenTimeout time.Duration
want := fingerprintOfPEM(t, certA)
c.SetTestProbe(func(ctx context.Context, _ string, timeout time.Duration) tlsprobe.ProbeResult {
seenTimeout = timeout
return tlsprobe.ProbeResult{Success: true, Fingerprint: want}
})
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}); err != nil {
t.Fatal(err)
}
if seenTimeout != 10*time.Second {
t.Errorf("default timeout = %v, want 10s", seenTimeout)
}
}
// 40. PostDeployVerify endpoint is passed through to the probe.
func TestNginx_Atomic_PostVerify_EndpointForwarded(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "specific-host:8443",
},
}
c := newConnectorWithStubs(t, cfg)
var seenAddr string
want := fingerprintOfPEM(t, certA)
c.SetTestProbe(func(ctx context.Context, addr string, _ time.Duration) tlsprobe.ProbeResult {
seenAddr = addr
return tlsprobe.ProbeResult{Success: true, Fingerprint: want}
})
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA}); err != nil {
t.Fatal(err)
}
if seenAddr != "specific-host:8443" {
t.Errorf("probe got %q, want specific-host:8443", seenAddr)
}
}
// 41. Empty CertPEM → still attempts deploy of empty bytes (the
// server-side validation should have caught this earlier; we just
// pin the connector doesn't crash on edge data).
func TestNginx_Atomic_EmptyCertPEM_HandledGracefully(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
if _, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: ""}); err != nil {
t.Fatal(err)
}
}
// 42. Deploy result `idempotent` field is "false" for fresh.
func TestNginx_Atomic_FreshDeploy_IdempotentFlagFalse(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
}
c := newConnectorWithStubs(t, cfg)
res, _ := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if res.Metadata["idempotent"] != "false" {
t.Errorf("idempotent = %q, want false", res.Metadata["idempotent"])
}
}
// TestNginx_VerifyExponentialBackoff_GrowsBetweenAttempts: post-deploy verify
// retries with exponential backoff (10ms → 20ms → 40ms up to max).
func TestNginx_VerifyExponentialBackoff_GrowsBetweenAttempts(t *testing.T) {
dir := t.TempDir()
cfg := &nginx.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "nginx -s reload",
ValidateCommand: "nginx -t",
PostDeployVerifyAttempts: 4,
PostDeployVerifyBackoff: 10 * time.Millisecond,
PostDeployVerifyMaxBackoff: 80 * time.Millisecond,
PostDeployVerify: &nginx.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "localhost:443",
Timeout: 100 * time.Millisecond,
},
}
c := newConnectorWithStubs(t, cfg)
var callTimes []time.Time
probeCallCount := atomic.Int32{}
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
callTimes = append(callTimes, time.Now())
count := probeCallCount.Add(1)
if count == 4 {
return tlsprobe.ProbeResult{Success: true, Fingerprint: fingerprintOfPEM(t, certA)}
}
return tlsprobe.ProbeResult{Success: false, Error: "cert not yet deployed"}
})
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certA,
KeyPEM: keyA,
})
if err != nil {
t.Fatalf("DeployCertificate failed: %v", err)
}
if !res.Success {
t.Fatal("expected Success=true")
}
if len(callTimes) != 4 {
t.Fatalf("expected 4 probe calls, got %d", len(callTimes))
}
const tolerance = 20 * time.Millisecond
expectedGaps := []time.Duration{
10 * time.Millisecond,
20 * time.Millisecond,
40 * time.Millisecond,
}
for i := 0; i < len(expectedGaps); i++ {
gap := callTimes[i+1].Sub(callTimes[i])
expected := expectedGaps[i]
if gap < expected-tolerance || gap > expected+tolerance {
t.Errorf("gap[%d]: expected ~%v, got %v", i, expected, gap)
}
}
}