package safeapply import ( "errors" "os" "path/filepath" "testing" "time" ) func TestRestartRecoveryAndConfirmation(t *testing.T) { dir := t.TempDir() state := filepath.Join(dir, "state") original := filepath.Join(dir, "config") created := filepath.Join(dir, "new") if err := os.WriteFile(original, []byte("before"), 0640); err != nil { t.Fatal(err) } now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) applyCalls := 0 manager := New(state, []string{original, created}, func() error { applyCalls++; return nil }) manager.now = func() time.Time { return now } token, deadline, err := manager.Begin(time.Minute) if err != nil { t.Fatal(err) } if _, _, err = manager.Begin(time.Minute); !errors.Is(err, ErrPending) { t.Fatal(err) } if err = manager.Confirm("wrong"); !errors.Is(err, ErrToken) { t.Fatal(err) } os.WriteFile(original, []byte("changed"), 0600) os.WriteFile(created, []byte("new"), 0600) restarted := New(state, []string{original, created}, func() error { applyCalls++; return nil }) restarted.now = func() time.Time { return deadline } if err = restarted.Confirm(token); !errors.Is(err, ErrExpired) { t.Fatal(err) } if ok, err := restarted.RecoverExpired(); err != nil || !ok { t.Fatalf("recover: %v %v", ok, err) } data, err := os.ReadFile(original) if err != nil || string(data) != "before" { t.Fatalf("restored %q %v", data, err) } if _, err = os.Stat(created); !os.IsNotExist(err) { t.Fatalf("created file remains: %v", err) } info, err := os.Stat(original) if err != nil || info.Mode().Perm() != 0640 { t.Fatalf("mode: %v %v", info, err) } if applyCalls != 1 { t.Fatal(applyCalls) } if status, err := restarted.Status(); err != nil || status.Pending { t.Fatalf("status: %+v %v", status, err) } } func TestRecoveryFailureRemainsRetryable(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "file") os.WriteFile(path, []byte("old"), 0600) now := time.Now() fail := true m := New(filepath.Join(dir, "state"), []string{path}, func() error { if fail { return errors.New("apply failed") } return nil }) m.now = func() time.Time { return now } _, deadline, err := m.Begin(time.Second) if err != nil { t.Fatal(err) } os.WriteFile(path, []byte("new"), 0600) now = deadline if ok, err := m.RecoverExpired(); err == nil || ok { t.Fatal("expected recovery failure") } if status, err := m.Status(); err != nil || !status.Pending { t.Fatalf("status %+v %v", status, err) } fail = false if ok, err := m.RecoverExpired(); err != nil || !ok { t.Fatalf("retry %v %v", ok, err) } } func TestConfirmPreventsRecovery(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "file") os.WriteFile(path, []byte("old"), 0600) now := time.Now() m := New(filepath.Join(dir, "state"), []string{path}, func() error { t.Fatal("unexpected recovery"); return nil }) m.now = func() time.Time { return now } token, deadline, err := m.Begin(time.Second) if err != nil { t.Fatal(err) } os.WriteFile(path, []byte("new"), 0600) if err = m.Confirm(token); err != nil { t.Fatal(err) } now = deadline if ok, err := m.RecoverExpired(); err != nil || ok { t.Fatalf("recovered %v %v", ok, err) } data, _ := os.ReadFile(path) if string(data) != "new" { t.Fatalf("got %q", data) } } func TestConfirmationAndRecoverySerialize(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config") if err := os.WriteFile(path, []byte("old"), 0600); err != nil { t.Fatal(err) } now := time.Now() state := filepath.Join(dir, "state") first := New(state, []string{path}, func() error { return nil }) first.now = func() time.Time { return now } token, deadline, err := first.Begin(time.Minute) if err != nil { t.Fatal(err) } second := New(state, []string{path}, func() error { return nil }) second.now = func() time.Time { return deadline } confirm := make(chan error, 1) recoverResult := make(chan bool, 1) recoverErr := make(chan error, 1) start := make(chan struct{}) go func() { <-start; confirm <- first.Confirm(token) }() go func() { <-start; ok, err := second.RecoverExpired(); recoverResult <- ok; recoverErr <- err }() close(start) confirmErr := <-confirm recovered := <-recoverResult err = <-recoverErr if err != nil { t.Fatal(err) } if confirmErr == nil && recovered { t.Fatal("both confirmation and recovery succeeded") } if confirmErr != nil && !errors.Is(confirmErr, ErrNoPending) { t.Fatalf("confirmation: %v", confirmErr) } if confirmErr != nil && !recovered { t.Fatal("neither confirmation nor recovery succeeded") } }