Files
Pulse/tests/integration/api/update_flow_test.go
rcourtman d19765e8bc fix: use 12+ char password for security setup test
Password validation requires minimum 12 characters.
2025-12-18 18:10:36 +00:00

296 lines
8.2 KiB
Go

package api_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"strings"
"testing"
"time"
)
type updateInfo struct {
Available bool `json:"available"`
Current string `json:"currentVersion"`
Latest string `json:"latestVersion"`
DownloadURL string `json:"downloadUrl"`
IsPrerelease bool `json:"isPrerelease"`
ReleaseNotes string `json:"releaseNotes"`
ReleaseDate string `json:"releaseDate"`
Warning string `json:"warning"`
}
type updatePlan struct {
CanAutoUpdate bool `json:"canAutoUpdate"`
}
type updateStatus struct {
Status string `json:"status"`
Progress int `json:"progress"`
Message string `json:"message"`
Error string `json:"error"`
UpdatedAt string `json:"updatedAt"`
}
func TestUpdateFlowIntegration(t *testing.T) {
baseURL := strings.TrimRight(os.Getenv("UPDATE_API_BASE_URL"), "/")
if baseURL == "" {
t.Skip("UPDATE_API_BASE_URL not set; skipping integration test")
}
username := getenvDefault("UPDATE_API_USERNAME", "admin")
password := getenvDefault("UPDATE_API_PASSWORD", "AdminPass123!")
bootstrapToken := getenvDefault("PULSE_E2E_BOOTSTRAP_TOKEN", "0123456789abcdef0123456789abcdef0123456789abcdef")
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("failed to create cookie jar: %v", err)
}
client := &http.Client{
Timeout: 15 * time.Second,
Jar: jar,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
}
waitForHealth(t, client, baseURL, 2*time.Minute)
setupCredentials(t, client, baseURL, bootstrapToken, username, password)
login(t, client, baseURL, username, password)
info := fetchUpdateInfo(t, client, baseURL)
if !info.Available {
t.Fatalf("expected update to be available, got %+v", info)
}
if info.DownloadURL == "" {
t.Fatalf("update info missing download URL: %+v", info)
}
plan := fetchUpdatePlan(t, client, baseURL, info.Latest)
if !plan.CanAutoUpdate {
t.Fatalf("expected plan to allow auto update: %+v", plan)
}
applyUpdate(t, client, baseURL, info.DownloadURL)
waitForCompletion(t, client, baseURL, 2*time.Minute)
}
func waitForHealth(t *testing.T, client *http.Client, baseURL string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for {
resp, err := client.Get(baseURL + "/api/health")
if err == nil && resp.StatusCode == http.StatusOK {
resp.Body.Close()
return
}
if resp != nil {
resp.Body.Close()
}
if time.Now().After(deadline) {
t.Fatalf("health check failed: %v", err)
}
time.Sleep(2 * time.Second)
}
}
func setupCredentials(t *testing.T, client *http.Client, baseURL, bootstrapToken, username, password string) {
t.Helper()
// Generate a dummy API token for tests (64 hex chars)
apiToken := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
payload := map[string]interface{}{
"username": username,
"password": password,
"apiToken": apiToken,
"setupToken": bootstrapToken,
}
req, err := http.NewRequest("POST", baseURL+"/api/security/quick-setup", nil)
if err != nil {
t.Fatalf("failed to create setup request: %v", err)
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal setup payload: %v", err)
}
req.Body = io.NopCloser(bytes.NewReader(data))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Setup-Token", bootstrapToken)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("setup request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("security setup failed with status %s: %s", resp.Status, string(body))
}
}
func login(t *testing.T, client *http.Client, baseURL, username, password string) {
t.Helper()
payload := map[string]string{
"username": username,
"password": password,
}
resp := doJSONRequest(t, client, "POST", baseURL+"/api/login", payload)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if client != nil && client.Jar != nil {
clearCookies(client.Jar, resp.Request.URL)
}
t.Fatalf("login failed with status %s", resp.Status)
}
}
func fetchUpdateInfo(t *testing.T, client *http.Client, baseURL string) updateInfo {
t.Helper()
resp := doRequest(t, client, "GET", baseURL+"/api/updates/check", nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("update check failed with status %s", resp.Status)
}
var info updateInfo
decodeJSON(t, resp, &info)
return info
}
func fetchUpdatePlan(t *testing.T, client *http.Client, baseURL, version string) updatePlan {
t.Helper()
endpoint := fmt.Sprintf("%s/api/updates/plan?version=%s", baseURL, url.QueryEscape(version))
resp := doRequest(t, client, "GET", endpoint, nil)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("update plan fetch failed with status %s", resp.Status)
}
var plan updatePlan
decodeJSON(t, resp, &plan)
return plan
}
func applyUpdate(t *testing.T, client *http.Client, baseURL, downloadURL string) {
t.Helper()
payload := map[string]string{"downloadUrl": downloadURL}
resp := doJSONRequest(t, client, "POST", baseURL+"/api/updates/apply", payload)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("apply update failed with status %s: %s", resp.Status, string(body))
}
}
func waitForCompletion(t *testing.T, client *http.Client, baseURL string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
seenStages := make(map[string]struct{})
for {
resp := doRequest(t, client, "GET", baseURL+"/api/updates/status", nil)
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
t.Fatalf("status endpoint returned %s", resp.Status)
}
var status updateStatus
decodeJSON(t, resp, &status)
resp.Body.Close()
seenStages[status.Status] = struct{}{}
if status.Error != "" {
t.Fatalf("update failed: %s (%s)", status.Error, status.Message)
}
if status.Status == "completed" {
if _, ok := seenStages["downloading"]; !ok {
t.Fatalf("expected downloading stage, got %+v", seenStages)
}
if _, ok := seenStages["applying"]; !ok {
t.Fatalf("expected applying stage, got %+v", seenStages)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("update did not complete within %s (last status: %+v)", timeout, status)
}
time.Sleep(100 * time.Millisecond)
}
}
func doJSONRequest(t *testing.T, client *http.Client, method, endpoint string, payload any) *http.Response {
t.Helper()
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal payload: %v", err)
}
return doRequest(t, client, method, endpoint, bytes.NewReader(data), "application/json")
}
func doRequest(t *testing.T, client *http.Client, method, endpoint string, body io.Reader, contentType ...string) *http.Response {
t.Helper()
req, err := http.NewRequest(method, endpoint, body)
if err != nil {
t.Fatalf("failed to create request: %v", err)
}
if len(contentType) > 0 && contentType[0] != "" {
req.Header.Set("Content-Type", contentType[0])
}
if client != nil && client.Jar != nil && methodRequiresCSRF(method) {
if token := csrfTokenForURL(client.Jar, req.URL); token != "" {
req.Header.Set("X-CSRF-Token", token)
} else {
req.Header.Del("X-CSRF-Token")
}
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("request %s %s failed: %v", method, endpoint, err)
}
return resp
}
func decodeJSON(t *testing.T, resp *http.Response, dest any) {
t.Helper()
if err := json.NewDecoder(resp.Body).Decode(dest); err != nil {
t.Fatalf("failed to decode JSON from %s: %v", resp.Request.URL, err)
}
}
func getenvDefault(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func methodRequiresCSRF(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
return false
default:
return true
}
}
func csrfTokenForURL(jar http.CookieJar, target *url.URL) string {
if jar == nil || target == nil {
return ""
}
for _, c := range jar.Cookies(target) {
if c.Name == "pulse_csrf" && c.Value != "" {
return c.Value
}
}
return ""
}
func clearCookies(jar http.CookieJar, target *url.URL) {
if jar == nil || target == nil {
return
}
jar.SetCookies(target, []*http.Cookie{})
}