mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 20:52:51 +00:00
837a5b8843
Manual checks previously bypassed only the browser cache, so a newly published preview could remain hidden behind a fresh-looking server result. Carry explicit freshness to the provider, retain prior evidence on failure, and omit unknown dates instead of rendering year one.
1080 lines
34 KiB
Go
1080 lines
34 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 {
|
|
CheckOptionsFunc func(context.Context, updates.UpdateCheckOptions) (*updates.UpdateInfo, error)
|
|
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) CheckForUpdatesWithOptions(ctx context.Context, options updates.UpdateCheckOptions) (*updates.UpdateInfo, error) {
|
|
if m.CheckOptionsFunc != nil {
|
|
return m.CheckOptionsFunc(ctx, options)
|
|
}
|
|
channel := options.Channel
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestHandleCheckUpdatesFreshness(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
query string
|
|
status int
|
|
force bool
|
|
}{
|
|
{"", 200, false}, {"?force=true", 200, true}, {"?channel=rc&force=true", 200, true}, {"?force=false", 200, false}, {"?force=maybe", 400, false}, {"?force=true&force=false", 400, false},
|
|
} {
|
|
t.Run(tc.query, func(t *testing.T) {
|
|
called := false
|
|
m := &MockUpdateManager{CheckOptionsFunc: func(_ context.Context, options updates.UpdateCheckOptions) (*updates.UpdateInfo, error) {
|
|
called = true
|
|
if options.Force != tc.force {
|
|
t.Fatalf("force=%v", options.Force)
|
|
}
|
|
if strings.Contains(tc.query, "channel=rc") && options.Channel != "rc" {
|
|
t.Fatalf("channel=%q", options.Channel)
|
|
}
|
|
return &updates.UpdateInfo{}, nil
|
|
}}
|
|
w := httptest.NewRecorder()
|
|
NewUpdateHandlers(m, nil).HandleCheckUpdates(w, httptest.NewRequest("GET", "/updates/check"+tc.query, nil))
|
|
if w.Code != tc.status || called != (tc.status == 200) {
|
|
t.Fatalf("status=%d called=%v", w.Code, called)
|
|
}
|
|
if tc.status == 200 && w.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatal("missing no-store")
|
|
}
|
|
})
|
|
}
|
|
}
|