From 5a0e0fa5c7e12bed6222a7a496c26c29377ed08c Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:53:49 +0200 Subject: [PATCH 1/2] fix: make update alert routing concurrency-safe --- e2e/mock-console.ts | 18 +- e2e/mutation-outcomes.spec.ts | 5 +- e2e/platform-console.spec.ts | 2 +- internal/web/hosts.go | 16 +- internal/web/hosts_test.go | 23 ++ internal/web/notification_handlers.go | 118 +++++++++- ...notification_update_routing_toggle_test.go | 213 ++++++++++++++++++ internal/web/permissions.go | 4 +- internal/web/platform.go | 67 +++++- internal/web/platform_test.go | 5 + internal/web/route_drift_test.go | 1 + internal/web/server.go | 12 +- internal/web/server_helpers_test.go | 1 + src/api.test.ts | 1 + src/api.ts | 2 + src/api.units.test.ts | 2 + src/pages/BaselineHosts.test.tsx | 6 +- src/pages/BaselineHosts.tsx | 21 +- src/pages/Hosts.test.tsx | 5 +- src/pages/Hosts.tsx | 19 +- src/pages/Notifications.test.tsx | 31 ++- src/pages/Notifications.tsx | 18 +- src/pages/platform/PlatformNotifications.tsx | 4 +- src/pages/platform/PlatformPages.test.tsx | 8 +- src/types.ts | 2 +- 25 files changed, 512 insertions(+), 92 deletions(-) create mode 100644 internal/web/notification_update_routing_toggle_test.go diff --git a/e2e/mock-console.ts b/e2e/mock-console.ts index 2ce76e14..0d0952bf 100644 --- a/e2e/mock-console.ts +++ b/e2e/mock-console.ts @@ -316,10 +316,13 @@ export async function mockConsole(page: Page, role: ConsoleRole = 'administrator await json({ entries: [{ id: 2, created_at: '2026-01-01T00:00:02Z', action: 'tenant.created', category: 'platform', actor: { kind: 'platform', username: 'platform' }, detail: 'business unit Retail created', unit: { id: 'unit-retail', name: 'Retail', slug: 'retail' } }], next_before: null }); return } if (parts.length === 1 && parts[0] === 'notifications' && method === 'GET') { await json({ destinations: [{ ...destination, id: 'platform-1', name: 'Platform pager' }], status: { deployment: 0, managed: 1, active: 1, locked: 0, key_state: 'ready' }, update_routing: platformRouting }); return } - if (parts.length === 2 && parts[0] === 'notifications' && parts[1] === 'update-routing' && method === 'PUT') { - const value = body() as { destinations?: string[] } + if (parts.length === 2 && parts[0] === 'notifications' && parts[1] === 'update-routing' && method === 'PATCH') { + const value = body() as { destination_id: string; enabled: boolean; password: string } record('platform-update-routing', value) - platformRouting = { configured: true, destinations: value.destinations ?? [] } + const selected = new Set(platformRouting.configured ? platformRouting.destinations : []) + if (value.enabled) selected.add(value.destination_id) + else selected.delete(value.destination_id) + platformRouting = { configured: true, destinations: [...selected].sort() } await json(platformRouting); return } if (parts.length === 1 && parts[0] === 'status' && method === 'GET') { @@ -382,11 +385,14 @@ export async function mockConsole(page: Page, role: ConsoleRole = 'administrator if (failures.delete('incident-suppress')) { await json(jsonError('incident-suppress'), 422); return } await route.fulfill({ status: 204 }); return } - if (path === '/notifications/update-routing' && method === 'PUT') { - const value = body() as { destinations?: string[] } + if (path === '/notifications/update-routing' && method === 'PATCH') { + const value = body() as { destination_id: string; enabled: boolean; password: string } record('update-routing', value) if (failures.delete('update-routing')) { await json(jsonError('update-routing'), 422); return } - updateRouting = { configured: true, destinations: value.destinations ?? [] } + const selected = new Set(updateRouting.configured ? updateRouting.destinations : ['dest-1']) + if (value.enabled) selected.add(value.destination_id) + else selected.delete(value.destination_id) + updateRouting = { configured: true, destinations: [...selected].sort() } await json(updateRouting); return } if (path === '/notifications/destinations' && method === 'POST') { await mutate('notification-create', destination, 201); return } diff --git a/e2e/mutation-outcomes.spec.ts b/e2e/mutation-outcomes.spec.ts index be6a89fe..4ee61ca3 100644 --- a/e2e/mutation-outcomes.spec.ts +++ b/e2e/mutation-outcomes.spec.ts @@ -58,7 +58,10 @@ test('inline update notification toggles expose failure and success outcomes', a await retryDialog.getByLabel('Account password').fill('fixture-password') await retryDialog.getByRole('button', { name: 'Disable update alerts' }).click() await expect(page.locator('.destination-feedback[role="status"]')).toContainText('Application update alerts disabled for Operations') - expect(controls.payloads['update-routing']).toHaveLength(2) + expect(controls.payloads['update-routing']).toEqual([ + { destination_id: 'dest-1', enabled: false, password: 'fixture-password' }, + { destination_id: 'dest-1', enabled: false, password: 'fixture-password' }, + ]) }) test('scanner profile validation exposes failure and success outcomes', async ({ page }, testInfo) => { diff --git a/e2e/platform-console.spec.ts b/e2e/platform-console.spec.ts index 806e7a73..b9d6c044 100644 --- a/e2e/platform-console.spec.ts +++ b/e2e/platform-console.spec.ts @@ -85,7 +85,7 @@ test('a platform administrator creates a unit, invites its administrator, recove await routing.getByLabel('Account password').fill('fixture-password') await routing.getByRole('button', { name: 'Enable update alerts' }).click() await expect(page.getByRole('checkbox', { name: 'Disable update alerts for Platform pager' })).toBeChecked() - expect(controls.payloads['platform-update-routing']).toEqual([{ destinations: ['platform-1'], password: 'fixture-password' }]) + expect(controls.payloads['platform-update-routing']).toEqual([{ destination_id: 'platform-1', enabled: true, password: 'fixture-password' }]) await page.getByRole('link', { name: 'Audit' }).click() await expect(page.getByText('business unit Retail created')).toBeVisible() diff --git a/internal/web/hosts.go b/internal/web/hosts.go index aee85670..5cdf6c91 100644 --- a/internal/web/hosts.go +++ b/internal/web/hosts.go @@ -26,6 +26,7 @@ func hostStoreNotFound(err error) bool { type hostSummary struct { Address string `json:"address"` AddressFamily string `json:"address_family,omitempty"` + Visibility string `json:"visibility"` SourceTargets []string `json:"source_targets,omitempty"` DNSNames []string `json:"dns_names,omitempty"` Protocols []hostProtocolSummary `json:"protocols,omitempty"` @@ -521,7 +522,7 @@ func summaryForHost(host model.HostObservation, legacy bool) hostSummary { // retained one protocol record per port chunk. Normalize the copy before // calculating counts so legacy rows cannot render duplicate TCP/UDP chips. dedupeHost(&host) - result := hostSummary{Address: host.Address, AddressFamily: host.AddressFamily, SourceTargets: append([]string(nil), host.SourceTargets...), DNSNames: append([]string(nil), host.DNSNames...), Legacy: legacy} + result := hostSummary{Address: host.Address, AddressFamily: host.AddressFamily, Visibility: hostVisibility(host.Address), SourceTargets: append([]string(nil), host.SourceTargets...), DNSNames: append([]string(nil), host.DNSNames...), Legacy: legacy} for _, protocol := range host.Protocols { summary := hostProtocolSummary{Protocol: protocol.Protocol, ScanType: protocol.ScanType, ScannedPorts: protocol.ScannedPorts, ScannedPortCount: protocol.ScannedPortCount, ServiceDetection: protocol.ServiceDetection} for _, port := range protocol.Ports { @@ -540,6 +541,19 @@ func summaryForHost(host model.HostObservation, legacy bool) hostSummary { return result } +// hostVisibility shares the RDAP special-use classification rather than +// duplicating an incomplete list of private address prefixes in the UI. +func hostVisibility(address string) string { + ip := net.ParseIP(address) + if ip == nil { + return "unknown" + } + if rdap.IsPrivateAddress(ip) { + return "non_public" + } + return "public" +} + func (s *Server) latestScannedHosts(ctx context.Context, ts *store.TenantStore) ([]allHostSummary, error) { latest := make(map[string]allHostSummary) archivedJobs := make(map[string]bool) diff --git a/internal/web/hosts_test.go b/internal/web/hosts_test.go index 3964e922..3445fae0 100644 --- a/internal/web/hosts_test.go +++ b/internal/web/hosts_test.go @@ -494,3 +494,26 @@ func TestSummaryForHostCollapsesChunkedProtocolObservations(t *testing.T) { } } } + +func TestSummaryForHostUsesRDAPSpecialUseVisibilityPolicy(t *testing.T) { + tests := []struct { + address string + want string + }{ + {address: "8.8.8.8", want: "public"}, + {address: "2001:4860:4860::8888", want: "public"}, + {address: "100.64.0.1", want: "non_public"}, + {address: "192.0.2.1", want: "non_public"}, + {address: "198.18.0.1", want: "non_public"}, + {address: "::ffff:192.168.1.1", want: "non_public"}, + {address: "2001:db8::1", want: "non_public"}, + {address: "not-an-ip", want: "unknown"}, + } + for _, test := range tests { + t.Run(test.address, func(t *testing.T) { + if got := summaryForHost(model.HostObservation{Address: test.address}, false).Visibility; got != test.want { + t.Fatalf("visibility = %q, want %q", got, test.want) + } + }) + } +} diff --git a/internal/web/notification_handlers.go b/internal/web/notification_handlers.go index 34719d69..98ba5da6 100644 --- a/internal/web/notification_handlers.go +++ b/internal/web/notification_handlers.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/http" + "sort" "strings" "time" @@ -37,19 +38,28 @@ func (s *Server) listNotificationDestinations(w http.ResponseWriter, r *http.Req return } routing := map[string]any{"configured": false, "destinations": []string{}} - if current, err := ts.ApplicationUpdateRouting(r.Context()); err == nil { - // Show legacy deployment digests as current selectors. The console - // drops selectors that are not in the destination list before saving. - destinations, _, _ := notifier.CanonicalSelection(r.Context(), current.Destinations) - if destinations == nil { - destinations = []string{} - } - routing = map[string]any{"configured": current.Configured, "destinations": destinations} - } else { + current, err := ts.ApplicationUpdateRouting(r.Context()) + if err != nil { if s.Log != nil { s.Log.Warn("application update routing state unavailable", "error", err) } + writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) + return + } + // Show legacy deployment digests as current selectors. The console + // drops selectors that are not in the destination list before saving. + destinations, _, err := notifier.CanonicalSelection(r.Context(), current.Destinations) + if err != nil { + if s.Log != nil { + s.Log.Warn("application update routing destinations unavailable", "error", err) + } + writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) + return } + if destinations == nil { + destinations = []string{} + } + routing = map[string]any{"configured": current.Configured, "destinations": destinations} writeJSON(w, http.StatusOK, map[string]any{"destinations": views, "status": status, "update_routing": routing}) } @@ -80,6 +90,8 @@ func (s *Server) updateNotificationRouting(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusBadRequest, "validation_failed", "destinations must be an array", map[string]string{"destinations": "select zero or more configured destinations"}) return } + s.updateRoutingMu.Lock() + defer s.updateRoutingMu.Unlock() // The routing may select only the tenant's own destinations; another // tenant's destination is refused as an unknown one. The store checks // the selection again when it writes it. @@ -107,6 +119,94 @@ func (s *Server) updateNotificationRouting(w http.ResponseWriter, r *http.Reques writeJSON(w, http.StatusOK, map[string]any{"configured": true, "destinations": destinations}) } +type toggleNotificationUpdateRoutingPayload struct { + DestinationID string `json:"destination_id"` + Enabled *bool `json:"enabled"` + Password string `json:"password"` +} + +// toggleNotificationUpdateRouting changes one selector against the latest +// persisted routing, instead of replacing the full list a browser may have +// read before another administrator changed a different destination. +func (s *Server) toggleNotificationUpdateRouting(w http.ResponseWriter, r *http.Request, session store.Session, ts *store.TenantStore) { + w.Header().Set("Cache-Control", "no-store") + var input toggleNotificationUpdateRoutingPayload + if !decodeJSON(w, r, &input) { + return + } + input.DestinationID = strings.TrimSpace(input.DestinationID) + if input.DestinationID == "" || input.Enabled == nil { + writeError(w, http.StatusBadRequest, "validation_failed", "destination_id and enabled are required", map[string]string{"destination_id": "select one configured destination"}) + return + } + if strings.TrimSpace(input.Password) == "" { + writeError(w, http.StatusBadRequest, "password_required", "account password confirmation is required", map[string]string{"password": "password confirmation is required"}) + return + } + if err := s.Auth.ConfirmPasswordForUser(r.Context(), r, session.UserID, input.Password); err != nil { + s.writeNotificationAuthError(w, err) + return + } + s.updateRoutingMu.Lock() + defer s.updateRoutingMu.Unlock() + notifier := s.App.Notifier.Tenant(ts) + if err := notifier.ValidateDestinationSelection(r.Context(), []string{input.DestinationID}); err != nil { + if !writeDestinationSelectionError(w, err) { + writeError(w, http.StatusInternalServerError, "notification", "notification destinations could not be loaded", nil) + } + return + } + current, err := ts.ApplicationUpdateRouting(r.Context()) + if err != nil { + if s.Log != nil { + s.Log.Warn("application update routing state unavailable", "error", err) + } + writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) + return + } + selection := current.Destinations + if !current.Configured { + selection, err = notifier.LegacySelection(r.Context()) + if err != nil { + if s.Log != nil { + s.Log.Warn("legacy application update routing unavailable", "error", err) + } + writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) + return + } + } + selection = toggleUpdateDestination(selection, input.DestinationID, *input.Enabled) + if err := ts.SetApplicationUpdateDestinations(r.Context(), selection, store.AuditEntry{Action: "notifications.update_routing", Detail: "application update notification routing changed", ActorUserID: session.UserID, ActorUsername: session.Username}); err != nil { + if writeDestinationSelectionError(w, err) || s.writeAuditUnavailable(w, err, "notifications.update_routing") { + return + } + writeError(w, http.StatusInternalServerError, "notification", "application update notification routing could not be saved", nil) + return + } + s.broadcastTo(context.WithoutCancel(r.Context()), audienceTenant(ts), map[string]any{"type": "notification.changed"}) + writeJSON(w, http.StatusOK, map[string]any{"configured": true, "destinations": selection}) +} + +func toggleUpdateDestination(selection []string, destinationID string, enabled bool) []string { + selected := make(map[string]struct{}, len(selection)+1) + for _, id := range selection { + if id = strings.TrimSpace(id); id != "" { + selected[id] = struct{}{} + } + } + if enabled { + selected[destinationID] = struct{}{} + } else { + delete(selected, destinationID) + } + result := make([]string, 0, len(selected)) + for id := range selected { + result = append(result, id) + } + sort.Strings(result) + return result +} + // writeDestinationSelectionError answers a routing selection that names a // destination outside the caller's own with a 400 that repeats the // selector, and reports whether err was one. The notifier's check and the diff --git a/internal/web/notification_update_routing_toggle_test.go b/internal/web/notification_update_routing_toggle_test.go new file mode 100644 index 00000000..fa46d76c --- /dev/null +++ b/internal/web/notification_update_routing_toggle_test.go @@ -0,0 +1,213 @@ +package web + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "slices" + "strings" + "sync" + "testing" + + "github.com/crypt0rr/edgewatch/internal/auth" + "github.com/crypt0rr/edgewatch/internal/store" +) + +func createUpdateRoutingTestDestination(t *testing.T, server *Server, admin store.Session, name, path string) string { + t.Helper() + recorder := httptest.NewRecorder() + body := fmt.Sprintf(`{"name":%q,"url":%q,"password":"administrator password"}`, name, "generic://localhost/"+path+"?disabletls=yes") + server.createNotificationDestination(recorder, routingRequest(t, http.MethodPost, "/api/v1/notifications/destinations", body), admin, defaultTenantStore(server)) + if recorder.Code != http.StatusCreated { + t.Fatalf("create destination %q = %d: %s", name, recorder.Code, recorder.Body.String()) + } + var result struct { + ID string `json:"id"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil { + t.Fatal(err) + } + return result.ID +} + +func toggleUpdateRoutingRequest(t *testing.T, server *Server, admin store.Session, destinationID string, enabled bool) *httptest.ResponseRecorder { + t.Helper() + recorder := httptest.NewRecorder() + body := fmt.Sprintf(`{"destination_id":%q,"enabled":%t,"password":"administrator password"}`, destinationID, enabled) + server.toggleNotificationUpdateRouting(recorder, routingRequest(t, http.MethodPatch, "/api/v1/notifications/update-routing", body), admin, defaultTenantStore(server)) + return recorder +} + +func TestNotificationRoutingTogglePreservesConcurrentDestinations(t *testing.T) { + ctx := context.Background() + server, db, admin := newUsersTestServer(t) + defer db.Close() + ts := defaultTenantStore(server) + a := createUpdateRoutingTestDestination(t, server, admin, "First", "first") + b := createUpdateRoutingTestDestination(t, server, admin, "Second", "second") + c := createUpdateRoutingTestDestination(t, server, admin, "Third", "third") + if err := ts.SetApplicationUpdateDestinations(ctx, []string{a}, store.AuditEntry{}); err != nil { + t.Fatal(err) + } + paused := false + if _, err := server.App.Notifier.Tenant(ts).UpdateManagedWithAudit(ctx, a, 1, "First", nil, &paused, store.AuditEntry{Action: "notifications.updated", ActorUserID: admin.UserID, ActorUsername: admin.Username}); err != nil { + t.Fatalf("pause selected destination: %v", err) + } + + results := make([]*httptest.ResponseRecorder, 2) + var wg sync.WaitGroup + for index, id := range []string{b, c} { + wg.Add(1) + go func(index int, id string) { + defer wg.Done() + results[index] = toggleUpdateRoutingRequest(t, server, admin, id, true) + }(index, id) + } + wg.Wait() + for _, result := range results { + if result.Code != http.StatusOK { + t.Fatalf("toggle = %d: %s", result.Code, result.Body.String()) + } + } + state, err := ts.ApplicationUpdateRouting(ctx) + if err != nil { + t.Fatal(err) + } + want := []string{a, b, c} + slices.Sort(want) + if !state.Configured || !slices.Equal(state.Destinations, want) { + t.Fatalf("routing = %#v, want all concurrent changes %v", state, want) + } +} + +func TestNotificationRoutingToggleMaterializesLegacySelection(t *testing.T) { + ctx := context.Background() + server, db, admin := newUsersTestServer(t) + defer db.Close() + ts := defaultTenantStore(server) + a := createUpdateRoutingTestDestination(t, server, admin, "First", "first") + b := createUpdateRoutingTestDestination(t, server, admin, "Second", "second") + state, err := ts.ApplicationUpdateRouting(ctx) + if err != nil || state.Configured { + t.Fatalf("initial routing = %#v, %v; want legacy routing", state, err) + } + legacy, err := server.App.Notifier.Tenant(ts).LegacySelection(ctx) + wantLegacy := []string{a, b} + slices.Sort(wantLegacy) + if err != nil || !slices.Equal(legacy, wantLegacy) { + t.Fatalf("legacy selection = %v, %v", legacy, err) + } + result := toggleUpdateRoutingRequest(t, server, admin, a, false) + if result.Code != http.StatusOK { + t.Fatalf("toggle = %d: %s", result.Code, result.Body.String()) + } + state, err = ts.ApplicationUpdateRouting(ctx) + if err != nil || !state.Configured || !slices.Equal(state.Destinations, []string{b}) { + t.Fatalf("routing after first toggle = %#v, %v; want explicit [%s]", state, err, b) + } + result = toggleUpdateRoutingRequest(t, server, admin, b, false) + if result.Code != http.StatusOK { + t.Fatalf("disable final destination = %d: %s", result.Code, result.Body.String()) + } + state, err = ts.ApplicationUpdateRouting(ctx) + if err != nil || !state.Configured || len(state.Destinations) != 0 { + t.Fatalf("routing after disabling final destination = %#v, %v; want configured empty", state, err) + } +} + +func TestNotificationDestinationListFailsClosedWhenRoutingCannotBeRead(t *testing.T) { + server, db, _ := newUsersTestServer(t) + defer db.Close() + if _, err := db.DB.Exec(`UPDATE tenants SET update_destinations_json='{' WHERE id=?`, store.DefaultTenantID); err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + server.listNotificationDestinations(recorder, routingRequest(t, http.MethodGet, "/api/v1/notifications/destinations", ""), defaultTenantStore(server)) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("destination list status = %d, want 500: %s", recorder.Code, recorder.Body.String()) + } + if strings.Contains(recorder.Body.String(), `"update_routing"`) { + t.Fatalf("failed routing read returned fabricated selection: %s", recorder.Body.String()) + } +} + +func TestPlatformNotificationListFailsClosedWhenRoutingCannotBeRead(t *testing.T) { + f := newPlatformFixture(t) + if _, err := f.db.DB.Exec(`UPDATE application_update_state SET notification_destinations_json='{' WHERE id=1`); err != nil { + t.Fatal(err) + } + recorder := f.call(t, actorPlatform, http.MethodGet, "/platform/notifications", "") + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("platform notification list status = %d, want 500: %s", recorder.Code, recorder.Body.String()) + } + if strings.Contains(recorder.Body.String(), `"update_routing"`) { + t.Fatalf("failed platform routing read returned fabricated selection: %s", recorder.Body.String()) + } +} + +func TestPlatformNotificationRoutingTogglePreservesConcurrentDestinations(t *testing.T) { + f := newPlatformFixture(t) + create := func(name string) string { + t.Helper() + recorder := f.call(t, actorPlatform, http.MethodPost, "/platform/notifications", confirmBody(fmt.Sprintf(`"name":%q,"url":%q`, name, "generic://localhost/"+name+"?disabletls=yes"))) + var response struct { + ID string `json:"id"` + } + expectResponse(t, recorder, http.StatusCreated, "create platform destination", &response) + return response.ID + } + a := create("First") + b := create("Second") + c := create("Third") + ctx := context.Background() + actor := store.AuditEntry{ActorUserID: f.users[actorPlatform].ID, ActorUsername: "platform-root", ActorKind: store.AuditActorPlatform} + if err := f.db.Platform().SetPlatformUpdateDestinations(ctx, []string{a}, actor); err != nil { + t.Fatal(err) + } + paused := false + if _, err := f.server.App.Notifier.Platform(f.db.Platform()).UpdateManagedWithAudit(ctx, a, 1, "First", nil, &paused, actor); err != nil { + t.Fatalf("pause selected platform destination: %v", err) + } + second, err := f.db.GetAccount(ctx, f.secondPlatformAdmin) + if err != nil { + t.Fatal(err) + } + const secondPlatformActor = "second platform administrator" + f.users[secondPlatformActor] = second + f.sessions[secondPlatformActor] = f.signIn(t, secondPlatformActor) + + results := make([]*httptest.ResponseRecorder, 2) + actors := []string{actorPlatform, secondPlatformActor} + var wg sync.WaitGroup + for index, id := range []string{b, c} { + wg.Add(1) + go func(index int, id string) { + defer wg.Done() + body := confirmBody(fmt.Sprintf(`"destination_id":%q,"enabled":true`, id)) + account := f.sessions[actors[index]] + request := httptest.NewRequest(http.MethodPatch, consoleAPIBase+"/platform/notifications/update-routing", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("X-CSRF-Token", account.session.CSRFToken) + request.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: account.raw}) + results[index] = httptest.NewRecorder() + f.server.api(results[index], request) + }(index, id) + } + wg.Wait() + for _, result := range results { + if result.Code != http.StatusOK { + t.Fatalf("platform toggle = %d: %s", result.Code, result.Body.String()) + } + } + state, err := f.db.Platform().GetApplicationUpdateState(ctx) + if err != nil { + t.Fatal(err) + } + want := []string{a, b, c} + slices.Sort(want) + if !state.UpdateNotificationDestinationsConfigured || !slices.Equal(state.UpdateNotificationDestinations, want) { + t.Fatalf("platform routing = %#v, want both concurrent changes %v", state, want) + } +} diff --git a/internal/web/permissions.go b/internal/web/permissions.go index 26972ba1..8f4981c7 100644 --- a/internal/web/permissions.go +++ b/internal/web/permissions.go @@ -72,7 +72,7 @@ func requiredPermission(path, method string) string { return auth.PermissionNotificationOptions } case path == "/notifications/update-routing": - if method == http.MethodPut { + if method == http.MethodPut || method == http.MethodPatch { return auth.PermissionNotificationsManage } case isUsersPath(path): @@ -518,6 +518,7 @@ var apiRoutes = []apiRoute{ {Method: http.MethodPost, Template: "/notifications/test", Permission: auth.PermissionNotificationsManage, Mutates: true, Example: "/notifications/test"}, {Method: http.MethodGet, Template: "/notifications/options", Permission: auth.PermissionNotificationOptions, Example: "/notifications/options"}, {Method: http.MethodPut, Template: "/notifications/update-routing", Permission: auth.PermissionNotificationsManage, Mutates: true, Example: "/notifications/update-routing"}, + {Method: http.MethodPatch, Template: "/notifications/update-routing", Permission: auth.PermissionNotificationsManage, Mutates: true, Example: "/notifications/update-routing"}, {Method: http.MethodGet, Template: "/notifications/destinations", Permission: auth.PermissionNotificationOptions, Example: "/notifications/destinations"}, {Method: http.MethodPost, Template: "/notifications/destinations", Permission: auth.PermissionNotificationsManage, Mutates: true, Example: "/notifications/destinations"}, {Method: http.MethodGet, Template: "/notifications/destinations/{id}", Permission: auth.PermissionNotificationOptions, Example: "/notifications/destinations/destination-1"}, @@ -605,6 +606,7 @@ var apiRoutes = []apiRoute{ {Method: http.MethodGet, Template: "/platform/notifications", Permission: auth.PermissionPlatformNotificationsManage, Example: "/platform/notifications"}, {Method: http.MethodPost, Template: "/platform/notifications", Permission: auth.PermissionPlatformNotificationsManage, Mutates: true, Example: "/platform/notifications"}, {Method: http.MethodPut, Template: "/platform/notifications/update-routing", Permission: auth.PermissionPlatformNotificationsManage, Mutates: true, Example: "/platform/notifications/update-routing"}, + {Method: http.MethodPatch, Template: "/platform/notifications/update-routing", Permission: auth.PermissionPlatformNotificationsManage, Mutates: true, Example: "/platform/notifications/update-routing"}, {Method: http.MethodPatch, Template: "/platform/notifications/{id}", Permission: auth.PermissionPlatformNotificationsManage, Mutates: true, Example: "/platform/notifications/destination-1"}, {Method: http.MethodDelete, Template: "/platform/notifications/{id}", Permission: auth.PermissionPlatformNotificationsManage, Mutates: true, Example: "/platform/notifications/destination-1"}, {Method: http.MethodGet, Template: "/platform/status", Permission: auth.PermissionPlatformStatusRead, Example: "/platform/status"}, diff --git a/internal/web/platform.go b/internal/web/platform.go index 35e7cba3..1f382125 100644 --- a/internal/web/platform.go +++ b/internal/web/platform.go @@ -95,7 +95,7 @@ func requiredPlatformPermission(path, method string) string { case "notifications": switch { case len(parts) == 1 && (method == http.MethodGet || method == http.MethodPost), - len(parts) == 2 && parts[1] == "update-routing" && method == http.MethodPut, + len(parts) == 2 && parts[1] == "update-routing" && (method == http.MethodPut || method == http.MethodPatch), len(parts) == 2 && (method == http.MethodPatch || method == http.MethodDelete): return auth.PermissionPlatformNotificationsManage } @@ -190,6 +190,8 @@ func (s *Server) platformRoute(w http.ResponseWriter, r *http.Request, session s s.createPlatformNotification(w, r, session) case len(parts) == 2 && parts[0] == "notifications" && parts[1] == "update-routing" && r.Method == http.MethodPut: s.updatePlatformNotificationRouting(w, r, session) + case len(parts) == 2 && parts[0] == "notifications" && parts[1] == "update-routing" && r.Method == http.MethodPatch: + s.togglePlatformNotificationRouting(w, r, session) case len(parts) == 2 && parts[0] == "notifications" && r.Method == http.MethodPatch: s.updatePlatformNotification(w, r, session, parts[1]) case len(parts) == 2 && parts[0] == "notifications" && r.Method == http.MethodDelete: @@ -922,16 +924,19 @@ func (s *Server) listPlatformNotifications(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) return } - routing := map[string]any{"configured": false, "destinations": []string{}} - if state, err := s.Store.Platform().GetApplicationUpdateState(r.Context()); err == nil { - destinations := state.UpdateNotificationDestinations - if destinations == nil { - destinations = []string{} + state, err := s.Store.Platform().GetApplicationUpdateState(r.Context()) + if err != nil { + if s.Log != nil { + s.Log.Warn("platform update routing state unavailable", "error", err) } - routing = map[string]any{"configured": state.UpdateNotificationDestinationsConfigured, "destinations": destinations} - } else { - s.Log.Warn("platform update routing state unavailable", "error", err) + writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) + return + } + destinations := state.UpdateNotificationDestinations + if destinations == nil { + destinations = []string{} } + routing := map[string]any{"configured": state.UpdateNotificationDestinationsConfigured, "destinations": destinations} writeJSON(w, http.StatusOK, map[string]any{"destinations": views, "status": status, "update_routing": routing}) } @@ -1027,6 +1032,8 @@ func (s *Server) updatePlatformNotificationRouting(w http.ResponseWriter, r *htt writeError(w, http.StatusBadRequest, "validation_failed", "destinations must be an array", map[string]string{"destinations": "select zero or more platform destinations"}) return } + s.updateRoutingMu.Lock() + defer s.updateRoutingMu.Unlock() // The store checks the selection again when it writes it. platform := s.Store.Platform() if err := s.App.Notifier.Platform(platform).ValidateDestinationSelection(r.Context(), input.Destinations); err != nil { @@ -1048,6 +1055,48 @@ func (s *Server) updatePlatformNotificationRouting(w http.ResponseWriter, r *htt writeJSON(w, http.StatusOK, map[string]any{"configured": true, "destinations": destinations}) } +func (s *Server) togglePlatformNotificationRouting(w http.ResponseWriter, r *http.Request, session store.Session) { + var input toggleNotificationUpdateRoutingPayload + if !decodeJSON(w, r, &input) || !s.confirmNotificationPassword(w, r, session, input.Password) { + return + } + input.DestinationID = strings.TrimSpace(input.DestinationID) + if input.DestinationID == "" || input.Enabled == nil { + writeError(w, http.StatusBadRequest, "validation_failed", "destination_id and enabled are required", map[string]string{"destination_id": "select one configured destination"}) + return + } + s.updateRoutingMu.Lock() + defer s.updateRoutingMu.Unlock() + platform := s.Store.Platform() + notifier := s.App.Notifier.Platform(platform) + if err := notifier.ValidateDestinationSelection(r.Context(), []string{input.DestinationID}); err != nil { + if !writeDestinationSelectionError(w, err) { + writeError(w, http.StatusInternalServerError, "notification", "notification destinations could not be loaded", nil) + } + return + } + state, err := platform.GetApplicationUpdateState(r.Context()) + if err != nil { + if s.Log != nil { + s.Log.Warn("platform update routing state unavailable", "error", err) + } + writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) + return + } + selection := state.UpdateNotificationDestinations + if !state.UpdateNotificationDestinationsConfigured { + selection = nil + } + selection = toggleUpdateDestination(selection, input.DestinationID, *input.Enabled) + if err := platform.SetPlatformUpdateDestinations(r.Context(), selection, platformActorAudit(session, "", "platform update notification routing changed")); err != nil { + if !writeDestinationSelectionError(w, err) { + s.writePlatformError(w, r, err, "platform_notifications.update_routing", "notification destination not found") + } + return + } + writeJSON(w, http.StatusOK, map[string]any{"configured": true, "destinations": selection}) +} + // platformStatus reports the deployment as numbers: the units by state, // their accounts, jobs and stored scans, the platform administrators, the // scan capacity and its use, and the version and update status. diff --git a/internal/web/platform_test.go b/internal/web/platform_test.go index e226c370..e136baf1 100644 --- a/internal/web/platform_test.go +++ b/internal/web/platform_test.go @@ -954,6 +954,11 @@ func TestPlatformNotifications(t *testing.T) { if state, err := f.db.Platform().GetApplicationUpdateState(context.Background()); err != nil || len(state.UpdateNotificationDestinations) != 1 { t.Fatalf("stored platform routing = %+v, %v", state.UpdateNotificationDestinations, err) } + toggled := f.call(t, actorPlatform, http.MethodPatch, "/platform/notifications/update-routing", confirmBody(`"destination_id":"`+view.ID+`","enabled":false`)) + expectResponse(t, toggled, http.StatusOK, "disable one update destination", &routed) + if !routed.Configured || len(routed.Destinations) != 0 { + t.Fatalf("platform routing after toggle = %+v, want configured empty", routed) + } destinationPath := "/platform/notifications/" + view.ID expectError(t, f.call(t, actorPlatform, http.MethodPatch, destinationPath, confirmBody(`"name":"renamed"`)), http.StatusBadRequest, "revision_required", "update without a revision") diff --git a/internal/web/route_drift_test.go b/internal/web/route_drift_test.go index 0cd5a6e7..2b787689 100644 --- a/internal/web/route_drift_test.go +++ b/internal/web/route_drift_test.go @@ -1029,6 +1029,7 @@ func TestRouteInventoryDriftDetectsRemovedEntries(t *testing.T) { "PATCH /platform/units/{id}/capacity", "DELETE /platform/units/{id}/accounts/{uid}/sessions", "PUT /platform/notifications/update-routing", + "PATCH /platform/notifications/update-routing", "GET " + publicAPIBase + "/dashboard", } { t.Run(removed, func(t *testing.T) { diff --git a/internal/web/server.go b/internal/web/server.go index 1e0a1d6a..b7081d91 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -57,9 +57,13 @@ type Server struct { now func() time.Time testMu sync.Mutex testLast map[string]time.Time - publicMu sync.Mutex - publicHits map[string][]time.Time - publicCacheMu sync.Mutex + // updateRoutingMu serializes read/modify/write changes to update-alert + // routing. The routing API exposes a per-destination toggle, so two admins + // changing different destinations cannot overwrite one another. + updateRoutingMu sync.Mutex + publicMu sync.Mutex + publicHits map[string][]time.Time + publicCacheMu sync.Mutex // publicPageCache caches the default tenant's page, which the legacy // public URLs serve, and publicPages the page of any other tenant, by // tenant ID. A request reads and fills only the cache of its public @@ -588,6 +592,8 @@ func (s *Server) api(w http.ResponseWriter, r *http.Request) { s.listNotificationDestinations(w, r, ts) case path == "/notifications/update-routing" && r.Method == http.MethodPut: s.updateNotificationRouting(w, r, session, ts) + case path == "/notifications/update-routing" && r.Method == http.MethodPatch: + s.toggleNotificationUpdateRouting(w, r, session, ts) case path == "/notifications/destinations" && r.Method == http.MethodPost: s.createNotificationDestination(w, r, session, ts) case strings.HasPrefix(path, "/notifications/destinations/"): diff --git a/internal/web/server_helpers_test.go b/internal/web/server_helpers_test.go index 4617c5f1..eae4074c 100644 --- a/internal/web/server_helpers_test.go +++ b/internal/web/server_helpers_test.go @@ -96,6 +96,7 @@ func TestRequiredPermissionAndMutationMatrix(t *testing.T) { {"/users/id/password-reset", http.MethodPost, "users.manage"}, {"/notifications/destinations/id/test", http.MethodPost, "notifications.manage"}, {"/notifications/update-routing", http.MethodPut, "notifications.manage"}, + {"/notifications/update-routing", http.MethodPatch, "notifications.manage"}, {"/notifications/update-routing", http.MethodGet, auth.PermissionDenied}, {"/scans/id/hosts/198.51.100.1", http.MethodGet, "hosts.read"}, {"/scans/id/hosts/198.51.100.1/rdap", http.MethodGet, "hosts.read"}, diff --git a/src/api.test.ts b/src/api.test.ts index 1727e23c..4e80365e 100644 --- a/src/api.test.ts +++ b/src/api.test.ts @@ -428,6 +428,7 @@ describe('API route helpers', () => { await apiRoutes.testNotificationDestination('destination/1') await apiRoutes.deleteNotificationDestination('destination/1', 2, 'password') await apiRoutes.updateNotificationRouting(['destination/1'], 'password') + await apiRoutes.toggleNotificationUpdateAlert('destination/1', false, 'password') await apiRoutes.scannerCapabilities() await apiRoutes.listScannerProfiles(true) await apiRoutes.getScannerProfile('profile/1') diff --git a/src/api.ts b/src/api.ts index 82d25240..2d69b102 100644 --- a/src/api.ts +++ b/src/api.ts @@ -261,6 +261,7 @@ export const listEvents = (offset = 0, limit = 20, jobId?: string) => api<{ even export const notificationTest = () => api<{ sent: number }>('/notifications/test', { method: 'POST' }) export const listNotificationDestinations = () => api('/notifications/destinations') export const updateNotificationRouting = (destinations: string[], password: string) => api('/notifications/update-routing', { method: 'PUT', body: JSON.stringify({ destinations, password }) }) +export const toggleNotificationUpdateAlert = (destinationID: string, enabled: boolean, password: string) => api('/notifications/update-routing', { method: 'PATCH', body: JSON.stringify({ destination_id: destinationID, enabled, password }) }) export const getNotificationDestination = (id: string) => api(`/notifications/destinations/${encodeURIComponent(id)}`) export const createNotificationDestination = (name: string, url: string, password: string, enabled = true) => api('/notifications/destinations', { method: 'POST', body: JSON.stringify({ name, url, password, enabled }) }) export const updateNotificationDestination = (id: string, revision: number, name: string, password: string, options: { url?: string; enabled?: boolean } = {}) => api(`/notifications/destinations/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ name, revision, password, ...options }) }) @@ -380,6 +381,7 @@ export const createPlatformNotification = (name: string, url: string, password: export const updatePlatformNotification = (id: string, revision: number, name: string, password: string, options: { url?: string; enabled?: boolean } = {}) => api(`/platform/notifications/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify({ name, revision, password, ...options }) }) export const deletePlatformNotification = (id: string, revision: number, password: string) => api(`/platform/notifications/${encodeURIComponent(id)}`, { method: 'DELETE', body: JSON.stringify({ revision, password }) }) export const updatePlatformNotificationRouting = (destinations: string[], password: string) => api('/platform/notifications/update-routing', { method: 'PUT', body: JSON.stringify({ destinations, password }) }) +export const togglePlatformNotificationUpdateAlert = (destinationID: string, enabled: boolean, password: string) => api('/platform/notifications/update-routing', { method: 'PATCH', body: JSON.stringify({ destination_id: destinationID, enabled, password }) }) export const platformStatus = () => api('/platform/status') // Both audit views are paged newest first by keyset: pass the previous page's diff --git a/src/api.units.test.ts b/src/api.units.test.ts index 4cf34844..f91033b7 100644 --- a/src/api.units.test.ts +++ b/src/api.units.test.ts @@ -36,6 +36,7 @@ describe('business units API contract', () => { await apiRoutes.updatePlatformNotification('dest/1', 5, 'Ops', 'pw', { enabled: false }) await apiRoutes.deletePlatformNotification('dest/1', 5, 'pw') await apiRoutes.updatePlatformNotificationRouting(['dest-1'], 'pw') + await apiRoutes.togglePlatformNotificationUpdateAlert('dest-1', true, 'pw') await apiRoutes.platformStatus() const calls = fetchMock.mock.calls.map(([url, init]) => `${init?.method ?? 'GET'} ${String(url)}`) expect(calls).toEqual([ @@ -64,6 +65,7 @@ describe('business units API contract', () => { 'PATCH /api/v1/platform/notifications/dest%2F1', 'DELETE /api/v1/platform/notifications/dest%2F1', 'PUT /api/v1/platform/notifications/update-routing', + 'PATCH /api/v1/platform/notifications/update-routing', 'GET /api/v1/platform/status', ]) const body = (index: number) => JSON.parse(String(fetchMock.mock.calls[index][1]?.body)) diff --git a/src/pages/BaselineHosts.test.tsx b/src/pages/BaselineHosts.test.tsx index 761a68de..66a3bba6 100644 --- a/src/pages/BaselineHosts.test.tsx +++ b/src/pages/BaselineHosts.test.tsx @@ -106,8 +106,8 @@ describe('baseline host explorer', () => { it('resets pagination for every filter and renders legacy/private host states', async () => { const hosts = [ - { address: '10.0.0.1', source_targets: [], protocols: [{ protocol: 'tcp', open_ports: 0, open_filtered_ports: 0 }], open_ports: 0, open_filtered_ports: 0, has_open_ports: false }, - { address: '2001:db8::1', address_family: 'IPv6', source_targets: ['dns.example'], protocols: [{ protocol: 'udp', open_ports: 1, open_filtered_ports: 2 }], open_ports: 1, open_filtered_ports: 2, has_open_ports: true, legacy: true }, + { address: '10.0.0.1', visibility: 'non_public', source_targets: [], protocols: [{ protocol: 'tcp', open_ports: 0, open_filtered_ports: 0 }], open_ports: 0, open_filtered_ports: 0, has_open_ports: false }, + { address: '2001:db8::1', address_family: 'IPv6', visibility: 'non_public', source_targets: ['dns.example'], protocols: [{ protocol: 'udp', open_ports: 1, open_filtered_ports: 2 }], open_ports: 1, open_filtered_ports: 2, has_open_ports: true, legacy: true }, ] as never vi.mocked(baselineHosts).mockImplementation(async (_job, filters) => ({ ...detailed, @@ -117,7 +117,7 @@ describe('baseline host explorer', () => { })) await renderPage() expect(container.textContent).toContain('Older scan details') - expect(container.textContent).toContain('Private') + expect(container.textContent).toContain('Non-public') expect(container.textContent).toContain('Legacy detail') expect(container.textContent).toContain('2 open|filtered') diff --git a/src/pages/BaselineHosts.tsx b/src/pages/BaselineHosts.tsx index a1c29144..f5f43509 100644 --- a/src/pages/BaselineHosts.tsx +++ b/src/pages/BaselineHosts.tsx @@ -12,25 +12,16 @@ function addressKind(address: string) { return 'IPv4' } -function visibility(address: string) { - const value = address.toLowerCase() - if (value === 'localhost') return 'Private' - if (value.includes(':')) { - // Host addresses are normalized by the API, so these prefixes cover the - // private, link-local, multicast, loopback, and unspecified IPv6 ranges. - if (value === '::' || value === '::1' || value.startsWith('fc') || value.startsWith('fd') || value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb') || value.startsWith('ff')) return 'Private' - return 'Public' - } - const octets = value.split('.').map(Number) - if (octets.length !== 4 || octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) return 'Public' - const [first, second] = octets - if (first === 0 || first === 10 || first === 127 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) || (first === 169 && second === 254) || (first === 100 && second >= 64 && second <= 127) || (first >= 224 && first <= 255)) return 'Private' - return 'Public' +function visibilityLabel(visibility: HostSummary['visibility']) { + if (visibility === 'public') return 'Public' + if (visibility === 'non_public') return 'Non-public' + return 'Unknown' } function HostRow({ host, jobID }: { host: HostSummary; jobID: string }) { + const visibility = visibilityLabel(host.visibility) return - {host.address}{host.address_family ?? addressKind(host.address)}{visibility(host.address)}{host.legacy && Legacy detail} + {host.address}{host.address_family ?? addressKind(host.address)}{visibility}{host.legacy && Legacy detail} {host.source_targets?.join(', ') || 'Configured target'} {host.protocols?.map(protocol => {protocol.protocol.toUpperCase()} {protocol.open_ports} open{protocol.open_filtered_ports ? ` · ${protocol.open_filtered_ports} open|filtered` : ''})} {host.open_ports + host.open_filtered_ports} positive ports diff --git a/src/pages/Hosts.test.tsx b/src/pages/Hosts.test.tsx index 34bdd6bf..e102b92c 100644 --- a/src/pages/Hosts.test.tsx +++ b/src/pages/Hosts.test.tsx @@ -15,8 +15,8 @@ vi.mock('../api', () => ({ listHosts: vi.fn() })) const response: GlobalHostsResponse = { hosts: [ - { address: '198.51.100.10', address_family: 'IPv4', source_targets: ['router.example'], protocols: [{ protocol: 'tcp', scanned_ports: '22,443', scanned_port_count: 2, service_detection: false, open_ports: 1, open_filtered_ports: 0 }], open_ports: 1, open_filtered_ports: 0, has_open_ports: true, job_id: 'job-1', job: 'production', scan_id: 'scan-1', scanned_at: '2026-09-12T07:00:00Z', data_quality: 'detailed' }, - { address: 'fd00::1', address_family: 'IPv6', dns_names: ['internal.example'], protocols: [{ protocol: 'udp', scanned_ports: '53', scanned_port_count: 1, service_detection: true, open_ports: 0, open_filtered_ports: 1 }], open_ports: 0, open_filtered_ports: 1, has_open_ports: true, job_id: 'job-2', job: 'retired', scan_id: 'scan-2', scanned_at: '2026-09-11T07:00:00Z', data_quality: 'legacy', archived: true, legacy: true }, + { address: '198.51.100.10', address_family: 'IPv4', visibility: 'non_public', source_targets: ['router.example'], protocols: [{ protocol: 'tcp', scanned_ports: '22,443', scanned_port_count: 2, service_detection: false, open_ports: 1, open_filtered_ports: 0 }], open_ports: 1, open_filtered_ports: 0, has_open_ports: true, job_id: 'job-1', job: 'production', scan_id: 'scan-1', scanned_at: '2026-09-12T07:00:00Z', data_quality: 'detailed' }, + { address: 'fd00::1', address_family: 'IPv6', visibility: 'non_public', dns_names: ['internal.example'], protocols: [{ protocol: 'udp', scanned_ports: '53', scanned_port_count: 1, service_detection: true, open_ports: 0, open_filtered_ports: 1 }], open_ports: 0, open_filtered_ports: 1, has_open_ports: true, job_id: 'job-2', job: 'retired', scan_id: 'scan-2', scanned_at: '2026-09-11T07:00:00Z', data_quality: 'legacy', archived: true, legacy: true }, ], pagination: { limit: 1, offset: 0, total: 2, has_more: true, next_offset: 1 }, } @@ -62,6 +62,7 @@ describe('global hosts explorer', () => { expect(container.textContent).toContain('Archived jobs') expect(container.textContent).toContain('198.51.100.10') expect(container.textContent).toContain('IPv6') + expect(container.textContent).toContain('Non-public') expect(container.textContent).toContain('Legacy detail') expect(container.querySelector('a[href="/scans/scan-1/hosts/198.51.100.10"]')).toBeTruthy() expect(container.querySelector('a[href="/scans/scan-2/hosts/fd00%3A%3A1"]')).toBeTruthy() diff --git a/src/pages/Hosts.tsx b/src/pages/Hosts.tsx index dcfb945f..884c9f16 100644 --- a/src/pages/Hosts.tsx +++ b/src/pages/Hosts.tsx @@ -13,25 +13,18 @@ function addressKind(address: string) { return address.includes(':') ? 'IPv6' : 'IPv4' } -function visibility(address: string) { - const value = address.toLowerCase() - if (value === 'localhost') return 'Private' - if (value.includes(':')) { - if (value === '::' || value === '::1' || value.startsWith('fc') || value.startsWith('fd') || value.startsWith('fe8') || value.startsWith('fe9') || value.startsWith('fea') || value.startsWith('feb') || value.startsWith('ff')) return 'Private' - return 'Public' - } - const octets = value.split('.').map(Number) - if (octets.length !== 4 || octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) return 'Public' - const [first, second] = octets - if (first === 0 || first === 10 || first === 127 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) || (first === 169 && second === 254) || (first === 100 && second >= 64 && second <= 127) || (first >= 224 && first <= 255)) return 'Private' - return 'Public' +function visibilityLabel(visibility: GlobalHostSummary['visibility']) { + if (visibility === 'public') return 'Public' + if (visibility === 'non_public') return 'Non-public' + return 'Unknown' } function HostRow({ host }: { host: GlobalHostSummary }) { const source = host.source_targets?.length ? host.source_targets.join(', ') : host.dns_names?.join(', ') const scannedAt = host.scanned_at ? formatDateTime(host.scanned_at) : 'Unknown time' + const visibility = visibilityLabel(host.visibility) return - {host.address}{host.address_family ?? addressKind(host.address)}{visibility(host.address)}{host.legacy && Legacy detail} + {host.address}{host.address_family ?? addressKind(host.address)}{visibility}{host.legacy && Legacy detail} {host.job || 'Legacy scan'}{scannedAt}{source || 'Configured target'} {host.protocols?.map(protocol => {protocol.protocol.toUpperCase()} {protocol.open_ports} open{protocol.open_filtered_ports ? ` · ${protocol.open_filtered_ports} open|filtered` : ''})} {host.open_ports + host.open_filtered_ports} positive ports diff --git a/src/pages/Notifications.test.tsx b/src/pages/Notifications.test.tsx index 9ff2bcb6..288581af 100644 --- a/src/pages/Notifications.test.tsx +++ b/src/pages/Notifications.test.tsx @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { getSession, listNotificationDestinations, - updateNotificationRouting, + toggleNotificationUpdateAlert, } from '../api' import { Notifications } from './Notifications' import { defaultUnitScope } from '../test/test-utils' @@ -22,7 +22,7 @@ vi.mock('../api', () => ({ listNotificationDestinations: vi.fn(), testNotificationDestination: vi.fn(), updateNotificationDestination: vi.fn(), - updateNotificationRouting: vi.fn(), + toggleNotificationUpdateAlert: vi.fn(), })) ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -60,7 +60,7 @@ describe('notification update-alert routing', () => { queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) vi.mocked(getSession).mockResolvedValue({ role: 'administrator', user_id: 'admin', username: 'admin', permissions: ['notifications.manage'], csrf_token: '', totp_enabled: false, password_requirements: { minimum_length: 12 }, ...defaultUnitScope }) vi.mocked(listNotificationDestinations).mockResolvedValue(response(false, [])) - vi.mocked(updateNotificationRouting).mockResolvedValue({ configured: true, destinations: [] }) + vi.mocked(toggleNotificationUpdateAlert).mockResolvedValue({ configured: true, destinations: [] }) }) afterEach(() => { @@ -96,6 +96,17 @@ describe('notification update-alert routing', () => { expect(container.querySelector('input[aria-label="Enable update alerts for Backup"]')).toBeTruthy() }) + it('does not render writable update routing when destination state is unavailable', async () => { + vi.mocked(listNotificationDestinations).mockRejectedValueOnce(new Error('routing unavailable')) + await act(async () => { + root.render() + await Promise.resolve() + }) + await vi.waitFor(() => expect(container.textContent).toContain('Could not load notification destinations'), { timeout: 1000 }) + expect(container.querySelector('.notification-update-toggle')).toBeNull() + expect(toggleNotificationUpdateAlert).not.toHaveBeenCalled() + }) + it('leaves the toggle unchanged when password confirmation is cancelled', async () => { vi.mocked(listNotificationDestinations).mockResolvedValue(response(true, ['dest-1'])) await renderPage() @@ -106,12 +117,12 @@ describe('notification update-alert routing', () => { act(() => (dialog.querySelector('button[type="button"]') as HTMLButtonElement).click()) expect(first.checked).toBe(true) - expect(updateNotificationRouting).not.toHaveBeenCalled() + expect(toggleNotificationUpdateAlert).not.toHaveBeenCalled() }) it('confirms each toggle, rolls back failed saves, and disables other toggles while pending', async () => { vi.mocked(listNotificationDestinations).mockResolvedValue(response(true, ['dest-1'])) - vi.mocked(updateNotificationRouting).mockRejectedValueOnce(new Error('server refused')) + vi.mocked(toggleNotificationUpdateAlert).mockRejectedValueOnce(new Error('server refused')) await renderPage() const first = container.querySelector('input[aria-label="Disable update alerts for Operations"]') as HTMLInputElement @@ -143,8 +154,8 @@ describe('notification update-alert routing', () => { }) await vi.waitFor(() => expect(container.querySelector('[role="status"]')?.textContent).toContain('Application update alerts disabled for Operations'), { timeout: 1000 }) expect(first.checked).toBe(false) - expect(updateNotificationRouting).toHaveBeenNthCalledWith(1, [], 'fixture-password') - expect(updateNotificationRouting).toHaveBeenNthCalledWith(2, [], 'fixture-password') + expect(toggleNotificationUpdateAlert).toHaveBeenNthCalledWith(1, 'dest-1', false, 'fixture-password') + expect(toggleNotificationUpdateAlert).toHaveBeenNthCalledWith(2, 'dest-1', false, 'fixture-password') }) describe('with routing changed in another session', () => { @@ -172,7 +183,7 @@ describe('notification update-alert routing', () => { beforeEach(() => { vi.mocked(listNotificationDestinations).mockResolvedValue(routed(['dest-1'])) - vi.mocked(updateNotificationRouting).mockImplementation(async selected => ({ configured: true, destinations: selected })) + vi.mocked(toggleNotificationUpdateAlert).mockImplementation(async (id, enabled) => ({ configured: true, destinations: enabled ? [id] : [] })) }) it('shows the refetched routing and toggles only the chosen destination', async () => { @@ -185,7 +196,7 @@ describe('notification update-alert routing', () => { act(() => (container.querySelector('input[aria-label="Enable update alerts for Pager"]') as HTMLInputElement).click()) await confirm('fixture-password') - await vi.waitFor(() => expect(updateNotificationRouting).toHaveBeenCalledWith(['dest-1', 'dest-2', 'dest-3'], 'fixture-password'), { timeout: 1000 }) + await vi.waitFor(() => expect(toggleNotificationUpdateAlert).toHaveBeenCalledWith('dest-3', true, 'fixture-password'), { timeout: 1000 }) }) it('applies a toggle to routing that changed while the password prompt was open', async () => { @@ -195,7 +206,7 @@ describe('notification update-alert routing', () => { vi.mocked(listNotificationDestinations).mockResolvedValue(routed(['dest-1', 'dest-2'])) await act(async () => { await queryClient.invalidateQueries({ queryKey: ['notifications'] }) }) await confirm('fixture-password') - await vi.waitFor(() => expect(updateNotificationRouting).toHaveBeenCalledWith(['dest-1', 'dest-2', 'dest-3'], 'fixture-password'), { timeout: 1000 }) + await vi.waitFor(() => expect(toggleNotificationUpdateAlert).toHaveBeenCalledWith('dest-3', true, 'fixture-password'), { timeout: 1000 }) }) }) }) diff --git a/src/pages/Notifications.tsx b/src/pages/Notifications.tsx index 41238979..34a5da60 100644 --- a/src/pages/Notifications.tsx +++ b/src/pages/Notifications.tsx @@ -10,8 +10,8 @@ import { type NotificationDestinationsResponse, type NotificationUpdateRouting, testNotificationDestination, + toggleNotificationUpdateAlert, updateNotificationDestination, - updateNotificationRouting, getSession, } from '../api' import { ActionDialog } from '../components/ActionDialog' @@ -47,7 +47,7 @@ export type NotificationScope = { remove: (id: string, revision: number, password: string) => Promise /** Sends a test message; the platform's destinations have none. */ test?: (id: string) => Promise - updateRouting: (destinations: string[], password: string) => Promise + toggleRouting: (destinationID: string, enabled: boolean, password: string) => Promise /** Whether routing that was never saved sends update alerts to every enabled destination. */ routingDefaultsToEnabled: boolean /** Whether the page reports the import of config.yaml notification URLs. */ @@ -66,7 +66,7 @@ const unitNotifications: NotificationScope = { update: (id, revision, name, password, options) => updateNotificationDestination(id, revision, name, password, options), remove: (id, revision, password) => deleteNotificationDestination(id, revision, password), test: id => testNotificationDestination(id), - updateRouting: (destinations, password) => updateNotificationRouting(destinations, password), + toggleRouting: (destinationID, enabled, password) => toggleNotificationUpdateAlert(destinationID, enabled, password), routingDefaultsToEnabled: true, configImport: true, eyebrow: 'Delivery', @@ -253,16 +253,12 @@ export function NotificationsView({ scope, canManage }: { scope: NotificationSco clearRowFeedback(destination.id) const confirmation = await askPassword(`Confirm update alerts for ${destination.name}`, `Enter your account password to ${checked ? 'send' : 'stop sending'} release and upgrade alerts through this destination.`, checked ? 'Enable update alerts' : 'Disable update alerts') if (confirmation === null) return - // The request replaces the whole routing list. Build it from the latest - // fetched routing, which may include changes saved in another session - // while the password prompt was open, and change only this destination. - const selected = selectedUpdateDestinationIds(client.getQueryData(scope.queryKey) ?? destinations.data, scope.routingDefaultsToEnabled) - const next = checked - ? [...new Set([...selected, destination.id])] - : selected.filter(id => id !== destination.id) setBusy(`update-routing:${destination.id}`) try { - const result = await scope.updateRouting(next, confirmation) + // This endpoint toggles just one selector against the latest persisted + // state, so a change made in another session while the prompt was open + // is preserved. + const result = await scope.toggleRouting(destination.id, checked, confirmation) client.setQueryData(scope.queryKey, current => current && { ...current, update_routing: result }) reportRowMessage(destination.id, `Application update alerts ${checked ? 'enabled' : 'disabled'} for ${destination.name}.`) await client.invalidateQueries({ queryKey: scope.queryKey }) diff --git a/src/pages/platform/PlatformNotifications.tsx b/src/pages/platform/PlatformNotifications.tsx index b8c7a209..988a0ecf 100644 --- a/src/pages/platform/PlatformNotifications.tsx +++ b/src/pages/platform/PlatformNotifications.tsx @@ -1,4 +1,4 @@ -import { createPlatformNotification, deletePlatformNotification, listPlatformNotifications, updatePlatformNotification, updatePlatformNotificationRouting } from '../../api' +import { createPlatformNotification, deletePlatformNotification, listPlatformNotifications, togglePlatformNotificationUpdateAlert, updatePlatformNotification } from '../../api' import { NotificationsView, type NotificationScope } from '../Notifications' // The platform's own destinations. They receive the platform's copy of each @@ -10,7 +10,7 @@ const platformNotifications: NotificationScope = { create: (name, url, password, enabled) => createPlatformNotification(name, url, password, enabled), update: (id, revision, name, password, options) => updatePlatformNotification(id, revision, name, password, options), remove: (id, revision, password) => deletePlatformNotification(id, revision, password), - updateRouting: (destinations, password) => updatePlatformNotificationRouting(destinations, password), + toggleRouting: (destinationID, enabled, password) => togglePlatformNotificationUpdateAlert(destinationID, enabled, password), routingDefaultsToEnabled: false, configImport: false, eyebrow: 'Platform', diff --git a/src/pages/platform/PlatformPages.test.tsx b/src/pages/platform/PlatformPages.test.tsx index 14a5b2d7..172dd4b6 100644 --- a/src/pages/platform/PlatformPages.test.tsx +++ b/src/pages/platform/PlatformPages.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { APIError, createPlatformNotification, deletePendingPlatformAdmin, deletePlatformNotification, getSession, invitePlatformAdmin, listPlatformAdmins, listPlatformNotifications, listUnits, platformAudit, platformStatus, renewPlatformAdminInvitation, revokePlatformAdminInvitation, setPlatformAdminEnabled, updatePlatformNotification, updatePlatformNotificationRouting } from '../../api' +import { APIError, createPlatformNotification, deletePendingPlatformAdmin, deletePlatformNotification, getSession, invitePlatformAdmin, listPlatformAdmins, listPlatformNotifications, listUnits, platformAudit, platformStatus, renewPlatformAdminInvitation, revokePlatformAdminInvitation, setPlatformAdminEnabled, togglePlatformNotificationUpdateAlert, updatePlatformNotification } from '../../api' import type { AuditEntry, NotificationDestination, UserSummary } from '../../api' import { formatDateTime, setDisplayTimeZone } from '../../format' import { businessUnit, deploymentLimits as limits, platformSession } from '../../test/platform-fixtures' @@ -14,7 +14,7 @@ import { PlatformStatusPage, updateSummary } from './PlatformStatus' vi.mock('../../api', async () => { const actual = await vi.importActual('../../api') - return { ...actual, createPlatformNotification: vi.fn(), deletePendingPlatformAdmin: vi.fn(), deletePlatformNotification: vi.fn(), getSession: vi.fn(), invitePlatformAdmin: vi.fn(), listPlatformAdmins: vi.fn(), listPlatformNotifications: vi.fn(), listUnits: vi.fn(), platformAudit: vi.fn(), platformStatus: vi.fn(), renewPlatformAdminInvitation: vi.fn(), revokePlatformAdminInvitation: vi.fn(), setPlatformAdminEnabled: vi.fn(), updatePlatformNotification: vi.fn(), updatePlatformNotificationRouting: vi.fn() } + return { ...actual, createPlatformNotification: vi.fn(), deletePendingPlatformAdmin: vi.fn(), deletePlatformNotification: vi.fn(), getSession: vi.fn(), invitePlatformAdmin: vi.fn(), listPlatformAdmins: vi.fn(), listPlatformNotifications: vi.fn(), listUnits: vi.fn(), platformAudit: vi.fn(), platformStatus: vi.fn(), renewPlatformAdminInvitation: vi.fn(), revokePlatformAdminInvitation: vi.fn(), setPlatformAdminEnabled: vi.fn(), togglePlatformNotificationUpdateAlert: vi.fn(), updatePlatformNotification: vi.fn() } }) function admin(overrides: Partial & Pick): UserSummary { @@ -327,7 +327,7 @@ describe('platform notifications', () => { vi.mocked(createPlatformNotification).mockResolvedValue(destination({ id: 'p-new', name: 'New' })) vi.mocked(updatePlatformNotification).mockResolvedValue(destination({ id: 'p-ops', name: 'Operations' })) vi.mocked(deletePlatformNotification).mockResolvedValue(undefined) - vi.mocked(updatePlatformNotificationRouting).mockResolvedValue({ configured: true, destinations: ['p-ops'] }) + vi.mocked(togglePlatformNotificationUpdateAlert).mockResolvedValue({ configured: true, destinations: ['p-ops'] }) }) it('manages the platform’s own destinations with write-only URLs and routes update alerts to none by default', async () => { @@ -351,7 +351,7 @@ describe('platform notifications', () => { fireEvent.click(screen.getByRole('checkbox', { name: 'Enable update alerts for Operations' })) await confirmWithPassword('Account password') - await waitFor(() => expect(updatePlatformNotificationRouting).toHaveBeenCalledWith(['p-ops'], 'my-password')) + await waitFor(() => expect(togglePlatformNotificationUpdateAlert).toHaveBeenCalledWith('p-ops', true, 'my-password')) const operations = screen.getByText('Operations').closest('.notification-row') as HTMLElement fireEvent.click(within(operations).getByRole('button', { name: 'Pause' })) diff --git a/src/types.ts b/src/types.ts index 3e7a4838..e245851d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,7 +56,7 @@ export type HostProtocolSummary = { service_detection: boolean; open_ports: number; open_filtered_ports: number } export type HostSummary = { - address: string; address_family?: string; source_targets?: string[]; dns_names?: string[]; protocols?: HostProtocolSummary[] + address: string; address_family?: string; visibility?: 'public' | 'non_public' | 'unknown' | string; source_targets?: string[]; dns_names?: string[]; protocols?: HostProtocolSummary[] open_ports: number; open_filtered_ports: number; has_open_ports: boolean; legacy?: boolean } export type GlobalHostSummary = HostSummary & { From ed0ff496416ec31f5c2864f92c2f2d3d429b0ec1 Mon Sep 17 00:00:00 2001 From: crypt0rr <57799908+crypt0rr@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:18:12 +0200 Subject: [PATCH 2/2] test: cover update routing failure paths --- internal/web/notification_handlers.go | 21 ++- ...notification_update_routing_toggle_test.go | 127 +++++++++++++++++- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/internal/web/notification_handlers.go b/internal/web/notification_handlers.go index 98ba5da6..4387dc96 100644 --- a/internal/web/notification_handlers.go +++ b/internal/web/notification_handlers.go @@ -28,16 +28,6 @@ type notificationPayload struct { func (s *Server) listNotificationDestinations(w http.ResponseWriter, r *http.Request, ts *store.TenantStore) { w.Header().Set("Cache-Control", "no-store") notifier := s.App.Notifier.Tenant(ts) - views, err := notifier.Destinations(r.Context()) - var status map[string]any - if err == nil { - status, err = notifier.Status(r.Context()) - } - if err != nil { - writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) - return - } - routing := map[string]any{"configured": false, "destinations": []string{}} current, err := ts.ApplicationUpdateRouting(r.Context()) if err != nil { if s.Log != nil { @@ -56,10 +46,19 @@ func (s *Server) listNotificationDestinations(w http.ResponseWriter, r *http.Req writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) return } + views, err := notifier.Destinations(r.Context()) + var status map[string]any + if err == nil { + status, err = notifier.Status(r.Context()) + } + if err != nil { + writeError(w, http.StatusInternalServerError, "notification_failed", "notification state could not be loaded", nil) + return + } if destinations == nil { destinations = []string{} } - routing = map[string]any{"configured": current.Configured, "destinations": destinations} + routing := map[string]any{"configured": current.Configured, "destinations": destinations} writeJSON(w, http.StatusOK, map[string]any{"destinations": views, "status": status, "update_routing": routing}) } diff --git a/internal/web/notification_update_routing_toggle_test.go b/internal/web/notification_update_routing_toggle_test.go index fa46d76c..640a89e3 100644 --- a/internal/web/notification_update_routing_toggle_test.go +++ b/internal/web/notification_update_routing_toggle_test.go @@ -36,10 +36,15 @@ func toggleUpdateRoutingRequest(t *testing.T, server *Server, admin store.Sessio t.Helper() recorder := httptest.NewRecorder() body := fmt.Sprintf(`{"destination_id":%q,"enabled":%t,"password":"administrator password"}`, destinationID, enabled) - server.toggleNotificationUpdateRouting(recorder, routingRequest(t, http.MethodPatch, "/api/v1/notifications/update-routing", body), admin, defaultTenantStore(server)) + toggleUpdateRoutingBody(t, server, admin, body, recorder) return recorder } +func toggleUpdateRoutingBody(t *testing.T, server *Server, admin store.Session, body string, recorder *httptest.ResponseRecorder) { + t.Helper() + server.toggleNotificationUpdateRouting(recorder, routingRequest(t, http.MethodPatch, "/api/v1/notifications/update-routing", body), admin, defaultTenantStore(server)) +} + func TestNotificationRoutingTogglePreservesConcurrentDestinations(t *testing.T) { ctx := context.Background() server, db, admin := newUsersTestServer(t) @@ -133,6 +138,70 @@ func TestNotificationDestinationListFailsClosedWhenRoutingCannotBeRead(t *testin } } +func TestNotificationDestinationListFailsClosedWhenCanonicalSelectionCannotBeRead(t *testing.T) { + server, db, _ := newUsersTestServer(t) + defer db.Close() + // The update-routing row remains readable, but loading the tenant-owned + // destination set fails while canonicalizing its selectors. + if _, err := db.DB.Exec(`DROP TABLE managed_notifications`); err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + server.listNotificationDestinations(recorder, routingRequest(t, http.MethodGet, "/api/v1/notifications/destinations", ""), defaultTenantStore(server)) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("destination list status = %d, want 500: %s", recorder.Code, recorder.Body.String()) + } + if strings.Contains(recorder.Body.String(), `"update_routing"`) { + t.Fatalf("failed canonicalization returned a fabricated selection: %s", recorder.Body.String()) + } +} + +func TestNotificationRoutingToggleRejectsInvalidInputAndStorageFailures(t *testing.T) { + server, db, admin := newUsersTestServer(t) + defer db.Close() + ts := defaultTenantStore(server) + destination := createUpdateRoutingTestDestination(t, server, admin, "Operations", "operations") + + tests := []struct { + name string + body string + status int + code string + }{ + {name: "invalid JSON", body: `{`, status: http.StatusBadRequest, code: "invalid_json"}, + {name: "missing fields", body: `{"password":"administrator password"}`, status: http.StatusBadRequest, code: "validation_failed"}, + {name: "missing password", body: fmt.Sprintf(`{"destination_id":%q,"enabled":true}`, destination), status: http.StatusBadRequest, code: "password_required"}, + {name: "wrong password", body: fmt.Sprintf(`{"destination_id":%q,"enabled":true,"password":"wrong password"}`, destination), status: http.StatusUnauthorized, code: "invalid_password"}, + {name: "unknown destination", body: `{"destination_id":"unknown","enabled":true,"password":"administrator password"}`, status: http.StatusBadRequest, code: "validation_failed"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + toggleUpdateRoutingBody(t, server, admin, test.body, recorder) + expectError(t, recorder, test.status, test.code, test.name) + }) + } + + if _, err := db.DB.Exec(`UPDATE tenants SET update_destinations_json='{' WHERE id=?`, store.DefaultTenantID); err != nil { + t.Fatal(err) + } + stateFailure := toggleUpdateRoutingRequest(t, server, admin, destination, true) + expectError(t, stateFailure, http.StatusInternalServerError, "notification_failed", "unreadable routing state") + if _, err := db.DB.Exec(`UPDATE tenants SET update_destinations_json='' WHERE id=?`, store.DefaultTenantID); err != nil { + t.Fatal(err) + } + + removeAuditFailure := failTableWrites(t, db, "security_audit", "INSERT", "audit unavailable") + auditFailure := toggleUpdateRoutingRequest(t, server, admin, destination, true) + expectError(t, auditFailure, http.StatusServiceUnavailable, "audit_unavailable", "routing audit failure") + removeAuditFailure() + + state, err := ts.ApplicationUpdateRouting(context.Background()) + if err != nil || state.Configured { + t.Fatalf("failed toggle changed routing = %#v, %v; want unconfigured", state, err) + } +} + func TestPlatformNotificationListFailsClosedWhenRoutingCannotBeRead(t *testing.T) { f := newPlatformFixture(t) if _, err := f.db.DB.Exec(`UPDATE application_update_state SET notification_destinations_json='{' WHERE id=1`); err != nil { @@ -147,6 +216,62 @@ func TestPlatformNotificationListFailsClosedWhenRoutingCannotBeRead(t *testing.T } } +func TestPlatformNotificationRoutingToggleValidationAndRecovery(t *testing.T) { + f := newPlatformFixture(t) + if recorder := f.call(t, actorPlatform, http.MethodGet, "/platform/notifications", ""); recorder.Code != http.StatusOK { + t.Fatalf("initial platform notification list = %d: %s", recorder.Code, recorder.Body.String()) + } + if _, err := f.db.DB.Exec(`UPDATE application_update_state SET notification_destinations_json='' WHERE id=1`); err != nil { + t.Fatal(err) + } + create := f.call(t, actorPlatform, http.MethodPost, "/platform/notifications", confirmBody(`"name":"Operations","url":"generic://localhost/platform-operations?disabletls=yes"`)) + var destination struct { + ID string `json:"id"` + } + expectResponse(t, create, http.StatusCreated, "create platform destination", &destination) + + tests := []struct { + name string + body string + status int + code string + }{ + {name: "invalid JSON", body: `{`, status: http.StatusBadRequest, code: "invalid_json"}, + {name: "missing fields", body: confirmBody(""), status: http.StatusBadRequest, code: "validation_failed"}, + {name: "wrong password", body: fmt.Sprintf(`{"password":"wrong password","destination_id":%q,"enabled":true}`, destination.ID), status: http.StatusUnauthorized, code: "invalid_password"}, + {name: "unknown destination", body: confirmBody(`"destination_id":"unknown","enabled":true`), status: http.StatusBadRequest, code: "validation_failed"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + expectError(t, f.call(t, actorPlatform, http.MethodPatch, "/platform/notifications/update-routing", test.body), test.status, test.code, test.name) + }) + } + + // The first explicit toggle starts from platform routing's empty default. + firstToggle := f.call(t, actorPlatform, http.MethodPatch, "/platform/notifications/update-routing", confirmBody(fmt.Sprintf(`"destination_id":%q,"enabled":true`, destination.ID))) + expectResponse(t, firstToggle, http.StatusOK, "toggle from unconfigured state", nil) + + if _, err := f.db.DB.Exec(`UPDATE application_update_state SET notification_destinations_json='{' WHERE id=1`); err != nil { + t.Fatal(err) + } + stateFailure := f.call(t, actorPlatform, http.MethodPatch, "/platform/notifications/update-routing", confirmBody(fmt.Sprintf(`"destination_id":%q,"enabled":false`, destination.ID))) + expectError(t, stateFailure, http.StatusInternalServerError, "notification_failed", "unreadable platform routing") + + if _, err := f.db.DB.Exec(`UPDATE application_update_state SET notification_destinations_json='["unknown"]' WHERE id=1`); err != nil { + t.Fatal(err) + } + unknownStored := f.call(t, actorPlatform, http.MethodPatch, "/platform/notifications/update-routing", confirmBody(fmt.Sprintf(`"destination_id":%q,"enabled":false`, destination.ID))) + expectError(t, unknownStored, http.StatusBadRequest, "validation_failed", "unknown stored platform selector") + + if _, err := f.db.DB.Exec(`UPDATE application_update_state SET notification_destinations_json='[]' WHERE id=1`); err != nil { + t.Fatal(err) + } + removeAuditFailure := failTableWrites(t, f.db, "security_audit", "INSERT", "audit unavailable") + auditFailure := f.call(t, actorPlatform, http.MethodPatch, "/platform/notifications/update-routing", confirmBody(fmt.Sprintf(`"destination_id":%q,"enabled":true`, destination.ID))) + expectError(t, auditFailure, http.StatusServiceUnavailable, "audit_unavailable", "platform routing audit failure") + removeAuditFailure() +} + func TestPlatformNotificationRoutingTogglePreservesConcurrentDestinations(t *testing.T) { f := newPlatformFixture(t) create := func(name string) string {