Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5749c447-6ecd-46bd-946d-21b4d101d084
255 lines
7.9 KiB
Go
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, "< b & 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)
|
|
}
|
|
}
|