Pulse/internal/updates/manager_rollback_test.go
rcourtman a0170d3252 Block silent downgrades on apply and give the updater a sanctioned rollback path
The in-app updater validated download URL and channel but never compared
the target against the running version, so any valid older release asset
URL installed silently while the UI presented it as an update. ApplyUpdate
now rejects targets at or below the running version on both the community
and Pro broker paths before any history entry or download, with an explicit
allowDowngrade opt-in on POST /api/updates/apply for sanctioned cases.

The rollback half already existed but nothing reached it: createBackup
retains three backups, restoreBackup works, and history records BackupPath,
yet no endpoint or UI called restoreBackup. RollbackToBackup restores the
retained backup recorded on a history entry after re-validating the path
against the managed backup roots, shares the update-in-flight slot with
ApplyUpdate, records an Action rollback history entry linked to the source
update, marks that update rolled_back, streams a restoring stage through
the existing status/SSE machinery, and restarts via the exit-for-systemd
path. POST /api/updates/rollback carries it with the same RequireAdmin plus
settings:write gating as apply. Rollback is purely local, so the Pro
edition gate never applies to it.

Settings now has the update history surface that was missing entirely:
nothing called /api/updates/history before. The Updates panel lists recent
updates with a Roll back action on successful entries whose backup is still
retained, behind a confirmation dialog naming the restore version, and the
rollback rides updateStore's shared pending-apply marker for the
post-restart toast. restoreBackup also honors PULSE_INSTALL_DIR now instead
of hardcoding /opt/pulse, matching createBackup.

Contract deltas ride along: api-contracts picks up the rollback transport
and downgrade-conflict semantics, agent-lifecycle and storage-recovery pin
rollback as server self-update plumbing, ai-runtime and cloud-paid pin the
update watcher stage vocabulary as non-assistant non-paid shell chrome, and
frontend-primitives adds UpdateHistorySection as the history/rollback
presentation owner with matching architecture proofs.
2026-07-10 01:46:08 +01:00

341 lines
11 KiB
Go

package updates
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestEnsureApplyTargetIsNewer(t *testing.T) {
cases := []struct {
name string
current string
target string
wantErr bool
}{
{"newer patch allowed", "6.0.4", "6.0.5", false},
{"older patch blocked", "6.0.5", "6.0.4", true},
{"same version blocked", "6.0.5", "6.0.5", true},
{"rc to stable of same base allowed", "6.0.5-rc.4", "6.0.5", false},
{"stable to rc of same base blocked", "6.0.5", "6.0.5-rc.4", true},
{"older rc blocked", "6.0.5-rc.4", "6.0.5-rc.3", true},
{"newer rc allowed", "6.0.5-rc.4", "6.0.5-rc.5", false},
{"v prefix handled", "v6.0.5", "v6.0.6", false},
{"unparseable current allowed", "not-a-version", "6.0.4", false},
{"unparseable target allowed", "6.0.5", "not-a-version", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ensureApplyTargetIsNewer(tc.current, tc.target)
if tc.wantErr && err == nil {
t.Fatalf("expected downgrade error for current=%s target=%s", tc.current, tc.target)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for current=%s target=%s: %v", tc.current, tc.target, err)
}
if err != nil && !strings.Contains(err.Error(), "not newer than the running version") {
t.Fatalf("expected canonical downgrade message, got %q", err.Error())
}
})
}
}
func TestApplyUpdateRejectsDowngradeBeforeDownload(t *testing.T) {
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
t.Setenv("PULSE_DATA_DIR", t.TempDir())
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
oldBuildVersion := BuildVersion
BuildVersion = "6.0.5"
t.Cleanup(func() { BuildVersion = oldBuildVersion })
manager := &Manager{}
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{
DownloadURL: "https://github.com/rcourtman/Pulse/releases/download/v6.0.4/pulse-v6.0.4-linux-amd64.tar.gz",
})
if err == nil {
t.Fatal("expected downgrade to be rejected")
}
if !strings.Contains(err.Error(), "not newer than the running version") {
t.Fatalf("expected downgrade rejection, got %v", err)
}
status := manager.GetStatus()
if status.Status != "error" {
t.Fatalf("expected error status after rejected downgrade, got %q", status.Status)
}
}
func TestApplyUpdateAllowDowngradeSkipsGuard(t *testing.T) {
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
t.Setenv("PULSE_DATA_DIR", t.TempDir())
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
// Serve 404s locally so the apply fails fast at the download stage
// without touching the real release host.
server := httptest.NewServer(http.NotFoundHandler())
t.Cleanup(server.Close)
t.Setenv("PULSE_UPDATE_SERVER", server.URL)
oldBuildVersion := BuildVersion
BuildVersion = "6.0.5"
t.Cleanup(func() { BuildVersion = oldBuildVersion })
manager := &Manager{}
// With the opt-in set the request must get past the downgrade guard; it
// then fails later at the download step instead.
err := manager.ApplyUpdate(context.Background(), ApplyUpdateRequest{
DownloadURL: server.URL + "/releases/download/v6.0.4/pulse-v6.0.4-linux-amd64.tar.gz",
AllowDowngrade: true,
})
if err == nil {
t.Fatal("expected apply to fail at a later stage in this environment")
}
if strings.Contains(err.Error(), "not newer than the running version") {
t.Fatalf("expected the downgrade guard to be skipped, got %v", err)
}
}
func TestValidateRetainedBackupDir(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
valid := filepath.Join(dataDir, "backup-20260710-010101")
if err := os.MkdirAll(valid, 0755); err != nil {
t.Fatalf("mkdir valid backup: %v", err)
}
t.Run("accepts managed backup", func(t *testing.T) {
got, err := validateRetainedBackupDir(valid)
if err != nil {
t.Fatalf("expected managed backup to validate: %v", err)
}
if got != filepath.Clean(valid) {
t.Fatalf("expected %q, got %q", valid, got)
}
})
t.Run("rejects path outside managed roots", func(t *testing.T) {
outside := filepath.Join(t.TempDir(), "backup-20260710-010101")
if err := os.MkdirAll(outside, 0755); err != nil {
t.Fatalf("mkdir outside backup: %v", err)
}
if _, err := validateRetainedBackupDir(outside); err == nil {
t.Fatal("expected unmanaged path to be rejected")
}
})
t.Run("rejects wrong prefix inside managed root", func(t *testing.T) {
wrongPrefix := filepath.Join(dataDir, "snapshots-20260710-010101")
if err := os.MkdirAll(wrongPrefix, 0755); err != nil {
t.Fatalf("mkdir wrong prefix: %v", err)
}
if _, err := validateRetainedBackupDir(wrongPrefix); err == nil {
t.Fatal("expected wrong-prefix path to be rejected")
}
})
t.Run("rejects missing directory", func(t *testing.T) {
missing := filepath.Join(dataDir, "backup-19990101-000000")
_, err := validateRetainedBackupDir(missing)
if err == nil || !strings.Contains(err.Error(), "no longer exists") {
t.Fatalf("expected missing-backup error, got %v", err)
}
})
}
func TestRollbackToBackupRestoresFilesAndRecordsHistory(t *testing.T) {
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
dataDir := t.TempDir()
installDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
t.Setenv("PULSE_INSTALL_DIR", installDir)
// Backup contents captured before the recorded update. The backup holds
// no "pulse" binary on purpose: restoring one would overwrite the running
// test executable via os.Executable.
backupDir := filepath.Join(dataDir, "backup-20260710-020202")
if err := os.MkdirAll(filepath.Join(backupDir, "config"), 0755); err != nil {
t.Fatalf("mkdir backup config: %v", err)
}
if err := os.WriteFile(filepath.Join(backupDir, "config", "system.json"), []byte(`{"restored":true}`), 0600); err != nil {
t.Fatalf("write backup config: %v", err)
}
if err := os.WriteFile(filepath.Join(backupDir, ".env"), []byte("RESTORED=1\n"), 0600); err != nil {
t.Fatalf("write backup env: %v", err)
}
if err := os.WriteFile(filepath.Join(backupDir, "VERSION"), []byte("6.0.4\n"), 0644); err != nil {
t.Fatalf("write backup VERSION: %v", err)
}
// Current install state that the rollback must replace.
if err := os.MkdirAll(filepath.Join(installDir, "config"), 0755); err != nil {
t.Fatalf("mkdir install config: %v", err)
}
if err := os.WriteFile(filepath.Join(installDir, "config", "system.json"), []byte(`{"restored":false}`), 0600); err != nil {
t.Fatalf("write install config: %v", err)
}
history, err := NewUpdateHistory(t.TempDir())
if err != nil {
t.Fatalf("NewUpdateHistory: %v", err)
}
sourceEventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
Action: "update",
Status: StatusSuccess,
VersionFrom: "6.0.4",
VersionTo: "6.0.5",
BackupPath: backupDir,
})
if err != nil {
t.Fatalf("CreateEntry: %v", err)
}
manager := &Manager{history: history}
if err := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: sourceEventID}); err != nil {
t.Fatalf("RollbackToBackup: %v", err)
}
restoredConfig, err := os.ReadFile(filepath.Join(installDir, "config", "system.json"))
if err != nil {
t.Fatalf("read restored config: %v", err)
}
if !strings.Contains(string(restoredConfig), `"restored":true`) {
t.Fatalf("expected config to be restored from backup, got %s", restoredConfig)
}
restoredEnv, err := os.ReadFile(filepath.Join(installDir, ".env"))
if err != nil {
t.Fatalf("read restored .env: %v", err)
}
if !strings.Contains(string(restoredEnv), "RESTORED=1") {
t.Fatalf("expected .env to be restored, got %s", restoredEnv)
}
source, err := history.GetEntry(sourceEventID)
if err != nil {
t.Fatalf("GetEntry source: %v", err)
}
if source.Status != StatusRolledBack {
t.Fatalf("expected source entry to be marked rolled_back, got %q", source.Status)
}
entries := history.ListEntries(HistoryFilter{Action: "rollback"})
if len(entries) != 1 {
t.Fatalf("expected one rollback history entry, got %d", len(entries))
}
rollback := entries[0]
if rollback.Status != StatusSuccess {
t.Fatalf("expected rollback entry success, got %q", rollback.Status)
}
if rollback.VersionTo != "6.0.4" {
t.Fatalf("expected rollback target version 6.0.4, got %q", rollback.VersionTo)
}
if rollback.RelatedEventID != sourceEventID {
t.Fatalf("expected rollback to reference the source update entry")
}
status := manager.GetStatus()
if status.Status != "completed" {
t.Fatalf("expected completed status after rollback, got %q", status.Status)
}
}
func TestRollbackToBackupRejectsBadRequests(t *testing.T) {
t.Setenv("PULSE_ALLOW_DOCKER_UPDATES", "true")
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
t.Setenv("PULSE_INSTALL_DIR", t.TempDir())
history, err := NewUpdateHistory(t.TempDir())
if err != nil {
t.Fatalf("NewUpdateHistory: %v", err)
}
manager := &Manager{history: history}
t.Run("no history sink", func(t *testing.T) {
bare := &Manager{}
if err := bare.RollbackToBackup(context.Background(), RollbackRequest{EventID: "x"}); err == nil {
t.Fatal("expected error without history")
}
})
t.Run("empty event ID", func(t *testing.T) {
err := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: " "})
if err == nil || !strings.Contains(err.Error(), "event ID is required") {
t.Fatalf("expected event ID error, got %v", err)
}
})
t.Run("unknown entry", func(t *testing.T) {
err := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: "does-not-exist"})
if err == nil || !strings.Contains(err.Error(), "history entry not found") {
t.Fatalf("expected not-found error, got %v", err)
}
})
t.Run("entry without backup", func(t *testing.T) {
eventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
Action: "update",
Status: StatusSuccess,
})
if err != nil {
t.Fatalf("CreateEntry: %v", err)
}
rollbackErr := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: eventID})
if rollbackErr == nil || !strings.Contains(rollbackErr.Error(), "no retained backup") {
t.Fatalf("expected no-backup error, got %v", rollbackErr)
}
})
t.Run("pruned backup directory", func(t *testing.T) {
eventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
Action: "update",
Status: StatusSuccess,
BackupPath: filepath.Join(dataDir, "backup-20200101-000000"),
})
if err != nil {
t.Fatalf("CreateEntry: %v", err)
}
rollbackErr := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: eventID})
if rollbackErr == nil || !strings.Contains(rollbackErr.Error(), "no longer exists") {
t.Fatalf("expected missing-backup error, got %v", rollbackErr)
}
})
t.Run("concurrent update in flight", func(t *testing.T) {
backupDir := filepath.Join(dataDir, "backup-20260710-030303")
if err := os.MkdirAll(backupDir, 0755); err != nil {
t.Fatalf("mkdir backup: %v", err)
}
eventID, err := history.CreateEntry(context.Background(), UpdateHistoryEntry{
Action: "update",
Status: StatusSuccess,
BackupPath: backupDir,
})
if err != nil {
t.Fatalf("CreateEntry: %v", err)
}
manager.updateMu.Lock()
manager.updateInFlight = true
manager.updateMu.Unlock()
t.Cleanup(func() {
manager.updateMu.Lock()
manager.updateInFlight = false
manager.updateMu.Unlock()
})
rollbackErr := manager.RollbackToBackup(context.Background(), RollbackRequest{EventID: eventID})
if rollbackErr == nil || !strings.Contains(rollbackErr.Error(), "already in progress") {
t.Fatalf("expected in-progress error, got %v", rollbackErr)
}
})
}