Files
Anand d9b2519b75 Fleet audit fixes: PBS page, shared SSE, safer ops, deep links + comment cleanup
- New PBS Backups page (/pbs): datastore usage, group browser, per-group
  prune, GC with live status, sync/verify jobs with admin-gated run
- Shared single-connection SSE store (lib/sse.ts) feeding NotificationBell
  and the telemetry pill; global offline banner with reconnect invalidation
- Correctness: CIFS storage credentials sent in JSON body; per-row alert
  silencing + unsilence endpoint; graceful stop-and-delete for running
  guests; accent/shadow token fixes for all look presets
- Safer operations: confirms for SDN apply and host upgrade-all; Refresh
  control on every page; 30s polling added to previously-static pages
- Wayfinding: guests deep-linkable via /inventory?focusGuest, scoped
  inventory views, topology guest click-through, Overview links
- Forms/consistency: node storage/bridge pick-lists in CreateGuestDialog,
  backup schedule validation, BulkOperationsPage on shared DataTable,
  progressive pagination on cluster access lists, shared Timestamp and
  chartToneFor helpers, editable alert rules and webhooks
- Security/tests: CSRF, authz-matrix, redaction and PBS test coverage;
  webhook outbox + connection TLS fingerprint migrations (00032/00033,
  sqlite + postgres)
- Comment audit: removed stale, duplicated and orphaned comments; corrected
  inaccurate doc comments (UpdateGuestConfig, InvalidateAll, breakpoint
  references); no behavior changes
2026-09-13 23:29:33 +05:30

472 lines
15 KiB
Go

package notify
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/google/uuid"
"ferrum/internal/config"
"ferrum/internal/events"
"ferrum/internal/secrets"
"ferrum/internal/store"
)
func openTestDB(t *testing.T) *store.DB {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "ferrum.db")
db, err := store.Open(config.DBConfig{Driver: "sqlite", Path: dbPath})
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func testBox(t *testing.T) *secrets.Box {
t.Helper()
box, err := secrets.New("webhook-test-secret")
if err != nil {
t.Fatalf("secrets.New: %v", err)
}
return box
}
func insertSubscription(t *testing.T, db *store.DB, url, secret string, eventTypes []string, active bool) string {
t.Helper()
id := uuid.NewString()
typesJSON, err := json.Marshal(eventTypes)
if err != nil {
t.Fatalf("marshal event types: %v", err)
}
activeCol := 0
if active {
activeCol = 1
}
now := time.Now().UTC().Format(time.RFC3339)
if _, err := db.Exec(`
INSERT INTO webhook_subscriptions (id, name, url, secret, event_types, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, "test sub", url, secret, string(typesJSON), activeCol, now, now,
); err != nil {
t.Fatalf("inserting subscription: %v", err)
}
return id
}
func TestSubscriptionMatches(t *testing.T) {
cases := []struct {
name string
subscribed []string
evtType events.Type
want bool
}{
{"empty filter matches everything", nil, events.TypeAlertTriggered, true},
{"exact match", []string{"alert.triggered", "connection.down"}, events.TypeAlertTriggered, true},
{"no match", []string{"connection.down"}, events.TypeAlertTriggered, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := subscriptionMatches(tc.subscribed, tc.evtType); got != tc.want {
t.Errorf("subscriptionMatches(%v, %v) = %v, want %v", tc.subscribed, tc.evtType, got, tc.want)
}
})
}
}
func TestSignBodyIsDeterministicAndKeyed(t *testing.T) {
body := []byte(`{"hello":"world"}`)
sig1 := signBody("secret-a", body)
sig2 := signBody("secret-a", body)
sig3 := signBody("secret-b", body)
if sig1 != sig2 {
t.Fatal("same secret+body should produce the same signature")
}
if sig1 == sig3 {
t.Fatal("different secrets should produce different signatures")
}
}
func TestWebhookDispatcherDeliversMatchingEventAndSignsBody(t *testing.T) {
var (
gotSignature string
gotEventType string
gotBody []byte
)
received := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
gotBody = body
gotSignature = r.Header.Get(SignatureHeader)
gotEventType = r.Header.Get(EventTypeHeader)
w.WriteHeader(http.StatusOK)
received <- struct{}{}
}))
defer server.Close()
db := openTestDB(t)
secret := "shh-its-a-secret"
insertSubscription(t, db, server.URL, secret, nil, true)
bus := events.New()
d := NewWebhookDispatcher(db, testBox(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go d.Run(ctx, bus)
// Give Run a moment to subscribe before publishing.
time.Sleep(50 * time.Millisecond)
bus.Publish(events.Event{Type: events.TypeAlertTriggered, ResourceID: "qemu/100"})
select {
case <-received:
case <-time.After(3 * time.Second):
t.Fatal("webhook was never delivered")
}
if gotEventType != string(events.TypeAlertTriggered) {
t.Fatalf("event type header = %q, want %q", gotEventType, events.TypeAlertTriggered)
}
wantSig := signBody(secret, gotBody)
if gotSignature != wantSig {
t.Fatalf("signature = %q, want %q (recomputed over received body)", gotSignature, wantSig)
}
// The delivery log write happens after the response is already returned
// to the server handler above, so poll briefly rather than racing it.
deadline := time.Now().Add(2 * time.Second)
var count, success int
for {
err := db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(success), 0) FROM webhook_deliveries`).Scan(&count, &success)
if err != nil {
t.Fatalf("querying webhook_deliveries: %v", err)
}
if count >= 1 || time.Now().After(deadline) {
break
}
time.Sleep(10 * time.Millisecond)
}
if count != 1 || success != 1 {
t.Fatalf("deliveries = %d (success=%d), want 1 (success=1)", count, success)
}
}
func TestWebhookDispatcherSkipsInactiveAndNonMatchingSubscriptions(t *testing.T) {
var hits int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
db := openTestDB(t)
insertSubscription(t, db, server.URL, "s1", nil, false) // inactive
insertSubscription(t, db, server.URL, "s2", []string{"connection.down"}, true) // wrong type
bus := events.New()
d := NewWebhookDispatcher(db, testBox(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go d.Run(ctx, bus)
time.Sleep(50 * time.Millisecond)
bus.Publish(events.Event{Type: events.TypeAlertTriggered})
time.Sleep(300 * time.Millisecond)
if got := atomic.LoadInt32(&hits); got != 0 {
t.Fatalf("expected no deliveries, got %d", got)
}
}
// --- outbox (durable, at-least-once delivery) ---
// A delivered event (receiver 200) must have its outbox row deleted and a
// success entry in the delivery log.
func TestWebhookDeliveryClearsOutboxRowOnSuccess(t *testing.T) {
received := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
received <- struct{}{}
}))
defer server.Close()
db := openTestDB(t)
insertSubscription(t, db, server.URL, "s", nil, true)
bus := events.New()
d := NewWebhookDispatcher(db, testBox(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go d.Run(ctx, bus)
time.Sleep(50 * time.Millisecond)
bus.Publish(events.Event{Type: events.TypeAlertTriggered})
select {
case <-received:
case <-time.After(3 * time.Second):
t.Fatal("webhook was never delivered")
}
waitFor(t, 2*time.Second, "outbox row to be deleted", func() (bool, string) {
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM webhook_outbox`).Scan(&count); err != nil {
return false, err.Error()
}
return count == 0, fmt.Sprintf("outbox rows = %d, want 0", count)
})
var total, success int
if err := db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(success), 0) FROM webhook_deliveries`).Scan(&total, &success); err != nil {
t.Fatalf("querying deliveries: %v", err)
}
if total != 1 || success != 1 {
t.Fatalf("deliveries = %d (success=%d), want 1 (success=1)", total, success)
}
}
// A failing receiver leaves the row queued with its attempt counter and
// next retry time recorded, so the sweep can pick it up after a restart.
func TestWebhookFailureRetainsOutboxRowWithBackoff(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
db := openTestDB(t)
insertSubscription(t, db, server.URL, "s", nil, true)
bus := events.New()
d := NewWebhookDispatcher(db, testBox(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go d.Run(ctx, bus)
time.Sleep(50 * time.Millisecond)
bus.Publish(events.Event{Type: events.TypeAlertTriggered})
// The first failed attempt marks the row immediately (before the retry
// sleep), so this resolves without waiting out the full backoff chain.
waitFor(t, 3*time.Second, "outbox row to record the failed attempt", func() (bool, string) {
var attempts int
var next sql.NullString
err := db.QueryRow(`SELECT attempts, next_attempt_at FROM webhook_outbox`).Scan(&attempts, &next)
if err != nil {
return false, err.Error()
}
if attempts < 1 {
return false, fmt.Sprintf("attempts = %d, want >= 1", attempts)
}
if !next.Valid || next.String == "" {
return false, "next_attempt_at is NULL, want a scheduled retry time"
}
return true, ""
})
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM webhook_outbox`).Scan(&count); err != nil {
t.Fatalf("querying outbox: %v", err)
}
if count != 1 {
t.Fatalf("outbox rows = %d, want 1 (failed delivery must stay queued)", count)
}
}
// sweep() re-attempts a due row — one a previous run (or an exhausted
// in-process retry budget) left queued — and deletes it once the recovered
// receiver accepts the event.
func TestSweepDispatchesDueRowAndDeletesItAfterReceiverRecovers(t *testing.T) {
var down atomic.Bool
down.Store(true)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if down.Load() {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
db := openTestDB(t)
subID := insertSubscription(t, db, server.URL, "s", nil, true)
// Seed the queue the way a failed live attempt (or a crash mid-retry)
// would have: attempts=1 and a next_attempt_at that has already passed.
evt := events.Event{ID: "evt-due", Type: events.TypeAlertTriggered, Timestamp: time.Now().UTC()}
body, err := json.Marshal(evt)
if err != nil {
t.Fatalf("marshaling event: %v", err)
}
now := time.Now().UTC()
past := now.Add(-time.Second).Format(time.RFC3339)
if _, err := db.Exec(`
INSERT INTO webhook_outbox (event_id, subscription_id, event_type, payload, attempts, next_attempt_at, created_at)
VALUES (?, ?, ?, ?, 1, ?, ?)`,
evt.ID, subID, string(evt.Type), string(body), past, now.Format(time.RFC3339)); err != nil {
t.Fatalf("seeding outbox row: %v", err)
}
d := NewWebhookDispatcher(db, testBox(t))
down.Store(false) // receiver recovered
d.sweep(context.Background())
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM webhook_outbox`).Scan(&count); err != nil {
t.Fatalf("querying outbox: %v", err)
}
if count != 0 {
t.Fatalf("outbox rows = %d after sweep, want 0 (due row must be delivered and deleted)", count)
}
var total, success, maxAttempt int
if err := db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(success), 0), COALESCE(MAX(attempt), 0) FROM webhook_deliveries`).Scan(&total, &success, &maxAttempt); err != nil {
t.Fatalf("querying deliveries: %v", err)
}
if total != 1 || success != 1 || maxAttempt != 2 {
t.Fatalf("deliveries = %d (success=%d, attempt=%d), want 1 (success=1, attempt=2)", total, success, maxAttempt)
}
}
// Re-publishing the same event must not duplicate or reset the queue row —
// the composite (event_id, subscription_id) key with ON CONFLICT DO NOTHING
// keeps the original row and its attempt state.
func TestOutboxInsertIsIdempotentPerSubscription(t *testing.T) {
db := openTestDB(t)
subID := insertSubscription(t, db, "http://receiver.invalid/hook", "s", nil, true)
d := NewWebhookDispatcher(db, testBox(t))
ctx := context.Background()
evt := events.Event{ID: "evt-dup", Type: events.TypeAlertTriggered, Timestamp: time.Now().UTC()}
body, err := json.Marshal(evt)
if err != nil {
t.Fatalf("marshaling event: %v", err)
}
if err := d.enqueueOutbox(ctx, subID, evt, body); err != nil {
t.Fatalf("first enqueueOutbox: %v", err)
}
// Simulate a previously-failed attempt, then re-publish the same event:
// the row must keep its attempt state, not be reset to a fresh one.
if _, err := db.Exec(`UPDATE webhook_outbox SET attempts = 3`); err != nil {
t.Fatalf("seeding attempts: %v", err)
}
if err := d.enqueueOutbox(ctx, subID, evt, body); err != nil {
t.Fatalf("duplicate enqueueOutbox: %v", err)
}
var count, attempts int
if err := db.QueryRow(`SELECT COUNT(*), MAX(attempts) FROM webhook_outbox`).Scan(&count, &attempts); err != nil {
t.Fatalf("querying outbox: %v", err)
}
if count != 1 {
t.Fatalf("outbox rows = %d, want 1 (duplicate insert must be a no-op)", count)
}
if attempts != 3 {
t.Fatalf("attempts = %d after duplicate insert, want 3 (row state preserved)", attempts)
}
}
// waitFor polls cond until it reports true or the deadline passes, failing
// with the condition's last explanation. Delivery log/outbox writes race the
// receiver's HTTP response, so polling beats sleeping.
func waitFor(t *testing.T, within time.Duration, what string, cond func() (bool, string)) {
t.Helper()
deadline := time.Now().Add(within)
for {
ok, detail := cond()
if ok {
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s: %s", what, detail)
}
time.Sleep(10 * time.Millisecond)
}
}
func TestSendTestEventDeliversSynchronouslyAndReportsFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
db := openTestDB(t)
d := NewWebhookDispatcher(db, testBox(t))
id := insertSubscription(t, db, server.URL, "s", nil, true)
sub := WebhookSubscription{ID: id, URL: server.URL, Secret: "s"}
if err := d.SendTestEvent(context.Background(), sub); err == nil {
t.Fatal("expected an error for a 500 response")
}
}
// TestWebhookSecretRoundTripAndLegacyPlaintextFallback covers the at-rest
// secret migration: a subscription whose stored secret is encrypted must be
// signed with the decrypted value, and one still holding a plaintext secret
// from before encryption existed must keep working unchanged.
func TestWebhookSecretRoundTripAndLegacyPlaintextFallback(t *testing.T) {
box := testBox(t)
secret := "legacy-plaintext-secret"
enc, err := box.Encrypt(secret)
if err != nil {
t.Fatalf("encrypting secret: %v", err)
}
var (
mu sync.Mutex
bodies []string
sigs []string
received = make(chan struct{}, 2)
)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
mu.Lock()
bodies = append(bodies, string(body))
sigs = append(sigs, r.Header.Get(SignatureHeader))
mu.Unlock()
w.WriteHeader(http.StatusOK)
received <- struct{}{}
}))
defer server.Close()
db := openTestDB(t)
insertSubscription(t, db, server.URL, enc, nil, true) // encrypted at rest
insertSubscription(t, db, server.URL, secret, nil, true) // legacy plaintext row
bus := events.New()
d := NewWebhookDispatcher(db, box)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go d.Run(ctx, bus)
time.Sleep(50 * time.Millisecond)
bus.Publish(events.Event{Type: events.TypeAlertTriggered})
for i := 0; i < 2; i++ {
select {
case <-received:
case <-time.After(3 * time.Second):
t.Fatalf("delivery %d of 2 never arrived", i+1)
}
}
mu.Lock()
defer mu.Unlock()
// One event fanned out to both subscriptions — identical bodies, so both
// signatures must match the PLAINTEXT secret (decrypted, or passed through).
if len(bodies) != 2 {
t.Fatalf("got %d deliveries, want 2", len(bodies))
}
for i, sig := range sigs {
if want := signBody(secret, []byte(bodies[i])); sig != want {
t.Fatalf("delivery %d signature = %q, want %q (signed with the plaintext secret)", i, sig, want)
}
}
}