mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-11 10:03:34 +00:00
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.
341 lines
11 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|