Pulse/internal/api/updates_test.go
2026-07-13 18:30:29 +01:00

1042 lines
32 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)
GetReleaseNotesFunc func(ctx context.Context, version string) (*updates.ReleaseNotesInfo, 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) GetReleaseNotes(ctx context.Context, version string) (*updates.ReleaseNotesInfo, error) {
if m.GetReleaseNotesFunc != nil {
return m.GetReleaseNotesFunc(ctx, version)
}
return nil, updates.ErrReleaseNotFound
}
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 TestHandleGetReleaseNotes(t *testing.T) {
publishedAt := time.Date(2026, 7, 13, 12, 0, 0, 0, time.UTC)
mockManager := &MockUpdateManager{
GetReleaseNotesFunc: func(_ context.Context, version string) (*updates.ReleaseNotesInfo, error) {
if version != "6.1.0-rc.1" {
t.Fatalf("expected running version 6.1.0-rc.1, got %q", version)
}
return &updates.ReleaseNotesInfo{
Version: version,
ReleaseNotes: "## Highlights\n- Reviewed actions",
ReleaseDate: publishedAt,
IsPrerelease: true,
}, nil
},
}
h := NewUpdateHandlers(mockManager, nil)
h.getCurrentVersion = func() (*updates.VersionInfo, error) {
return &updates.VersionInfo{Version: "6.1.0-rc.1"}, nil
}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/updates/release-notes", nil)
h.HandleGetReleaseNotes(w, r)
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
var info updates.ReleaseNotesInfo
if err := json.NewDecoder(w.Body).Decode(&info); err != nil {
t.Fatalf("decode release notes response: %v", err)
}
if info.Version != "6.1.0-rc.1" || info.ReleaseNotes == "" || !info.IsPrerelease {
t.Fatalf("unexpected release notes response: %+v", info)
}
if !info.ReleaseDate.Equal(publishedAt) {
t.Fatalf("expected release date %v, got %v", publishedAt, info.ReleaseDate)
}
}
func TestHandleGetReleaseNotesRejectsDevelopmentBuild(t *testing.T) {
h := NewUpdateHandlers(&MockUpdateManager{}, nil)
h.getCurrentVersion = func() (*updates.VersionInfo, error) {
return &updates.VersionInfo{Version: "6.1.0-rc.1+git.1", IsDevelopment: true}, nil
}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodGet, "/api/updates/release-notes", nil)
h.HandleGetReleaseNotes(w, r)
if w.Code != http.StatusNotFound {
t.Fatalf("expected status 404, got %d: %s", w.Code, w.Body.String())
}
}
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)
}
}