mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-21 01:53:19 +00:00
d9b2519b75
- 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
472 lines
15 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|