Files
pulse/internal/api/updates_test.go
T
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

980 lines
30 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
)
// MockUpdateManager implements UpdateManager interface for testing
type MockUpdateManager struct {
CheckForUpdatesFunc func(ctx context.Context, channel string) (*updates.UpdateInfo, error)
ApplyUpdateFunc func(ctx context.Context, req updates.ApplyUpdateRequest) error
RollbackToBackupFunc func(ctx context.Context, req updates.RollbackRequest) error
GetStatusFunc func() updates.UpdateStatus
GetSSECachedStatusFunc func() (updates.UpdateStatus, time.Time)
AddSSEClientFunc func(w http.ResponseWriter, clientID string) *updates.SSEClient
RemoveSSEClientFunc func(clientID string)
}
func (m *MockUpdateManager) CheckForUpdatesWithChannel(ctx context.Context, channel string) (*updates.UpdateInfo, error) {
if m.CheckForUpdatesFunc != nil {
return m.CheckForUpdatesFunc(ctx, channel)
}
return nil, nil
}
func (m *MockUpdateManager) ApplyUpdate(ctx context.Context, req updates.ApplyUpdateRequest) error {
if m.ApplyUpdateFunc != nil {
return m.ApplyUpdateFunc(ctx, req)
}
return nil
}
func (m *MockUpdateManager) RollbackToBackup(ctx context.Context, req updates.RollbackRequest) error {
if m.RollbackToBackupFunc != nil {
return m.RollbackToBackupFunc(ctx, req)
}
return nil
}
func (m *MockUpdateManager) GetStatus() updates.UpdateStatus {
if m.GetStatusFunc != nil {
return m.GetStatusFunc()
}
return updates.UpdateStatus{}
}
func (m *MockUpdateManager) GetSSECachedStatus() (updates.UpdateStatus, time.Time) {
if m.GetSSECachedStatusFunc != nil {
return m.GetSSECachedStatusFunc()
}
return updates.UpdateStatus{}, time.Time{}
}
func (m *MockUpdateManager) AddSSEClient(w http.ResponseWriter, clientID string) *updates.SSEClient {
if m.AddSSEClientFunc != nil {
return m.AddSSEClientFunc(w, clientID)
}
return nil
}
func (m *MockUpdateManager) RemoveSSEClient(clientID string) {
if m.RemoveSSEClientFunc != nil {
m.RemoveSSEClientFunc(clientID)
}
}
func TestHandleCheckUpdates_Success(t *testing.T) {
mockManager := &MockUpdateManager{
CheckForUpdatesFunc: func(ctx context.Context, channel string) (*updates.UpdateInfo, error) {
return &updates.UpdateInfo{
Available: true,
LatestVersion: "v1.2.3",
CurrentVersion: "v1.0.0",
}, nil
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/updates/check", nil)
h.HandleCheckUpdates(w, r)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var info updates.UpdateInfo
_ = json.NewDecoder(w.Body).Decode(&info)
if !info.Available || info.LatestVersion != "v1.2.3" {
t.Errorf("Unexpected response: %+v", info)
}
}
func TestHandleCheckUpdates_Error(t *testing.T) {
mockManager := &MockUpdateManager{
CheckForUpdatesFunc: func(ctx context.Context, channel string) (*updates.UpdateInfo, error) {
return nil, errors.New("github down")
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/updates/check", nil)
h.HandleCheckUpdates(w, r)
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", w.Code)
}
}
func TestHandleCheckUpdates_InvalidChannel(t *testing.T) {
mockManager := &MockUpdateManager{
CheckForUpdatesFunc: func(ctx context.Context, channel string) (*updates.UpdateInfo, error) {
t.Fatalf("CheckForUpdatesWithChannel should not be called for invalid channels")
return nil, nil
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/updates/check?channel=beta", nil)
h.HandleCheckUpdates(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
func TestHandleApplyUpdate_Success(t *testing.T) {
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
if req.DownloadURL != "http://example.com/update.tar.gz" {
return errors.New("wrong url")
}
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
body := `{"downloadUrl": "http://example.com/update.tar.gz"}`
r := httptest.NewRequest("POST", "/updates/apply", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Note: ApplyUpdate runs in background, so we just check it was accepted
}
func TestHandleApplyUpdate_PassesAllowDowngrade(t *testing.T) {
received := make(chan updates.ApplyUpdateRequest, 1)
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
received <- req
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
body := `{"downloadUrl": "https://example.com/update.tar.gz", "allowDowngrade": true}`
r := httptest.NewRequest(http.MethodPost, "/updates/apply", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
select {
case req := <-received:
if !req.AllowDowngrade {
t.Fatal("expected AllowDowngrade to be passed through to the manager request")
}
case <-time.After(time.Second):
t.Fatal("ApplyUpdate was not invoked")
}
}
func TestHandleRollbackUpdate_Success(t *testing.T) {
received := make(chan updates.RollbackRequest, 1)
mockManager := &MockUpdateManager{
RollbackToBackupFunc: func(ctx context.Context, req updates.RollbackRequest) error {
received <- req
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
body := `{"eventId": "01JZEXAMPLE"}`
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(body))
h.HandleRollbackUpdate(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp["status"] != "started" {
t.Fatalf("expected started status, got %q", resp["status"])
}
select {
case req := <-received:
if req.EventID != "01JZEXAMPLE" {
t.Fatalf("expected event ID to be passed through, got %q", req.EventID)
}
if req.InitiatedBy != updates.InitiatedByUser || req.InitiatedVia != updates.InitiatedViaUI {
t.Fatalf("expected user/ui initiation, got %q/%q", req.InitiatedBy, req.InitiatedVia)
}
case <-time.After(time.Second):
t.Fatal("RollbackToBackup was not invoked")
}
}
func TestHandleRollbackUpdate_InvalidRequests(t *testing.T) {
mockManager := &MockUpdateManager{
RollbackToBackupFunc: func(ctx context.Context, req updates.RollbackRequest) error {
t.Fatal("RollbackToBackup should not be called for invalid requests")
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
t.Run("rejects non-POST", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/updates/rollback", nil)
h.HandleRollbackUpdate(w, r)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("Expected status %d, got %d", http.StatusMethodNotAllowed, w.Code)
}
})
t.Run("rejects unknown fields", func(t *testing.T) {
w := httptest.NewRecorder()
body := `{"eventId":"01JZEXAMPLE","unexpected":true}`
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(body))
h.HandleRollbackUpdate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
t.Run("rejects missing event ID", func(t *testing.T) {
w := httptest.NewRecorder()
body := `{"eventId":" "}`
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(body))
h.HandleRollbackUpdate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
}
func TestHandleRollbackUpdate_ErrorMapping(t *testing.T) {
cases := []struct {
name string
managerErr error
wantCode int
}{
{"entry not found", errors.New("update history entry not found: x"), http.StatusNotFound},
{"backup pruned", errors.New("no retained backup for this update; it may have been pruned by backup retention"), http.StatusConflict},
{"backup missing on disk", errors.New("backup no longer exists on disk: /var/lib/pulse/backup-1"), http.StatusConflict},
{"unmanaged path", errors.New("backup path is not a managed update backup: /etc/passwd"), http.StatusConflict},
{"docker", errors.New("rollback cannot be applied in Docker environment"), http.StatusConflict},
{"in progress", errors.New("update already in progress"), http.StatusConflict},
{"unexpected", errors.New("disk exploded"), http.StatusInternalServerError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
mockManager := &MockUpdateManager{
RollbackToBackupFunc: func(ctx context.Context, req updates.RollbackRequest) error {
return tc.managerErr
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/updates/rollback", strings.NewReader(`{"eventId":"01JZEXAMPLE"}`))
h.HandleRollbackUpdate(w, r)
if w.Code != tc.wantCode {
t.Fatalf("expected status %d, got %d: %s", tc.wantCode, w.Code, w.Body.String())
}
})
}
}
func TestHandleApplyUpdate_AlreadyInProgress(t *testing.T) {
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
return errors.New("update already in progress")
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
body := `{"downloadUrl": "https://example.com/update.tar.gz"}`
r := httptest.NewRequest(http.MethodPost, "/updates/apply", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("Expected status %d, got %d", http.StatusConflict, w.Code)
}
if !strings.Contains(strings.ToLower(w.Body.String()), "already in progress") {
t.Fatalf("expected conflict message, got %q", w.Body.String())
}
}
func TestHandleApplyUpdate_InvalidRequestBody(t *testing.T) {
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
t.Fatalf("ApplyUpdate should not be called for invalid request bodies")
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
t.Run("rejects unknown fields", func(t *testing.T) {
w := httptest.NewRecorder()
body := `{"downloadUrl":"https://example.com/update.tar.gz","unexpected":"x"}`
r := httptest.NewRequest(http.MethodPost, "/updates/apply", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
t.Run("rejects trailing json", func(t *testing.T) {
w := httptest.NewRecorder()
body := `{"downloadUrl":"https://example.com/update.tar.gz"} {"extra":true}`
r := httptest.NewRequest(http.MethodPost, "/updates/apply", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
})
t.Run("rejects whitespace download url", func(t *testing.T) {
w := httptest.NewRecorder()
body := `{"downloadUrl":" "}`
r := httptest.NewRequest(http.MethodPost, "/updates/apply", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
if !strings.Contains(strings.ToLower(w.Body.String()), "download url is required") {
t.Fatalf("expected missing URL error, got %q", w.Body.String())
}
})
}
func TestHandleApplyUpdate_InvalidChannel(t *testing.T) {
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
t.Fatalf("ApplyUpdate should not be called for invalid channels")
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
body := `{"downloadUrl":"https://github.com/rcourtman/Pulse/releases/download/v6.0.0/pulse-v6.0.0-linux-amd64.tar.gz"}`
r := httptest.NewRequest(http.MethodPost, "/updates/apply?channel=beta", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
func TestHandleApplyUpdate_StableRejectsPrereleaseTarget(t *testing.T) {
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
return errors.New("stable channel cannot install prerelease builds")
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
body := `{"downloadUrl":"https://github.com/rcourtman/Pulse/releases/download/v6.0.0-rc.1/pulse-v6.0.0-rc.1-linux-amd64.tar.gz"}`
r := httptest.NewRequest(http.MethodPost, "/updates/apply?channel=stable", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("Expected status %d, got %d", http.StatusConflict, w.Code)
}
if !strings.Contains(strings.ToLower(w.Body.String()), "prerelease") {
t.Fatalf("expected prerelease conflict message, got %q", w.Body.String())
}
}
func TestHandleApplyUpdate_BlocksWhenReadinessBlocked(t *testing.T) {
setMockModeForTest(t, true)
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
applyCalled := false
mockManager := &MockUpdateManager{
ApplyUpdateFunc: func(ctx context.Context, req updates.ApplyUpdateRequest) error {
applyCalled = true
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
h.now = func() time.Time { return now }
h.SetUpdateReadinessSources(
func(context.Context) *config.Config {
return &config.Config{}
},
func(context.Context) []models.Host {
return []models.Host{{
ID: "host-1",
Hostname: "host-1",
LastSeen: now.Add(-30 * time.Second),
AgentVersion: "5.1.23",
IsLegacy: true,
}}
},
)
h.registry.Register("mock", &mockUpdater{
prepareFunc: func(ctx context.Context, req updates.UpdateRequest) (*updates.UpdatePlan, error) {
if req.Version != "v6.0.0" {
t.Fatalf("PrepareUpdate version = %q, want v6.0.0", req.Version)
}
return &updates.UpdatePlan{
CanAutoUpdate: true,
RollbackSupport: true,
Instructions: []string{"install"},
}, nil
},
})
w := httptest.NewRecorder()
body := `{"downloadUrl":"https://github.com/rcourtman/Pulse/releases/download/v6.0.0/pulse-v6.0.0-linux-amd64.tar.gz"}`
r := httptest.NewRequest(http.MethodPost, "/updates/apply?channel=stable", strings.NewReader(body))
h.HandleApplyUpdate(w, r)
if w.Code != http.StatusConflict {
t.Fatalf("Expected status %d, got %d: %s", http.StatusConflict, w.Code, w.Body.String())
}
if applyCalled {
t.Fatal("ApplyUpdate should not be called when readiness is blocked")
}
if !strings.Contains(w.Body.String(), "Resolve 1 blocked upgrade check") {
t.Fatalf("expected readiness summary, got %q", w.Body.String())
}
}
func TestHandleUpdateStatus_Fresh(t *testing.T) {
mockManager := &MockUpdateManager{
GetStatusFunc: func() updates.UpdateStatus {
return updates.UpdateStatus{Status: "idle"}
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/updates/status", nil)
h.HandleUpdateStatus(w, r)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
if w.Header().Get("X-Cache") != "MISS" {
t.Error("Expected X-Cache: MISS")
}
}
func TestHandleUpdateStatus_Cached(t *testing.T) {
mockManager := &MockUpdateManager{
GetStatusFunc: func() updates.UpdateStatus {
return updates.UpdateStatus{Status: "fresh"}
},
GetSSECachedStatusFunc: func() (updates.UpdateStatus, time.Time) {
return updates.UpdateStatus{Status: "cached"}, time.Now()
},
}
h := NewUpdateHandlers(mockManager, nil)
// First request - MISS
r1 := httptest.NewRequest("GET", "/updates/status", nil)
r1.RemoteAddr = "1.2.3.4:1234"
w1 := httptest.NewRecorder()
h.HandleUpdateStatus(w1, r1)
if w1.Header().Get("X-Cache") != "MISS" {
t.Error("Expected first request to be MISS")
}
// Second request immediately after - HIT
r2 := httptest.NewRequest("GET", "/updates/status", nil)
r2.RemoteAddr = "1.2.3.4:5678" // Same IP
w2 := httptest.NewRecorder()
h.HandleUpdateStatus(w2, r2)
if w2.Header().Get("X-Cache") != "HIT" {
t.Error("Expected second request to be HIT")
}
var status updates.UpdateStatus
_ = json.NewDecoder(w2.Body).Decode(&status)
if status.Status != "cached" {
t.Errorf("Expected cached status, got %s", status.Status)
}
}
func TestHandleUpdateStream(t *testing.T) {
mockManager := &MockUpdateManager{
AddSSEClientFunc: func(w http.ResponseWriter, clientID string) *updates.SSEClient {
return &updates.SSEClient{
ID: clientID,
Done: make(chan bool),
Flusher: w.(http.Flusher),
}
},
RemoveSSEClientFunc: func(clientID string) {},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/updates/stream", nil)
// Create context that we can cancel to simulate client disconnect
ctx, cancel := context.WithCancel(context.Background())
r = r.WithContext(ctx)
// This blocks until context cancel, so run in goroutine
done := make(chan bool)
go func() {
h.HandleUpdateStream(w, r)
close(done)
}()
// Give it a moment to establish
time.Sleep(50 * time.Millisecond)
// Cancel/Disconnect
cancel()
select {
case <-done:
// Success
case <-time.After(1 * time.Second):
t.Fatal("HandleUpdateStream didn't return after context cancel")
}
if w.Header().Get("Content-Type") != "text/event-stream" {
t.Error("Expected text/event-stream content type")
}
}
func TestHandleUpdateStream_StreamingNotSupported(t *testing.T) {
mockManager := &MockUpdateManager{
AddSSEClientFunc: func(w http.ResponseWriter, clientID string) *updates.SSEClient {
return nil
},
}
h := NewUpdateHandlers(mockManager, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/updates/stream", nil)
h.HandleUpdateStream(w, r)
if w.Code != http.StatusInternalServerError {
t.Fatalf("Expected status 500, got %d", w.Code)
}
}
func TestHandleListUpdateHistory(t *testing.T) {
tmp := t.TempDir()
history, _ := updates.NewUpdateHistory(tmp)
// Pre-populate history
if _, err := history.CreateEntry(context.Background(), updates.UpdateHistoryEntry{
EventID: "test-entry",
Status: updates.StatusSuccess,
VersionTo: "v1.2.3",
}); err != nil {
t.Fatalf("Failed to create history entry: %v", err)
}
h := NewUpdateHandlers(&MockUpdateManager{}, history)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/updates/history", nil)
h.HandleListUpdateHistory(w, r)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var entries []updates.UpdateHistoryEntry
_ = json.NewDecoder(w.Body).Decode(&entries)
if len(entries) != 1 {
t.Errorf("Expected 1 entry, got %d", len(entries))
}
}
func TestHandleListUpdateHistory_ErrorPaths(t *testing.T) {
h := NewUpdateHandlers(&MockUpdateManager{}, nil)
t.Run("method not allowed", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/updates/history", nil)
h.HandleListUpdateHistory(w, r)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("Expected status 405, got %d", w.Code)
}
})
t.Run("history unavailable", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/updates/history", nil)
h.HandleListUpdateHistory(w, r)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("Expected status 503, got %d", w.Code)
}
})
}
func TestHandleGetUpdateHistoryEntry(t *testing.T) {
tmp := t.TempDir()
history, _ := updates.NewUpdateHistory(tmp)
// Pre-populate history
if _, err := history.CreateEntry(context.Background(), updates.UpdateHistoryEntry{
EventID: "test-entry-1",
Status: updates.StatusSuccess,
VersionTo: "v1.2.3",
}); err != nil {
t.Fatalf("Failed to create history entry: %v", err)
}
h := NewUpdateHandlers(&MockUpdateManager{}, history)
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/updates/history/entry?id=test-entry-1", nil)
h.HandleGetUpdateHistoryEntry(w, r)
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
var entry updates.UpdateHistoryEntry
_ = json.NewDecoder(w.Body).Decode(&entry)
if entry.EventID != "test-entry-1" {
t.Errorf("Expected EventID test-entry-1, got %s", entry.EventID)
}
}
func TestHandleGetUpdateHistoryEntry_ErrorPaths(t *testing.T) {
tmp := t.TempDir()
history, err := updates.NewUpdateHistory(tmp)
if err != nil {
t.Fatalf("failed to create update history: %v", err)
}
h := NewUpdateHandlers(&MockUpdateManager{}, history)
t.Run("method not allowed", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPost, "/updates/history/entry?id=test-entry", nil)
h.HandleGetUpdateHistoryEntry(w, r)
if w.Code != http.StatusMethodNotAllowed {
t.Fatalf("Expected status 405, got %d", w.Code)
}
})
t.Run("missing id", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/updates/history/entry", nil)
h.HandleGetUpdateHistoryEntry(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status 400, got %d", w.Code)
}
})
t.Run("entry not found", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/updates/history/entry?id=does-not-exist", nil)
h.HandleGetUpdateHistoryEntry(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("Expected status 404, got %d", w.Code)
}
})
t.Run("history unavailable", func(t *testing.T) {
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/updates/history/entry?id=test-entry", nil)
NewUpdateHandlers(&MockUpdateManager{}, nil).HandleGetUpdateHistoryEntry(w, r)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("Expected status 503, got %d", w.Code)
}
})
}
func TestDoCleanupRateLimits(t *testing.T) {
h := NewUpdateHandlers(nil, nil)
now := time.Now()
h.statusRateLimits["old"] = now.Add(-15 * time.Minute)
h.statusRateLimits["new"] = now.Add(-5 * time.Minute)
h.doCleanupRateLimits(now)
if _, ok := h.statusRateLimits["old"]; ok {
t.Error("Old entry not cleaned up")
}
if _, ok := h.statusRateLimits["new"]; !ok {
t.Error("New entry cleaned up prematurely")
}
}
type mockUpdater struct {
updates.Updater
prepareFunc func(ctx context.Context, req updates.UpdateRequest) (*updates.UpdatePlan, error)
}
func (m *mockUpdater) PrepareUpdate(ctx context.Context, req updates.UpdateRequest) (*updates.UpdatePlan, error) {
return m.prepareFunc(ctx, req)
}
func TestHandleGetUpdatePlan(t *testing.T) {
// Set mock mode so GetCurrentVersion returns "mock"
setMockModeForTest(t, true)
mu := &mockUpdater{
prepareFunc: func(ctx context.Context, req updates.UpdateRequest) (*updates.UpdatePlan, error) {
return &updates.UpdatePlan{
Instructions: []string{"test"},
}, nil
},
}
h := NewUpdateHandlers(nil, nil)
h.registry.Register("mock", mu)
// Test missing version
r := httptest.NewRequest("GET", "/api/updates/plan", nil)
w := httptest.NewRecorder()
h.HandleGetUpdatePlan(w, r)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d", w.Code)
}
// Test success
r = httptest.NewRequest("GET", "/api/updates/plan?version=v1.2.3", nil)
w = httptest.NewRecorder()
h.HandleGetUpdatePlan(w, r)
if w.Code != http.StatusOK {
t.Errorf("Expected 200, got %d: %s", w.Code, w.Body.String())
}
var plan updates.UpdatePlan
_ = json.NewDecoder(w.Body).Decode(&plan)
if len(plan.Instructions) != 1 {
t.Errorf("Expected 1 instruction, got %d", len(plan.Instructions))
}
if plan.Prerequisites == nil {
t.Fatal("expected prerequisites to normalize to an empty slice")
}
}
// TestUpdateHandlersRegistryProvidesPlans pins the registry wiring behind
// GET /api/updates/plan: every registered deployment adapter is a plan
// provider that yields usable instructions. The adapters own no apply or
// rollback machinery; the real apply runs through the manager pipeline.
func TestUpdateHandlersRegistryProvidesPlans(t *testing.T) {
h := NewUpdateHandlers(nil, nil)
cases := []struct {
deploymentType string
supportsApply bool
}{
{"systemd", true},
{"proxmoxve", true},
{"docker", false},
{"aur", false},
}
for _, tc := range cases {
t.Run(tc.deploymentType, func(t *testing.T) {
updater, err := h.registry.Get(tc.deploymentType)
if err != nil {
t.Fatalf("registry.Get(%q): %v", tc.deploymentType, err)
}
if updater.SupportsApply() != tc.supportsApply {
t.Errorf("SupportsApply() = %v, want %v", updater.SupportsApply(), tc.supportsApply)
}
plan, err := updater.PrepareUpdate(context.Background(), updates.UpdateRequest{Version: "v6.0.5"})
if err != nil {
t.Fatalf("PrepareUpdate: %v", err)
}
if len(plan.Instructions) == 0 {
t.Errorf("expected plan instructions for %q", tc.deploymentType)
}
if plan.CanAutoUpdate != tc.supportsApply {
t.Errorf("CanAutoUpdate = %v, want %v", plan.CanAutoUpdate, tc.supportsApply)
}
})
}
}
func TestHandleGetUpdatePlan_IncludesUpgradeReadiness(t *testing.T) {
setMockModeForTest(t, true)
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
rawToken := "abcdef1234567890abcdef1234567890"
record, err := config.NewAPITokenRecord(rawToken, "agent", []string{config.ScopeAgentReport})
if err != nil {
t.Fatalf("NewAPITokenRecord: %v", err)
}
h := NewUpdateHandlers(nil, nil)
h.now = func() time.Time { return now }
h.SetUpdateReadinessSources(
func(context.Context) *config.Config {
return &config.Config{APITokens: []config.APITokenRecord{*record}}
},
func(context.Context) []models.Host {
return []models.Host{{
ID: "host-1",
Hostname: "host-1",
LastSeen: now.Add(-30 * time.Second),
AgentVersion: "5.1.23",
IsLegacy: true,
}}
},
)
h.registry.Register("mock", &mockUpdater{
prepareFunc: func(ctx context.Context, req updates.UpdateRequest) (*updates.UpdatePlan, error) {
return &updates.UpdatePlan{
CanAutoUpdate: true,
RollbackSupport: true,
Instructions: []string{"install"},
}, nil
},
})
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/updates/plan?version=v6.0.0", nil)
h.HandleGetUpdatePlan(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var plan updates.UpdatePlan
if err := json.NewDecoder(w.Body).Decode(&plan); err != nil {
t.Fatalf("decode plan: %v", err)
}
if plan.Readiness == nil {
t.Fatal("expected readiness on update plan")
}
if plan.Readiness.Status != "attention" {
t.Fatalf("readiness status = %q, want attention: %#v", plan.Readiness.Status, plan.Readiness)
}
if len(plan.Readiness.Checks) != 4 {
t.Fatalf("readiness checks = %d, want 4", len(plan.Readiness.Checks))
}
if got := plan.Readiness.Checks[2].ID; got != "agent-migration-security" {
t.Fatalf("readiness check[2] id = %q, want agent-migration-security", got)
}
if got := plan.Readiness.Checks[2].Status; got != "warning" {
t.Fatalf("agent migration security status = %q, want warning", got)
}
}
func TestHandleGetUpdatePlan_InvalidChannel(t *testing.T) {
setMockModeForTest(t, true)
h := NewUpdateHandlers(nil, nil)
h.registry.Register("mock", &mockUpdater{
prepareFunc: func(ctx context.Context, req updates.UpdateRequest) (*updates.UpdatePlan, error) {
t.Fatalf("PrepareUpdate should not be called for invalid channels")
return nil, nil
},
})
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/updates/plan?version=v1.2.3&channel=beta", nil)
h.HandleGetUpdatePlan(w, r)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status %d, got %d", http.StatusBadRequest, w.Code)
}
}
func TestHandleGetUpdatePlan_PrepareError(t *testing.T) {
setMockModeForTest(t, true)
h := NewUpdateHandlers(nil, nil)
h.registry.Register("mock", &mockUpdater{
prepareFunc: func(ctx context.Context, req updates.UpdateRequest) (*updates.UpdatePlan, error) {
return nil, errors.New("prepare failed")
},
})
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/updates/plan?version=v1.2.3", nil)
h.HandleGetUpdatePlan(w, r)
if w.Code != http.StatusInternalServerError {
t.Fatalf("Expected status 500, got %d", w.Code)
}
}
func TestHandleGetUpdatePlan_ManualFallback(t *testing.T) {
h := NewUpdateHandlers(nil, nil)
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/updates/plan?version=v6.0.0-rc.1", nil)
h.HandleGetUpdatePlan(w, r)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var plan updates.UpdatePlan
if err := json.NewDecoder(w.Body).Decode(&plan); err != nil {
t.Fatalf("decode plan: %v", err)
}
if plan.CanAutoUpdate {
t.Fatalf("Expected manual fallback plan to disable auto updates")
}
if len(plan.Instructions) == 0 {
t.Fatalf("Expected manual fallback plan to include instructions")
}
if len(plan.Prerequisites) == 0 {
t.Fatalf("Expected manual fallback plan to include prerequisites")
}
}
// TestClassifyApplyUpdateStartError_ProActivation guards the update transport
// contract for the Pro broker path: an unactivated Pro binary's refusal is a
// client-resolvable conflict whose actionable message (activate, or use the
// portal archive path) must reach the UI verbatim, not collapse into the
// generic 500 "Failed to start update".
func TestClassifyApplyUpdateStartError_ProActivation(t *testing.T) {
err := errors.New("Pulse Pro updates need an activated license: activate in Settings → License, or download the archive from https://pulserelay.pro/download.html and run install.sh --archive")
status, msg := classifyApplyUpdateStartError(err)
if status != http.StatusConflict {
t.Fatalf("expected 409 for the unactivated Pro refusal, got %d", status)
}
if !strings.Contains(msg, "activated license") {
t.Fatalf("expected the actionable refusal message to pass through, got %q", msg)
}
}