postfix: add atomic-test variants for Mode=dovecot (happy path + verify-rollback)

Closes Bundle 11 of the 2026-05-02 deployment-target coverage audit
(see cowork/deployment-target-audit-2026-05-02/RESULTS.md). Pre-fix,
postfix_atomic_test.go exercised the atomic deploy path under Mode=
postfix only — the existing TestPostfix_DovecotMode at L233-246
asserted only the DeploymentID prefix, leaving applyDefaults's
dovecot-specific validate/reload command set + the rollback's
file-content-restoration unverified at the deploy-test layer.
Audit's only test-coverage gap on the otherwise-production-grade
Postfix/Dovecot connector.

This commit adds two new tests (test-only commit; no production-
code changes):

1. TestPostfix_Atomic_DovecotMode_HappyPath. Builds a Config with
   Mode: "dovecot" and NO ValidateCommand / NO ReloadCommand set.
   Calls ValidateConfig (which is what triggers applyDefaults via
   its JSON-marshal-then-parse path) before DeployCertificate.
   Captures the validate + reload commands threaded through the
   SetTestRunValidate / SetTestRunReload hooks. Asserts:
     - capturedValidateCmd contains "doveconf -n" (applyDefaults
       populated it from the dovecot branch).
     - capturedReloadCmd contains "doveadm reload".
     - DeploymentID prefix "dovecot-" + result.Metadata["mode"] is
       "dovecot" (Mode survived end-to-end).

2. TestPostfix_Atomic_DovecotMode_VerifyFails_Rollback. Pre-creates
   cert.pem AND key.pem with known "ORIG-CERT" / "ORIG-KEY" bytes.
   Builds Config with Mode: "dovecot", PostDeployVerify enabled
   (Endpoint pointing at a dovecot-IMAPS-style :993 — value unused
   by the probe stub), PostDeployVerifyAttempts: 1 (default is 3
   attempts × 2s backoff = 4+ seconds; we don't need that for a
   unit test). Probe stub returns Success: false, which
   runPostDeployVerify wraps as "TLS probe failed: ...". Asserts:
     - DeployCertificate returns error containing "TLS probe failed".
     - cert.pem AND key.pem on disk contain the ORIG bytes
       verbatim — Bundle 11's load-bearing assertion that the
       rollback restored the pre-deploy file state under
       Mode=dovecot. The existing TestPostfix_VerifyMismatch_Rollback
       (Mode=postfix) only asserts the error; this test extends to
       file-content restoration.

Existing TestPostfix_DovecotMode (L233-246) preserved as-is — the
minimal DeploymentID-prefix smoke test complements the new richer
tests without duplicating their scope.

The encoding/json import is added to support the HappyPath test's
json.Marshal call. No other dependency changes.

No production-code changes; the connector itself was already
correct for Mode=dovecot. Only the test pin was missing.

Verified locally:
- gofmt -l ./internal/connector/target/postfix/  clean
- go vet ./internal/connector/target/postfix/  clean
- go build ./cmd/agent/...  clean (no signature changes)
- go test -race -count=1 ./internal/connector/target/postfix/  green
  (24 tests total: 22 pre-existing + 2 new)

Audit reference: cowork/deployment-target-audit-2026-05-02/RESULTS.md
Bundle 11.
This commit is contained in:
shankar0123
2026-05-02 19:34:58 +00:00
parent e292faafc6
commit b8293653a5
@@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -244,3 +245,188 @@ func TestPostfix_DovecotMode(t *testing.T) {
t.Errorf("DeploymentID = %q", res.DeploymentID) t.Errorf("DeploymentID = %q", res.DeploymentID)
} }
} }
// --- Bundle 11: Mode=dovecot atomic-test variants ---
//
// The existing TestPostfix_DovecotMode (above) is a smoke test that
// asserts the DeploymentID prefix only — it sets ReloadCommand and
// ValidateCommand explicitly, so it doesn't pin applyDefaults's
// dovecot-specific behavior. The two tests below close that gap:
//
// 1. TestPostfix_Atomic_DovecotMode_HappyPath: builds a Config with
// Mode="dovecot" and NO ValidateCommand / NO ReloadCommand set,
// runs ValidateConfig (which is what triggers applyDefaults),
// then asserts the deploy uses `doveconf -n` for validate and
// `doveadm reload` for reload — i.e. applyDefaults populated
// them AND DeployCertificate threaded them all the way to the
// runValidate / runReload hooks.
//
// 2. TestPostfix_Atomic_DovecotMode_VerifyFails_Rollback: pre-populates
// cert+key with known "ORIG" bytes, configures the post-deploy
// TLS verify probe to fail, and asserts the rollback restored
// the original bytes verbatim under Mode="dovecot". Mirrors the
// existing TestPostfix_VerifyMismatch_Rollback (which exercises
// Mode="postfix") but additionally pins the file-content
// restoration that the existing test doesn't.
func TestPostfix_Atomic_DovecotMode_HappyPath(t *testing.T) {
dir := t.TempDir()
// Build the Config WITHOUT setting ValidateCommand / ReloadCommand.
// The whole point of this test is to assert applyDefaults populates
// them with the dovecot strings (`doveconf -n` / `doveadm reload`)
// — and that DeployCertificate then threads those captured values
// through to the test hooks.
cfgIn := postfix.Config{
Mode: "dovecot",
CertPath: filepath.Join(dir, "cert.pem"),
KeyPath: filepath.Join(dir, "key.pem"),
// NO ChainPath: empty path means the connector appends the
// chain to the cert (mail-server convention; preserved by
// applyDefaults's no-op for an unset ChainPath).
}
rawCfg, err := json.Marshal(cfgIn)
if err != nil {
t.Fatalf("marshal config: %v", err)
}
// Build an empty Config — ValidateConfig will overwrite the
// connector's internal config from the parsed-and-defaulted JSON.
c := postfix.New(&postfix.Config{}, quietLogger())
var capturedValidateCmd, capturedReloadCmd string
c.SetTestRunValidate(func(_ context.Context, cmd string) ([]byte, error) {
capturedValidateCmd = cmd
return nil, nil
})
c.SetTestRunReload(func(_ context.Context, cmd string) ([]byte, error) {
capturedReloadCmd = cmd
return nil, nil
})
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: true, Fingerprint: "x"}
})
// Trigger applyDefaults via ValidateConfig — that's what populates
// the dovecot-specific defaults onto cfgIn.
if err := c.ValidateConfig(context.Background(), rawCfg); err != nil {
t.Fatalf("ValidateConfig: %v", err)
}
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certA,
KeyPEM: keyA,
})
if err != nil {
t.Fatalf("deploy failed: %v", err)
}
if !res.Success {
t.Fatalf("expected success, got: %s", res.Message)
}
// applyDefaults must have populated the dovecot validate command
// AND DeployCertificate must have threaded it through to runValidate.
if capturedValidateCmd == "" {
t.Fatal("expected runValidate to be invoked (ValidateCommand should be populated by applyDefaults)")
}
if !strings.Contains(capturedValidateCmd, "doveconf -n") {
t.Errorf("expected validate command to contain 'doveconf -n', got: %q", capturedValidateCmd)
}
// Same contract for ReloadCommand → runReload.
if capturedReloadCmd == "" {
t.Fatal("expected runReload to be invoked (ReloadCommand should be populated by applyDefaults)")
}
if !strings.Contains(capturedReloadCmd, "doveadm reload") {
t.Errorf("expected reload command to contain 'doveadm reload', got: %q", capturedReloadCmd)
}
// DeploymentID prefix sanity (matches the smoke test's assertion +
// confirms Mode=dovecot survived through to the result message).
if !strings.HasPrefix(res.DeploymentID, "dovecot-") {
t.Errorf("expected DeploymentID prefix 'dovecot-', got: %q", res.DeploymentID)
}
if res.Metadata["mode"] != "dovecot" {
t.Errorf("expected metadata.mode='dovecot', got: %q", res.Metadata["mode"])
}
}
func TestPostfix_Atomic_DovecotMode_VerifyFails_Rollback(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "cert.pem")
keyPath := filepath.Join(dir, "key.pem")
// Pre-populate cert AND key with known "ORIG" bytes so the rollback
// has something to restore to (vs. first-time deploy where rollback
// removes the new files instead). This is a Bundle-11 strengthening
// over the existing TestPostfix_VerifyMismatch_Rollback (Mode=postfix)
// which only pre-creates the cert.
const origCert = "-----BEGIN CERTIFICATE-----\nT1JJRy1DRVJU\n-----END CERTIFICATE-----\n"
const origKey = "-----BEGIN PRIVATE KEY-----\nT1JJRy1LRVk=\n-----END PRIVATE KEY-----\n"
if err := os.WriteFile(certPath, []byte(origCert), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(keyPath, []byte(origKey), 0600); err != nil {
t.Fatal(err)
}
// PostDeployVerifyAttempts=1 so the verify path fails fast (default
// is 3 attempts × 2s backoff = 4+ seconds; we don't need that for
// a unit test). Endpoint just needs to be non-empty so
// runPostDeployVerify takes the probe path rather than the
// "no endpoint configured; skipping" early-return.
c := newC(t, &postfix.Config{
Mode: "dovecot",
CertPath: certPath,
KeyPath: keyPath,
ReloadCommand: "doveadm reload",
ValidateCommand: "doveconf -n",
PostDeployVerifyAttempts: 1,
PostDeployVerify: &postfix.PostDeployVerifyConfig{
Enabled: true,
Endpoint: "loadtest-target:993", // dovecot IMAPS — value unused by the test probe stub.
Timeout: 100 * time.Millisecond,
},
})
// Probe stub returns Success=false. runPostDeployVerify treats this
// as a verify failure → DeployCertificate calls rollbackToBackups.
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
return tlsprobe.ProbeResult{Success: false, Error: "tls handshake failed"}
})
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{
CertPEM: certA,
KeyPEM: keyA,
})
if err == nil {
t.Fatal("expected verify-failure error")
}
if res != nil && res.Success {
t.Fatal("expected Success=false on verify-failure")
}
// runPostDeployVerify wraps the probe failure as "TLS probe failed:
// <error>"; assert that surfaces in the returned error so operators
// see what failed instead of a generic "deploy failed" message.
if !strings.Contains(err.Error(), "TLS probe failed") {
t.Errorf("expected error to mention TLS probe failure, got: %v", err)
}
// Rollback must have restored the ORIGINAL cert + key bytes verbatim.
// This is the load-bearing assertion Bundle 11 adds over the existing
// Mode=postfix variant.
gotCert, err := os.ReadFile(certPath)
if err != nil {
t.Fatalf("read cert after rollback: %v", err)
}
if string(gotCert) != origCert {
t.Errorf("rollback did not restore original cert bytes:\n got: %q\n want: %q", gotCert, origCert)
}
gotKey, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("read key after rollback: %v", err)
}
if string(gotKey) != origKey {
t.Errorf("rollback did not restore original key bytes:\n got: %q\n want: %q", gotKey, origKey)
}
}