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, " 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, "Verdict: WATCH") { t.Fatalf("missing verdict badge:\n%s", got) } // Redundant leading Verdict/Confidence lines are trimmed from the triage body. if strings.Contains(got, "

Verdict: watch

") { 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, "
Investigation transcript
") {
		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 
, not rendered as HTML.
	if strings.Contains(got, "
  • ") && strings.Index(got, "
    ") < strings.Index(got, "
  • ") { 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, "
    ") { 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{ "

    Likely causes

    ", "
    • runaway process
    • ", "
    • use top
    ", "

    plain para

    ", } { 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, "bold") { 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) } }