Files
certctl/internal/connector/target/postfix/postfix_atomic_test.go
T
claude 758dbb283f feat(traefik,caddy,envoy,postfix): atomic deploy + post-deploy TLS verify + rollback + ValidateOnly
Phase 7 of the deploy-hardening I master bundle. Retrofits the
remaining file-based connectors against the canonical NGINX template.
Per-connector quirks codified:

- Postfix/Dovecot: full retrofit with PreCommit (postfix check /
  doveconf -n) + PostCommit (postfix reload / doveadm reload) +
  post-deploy TLS verify. Quirk preserved: when ChainPath is empty,
  chain is appended to cert (Postfix/Dovecot's "no separate chain"
  mode). Per-distro user defaults: postfix, dovecot, _postfix.
  Default key mode 0600. ValidateOnly real impl returns sentinel
  when no ValidateCommand.

- Traefik: simpler retrofit — no PreCommit/PostCommit because
  Traefik watches the cert directory via inotify and auto-reloads.
  Atomic-write via deploy.AtomicWriteFile + post-deploy TLS verify
  + cert rollback on verify mismatch. Default key mode 0600.
  ValidateOnly returns sentinel (no validate-with-the-target
  command exists for Traefik).

- Caddy: retrofitted both modes. File mode replaces os.WriteFile
  with deploy.AtomicWriteFile (preserves the file watcher's auto-
  reload). API mode unchanged (POST /load already atomic at the
  Caddy admin server). ValidateOnly real impl: API mode probes
  the admin /config/ endpoint to confirm Caddy is reachable;
  file mode returns sentinel.

- Envoy: file mode atomic-write via deploy.AtomicWriteFile.
  Envoy's SDS file watcher picks up the rename atomically without
  config reload. ValidateOnly returns sentinel (no Envoy CLI
  validate command exists for individual cert files).

Test counts (all packages above the prompt's >=20 bar):
- Postfix: 30 (12 new in postfix_atomic_test.go + 18 pre-existing)
- Traefik: 22 (12 new in traefik_atomic_test.go + 10 pre-existing)
- Caddy: 22 (10 new in caddy_atomic_test.go + 12 pre-existing)
- Envoy: 21 (5 new in envoy_atomic_test.go + 16 pre-existing)

Coverage: each connector at the prompt's >=80% target. golangci-lint
v2.11.4 clean across all 4 connector packages.

Smoke test connectorsAtPhase3 list shrunk from 10 to 6 entries
(postfix removed alongside nginx + apache + haproxy; traefik /
caddy / envoy retain their stubs in the list because their
ValidateOnly returns the sentinel for V2 — the real implementation
arrives only when there's a meaningful validate-with-the-target
command).

Wait — actually the smoke test still pins all 4 because their
ValidateOnly returns the sentinel. Postfix's real impl returns nil
on success (when ValidateCommand is set), so postfix MUST be
removed. Caddy's API mode is real-impl. Traefik + Envoy still
return sentinel always — they stay in the smoke list.

Phase 8 next: F5 + IIS — explicit post-deploy TLS verify +
on-failure rollback. Both already have transactional semantics
internally; the Phase 8 work is making rollback explicit + adding
the post-deploy verify.
2026-04-30 15:12:11 +00:00

247 lines
8.3 KiB
Go

package postfix_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/postfix"
"github.com/shankar0123/certctl/internal/deploy"
"github.com/shankar0123/certctl/internal/tlsprobe"
)
// Phase 7 of the deploy-hardening I master bundle: atomic + verify
// + rollback for Postfix/Dovecot. Pre-existing 18 tests + these
// new ones puts the connector well above the >=20 target.
const (
certA = "-----BEGIN CERTIFICATE-----\nQUxQSEEtQ0VSVA==\n-----END CERTIFICATE-----\n"
chain = "-----BEGIN CERTIFICATE-----\nSU5UQ0hBSU4=\n-----END CERTIFICATE-----\n"
keyA = "-----BEGIN PRIVATE KEY-----\nZmFrZS1rZXk=\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(pem string) string {
beg := strings.Index(pem, "-----BEGIN CERTIFICATE-----") + len("-----BEGIN CERTIFICATE-----")
body := pem[beg:]
end := strings.Index(body, "-----END CERTIFICATE-----")
body = strings.TrimSpace(body[:end])
body = strings.ReplaceAll(body, "\n", "")
der, _ := base64.StdEncoding.DecodeString(body)
h := sha256.Sum256(der)
return hex.EncodeToString(h[:])
}
func newC(_ *testing.T, cfg *postfix.Config) *postfix.Connector {
c := postfix.New(cfg, quietLogger())
c.SetTestRunValidate(func(_ context.Context, _ string) ([]byte, error) { return nil, nil })
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) { return nil, nil })
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: true, Fingerprint: "x"}
})
return c
}
func cfg(dir string) *postfix.Config {
return &postfix.Config{
Mode: "postfix",
CertPath: filepath.Join(dir, "cert.pem"),
KeyPath: filepath.Join(dir, "key.pem"),
ChainPath: filepath.Join(dir, "chain.pem"),
ReloadCommand: "postfix reload",
ValidateCommand: "postfix check",
}
}
func TestPostfix_HappyPath(t *testing.T) {
c := newC(t, cfg(t.TempDir()))
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain, KeyPEM: keyA})
if err != nil || !res.Success {
t.Fatal(err)
}
}
func TestPostfix_ValidateFails(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("OLD"), 0644)
c := newC(t, &postfix.Config{Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"})
c.SetTestRunValidate(func(_ context.Context, _ string) ([]byte, error) {
return []byte("err"), errors.New("bad config")
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if !errors.Is(err, deploy.ErrValidateFailed) {
t.Errorf("got %v", err)
}
if got, _ := os.ReadFile(cert); string(got) != "OLD" {
t.Error("cert modified")
}
}
func TestPostfix_ReloadFails_Rollback(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("OLD"), 0644)
c := newC(t, &postfix.Config{Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"})
var n int32
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
if atomic.AddInt32(&n, 1) == 1 {
return nil, errors.New("reload failed")
}
return nil, nil
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if !errors.Is(err, deploy.ErrReloadFailed) {
t.Errorf("got %v", err)
}
}
func TestPostfix_VerifyMismatch_Rollback(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("ORIG"), 0644)
cfgV := &postfix.Config{
Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &postfix.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:25"},
}
c := newC(t, cfgV)
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: true, Fingerprint: "0000"}
})
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err == nil {
t.Error("expected verify error")
}
}
func TestPostfix_VerifyMatch_Success(t *testing.T) {
dir := t.TempDir()
cfgV := &postfix.Config{
Mode: "postfix", CertPath: filepath.Join(dir, "cert.pem"), ReloadCommand: "x", ValidateCommand: "x",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &postfix.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:25"},
}
c := newC(t, cfgV)
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: true, Fingerprint: fingerprintOfPEM(certA)}
})
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil || !res.Success {
t.Fatal(err)
}
}
func TestPostfix_Idempotency(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte(certA), 0644)
c := newC(t, &postfix.Config{Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"})
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})
if n != 0 {
t.Errorf("reload calls = %d", n)
}
}
func TestPostfix_ChainAppendedToCert_WhenNoChainPath(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
c := newC(t, &postfix.Config{Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"})
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain})
body, _ := os.ReadFile(cert)
s := string(body)
if !strings.Contains(s, "ALPHA") || !strings.Contains(s, "INTCHAIN") {
// (b64 encoded — check headers instead)
}
first := strings.Index(s, "BEGIN CERTIFICATE")
second := strings.Index(s[first+1:], "BEGIN CERTIFICATE")
if second < 0 {
t.Errorf("chain not appended to cert: %s", s)
}
}
func TestPostfix_DefaultKeyMode_0600(t *testing.T) {
dir := t.TempDir()
c := newC(t, &postfix.Config{
Mode: "postfix", CertPath: filepath.Join(dir, "cert.pem"),
KeyPath: filepath.Join(dir, "key.pem"),
ReloadCommand: "x", ValidateCommand: "x",
})
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
stat, _ := os.Stat(filepath.Join(dir, "key.pem"))
if stat.Mode().Perm() != 0600 {
t.Errorf("key mode = %#o", stat.Mode().Perm())
}
}
func TestPostfix_ValidateOnly_Happy(t *testing.T) {
c := newC(t, cfg(t.TempDir()))
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil {
t.Errorf("got %v", err)
}
}
func TestPostfix_ValidateOnly_Sentinel_NoCommand(t *testing.T) {
c := postfix.New(&postfix.Config{}, quietLogger())
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("got %v", err)
}
}
func TestPostfix_BackupRetention(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "cert.pem")
os.WriteFile(cert, []byte("V0"), 0644)
c := newC(t, &postfix.Config{
Mode: "postfix", CertPath: cert, ReloadCommand: "x", ValidateCommand: "x", BackupRetention: 2,
})
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("count = %d", cnt)
}
}
func TestPostfix_DovecotMode(t *testing.T) {
dir := t.TempDir()
c := newC(t, &postfix.Config{
Mode: "dovecot", CertPath: filepath.Join(dir, "cert.pem"),
ReloadCommand: "doveadm reload", ValidateCommand: "doveconf -n",
})
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
if err != nil || !res.Success {
t.Fatal(err)
}
if !strings.HasPrefix(res.DeploymentID, "dovecot-") {
t.Errorf("DeploymentID = %q", res.DeploymentID)
}
}