Files
felhom-agent/internal/localapi/debuglogs_test.go
T
admin cb692f8788 v0.83.0: observability pass — always-DEBUG capture ring + GET /debug/logs + heartbeat log-pull + gap-fill sweep
Capture layer: applog.New returns (logger, Ring) — slog fan-out, stderr at the
configured level, ~1000-entry ring fixed at LevelDebug (remote diagnostics
without a config flip). GET /debug/logs (token-authed, ?raw=1) + request-level
DEBUG middleware. Heartbeat log-pull mirrors the report logtail pattern:
envelope log_tail_requested -> next heartbeat carries log_tail (128KB cap,
consume-once, failed-push retry proven). Gap-fill sweep over netverify/
netstorage/netmount/signedjobs/selfupdate/disks/controller-swap/desired/loop.
Red-proofs: ring-at-emit-level FAILs capture test; drain removed FAILs
consume-once; dropped phase line FAILs the S7 log-sequence smoke.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-11 16:24:07 +02:00

131 lines
4.3 KiB
Go

package localapi
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"strings"
"testing"
applog "gitea.dooplex.hu/admin/felhom-agent/internal/log"
)
// newDebugLogServer builds a minimal server with the debug ring wired and one line
// of each level captured (emit level info — the capture-at-info posture).
func newDebugLogServer(t *testing.T) (*Server, *applog.Ring) {
t.Helper()
logger, ring := applog.NewWithWriter(&bytes.Buffer{}, "info", 50)
logger.Debug("netverify: flow detail", "step", "probe")
logger.Info("netmount: ensured network mount", "name", "nas")
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0",
Guests: &fakeGuests{},
Backups: &fakeBackups{},
Store: &fakeStore{},
Storage: fakeStorage{},
Tokens: staticTokens{"A": 8200},
LogRing: ring,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
return srv, ring
}
// GET /debug/logs serves the ring's entries — INCLUDING the DEBUG line captured at
// emit level info (the endpoint exists so the controller's agent tab can show flow
// detail without a config flip). Companion red-proof: unwire LogRing → 503.
func TestDebugLogs_ServesRingEntriesIncludingDebug(t *testing.T) {
srv, _ := newDebugLogServer(t)
w := do(t, srv.Handler(), "GET", "/debug/logs", "A", "")
if w.Code != 200 {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var resp struct {
OK bool `json:"ok"`
Data struct {
Entries []applog.Entry `json:"entries"`
Total int `json:"total"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.OK || resp.Data.Total != 2 || len(resp.Data.Entries) != 2 {
t.Fatalf("resp = %+v, want ok with 2 entries", resp)
}
if resp.Data.Entries[0].Level != "DEBUG" || !strings.Contains(resp.Data.Entries[0].Message, "flow detail") {
t.Errorf("entry 0 = %+v, want the captured DEBUG line", resp.Data.Entries[0])
}
}
// ?raw=1 serves plain text lines (the controller viewer's raw variant).
func TestDebugLogs_RawVariant(t *testing.T) {
srv, _ := newDebugLogServer(t)
w := do(t, srv.Handler(), "GET", "/debug/logs?raw=1", "A", "")
if w.Code != 200 || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") {
t.Fatalf("status=%d content-type=%q", w.Code, w.Header().Get("Content-Type"))
}
body := w.Body.String()
if !strings.Contains(body, "[DEBUG]") || !strings.Contains(body, "ensured network mount") {
t.Errorf("raw body missing lines:\n%s", body)
}
}
// The route is auth-gated like every sibling (no token → 401), and reports "not
// configured" (503) when the ring is not wired — never a panic, never an empty 200.
func TestDebugLogs_AuthAndUnconfigured(t *testing.T) {
srv, _ := newDebugLogServer(t)
if w := do(t, srv.Handler(), "GET", "/debug/logs", "", ""); w.Code != 401 {
t.Errorf("unauthed status = %d, want 401", w.Code)
}
bare, err := NewServer(Options{
ListenAddr: "127.0.0.1:0",
Guests: &fakeGuests{},
Backups: &fakeBackups{},
Store: &fakeStore{},
Storage: fakeStorage{},
Tokens: staticTokens{"A": 8200},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
if w := do(t, bare.Handler(), "GET", "/debug/logs", "A", ""); w.Code != 503 {
t.Errorf("unconfigured status = %d, want 503", w.Code)
}
}
// The request middleware logs method/path/status/duration at DEBUG into the wired
// logger — proven through a ring-backed server logger (the line must land in the ring).
func TestRequestLogging_DebugLineInRing(t *testing.T) {
logger, ring := applog.NewWithWriter(&bytes.Buffer{}, "info", 50)
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0",
Guests: &fakeGuests{},
Backups: &fakeBackups{},
Store: &fakeStore{},
Storage: fakeStorage{},
Tokens: staticTokens{"A": 8200},
LogRing: ring,
Logger: logger,
})
if err != nil {
t.Fatalf("new server: %v", err)
}
do(t, srv.Handler(), "GET", "/storage", "A", "")
entries, _ := ring.Entries(0)
found := false
for _, e := range entries {
if e.Level == "DEBUG" && strings.Contains(e.Message, "local-api: request") &&
strings.Contains(e.Message, "path=/storage") {
found = true
}
}
if !found {
t.Errorf("no request DEBUG line in the ring; entries=%+v", entries)
}
}