Files
shankar0123 75097909e9
2026-05-05 18:18:29 +00:00

821 lines
24 KiB
Go

package deploy
import (
"context"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// Phase 1 of the deploy-hardening I master bundle. The 12 named
// tests below pin the load-bearing invariants of the
// internal/deploy/ package: atomic-or-nothing across files,
// validate-fail-cleans-up, reload-fail-rolls-back,
// rollback-also-fails-escalates, SHA-256 idempotency,
// owner/mode preservation + override, file-level serialization,
// backup retention janitor, and AtomicWriteFile temp-file +
// rename-race correctness.
//
// All 12 are required by the prompt at
// the project's deploy-hardening I spec::"Test plan (Phase 1
// ships ≥95% coverage on the new package)".
//
// The tests run in non-root environments — they do NOT exercise
// cross-user chown (which requires CAP_CHOWN). The chown wiring
// is exercised via the same-user case (chown to os.Getuid()
// always succeeds) + the resolveOwnership white-box tests.
const testCert1 = "-----BEGIN CERTIFICATE-----\nFAKE-CERT-1-PAYLOAD\n-----END CERTIFICATE-----\n"
const testCert2 = "-----BEGIN CERTIFICATE-----\nFAKE-CERT-2-DIFFERENT\n-----END CERTIFICATE-----\n"
// TestApply_HappyPath_PreCommitSucceeds_PostCommitSucceeds_FilesAtomic
// pins the canonical happy path: write multiple files, validate
// passes, all atomic-rename, reload passes. Every File ends up
// with the new bytes; PreCommit + PostCommit each fired once.
func TestApply_HappyPath_PreCommitSucceeds_PostCommitSucceeds_FilesAtomic(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
key := filepath.Join(dir, "tls.key")
preCalls, postCalls := 0, 0
var seenTempPaths map[string]string
plan := Plan{
Files: []File{
{Path: cert, Bytes: []byte(testCert1)},
{Path: key, Bytes: []byte(testCert2)},
},
PreCommit: func(ctx context.Context, tempPaths map[string]string) error {
preCalls++
seenTempPaths = tempPaths
// Both temp files exist + readable + carry the new
// bytes (the load-bearing invariant for "validate-
// against-temp" semantics).
for finalPath, tempPath := range tempPaths {
if _, err := os.Stat(tempPath); err != nil {
return fmt.Errorf("temp for %s missing: %w", finalPath, err)
}
}
return nil
},
PostCommit: func(ctx context.Context) error {
postCalls++
return nil
},
}
res, err := Apply(context.Background(), plan)
if err != nil {
t.Fatalf("Apply: %v", err)
}
if res.SkippedAsIdempotent {
t.Errorf("expected fresh write, got idempotent skip")
}
if !res.ValidateOK || !res.Reloaded {
t.Errorf("ValidateOK=%v Reloaded=%v, want true/true", res.ValidateOK, res.Reloaded)
}
if preCalls != 1 || postCalls != 1 {
t.Errorf("PreCommit/PostCommit calls = %d/%d, want 1/1", preCalls, postCalls)
}
if len(seenTempPaths) != 2 {
t.Errorf("PreCommit saw %d temp paths, want 2", len(seenTempPaths))
}
// Final files have new bytes.
if got, _ := os.ReadFile(cert); string(got) != testCert1 {
t.Errorf("cert content = %q, want %q", got, testCert1)
}
if got, _ := os.ReadFile(key); string(got) != testCert2 {
t.Errorf("key content = %q, want %q", got, testCert2)
}
}
// TestApply_PreCommitFails_NoFilesChanged pins the all-or-nothing
// invariant on the validate path: PreCommit returns an error →
// neither destination is touched, ErrValidateFailed is returned.
func TestApply_PreCommitFails_NoFilesChanged(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
key := filepath.Join(dir, "tls.key")
if err := os.WriteFile(cert, []byte("ORIGINAL-CERT"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(key, []byte("ORIGINAL-KEY"), 0600); err != nil {
t.Fatal(err)
}
postCalls := 0
plan := Plan{
Files: []File{
{Path: cert, Bytes: []byte(testCert1)},
{Path: key, Bytes: []byte(testCert2)},
},
PreCommit: func(ctx context.Context, tempPaths map[string]string) error {
return errors.New("nginx -t says: invalid SAN")
},
PostCommit: func(ctx context.Context) error {
postCalls++
return nil
},
}
_, err := Apply(context.Background(), plan)
if !errors.Is(err, ErrValidateFailed) {
t.Fatalf("expected ErrValidateFailed, got %v", err)
}
if postCalls != 0 {
t.Errorf("PostCommit called %d times after PreCommit failure, want 0", postCalls)
}
// Both destinations untouched.
if got, _ := os.ReadFile(cert); string(got) != "ORIGINAL-CERT" {
t.Errorf("cert was modified despite PreCommit failure: %q", got)
}
if got, _ := os.ReadFile(key); string(got) != "ORIGINAL-KEY" {
t.Errorf("key was modified despite PreCommit failure: %q", got)
}
// No temp files leaked.
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.Contains(e.Name(), TempSuffix) {
t.Errorf("temp file leaked: %s", e.Name())
}
}
}
// TestApply_PostCommitFails_FilesRolledBack pins the rollback
// wire: PostCommit fails → restore from backup → re-call
// PostCommit → second one succeeds → return ErrReloadFailed +
// RolledBack=true. The destinations now hold the ORIGINAL bytes.
func TestApply_PostCommitFails_FilesRolledBack(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
postCalls := 0
plan := Plan{
Files: []File{
{Path: cert, Bytes: []byte(testCert1)},
},
PostCommit: func(ctx context.Context) error {
postCalls++
if postCalls == 1 {
return errors.New("nginx -s reload exited 1")
}
return nil
},
}
res, err := Apply(context.Background(), plan)
if !errors.Is(err, ErrReloadFailed) {
t.Fatalf("expected ErrReloadFailed, got %v", err)
}
if !res.RolledBack {
t.Error("expected RolledBack=true")
}
if res.Reloaded {
t.Error("expected Reloaded=false after rollback")
}
if postCalls != 2 {
t.Errorf("PostCommit calls = %d, want 2 (once for the new bytes, once for the restored bytes)", postCalls)
}
if got, _ := os.ReadFile(cert); string(got) != "ORIGINAL" {
t.Errorf("cert after rollback = %q, want %q", got, "ORIGINAL")
}
}
// TestApply_RollbackAlsoFails_ReturnsErrRollbackFailed is the
// escalation path: PostCommit fails + the second PostCommit (after
// restore) also fails. ErrRollbackFailed surfaces;
// operator-actionable.
func TestApply_RollbackAlsoFails_ReturnsErrRollbackFailed(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
plan := Plan{
Files: []File{
{Path: cert, Bytes: []byte(testCert1)},
},
PostCommit: func(ctx context.Context) error {
return errors.New("nginx is wedged")
},
}
_, err := Apply(context.Background(), plan)
if !errors.Is(err, ErrRollbackFailed) {
t.Fatalf("expected ErrRollbackFailed, got %v", err)
}
}
// TestApply_IdempotentSkip_SHA256Match pins the idempotency
// short-circuit: when every File's destination already matches
// SHA-256, neither PreCommit nor PostCommit fires; the result
// reports SkippedAsIdempotent=true.
func TestApply_IdempotentSkip_SHA256Match(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
if err := os.WriteFile(cert, []byte(testCert1), 0644); err != nil {
t.Fatal(err)
}
preCalls, postCalls := 0, 0
plan := Plan{
Files: []File{
{Path: cert, Bytes: []byte(testCert1)},
},
PreCommit: func(ctx context.Context, _ map[string]string) error {
preCalls++
return nil
},
PostCommit: func(ctx context.Context) error {
postCalls++
return nil
},
}
res, err := Apply(context.Background(), plan)
if err != nil {
t.Fatalf("Apply: %v", err)
}
if !res.SkippedAsIdempotent {
t.Error("expected SkippedAsIdempotent=true")
}
if preCalls != 0 || postCalls != 0 {
t.Errorf("expected no Pre/PostCommit calls, got %d/%d", preCalls, postCalls)
}
if len(res.BackupPaths) != 0 {
t.Errorf("expected zero backups for idempotent skip, got %d", len(res.BackupPaths))
}
// Verify SkipIdempotent forces the calls.
plan.SkipIdempotent = true
res, err = Apply(context.Background(), plan)
if err != nil {
t.Fatalf("Apply with SkipIdempotent: %v", err)
}
if res.SkippedAsIdempotent {
t.Error("expected SkipIdempotent override to force the deploy")
}
if preCalls != 1 || postCalls != 1 {
t.Errorf("expected 1/1 calls under SkipIdempotent, got %d/%d", preCalls, postCalls)
}
}
// TestApply_PreservesExistingOwnerAndMode_WhenNotOverridden pins
// the silent-failure-mode-defense: an existing nginx:nginx 0640
// file MUST stay nginx:nginx 0640 across a renewal, NOT get
// clobbered to root:root 0600.
//
// We can't actually create a non-current-user file in a non-root
// test, so this test verifies mode preservation only (the chown
// preservation is exercised by the resolveOwnership unit test
// below).
func TestApply_PreservesExistingOwnerAndMode_WhenNotOverridden(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
// Pre-existing file with very specific mode.
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0640); err != nil {
t.Fatal(err)
}
// Some umasks downgrade 0640 → 0620; force the desired bits
// after creation.
if err := os.Chmod(cert, 0640); err != nil {
t.Fatal(err)
}
plan := Plan{
Files: []File{
{Path: cert, Bytes: []byte(testCert1)}, // no Mode/Owner/Group set
},
}
if _, err := Apply(context.Background(), plan); err != nil {
t.Fatalf("Apply: %v", err)
}
stat, err := os.Stat(cert)
if err != nil {
t.Fatal(err)
}
if stat.Mode().Perm() != 0640 {
t.Errorf("mode after deploy = %#o, want %#o (preservation broken)", stat.Mode().Perm(), os.FileMode(0640))
}
}
// TestApply_RespectsOverrides_OwnerGroupMode pins the override
// path: when File.Mode is set, the existing mode is overridden.
// We use the current user/group so chown succeeds on non-root.
func TestApply_RespectsOverrides_OwnerGroupMode(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0640); err != nil {
t.Fatal(err)
}
if err := os.Chmod(cert, 0640); err != nil {
t.Fatal(err)
}
currentUser, err := user.Current()
if err != nil {
t.Fatal(err)
}
currentGroup, err := user.LookupGroupId(currentUser.Gid)
if err != nil {
t.Fatal(err)
}
plan := Plan{
Files: []File{{
Path: cert,
Bytes: []byte(testCert1),
Mode: 0644,
Owner: currentUser.Username,
Group: currentGroup.Name,
}},
}
if _, err := Apply(context.Background(), plan); err != nil {
t.Fatalf("Apply: %v", err)
}
stat, err := os.Stat(cert)
if err != nil {
t.Fatal(err)
}
if stat.Mode().Perm() != 0644 {
t.Errorf("override mode = %#o, want 0644", stat.Mode().Perm())
}
}
// TestApply_ConcurrentApplyToSameFile_Serializes pins the
// file-level mutex: 10 concurrent Applies to the same destination
// see exactly 10 PostCommit invocations and the file ends with
// one of the writers' bytes (no torn write).
func TestApply_ConcurrentApplyToSameFile_Serializes(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
const N = 10
var inFlight, maxInFlight int32
var postCount int32
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
plan := Plan{
Files: []File{{
Path: cert,
Bytes: []byte(fmt.Sprintf("WRITER-%d", idx)),
}},
SkipIdempotent: true, // force every call through the full path
PostCommit: func(ctx context.Context) error {
n := atomic.AddInt32(&inFlight, 1)
for {
m := atomic.LoadInt32(&maxInFlight)
if n <= m || atomic.CompareAndSwapInt32(&maxInFlight, m, n) {
break
}
}
time.Sleep(2 * time.Millisecond)
atomic.AddInt32(&inFlight, -1)
atomic.AddInt32(&postCount, 1)
return nil
},
}
if _, err := Apply(context.Background(), plan); err != nil {
t.Errorf("Apply: %v", err)
}
}(i)
}
wg.Wait()
if postCount != N {
t.Errorf("postCount = %d, want %d", postCount, N)
}
if maxInFlight > 1 {
t.Errorf("max concurrent PostCommit = %d, want 1 (serialization broken)", maxInFlight)
}
// File must contain exactly one of the writers' contents.
got, _ := os.ReadFile(cert)
if !strings.HasPrefix(string(got), "WRITER-") {
t.Errorf("file content not from any writer: %q", got)
}
}
// TestApply_BackupRetention_KeepsLastN pins the janitor: after
// many deploys, only the last N backups remain.
func TestApply_BackupRetention_KeepsLastN(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
// Initial file.
if err := os.WriteFile(cert, []byte("V0"), 0644); err != nil {
t.Fatal(err)
}
const keep = 2
for i := 1; i <= 5; i++ {
plan := Plan{
Files: []File{{
Path: cert,
Bytes: []byte(fmt.Sprintf("V%d", i)),
}},
BackupRetention: keep,
}
if _, err := Apply(context.Background(), plan); err != nil {
t.Fatalf("Apply iter %d: %v", i, err)
}
// Stagger to ensure distinct nanosecond stamps.
time.Sleep(2 * time.Millisecond)
}
entries, _ := os.ReadDir(dir)
count := 0
for _, e := range entries {
if strings.Contains(e.Name(), BackupSuffix) {
count++
}
}
if count != keep {
t.Errorf("backup count after 5 deploys with retention=%d = %d, want %d", keep, count, keep)
}
}
// TestApply_NoExistingFile_UsesDefaultsForOwnerGroupMode covers
// the first-deploy path: destination doesn't exist; FileDefaults
// applies. We verify the mode default lands; owner/group default
// is exercised in resolveOwnership unit tests (would require root
// for cross-user chown).
func TestApply_NoExistingFile_UsesDefaultsForOwnerGroupMode(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
plan := Plan{
Files: []File{
{Path: cert, Bytes: []byte(testCert1)},
},
Defaults: FileDefaults{Mode: 0640},
}
if _, err := Apply(context.Background(), plan); err != nil {
t.Fatalf("Apply: %v", err)
}
stat, err := os.Stat(cert)
if err != nil {
t.Fatal(err)
}
if stat.Mode().Perm() != 0640 {
t.Errorf("default mode for new file = %#o, want 0640", stat.Mode().Perm())
}
}
// TestAtomicWriteFile_TempFileCleanedUpOnError checks that a
// failure mid-flight (we simulate by passing an unwritable
// directory) leaves no .certctl-tmp.* file behind.
func TestAtomicWriteFile_TempFileCleanedUpOnError(t *testing.T) {
dir := t.TempDir()
// Make the directory read-only AFTER the temp open would fail.
// Easier: target a path inside a directory that doesn't exist.
ghost := filepath.Join(dir, "does-not-exist", "tls.crt")
_, err := AtomicWriteFile(context.Background(), ghost, []byte(testCert1), WriteOptions{})
if err == nil {
t.Fatal("expected error writing into nonexistent directory")
}
// No leaked temps in the parent (which does exist).
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.Contains(e.Name(), TempSuffix) {
t.Errorf("temp file leaked: %s", e.Name())
}
}
}
// TestAtomicWriteFile_RenameRaceWithReader_AtomicReadAlwaysSeesOldOrNew
// pins the load-bearing POSIX-rename atomicity: a concurrent
// reader hitting the destination during a write either sees the
// pre-write bytes or the post-write bytes; never an intermediate
// state.
func TestAtomicWriteFile_RenameRaceWithReader_AtomicReadAlwaysSeesOldOrNew(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tls.crt")
old := []byte(strings.Repeat("OLD", 1000))
newer := []byte(strings.Repeat("NEW", 1000))
if err := os.WriteFile(path, old, 0644); err != nil {
t.Fatal(err)
}
stop := make(chan struct{})
var torn atomic.Bool
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
b, err := os.ReadFile(path)
if err != nil {
continue
}
s := string(b)
if s != string(old) && s != string(newer) {
torn.Store(true)
return
}
}
}()
// Issue many writes back and forth.
for i := 0; i < 30; i++ {
writeBytes := old
if i%2 == 0 {
writeBytes = newer
}
if _, err := AtomicWriteFile(context.Background(), path, writeBytes, WriteOptions{
SkipIdempotent: true,
}); err != nil {
t.Fatalf("AtomicWriteFile %d: %v", i, err)
}
}
close(stop)
wg.Wait()
if torn.Load() {
t.Error("torn read observed (rename was not atomic)")
}
}
// --- White-box tests for resolveOwnership (chown semantics under
// non-root require this, since we can't write a chown-to-root
// integration test without sudo). ---
// TestResolveOwnership_ExplicitOverride_Wins verifies that an
// explicit File.Mode/Owner/Group beats both existing-file
// preservation and Defaults fallback.
func TestResolveOwnership_ExplicitOverride_Wins(t *testing.T) {
currentUser, _ := user.Current()
currentGroup, _ := user.LookupGroupId(currentUser.Gid)
dir := t.TempDir()
path := filepath.Join(dir, "f")
if err := os.WriteFile(path, []byte("x"), 0600); err != nil {
t.Fatal(err)
}
stat, _ := os.Stat(path)
res, err := resolveOwnership(File{
Path: path,
Mode: 0644,
Owner: currentUser.Username,
Group: currentGroup.Name,
}, FileDefaults{Mode: 0400, Owner: "nobody", Group: "nogroup"}, stat)
if err != nil {
t.Fatal(err)
}
if res.Mode != 0644 {
t.Errorf("mode = %#o, want 0644 (override should win)", res.Mode)
}
if res.OwnerLabel != currentUser.Username {
t.Errorf("owner label = %q, want %q (override should win)", res.OwnerLabel, currentUser.Username)
}
}
// TestResolveOwnership_PreservesExisting_WhenNoOverride verifies
// the preservation path: no explicit override + existing file →
// existing uid/gid/mode are returned.
func TestResolveOwnership_PreservesExisting_WhenNoOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f")
if err := os.WriteFile(path, []byte("x"), 0640); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, 0640); err != nil {
t.Fatal(err)
}
stat, _ := os.Stat(path)
res, err := resolveOwnership(File{Path: path}, FileDefaults{Mode: 0400}, stat)
if err != nil {
t.Fatal(err)
}
if res.Mode != 0640 {
t.Errorf("mode = %#o, want 0640 (preservation)", res.Mode)
}
uid, gid, ok := unixOwnerFromStat(stat)
if !ok {
t.Skip("non-unix platform")
}
if res.UID != uid || res.GID != gid {
t.Errorf("uid/gid = %d/%d, want %d/%d", res.UID, res.GID, uid, gid)
}
}
// TestResolveOwnership_NewFile_FallsBackToDefaults verifies the
// defaults path: no override + no existing file → Plan.Defaults.
func TestResolveOwnership_NewFile_FallsBackToDefaults(t *testing.T) {
currentUser, _ := user.Current()
currentGroup, _ := user.LookupGroupId(currentUser.Gid)
res, err := resolveOwnership(File{Path: "/tmp/never"}, FileDefaults{
Mode: 0640,
Owner: currentUser.Username,
Group: currentGroup.Name,
}, nil)
if err != nil {
t.Fatal(err)
}
if res.Mode != 0640 {
t.Errorf("mode = %#o, want 0640 (default)", res.Mode)
}
if res.OwnerLabel != currentUser.Username {
t.Errorf("owner = %q, want %q (default)", res.OwnerLabel, currentUser.Username)
}
}
// TestApply_RejectsInvalidPlan_NoFiles + duplicate-paths + empty-
// path. Pin the validatePlan gate.
func TestApply_RejectsInvalidPlan(t *testing.T) {
tests := []struct {
name string
plan Plan
}{
{"no files", Plan{}},
{"empty path", Plan{Files: []File{{Path: "", Bytes: []byte("x")}}}},
{"duplicate", Plan{Files: []File{
{Path: "/tmp/dup", Bytes: []byte("a")},
{Path: "/tmp/dup", Bytes: []byte("b")},
}}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := Apply(context.Background(), tc.plan)
if !errors.Is(err, ErrPlanInvalid) {
t.Errorf("got %v, want ErrPlanInvalid", err)
}
})
}
}
// TestApply_ContextCancelledBeforeStart_AbortsCleanly pins the
// context-respect contract: a cancelled context aborts before
// any I/O.
func TestApply_ContextCancelledBeforeStart_AbortsCleanly(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := Apply(ctx, Plan{
Files: []File{{Path: cert, Bytes: []byte(testCert1)}},
})
if err == nil || !errors.Is(err, context.Canceled) {
t.Errorf("got %v, want context.Canceled", err)
}
if _, statErr := os.Stat(cert); statErr == nil {
t.Error("file was created despite cancelled context")
}
}
// TestApply_NoBackupRetention_DisablesBackups pins
// BackupRetention = -1 sentinel: no backup created; rollback
// becomes impossible.
func TestApply_NoBackupRetention_DisablesBackups(t *testing.T) {
dir := t.TempDir()
cert := filepath.Join(dir, "tls.crt")
if err := os.WriteFile(cert, []byte("ORIGINAL"), 0644); err != nil {
t.Fatal(err)
}
plan := Plan{
Files: []File{{Path: cert, Bytes: []byte(testCert1)}},
BackupRetention: -1,
}
if _, err := Apply(context.Background(), plan); err != nil {
t.Fatalf("Apply: %v", err)
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.Contains(e.Name(), BackupSuffix) {
t.Errorf("backup created despite BackupRetention=-1: %s", e.Name())
}
}
}
// TestAtomicWriteFile_HappyPath_ReplacesExistingAtomically covers
// the simple AtomicWriteFile path used by F5 + K8s connectors.
func TestAtomicWriteFile_HappyPath_ReplacesExistingAtomically(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f")
if err := os.WriteFile(path, []byte("OLD"), 0644); err != nil {
t.Fatal(err)
}
res, err := AtomicWriteFile(context.Background(), path, []byte("NEW"), WriteOptions{})
if err != nil {
t.Fatalf("AtomicWriteFile: %v", err)
}
if !res.Replaced {
t.Error("Replaced=false; want true")
}
if res.BackupPath == "" {
t.Error("expected non-empty BackupPath")
}
if got, _ := os.ReadFile(path); string(got) != "NEW" {
t.Errorf("file = %q, want NEW", got)
}
}
// TestAtomicWriteFile_IdempotentSkip covers the AtomicWriteFile
// SHA-256 skip — same coverage as Plan.Apply but for the lower-
// level entry point used by F5/K8s.
func TestAtomicWriteFile_IdempotentSkip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "f")
if err := os.WriteFile(path, []byte("SAME"), 0644); err != nil {
t.Fatal(err)
}
res, err := AtomicWriteFile(context.Background(), path, []byte("SAME"), WriteOptions{})
if err != nil {
t.Fatalf("AtomicWriteFile: %v", err)
}
if !res.Idempotent {
t.Error("Idempotent=false; want true")
}
if res.Replaced {
t.Error("Replaced=true on idempotent skip; want false")
}
}
// TestAtomicWriteFile_RejectsEmptyPath pins the input validation.
func TestAtomicWriteFile_RejectsEmptyPath(t *testing.T) {
_, err := AtomicWriteFile(context.Background(), "", []byte("x"), WriteOptions{})
if !errors.Is(err, ErrPlanInvalid) {
t.Errorf("got %v, want ErrPlanInvalid", err)
}
}
// TestPruneBackups_NoOp_WhenUnderRetention pins the early return
// when there are fewer backups than the retention bar.
func TestPruneBackups_NoOp_WhenUnderRetention(t *testing.T) {
dir := t.TempDir()
abs := filepath.Join(dir, "f")
// Create two backup-style files.
os.WriteFile(abs+BackupSuffix+"0000000000000000001", []byte("a"), 0644)
os.WriteFile(abs+BackupSuffix+"0000000000000000002", []byte("b"), 0644)
if err := pruneBackups(abs, 5); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
count := 0
for _, e := range entries {
if strings.Contains(e.Name(), BackupSuffix) {
count++
}
}
if count != 2 {
t.Errorf("count = %d, want 2 (no pruning under retention)", count)
}
}
// TestLookupUID_Numeric covers the "numeric passthrough" branch
// of lookupUID — agents can configure with either "nginx" or "1000".
func TestLookupUID_Numeric(t *testing.T) {
uid, err := lookupUID("12345")
if err != nil {
t.Fatal(err)
}
if uid != 12345 {
t.Errorf("uid = %d, want 12345", uid)
}
}
// TestLookupGID_Numeric mirror.
func TestLookupGID_Numeric(t *testing.T) {
gid, err := lookupGID("54321")
if err != nil {
t.Fatal(err)
}
if gid != 54321 {
t.Errorf("gid = %d, want 54321", gid)
}
}
// TestSHA256Eq_EdgeCases pins the helper used by the idempotency
// short-circuit.
func TestSHA256Eq_EdgeCases(t *testing.T) {
if !sha256Eq([]byte{}, []byte{}) {
t.Error("empty == empty failed")
}
if sha256Eq([]byte("a"), []byte("b")) {
t.Error("a == b unexpectedly true")
}
if sha256Eq([]byte("ab"), []byte("ac")) {
t.Error("ab == ac unexpectedly true")
}
if !sha256Eq([]byte("abc"), []byte("abc")) {
t.Error("abc == abc failed")
}
}