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

169 lines
6.4 KiB
Go

package api
import (
"net/http"
"net/http/httptest"
"testing"
"ferrum/internal/notify"
)
func TestWebhookCRUDRequiresAdmin(t *testing.T) {
e := newTestEnv(t)
admin := e.loginAs(t, "admin", "admin@example.com", "correct horse battery staple", true)
_ = e.do(t, http.MethodPost, "/api/v1/users/", map[string]any{
"username": "user", "email": "user@example.com", "password": "user's own password",
}, admin)
user := e.loginAs(t, "user", "user@example.com", "user's own password", false)
body := map[string]any{"name": "test hook", "url": "https://example.com/hook", "eventTypes": []string{"alert.triggered"}}
if rec := e.do(t, http.MethodPost, "/api/v1/settings/webhooks/", body, user); rec.Code != http.StatusForbidden {
t.Fatalf("non-admin create status = %d, want 403, body %s", rec.Code, rec.Body.String())
}
rec := e.do(t, http.MethodPost, "/api/v1/settings/webhooks/", body, admin)
if rec.Code != http.StatusCreated {
t.Fatalf("admin create status = %d, want 201, body %s", rec.Code, rec.Body.String())
}
created := decode[webhookDTO](t, rec)
if created.Secret == "" {
t.Fatal("expected a generated secret on create")
}
if created.ID == "" {
t.Fatal("expected a generated id")
}
// The list response must never echo the secret back.
listRec := e.get(t, "/api/v1/settings/webhooks/", admin)
if listRec.Code != http.StatusOK {
t.Fatalf("list status = %d, want 200", listRec.Code)
}
list := decode[[]webhookDTO](t, listRec)
if len(list) != 1 {
t.Fatalf("expected 1 webhook, got %d", len(list))
}
if list[0].Secret != "" {
t.Fatal("list response must not include the signing secret")
}
if !list[0].Active {
t.Fatal("expected a newly created webhook to default to active")
}
updateBody := map[string]any{"name": "renamed", "url": "https://example.com/hook2", "eventTypes": []string{}, "active": false}
updRec := e.do(t, http.MethodPut, "/api/v1/settings/webhooks/"+created.ID+"/", updateBody, admin)
if updRec.Code != http.StatusOK {
t.Fatalf("update status = %d, want 200, body %s", updRec.Code, updRec.Body.String())
}
updated := decode[webhookDTO](t, updRec)
if updated.Name != "renamed" || updated.Active {
t.Fatalf("update did not apply: %+v", updated)
}
delRec := e.do(t, http.MethodDelete, "/api/v1/settings/webhooks/"+created.ID+"/", nil, admin)
if delRec.Code != http.StatusOK {
t.Fatalf("delete status = %d, want 200", delRec.Code)
}
if rec := e.get(t, "/api/v1/settings/webhooks/", admin); rec.Code == http.StatusOK {
if got := decode[[]webhookDTO](t, rec); len(got) != 0 {
t.Fatalf("expected 0 webhooks after delete, got %d", len(got))
}
}
if rec := e.do(t, http.MethodDelete, "/api/v1/settings/webhooks/"+created.ID+"/", nil, admin); rec.Code != http.StatusNotFound {
t.Fatalf("re-delete status = %d, want 404", rec.Code)
}
}
func TestCreateWebhookValidatesInput(t *testing.T) {
e := newTestEnv(t)
admin := e.loginAs(t, "admin", "admin@example.com", "correct horse battery staple", true)
cases := []struct {
name string
body map[string]any
}{
{"missing name", map[string]any{"url": "https://example.com"}},
{"missing url", map[string]any{"name": "x"}},
{"bad url scheme", map[string]any{"name": "x", "url": "ftp://example.com"}},
{"unknown event type", map[string]any{"name": "x", "url": "https://example.com", "eventTypes": []string{"not.a.real.type"}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := e.do(t, http.MethodPost, "/api/v1/settings/webhooks/", tc.body, admin)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400, body %s", rec.Code, rec.Body.String())
}
})
}
}
func TestTestWebhookDeliversAndLogsDelivery(t *testing.T) {
var hit bool
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit = true
if r.Header.Get(notify.SignatureHeader) == "" {
t.Error("expected a signature header on the test delivery")
}
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
e := newTestEnv(t)
e.server.SetWebhookDispatcher(notify.NewWebhookDispatcher(e.db, e.server.secrets))
admin := e.loginAs(t, "admin", "admin@example.com", "correct horse battery staple", true)
createRec := e.do(t, http.MethodPost, "/api/v1/settings/webhooks/",
map[string]any{"name": "test", "url": upstream.URL, "eventTypes": []string{}}, admin)
created := decode[webhookDTO](t, createRec)
// The signing secret must be encrypted at rest, while the test delivery
// (below) still signs with the plaintext — and the response above is the
// only time the plaintext is ever returned.
var stored string
if err := e.db.QueryRow(`SELECT secret FROM webhook_subscriptions WHERE id = ?`, created.ID).Scan(&stored); err != nil {
t.Fatalf("reading stored secret: %v", err)
}
if stored == created.Secret {
t.Fatal("webhook signing secret must be encrypted at rest")
}
testRec := e.do(t, http.MethodPost, "/api/v1/settings/webhooks/"+created.ID+"/test", nil, admin)
if testRec.Code != http.StatusOK {
t.Fatalf("test status = %d, want 200, body %s", testRec.Code, testRec.Body.String())
}
result := decode[map[string]any](t, testRec)
if delivered, _ := result["delivered"].(bool); !delivered {
t.Fatalf("expected delivered=true, got %+v", result)
}
if !hit {
t.Fatal("test webhook never reached the upstream server")
}
deliveriesRec := e.get(t, "/api/v1/settings/webhooks/"+created.ID+"/deliveries", admin)
if deliveriesRec.Code != http.StatusOK {
t.Fatalf("deliveries status = %d, want 200", deliveriesRec.Code)
}
deliveries := decode[[]webhookDeliveryDTO](t, deliveriesRec)
if len(deliveries) != 1 {
t.Fatalf("expected 1 delivery logged, got %d", len(deliveries))
}
if !deliveries[0].Success {
t.Fatalf("expected the logged delivery to be marked successful: %+v", deliveries[0])
}
}
func TestTestWebhookWithoutDispatcherIsUnavailable(t *testing.T) {
e := newTestEnv(t) // no SetWebhookDispatcher call — mirrors a boot path that hasn't wired it yet
admin := e.loginAs(t, "admin", "admin@example.com", "correct horse battery staple", true)
createRec := e.do(t, http.MethodPost, "/api/v1/settings/webhooks/",
map[string]any{"name": "test", "url": "https://example.com/hook", "eventTypes": []string{}}, admin)
created := decode[webhookDTO](t, createRec)
rec := e.do(t, http.MethodPost, "/api/v1/settings/webhooks/"+created.ID+"/test", nil, admin)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}