From b8293653a5b7d08e9478f343cba7a8d27646a4b3 Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 2 May 2026 19:34:58 +0000 Subject: [PATCH] postfix: add atomic-test variants for Mode=dovecot (happy path + verify-rollback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../target/postfix/postfix_atomic_test.go | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/internal/connector/target/postfix/postfix_atomic_test.go b/internal/connector/target/postfix/postfix_atomic_test.go index 2280eca..6d6df41 100644 --- a/internal/connector/target/postfix/postfix_atomic_test.go +++ b/internal/connector/target/postfix/postfix_atomic_test.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/base64" "encoding/hex" + "encoding/json" "errors" "fmt" "log/slog" @@ -244,3 +245,188 @@ func TestPostfix_DovecotMode(t *testing.T) { 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: + // "; 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) + } +}