mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-15 00:22:28 +00:00
38b2007134
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.
220 lines
6.4 KiB
Go
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)
|
|
}
|