package postfix_test import ( "context" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "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/postfix" "github.com/certctl-io/certctl/internal/deploy" "github.com/certctl-io/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) } } // --- 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) } } func TestPostfix_VerifyExponentialBackoff_GrowsBetweenAttempts(t *testing.T) { dir := t.TempDir() c := newC(t, &postfix.Config{ Mode: "postfix", CertPath: filepath.Join(dir, "cert.pem"), ReloadCommand: "postfix reload", PostDeployVerifyAttempts: 4, PostDeployVerifyBackoff: 10 * time.Millisecond, PostDeployVerifyMaxBackoff: 80 * time.Millisecond, PostDeployVerify: &postfix.PostDeployVerifyConfig{ Enabled: true, Endpoint: "localhost:25", Timeout: 100 * time.Millisecond, }, }) 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 { // Succeed on 4th attempt return tlsprobe.ProbeResult{Success: true, Fingerprint: fingerprintOfPEM(certA)} } // Fail on attempts 1-3 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") } // Verify we made exactly 4 calls if len(callTimes) != 4 { t.Fatalf("expected 4 probe calls, got %d", len(callTimes)) } // Assert gaps between attempts are approximately 10ms, 20ms, 40ms. // Allow ±20ms tolerance for scheduler noise. 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) } } }