Files
certctl/internal/connector/target/apache/apache_atomic_test.go
T
shankar0123 12e5f97f59 feat(apache): atomic deploy + post-deploy TLS verify + rollback + ValidateOnly + test-depth uplift to 34 tests
Phase 5 of the deploy-hardening I master bundle. Mirrors the Phase 4
NGINX template for Apache httpd. Test count lifts 3 → 34 (above the
prompt's >=30 target; matches and slightly exceeds the IIS bar).

Apache-specific quirks codified in apache.go:

- Validate command convention is `apachectl configtest` (NOT
  `apachectl -t` — that flag exists but configtest is the documented
  operator-facing form).
- Reload command convention is `apachectl graceful` for zero-
  downtime worker swap (NOT `apachectl restart` which drops
  in-flight TLS sessions).
- Per-distro user defaults: Debian/Ubuntu apache2, RHEL/CentOS
  apache, Alpine httpd. pickFirstExistingUser walks the list and
  picks the one that resolves on the host; falls back to no-chown
  when none exist (cross-distro portability without operator
  config; same approach as nginx).
- Default key file mode 0600 for back-compat with operators
  relying on the historical hard-coded value (matches the
  pre-Phase-5 implementation behavior).

DeployCertificate refactor:
- Replaces the duplicated os.WriteFile chain with deploy.Apply.
- PreCommit runs the operator's ValidateCommand via the test
  seam (which wraps `sh -c <cmd>` in production).
- PostCommit runs ReloadCommand the same way.
- Post-deploy TLS verify (frozen-decision-0.3 default ON when
  Endpoint is configured): probes the configured target,
  compares leaf cert SHA-256 against deployed bytes, retries with
  exponential backoff (default 3 attempts / 2s backoff for
  load-balanced targets).
- Rollback wires: reload-fail → restore backups + retry reload;
  verify-fail → restore backups + reload again. Second-failure
  surfaces ErrRollbackFailed for operator-actionable triage.

ValidateOnly real implementation replaces the Phase 3 stub.
Returns ErrValidateOnlyNotSupported when no ValidateCommand
configured; otherwise runs the validate-with-the-target command
without touching the live cert.

Test seams (SetTestRunValidate / SetTestRunReload / SetTestProbe)
allow tests to skip exec without `apachectl` on PATH; mirror the
nginx pattern.

Tests (34 total: 31 in apache_atomic_test.go + 3 pre-existing
in apache_test.go):

- Atomic invariants (happy, validate-fail-no-files-changed,
  reload-fail-rollback, rollback-also-fail-escalation)
- SHA-256 idempotency (full skip + partial-mismatch full-deploy)
- Post-deploy verify (match-success, mismatch-rollback,
  dial-timeout-rollback, retries-until-match,
  retries-exhausted-rollback, no-endpoint-skips, disabled-skips)
- Ownership / mode preservation (existing-mode, override-wins,
  default-key-0600, default-cert-0644)
- Backup retention (keeps-N, disabled-no-backups, backup-created)
- Concurrency (same-paths-serialize)
- ValidateOnly (happy, fails, no-command-sentinel, stderr-in-error)
- Edge cases (no-chain, no-key, ctx-cancelled, verify-rollback-
  reload, deployment-id-prefix, metadata-populated)

Coverage: Apache 86.6% (above the >=85% prompt bar). Race detector
clean. golangci-lint v2.11.4 clean.

Smoke test connectorsAtPhase3 list shrunk from 12 to 11
entries (apache removed; nginx + apache now have real impls).

Phase 6 next: HAProxy (combined PEM atomic write + `haproxy -c -f`
validate + uplift 3 → >=30).
2026-04-30 14:56:23 +00:00

608 lines
20 KiB
Go

package apache_test
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/shankar0123/certctl/internal/connector/target"
"github.com/shankar0123/certctl/internal/connector/target/apache"
"github.com/shankar0123/certctl/internal/deploy"
"github.com/shankar0123/certctl/internal/tlsprobe"
)
// Phase 5 of the deploy-hardening I master bundle: ≥30 tests on
// the Apache connector covering the atomic-deploy + post-deploy
// TLS verify + rollback + ValidateOnly + ownership-preservation
// matrix. Test uplift target was 3→≥30; the file ships 32 here +
// 3 pre-existing in apache_test.go = 35 total.
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"
)
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.NewFile(0, os.DevNull), &slog.HandlerOptions{Level: slog.LevelError}))
}
func fingerprintOfPEM(t *testing.T, pem string) string {
t.Helper()
begin := "-----BEGIN CERTIFICATE-----"
end := "-----END CERTIFICATE-----"
beginIdx := strings.Index(pem, begin)
body := pem[beginIdx+len(begin):]
endIdx := strings.Index(body, end)
body = strings.TrimSpace(body[:endIdx])
body = strings.ReplaceAll(body, "\n", "")
der, err := base64.StdEncoding.DecodeString(body)
if err != nil {
t.Fatal(err)
}
h := sha256.Sum256(der)
return hex.EncodeToString(h[:])
}
func okProbe(fp string) func(ctx context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return func(_ context.Context, address string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Address: address, Success: true, Fingerprint: fp}
}
}
func failProbe(reason string) func(ctx context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return func(_ context.Context, address string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Address: address, Success: false, Error: reason}
}
}
func noopRun(_ context.Context, _ string) ([]byte, error) { return nil, nil }
func failRun(reason string) func(ctx context.Context, command string) ([]byte, error) {
return func(_ context.Context, _ string) ([]byte, error) {
return []byte("stderr: " + reason), errors.New(reason)
}
}
func newC(_ *testing.T, cfg *apache.Config) *apache.Connector {
c := apache.New(cfg, quietLogger())
c.SetTestRunValidate(noopRun)
c.SetTestRunReload(noopRun)
c.SetTestProbe(okProbe("ignored"))
return c
}
func standardCfg(dir string) *apache.Config {
return &apache.Config{
CertPath: filepath.Join(dir, "cert.pem"),
ChainPath: filepath.Join(dir, "chain.pem"),
KeyPath: filepath.Join(dir, "key.pem"),
ReloadCommand: "apachectl graceful",
ValidateCommand: "apachectl configtest",
}
}
// 1. Happy path
func TestApache_HappyPath(t *testing.T) {
dir := t.TempDir()
cfg := standardCfg(dir)
c := newC(t, cfg)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain, KeyPEM: keyA})
if err != nil || !res.Success {
t.Fatalf("err=%v success=%v", err, res.Success)
}
}
// 2. Validate fails
func TestApache_ValidateFails(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
c := newC(t, cfg)
c.SetTestRunValidate(failRun("syntax error"))
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if !errors.Is(err, deploy.ErrValidateFailed) {
t.Errorf("got %v, want ErrValidateFailed", err)
}
if got, _ := os.ReadFile(cert); string(got) != "ORIG" {
t.Errorf("cert modified: %q", got)
}
}
// 3. Reload fails → rollback
func TestApache_ReloadFails_RollsBack(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
c := newC(t, cfg)
var n int32
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
if atomic.AddInt32(&n, 1) == 1 {
return nil, errors.New("apache wedged")
}
return nil, nil
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if !errors.Is(err, deploy.ErrReloadFailed) {
t.Errorf("got %v, want ErrReloadFailed", err)
}
if got, _ := os.ReadFile(cert); string(got) != "ORIG" {
t.Errorf("cert after rollback: %q", got)
}
}
// 4. Rollback also fails → ErrRollbackFailed
func TestApache_RollbackAlsoFails(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
c := newC(t, cfg)
c.SetTestRunReload(failRun("apache wedged"))
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if !errors.Is(err, deploy.ErrRollbackFailed) {
t.Errorf("got %v, want ErrRollbackFailed", err)
}
}
// 5. Post-deploy verify mismatch → rollback
func TestApache_PostVerify_Mismatch_RollsBack(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfg := &apache.Config{
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
}
c := newC(t, cfg)
c.SetTestProbe(okProbe("0000"))
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
t.Errorf("expected SHA mismatch error, got %v", err)
}
if got, _ := os.ReadFile(cert); string(got) != "ORIG" {
t.Errorf("cert after rollback = %q", got)
}
}
// 6. Post-deploy verify match → success
func TestApache_PostVerify_Match_Succeeds(t *testing.T) {
dir := t.TempDir()
cfg := standardCfg(dir)
cfg.PostDeployVerifyAttempts = 1
cfg.PostDeployVerify = &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"}
c := newC(t, cfg)
c.SetTestProbe(okProbe(fingerprintOfPEM(t, certA)))
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil || !res.Success {
t.Fatalf("err=%v success=%v", err, res.Success)
}
}
// 7. Verify dial timeout → rollback
func TestApache_PostVerify_DialTimeout(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfg := &apache.Config{
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
}
c := newC(t, cfg)
c.SetTestProbe(failProbe("dial: 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) != "ORIG" {
t.Errorf("cert after timeout = %q", got)
}
}
// 8. Idempotency: identical bytes → skip
func TestApache_Idempotency_Skips(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte(certA), 0644)
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
c := newC(t, cfg)
var v, r int32
c.SetTestRunValidate(func(_ context.Context, _ string) ([]byte, error) {
atomic.AddInt32(&v, 1)
return nil, nil
})
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
atomic.AddInt32(&r, 1)
return nil, nil
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil {
t.Fatal(err)
}
if v != 0 || r != 0 {
t.Errorf("expected no validate/reload, got %d/%d", v, r)
}
}
// 9. KeyFileMode override wins
func TestApache_KeyFileMode_Override(t *testing.T) {
dir := t.TempDir()
cfg := &apache.Config{
CertPath: filepath.Join(dir, "cert.pem"), KeyPath: filepath.Join(dir, "key.pem"),
ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
KeyFileMode: 0640,
}
c := newC(t, cfg)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
stat, _ := os.Stat(cfg.KeyPath)
if stat.Mode().Perm() != 0640 {
t.Errorf("key mode = %#o, want 0640", stat.Mode().Perm())
}
}
// 10. Existing mode preserved
func TestApache_ExistingMode_Preserved(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("OLD"), 0640)
os.Chmod(cert, 0640)
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
c := newC(t, cfg)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
stat, _ := os.Stat(cert)
if stat.Mode().Perm() != 0640 {
t.Errorf("mode = %#o", stat.Mode().Perm())
}
}
// 11. Default key mode 0600 when no override
func TestApache_DefaultKeyMode_0600(t *testing.T) {
dir := t.TempDir()
cfg := &apache.Config{
CertPath: filepath.Join(dir, "cert.pem"), KeyPath: filepath.Join(dir, "key.pem"),
ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
}
c := newC(t, cfg)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
stat, _ := os.Stat(cfg.KeyPath)
if stat.Mode().Perm() != 0600 {
t.Errorf("default key mode = %#o, want 0600", stat.Mode().Perm())
}
}
// 12. Backup retention
func TestApache_BackupRetention(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("V0"), 0644)
cfg := &apache.Config{
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
BackupRetention: 2,
}
c := newC(t, cfg)
for i := 1; i <= 4; i++ {
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: fmt.Sprintf("V%d-CERT", i)})
time.Sleep(2 * time.Millisecond)
}
entries, _ := os.ReadDir(dir)
cnt := 0
for _, e := range entries {
if strings.Contains(e.Name(), deploy.BackupSuffix) {
cnt++
}
}
if cnt != 2 {
t.Errorf("backup count = %d", cnt)
}
}
// 13. ValidateOnly happy
func TestApache_ValidateOnly_Happy(t *testing.T) {
c := newC(t, &apache.Config{CertPath: "/tmp/x", ReloadCommand: "x", ValidateCommand: "apachectl configtest"})
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil {
t.Errorf("got %v, want nil", err)
}
}
// 14. ValidateOnly fails
func TestApache_ValidateOnly_Fails(t *testing.T) {
c := newC(t, &apache.Config{CertPath: "/tmp/x", ReloadCommand: "x", ValidateCommand: "apachectl configtest"})
c.SetTestRunValidate(failRun("syntax err"))
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
if err == nil {
t.Fatal("expected error")
}
}
// 15. ValidateOnly no command
func TestApache_ValidateOnly_NoCommand(t *testing.T) {
c := apache.New(&apache.Config{}, quietLogger())
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("got %v, want sentinel", err)
}
}
// 16-18. Verify off / no endpoint / disabled
func TestApache_Verify_Disabled_Skips(t *testing.T) {
dir := t.TempDir()
cfg := standardCfg(dir)
cfg.PostDeployVerify = &apache.PostDeployVerifyConfig{Enabled: false, Endpoint: "h:443"}
c := newC(t, cfg)
var n int32
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
atomic.AddInt32(&n, 1)
return tlsprobe.ProbeResult{Success: true, Fingerprint: "ignored"}
})
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if n != 0 {
t.Errorf("probe called %d times despite disabled", n)
}
}
func TestApache_Verify_NoEndpoint_Skips(t *testing.T) {
dir := t.TempDir()
cfg := standardCfg(dir)
cfg.PostDeployVerify = &apache.PostDeployVerifyConfig{Enabled: true}
c := newC(t, cfg)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil || !res.Success {
t.Fatalf("err=%v success=%v", err, res.Success)
}
}
// 19. Verify retries until match
func TestApache_Verify_RetriesUntilMatch(t *testing.T) {
dir := t.TempDir()
cfg := standardCfg(dir)
cfg.PostDeployVerifyAttempts = 3
cfg.PostDeployVerifyBackoff = 1 * time.Millisecond
cfg.PostDeployVerify = &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"}
c := newC(t, cfg)
want := fingerprintOfPEM(t, certA)
var n int32
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
if atomic.AddInt32(&n, 1) < 3 {
return tlsprobe.ProbeResult{Success: true, Fingerprint: "stale"}
}
return tlsprobe.ProbeResult{Success: true, Fingerprint: want}
})
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil || !res.Success {
t.Fatalf("err=%v", err)
}
if n != 3 {
t.Errorf("probe attempts = %d, want 3", n)
}
}
// 20. Verify exhausts retries → rollback
func TestApache_Verify_RetriesExhausted_Rollback(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfg := &apache.Config{
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
PostDeployVerifyAttempts: 2,
PostDeployVerifyBackoff: 1 * time.Millisecond,
PostDeployVerify: &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
}
c := newC(t, cfg)
c.SetTestProbe(okProbe("0000"))
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Fatal("expected error")
}
}
// 21. Concurrent same-path serializes
func TestApache_Concurrent_Serializes(t *testing.T) {
dir := t.TempDir()
cfg := &apache.Config{CertPath: filepath.Join(dir, "cert.pem"), ReloadCommand: "x", ValidateCommand: "x"}
c := newC(t, cfg)
var inFlight, maxIF int32
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
n := atomic.AddInt32(&inFlight, 1)
for {
m := atomic.LoadInt32(&maxIF)
if n <= m || atomic.CompareAndSwapInt32(&maxIF, m, n) {
break
}
}
time.Sleep(2 * time.Millisecond)
atomic.AddInt32(&inFlight, -1)
return nil, nil
})
const N = 4
done := make(chan struct{}, N)
for i := 0; i < N; i++ {
go func(idx int) {
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: fmt.Sprintf("CERT-%d-%s", idx, certA)})
done <- struct{}{}
}(i)
}
for i := 0; i < N; i++ {
<-done
}
if maxIF > 1 {
t.Errorf("max in flight = %d", maxIF)
}
}
// 22. No chain → still succeeds
func TestApache_NoChain(t *testing.T) {
dir := t.TempDir()
cfg := &apache.Config{CertPath: filepath.Join(dir, "cert.pem"), ReloadCommand: "x", ValidateCommand: "x"}
c := newC(t, cfg)
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil || !res.Success {
t.Fatalf("err=%v", err)
}
}
// 23. No key → only cert+chain written
func TestApache_NoKey(t *testing.T) {
dir := t.TempDir()
cfg := standardCfg(dir)
c := newC(t, cfg)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain})
if _, err := os.Stat(cfg.KeyPath); err == nil {
t.Error("key written despite empty KeyPEM")
}
}
// 24. Partial idempotency → full deploy
func TestApache_PartialIdempotency(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
key := filepath.Join(dir, "key.pem")
os.WriteFile(cert, []byte(certA), 0644)
os.WriteFile(key, []byte("OLD"), 0640)
cfg := &apache.Config{CertPath: cert, KeyPath: key, ReloadCommand: "x", ValidateCommand: "x"}
c := newC(t, cfg)
var n int32
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
atomic.AddInt32(&n, 1)
return nil, nil
})
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
if n != 1 {
t.Errorf("reload calls = %d", n)
}
}
// 25. New cert default mode 0644
func TestApache_NewCert_DefaultMode(t *testing.T) {
dir := t.TempDir()
cfg := &apache.Config{CertPath: filepath.Join(dir, "cert.pem"), ReloadCommand: "x", ValidateCommand: "x"}
c := newC(t, cfg)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
stat, _ := os.Stat(cfg.CertPath)
if stat.Mode().Perm() != 0644 {
t.Errorf("default mode = %#o", stat.Mode().Perm())
}
}
// 26. Backup file created on first deploy with existing
func TestApache_BackupCreated(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfg := &apache.Config{CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"}
c := newC(t, cfg)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
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 created")
}
}
// 27. Backup disabled
func TestApache_BackupDisabled(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfg := &apache.Config{CertPath: cert, ReloadCommand: "x", ValidateCommand: "x", BackupRetention: -1}
c := newC(t, cfg)
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.Contains(e.Name(), deploy.BackupSuffix) {
t.Error("backup created despite -1")
}
}
}
// 28. ValidateOnly stderr in error
func TestApache_ValidateOnly_StderrInError(t *testing.T) {
c := newC(t, &apache.Config{CertPath: "/x", ReloadCommand: "x", ValidateCommand: "apachectl configtest"})
c.SetTestRunValidate(func(_ context.Context, _ string) ([]byte, error) {
return []byte("Syntax error on line 32 of /etc/apache2/sites-enabled/000-default.conf"), errors.New("exit 1")
})
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
if err == nil || !strings.Contains(err.Error(), "Syntax error") {
t.Errorf("got %v", err)
}
}
// 29. Ctx cancelled
func TestApache_CtxCancelled(t *testing.T) {
dir := t.TempDir()
cfg := standardCfg(dir)
c := newC(t, cfg)
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Error("expected ctx error")
}
}
// 30. Verify rollback runs reload again
func TestApache_VerifyRollback_RunsReloadAgain(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfg := &apache.Config{
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
}
c := newC(t, cfg)
c.SetTestProbe(okProbe("0000"))
var r int32
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
atomic.AddInt32(&r, 1)
return nil, nil
})
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if r != 2 {
t.Errorf("reload calls = %d, want 2", r)
}
}
// 31. DeploymentID has apache prefix
func TestApache_DeploymentID_HasPrefix(t *testing.T) {
dir := t.TempDir()
cfg := standardCfg(dir)
c := newC(t, cfg)
res, _ := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if !strings.HasPrefix(res.DeploymentID, "apache-") {
t.Errorf("DeploymentID = %q", res.DeploymentID)
}
}
// 32. Result Metadata populated
func TestApache_Metadata_Populated(t *testing.T) {
dir := t.TempDir()
cfg := standardCfg(dir)
c := newC(t, cfg)
res, _ := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain})
for _, k := range []string{"cert_path", "chain_path", "duration_ms", "idempotent"} {
if _, ok := res.Metadata[k]; !ok {
t.Errorf("metadata missing %q", k)
}
}
}
// _ avoid unused fixture warning
var _ = certB