Files
2026-07-30 18:51:25 +02:00

255 lines
7.9 KiB
Go

package server
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"gitea.alexandru.macocian.me/amacocian/jarvis/internal/diagnose"
"gitea.alexandru.macocian.me/amacocian/jarvis/internal/store"
)
const sampleWebhook = `{
"receiver": "jarvis",
"status": "firing",
"groupLabels": {"alertname": "HostHighCPUUsage"},
"externalURL": "https://grafana.example",
"alerts": [{
"status": "firing",
"labels": {"alertname": "HostHighCPUUsage", "severity": "warning", "host_name": "morgott"},
"annotations": {"summary": "CPU on morgott above 70% for 15m"},
"startsAt": "2024-01-01T00:00:00Z",
"fingerprint": "abc123"
}]
}`
func newTestServer(t *testing.T) (*Server, *Worker, *store.Store) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "jarvis.db")
st, err := store.Open(context.Background(), dbPath)
if err != nil {
t.Fatalf("open store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
log := slog.New(slog.DiscardHandler)
w := NewWorker(diagnose.Stub{}, st, log, 8)
cfg := Config{
WebhookToken: "hook-secret",
FeedUser: "feeduser",
FeedPassword: "feedpass",
BaseURL: "https://jarvis.example",
}
return New(cfg, st, w, log), w, st
}
func TestWebhookToFeed(t *testing.T) {
srv, w, _ := newTestServer(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go w.Run(ctx)
h := srv.Handler()
// Missing auth is rejected.
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana", strings.NewReader(sampleWebhook))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauth webhook: got %d want 401", rec.Code)
}
// Correct token is accepted.
req = httptest.NewRequest(http.MethodPost, "/webhook/grafana", strings.NewReader(sampleWebhook))
req.Header.Set("Authorization", "Bearer hook-secret")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("auth webhook: got %d want 202", rec.Code)
}
// The worker processes asynchronously; wait for the entry to land.
waitForEntry(t, srv.store)
// Feed requires basic auth.
req = httptest.NewRequest(http.MethodGet, "/feed.atom", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("unauth feed: got %d want 401", rec.Code)
}
req = httptest.NewRequest(http.MethodGet, "/feed.atom", nil)
req.SetBasicAuth("feeduser", "feedpass")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("auth feed: got %d want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "<feed") || !strings.Contains(body, "HostHighCPUUsage") {
t.Fatalf("feed missing expected content:\n%s", body)
}
if !strings.Contains(body, "CPU on morgott above 70%") {
t.Fatalf("feed missing alert summary:\n%s", body)
}
}
func waitForEntry(t *testing.T, st *store.Store) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
entries, err := st.Recent(context.Background(), 10)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(entries) > 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("timed out waiting for feed entry")
}
func TestSplitDiagnostic(t *testing.T) {
diag := "● run_command (MCP: gssh) · free -h\n└ MemAvailable 10G\n\n" +
"**Verdict**: watch\n**Confidence**: medium\n" +
"**What fired**: memory crossed 90%\n**Findings**:\n- already recovered"
e := store.Entry{Verdict: "watch", Confidence: "medium", Summary: "mem high", Diagnostic: diag}
got := entryHTML(e)
// Badge verdict is present once, up top.
if !strings.Contains(got, "<strong>Verdict: WATCH</strong>") {
t.Fatalf("missing verdict badge:\n%s", got)
}
// Redundant leading Verdict/Confidence lines are trimmed from the triage body.
if strings.Contains(got, "<p><strong>Verdict</strong>: watch</p>") {
t.Errorf("triage body should not repeat the verdict line:\n%s", got)
}
// Triage prose is rendered.
if !strings.Contains(got, "memory crossed 90%") || !strings.Contains(got, "already recovered") {
t.Errorf("triage body missing:\n%s", got)
}
// Transcript is tucked into a collapsible details block, escaped.
if !strings.Contains(got, "<details><summary>Investigation transcript</summary><pre>") {
t.Errorf("transcript details block missing:\n%s", got)
}
if !strings.Contains(got, "run_command (MCP: gssh)") {
t.Errorf("transcript content missing:\n%s", got)
}
// The transcript's markdown bullet must be escaped inside <pre>, not rendered as HTML.
if strings.Contains(got, "<li>") && strings.Index(got, "<details>") < strings.Index(got, "<li>") {
t.Errorf("transcript should be preformatted, not HTML-rendered:\n%s", got)
}
}
func TestSplitDiagnosticNoVerdict(t *testing.T) {
e := store.Entry{Summary: "x", Diagnostic: "just some prose, no verdict line"}
got := entryHTML(e)
if strings.Contains(got, "<details>") {
t.Errorf("no transcript expected when there is no verdict marker:\n%s", got)
}
if !strings.Contains(got, "just some prose") {
t.Errorf("prose missing:\n%s", got)
}
}
func TestMarkdownToHTML(t *testing.T) {
md := "## Likely causes\n- runaway **process**\n- use `top`\n\nplain para"
got := markdownToHTML(md)
for _, want := range []string{
"<h3>Likely causes</h3>",
"<ul><li>runaway <strong>process</strong></li>",
"<li>use <code>top</code></li></ul>",
"<p>plain para</p>",
} {
if !strings.Contains(got, want) {
t.Errorf("markdown output missing %q\ngot: %s", want, got)
}
}
}
func TestInlineEscapes(t *testing.T) {
got := inline("a < b & c **bold**")
if strings.Contains(got, "<b ") || !strings.Contains(got, "&lt; b &amp; c") {
t.Errorf("inline did not escape: %s", got)
}
if !strings.Contains(got, "<strong>bold</strong>") {
t.Errorf("inline bold failed: %s", got)
}
}
const resolvedWebhook = `{
"receiver": "jarvis",
"status": "resolved",
"alerts": [{
"status": "resolved",
"labels": {"alertname": "HostHighCPUUsage", "severity": "warning", "host_name": "morgott"},
"annotations": {"summary": "CPU back to normal"},
"startsAt": "2024-01-01T00:00:00Z",
"fingerprint": "abc123"
}]
}`
func TestWebhookDropsResolved(t *testing.T) {
srv, w, st := newTestServer(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go w.Run(ctx)
h := srv.Handler()
req := httptest.NewRequest(http.MethodPost, "/webhook/grafana", strings.NewReader(resolvedWebhook))
req.Header.Set("Authorization", "Bearer hook-secret")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusAccepted {
t.Fatalf("resolved webhook: got %d want 202", rec.Code)
}
// Resolved alerts are acknowledged but never enriched: no entry lands.
time.Sleep(200 * time.Millisecond)
entries, err := st.Recent(context.Background(), 10)
if err != nil {
t.Fatalf("recent: %v", err)
}
if len(entries) != 0 {
t.Fatalf("resolved alert should not produce an entry, got %d", len(entries))
}
}
func TestFeedDailyWindow(t *testing.T) {
srv, _, st := newTestServer(t)
h := srv.Handler()
// One entry from today, one from two days ago.
today := store.Entry{ID: "today", Title: "TodayAlert", Summary: "today", CreatedAt: time.Now().UTC()}
old := store.Entry{ID: "old", Title: "OldAlert", Summary: "old", CreatedAt: time.Now().UTC().Add(-48 * time.Hour)}
if err := st.Insert(context.Background(), today); err != nil {
t.Fatal(err)
}
if err := st.Insert(context.Background(), old); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/feed.atom", nil)
req.SetBasicAuth("feeduser", "feedpass")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("feed: got %d want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "TodayAlert") {
t.Errorf("feed should include today's entry:\n%s", body)
}
if strings.Contains(body, "OldAlert") {
t.Errorf("feed should exclude entries older than today:\n%s", body)
}
}