mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
a0170d3252
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.
980 lines
30 KiB
Go
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)
|
|
}
|
|
}
|