Files
pulse/internal/api/log_handlers_additional_test.go
T
rcourtman 38b2007134 test(api): make log stream SSE test deterministic via history replay
TestLogHandlers_HandleStreamLogs_SendsEvents raced the handler
goroutine against a broadcaster write and a 10ms sleep. The global
broadcaster is shared with every concurrently running test and drops
messages to full subscriber channels, so under CI load the test's
line could be dropped and the body stayed empty; it never failed
locally because the flood only exists during a full package run on a
slow runner (first CI execution of the Go steps in ~100 runs exposed
it).

Write the line into broadcaster history first and run the handler
synchronously with a pre-cancelled context: Subscribe replays history
before the live select loop, so subscription, SSE formatting, and
context cancellation are all exercised with no goroutine, no sleep,
and no dependence on concurrent traffic. Live-channel delivery stays
covered by the broadcaster's own package tests.
2026-06-11 13:10:16 +01:00

220 lines
6.4 KiB
Go

package api
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
)
type noFlushWriter struct {
header http.Header
code int
body bytes.Buffer
}
func (w *noFlushWriter) Header() http.Header {
if w.header == nil {
w.header = http.Header{}
}
return w.header
}
func (w *noFlushWriter) Write(p []byte) (int, error) {
return w.body.Write(p)
}
func (w *noFlushWriter) WriteHeader(statusCode int) {
w.code = statusCode
}
func TestLogHandlers_HandleStreamLogs_RequiresFlusher(t *testing.T) {
handler := NewLogHandlers(&config.Config{}, nil)
req := httptest.NewRequest(http.MethodGet, "/api/logs/stream", nil)
w := &noFlushWriter{}
handler.HandleStreamLogs(w, req)
if w.code != http.StatusInternalServerError {
t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, w.code)
}
}
func TestLogHandlers_HandleStreamLogs_SendsEvents(t *testing.T) {
handler := NewLogHandlers(&config.Config{}, nil)
// The global broadcaster is shared with every concurrently running test,
// so live-channel delivery cannot be asserted without racing their log
// traffic (full subscriber channels drop messages). Write the line first
// and rely on Subscribe's history replay, which the handler performs
// before its live loop; a pre-cancelled context then makes the handler
// return synchronously after the replay.
_, _ = logging.GetBroadcaster().Write([]byte("test-log-line"))
ctx, cancel := context.WithCancel(context.Background())
cancel()
req := httptest.NewRequest(http.MethodGet, "/api/logs/stream", nil).WithContext(ctx)
rr := httptest.NewRecorder()
handler.HandleStreamLogs(rr, req)
body := rr.Body.String()
if !strings.Contains(body, "data:") {
t.Fatalf("expected SSE data in response, got %q", body)
}
if !strings.Contains(body, "test-log-line") {
t.Fatalf("expected log line in response, got %q", body)
}
}
func TestLogHandlers_HandleDownloadBundle(t *testing.T) {
logFile := filepathWithTemp(t, "pulse.log")
if err := os.WriteFile(logFile, []byte("log-line"), 0600); err != nil {
t.Fatalf("write log file: %v", err)
}
t.Setenv("TEST_SECRET", "super-secret")
cfg := &config.Config{
LogFile: logFile,
AuthPass: "password",
APIToken: "token",
ProxyAuthSecret: "proxy-secret",
PVEInstances: []config.PVEInstance{{Password: "pve-pass", TokenValue: "pve-token"}},
PBSInstances: []config.PBSInstance{{Password: "pbs-pass", TokenValue: "pbs-token"}},
PMGInstances: []config.PMGInstance{{Password: "pmg-pass", TokenValue: "pmg-token"}},
}
handler := NewLogHandlers(cfg, nil)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/logs/bundle", nil)
handler.HandleDownloadBundle(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
zr, err := zip.NewReader(bytes.NewReader(rr.Body.Bytes()), int64(rr.Body.Len()))
if err != nil {
t.Fatalf("zip reader: %v", err)
}
var haveLog, haveSystem bool
var systemInfo []byte
for _, f := range zr.File {
switch f.Name {
case "pulse.log", "pulse-tail.log":
haveLog = true
case "system-info.json":
haveSystem = true
rc, err := f.Open()
if err != nil {
t.Fatalf("open system-info.json: %v", err)
}
defer rc.Close()
systemInfo, _ = io.ReadAll(rc)
}
}
if !haveLog {
t.Fatalf("expected log file in bundle")
}
if !haveSystem {
t.Fatalf("expected system-info.json in bundle")
}
var payload struct {
Config config.Config `json:"Config"`
Env []string `json:"Env"`
}
if err := json.Unmarshal(systemInfo, &payload); err != nil {
t.Fatalf("unmarshal system-info: %v", err)
}
if payload.Config.AuthPass != "[REDACTED]" {
t.Fatalf("expected AuthPass redacted, got %q", payload.Config.AuthPass)
}
if payload.Config.APIToken != "[REDACTED]" {
t.Fatalf("expected APIToken redacted, got %q", payload.Config.APIToken)
}
if payload.Config.ProxyAuthSecret != "[REDACTED]" {
t.Fatalf("expected ProxyAuthSecret redacted, got %q", payload.Config.ProxyAuthSecret)
}
if payload.Config.PVEInstances[0].Password != "[REDACTED]" {
t.Fatalf("expected PVE password redacted, got %q", payload.Config.PVEInstances[0].Password)
}
var redactedEnv bool
for _, env := range payload.Env {
if strings.HasPrefix(env, "TEST_SECRET=") {
redactedEnv = strings.Contains(env, "[REDACTED]")
break
}
}
if !redactedEnv {
t.Fatalf("expected TEST_SECRET to be redacted, got env: %#v", payload.Env)
}
}
func TestLogHandlers_HandleSetLevelAndGetLevel(t *testing.T) {
originalLevel := logging.GetGlobalLevel()
t.Cleanup(func() {
logging.SetGlobalLevel(originalLevel)
})
cfg := &config.Config{}
persistence := config.NewConfigPersistence(t.TempDir())
if err := persistence.SaveSystemSettings(config.SystemSettings{LogLevel: "info"}); err != nil {
t.Fatalf("save system settings: %v", err)
}
handler := NewLogHandlers(cfg, persistence)
req := httptest.NewRequest(http.MethodPost, "/api/logs/level", strings.NewReader("{bad"))
rr := httptest.NewRecorder()
handler.HandleSetLevel(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
}
req = httptest.NewRequest(http.MethodPost, "/api/logs/level", strings.NewReader(`{"level":"invalid"}`))
rr = httptest.NewRecorder()
handler.HandleSetLevel(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rr.Code)
}
req = httptest.NewRequest(http.MethodPost, "/api/logs/level", strings.NewReader(`{"level":"debug"}`))
rr = httptest.NewRecorder()
handler.HandleSetLevel(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
if cfg.LogLevel != "debug" {
t.Fatalf("expected config log level updated, got %q", cfg.LogLevel)
}
req = httptest.NewRequest(http.MethodGet, "/api/logs/level", nil)
rr = httptest.NewRecorder()
handler.HandleGetLevel(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code)
}
if !strings.Contains(rr.Body.String(), "debug") {
t.Fatalf("expected response to include debug level, got %q", rr.Body.String())
}
}
func filepathWithTemp(t *testing.T, name string) string {
t.Helper()
return filepath.Join(t.TempDir(), name)
}