From 58cadd5c5a97b792c95af3a1d77f45cf8d9b7fd5 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:51:55 +0000 Subject: [PATCH 1/3] Keep unavailable VM inventory out of successful downloads Reject missing selected-organization inventory with non-cacheable structured guidance, matching saved inventory reports. Preserve genuine empty fleets and populated CSV exports, and cover the existing consuming warning path. Contract-Neutral: Restores the existing organization-bound inventory contract: unavailable source data is not a successful empty fleet; schemas, CSV columns, routes, authorization and scheduling are unchanged. Change-source: pulse-maintainer --- .../__tests__/useReportingPanelState.test.ts | 26 ++++++ internal/api/report_schedules.go | 2 +- internal/api/reporting_handlers_test.go | 4 +- .../reporting_inventory_availability_test.go | 81 +++++++++++++++++++ internal/api/reporting_inventory_handlers.go | 14 ++-- 5 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 internal/api/reporting_inventory_availability_test.go diff --git a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts index ac139d913e..c3ec508f07 100644 --- a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts @@ -186,6 +186,32 @@ describe('useReportingPanelState', () => { return { dispose, hookState: hookState! }; }; + it('shows unavailable inventory guidance without downloading an empty fleet', async () => { + const message = + "VM inventory is unavailable. Check this organization's infrastructure connections before running again."; + apiFetchMock.mockImplementation((url: string) => { + if (url.startsWith('/api/admin/reports/inventory/vms/export?')) { + return Promise.resolve(jsonResponse({ code: 'inventory_unavailable', error: message }, 503)); + } + return Promise.resolve( + jsonResponse(url.endsWith('/schedules') ? schedulesPayload : catalogPayload), + ); + }); + const { hookState, dispose } = mountHook(); + await settleMountLoads(hookState); + const createElement = vi.spyOn(document, 'createElement'); + try { + await hookState.handleExportVMInventory(); + expect(showWarningMock).toHaveBeenCalledExactlyOnceWith(message); + expect(showSuccessMock).not.toHaveBeenCalled(); + expect(createElement).not.toHaveBeenCalledWith('a'); + expect(hookState.exportingInventory()).toBe(false); + } finally { + createElement.mockRestore(); + dispose(); + } + }); + it('seeds reporting selections from the backend catalog defaults', async () => { const { hookState, dispose } = mountHook(); diff --git a/internal/api/report_schedules.go b/internal/api/report_schedules.go index 4685410cf0..93d703a694 100644 --- a/internal/api/report_schedules.go +++ b/internal/api/report_schedules.go @@ -722,7 +722,7 @@ func (h *ReportingHandlers) generateScheduledFileReport(ctx context.Context, sch if schedule.Kind == config.ReportScheduleKindVMInventory { snapshot, ok := h.getReportingEnrichmentSnapshot(ctx, orgID) if !ok { - return generatedMultiReport{}, fmt.Errorf("VM inventory is unavailable. Check this organization's infrastructure connections before running again.") + return generatedMultiReport{}, errors.New(vmInventoryUnavailableMessage) } return generateVMInventoryReport(snapshot, now) } diff --git a/internal/api/reporting_handlers_test.go b/internal/api/reporting_handlers_test.go index 4ff7fd89da..14927dc1c1 100644 --- a/internal/api/reporting_handlers_test.go +++ b/internal/api/reporting_handlers_test.go @@ -885,8 +885,8 @@ func TestReportingHandlers_GenerateMultiReport_RejectsOversizedBody(t *testing.T } } -func TestReportingHandlers_ExportVMInventory_EmptySnapshotStillReturnsCSVHeader(t *testing.T) { - handler := NewReportingHandlers(nil, nil) +func TestReportingHandlers_ExportVMInventory_EmptyFleetStillReturnsCSVHeader(t *testing.T) { + handler := newReportingHandlersWithInventoryForTest(t, "default") req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export", nil) rr := httptest.NewRecorder() diff --git a/internal/api/reporting_inventory_availability_test.go b/internal/api/reporting_inventory_availability_test.go new file mode 100644 index 0000000000..1135a17e72 --- /dev/null +++ b/internal/api/reporting_inventory_availability_test.go @@ -0,0 +1,81 @@ +package api + +import ( + "encoding/csv" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestVMInventoryDownloadUnavailableIsNotAnEmptyFleet(t *testing.T) { + populated, _, _, _, _ := inventoryScheduleFixture(t) + for _, tc := range []struct { + name string + handler *ReportingHandlers + orgID string + }{ + {name: "nil handler"}, + {name: "missing monitor manager", handler: NewReportingHandlers(nil, nil)}, + {name: "missing organization", handler: populated, orgID: "missing-organization"}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export?format=csv", nil) + rec := httptest.NewRecorder() + tc.handler.HandleExportVMInventory(rec, req.WithContext(orgScopeContext(tc.orgID))) + + require.Equal(t, http.StatusServiceUnavailable, rec.Code) + require.Equal(t, "application/json", rec.Header().Get("Content-Type")) + require.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + require.Empty(t, rec.Header().Get("Content-Disposition"), "errors must not be offered as report attachments") + var payload struct { + Code string `json:"code"` + Error string `json:"error"` + StatusCode int `json:"status_code"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, "inventory_unavailable", payload.Code) + require.Equal(t, vmInventoryUnavailableMessage, payload.Error) + require.Equal(t, http.StatusServiceUnavailable, payload.StatusCode) + require.NotContains(t, rec.Body.String(), "Resource ID") + require.NotContains(t, rec.Body.String(), "inventory-vm-", "never fall back to another organization's inventory") + }) + } +} + +func TestVMInventoryDownloadAvailableEmptyFleetIsStillDownloadable(t *testing.T) { + h := newReportingHandlersWithInventoryForTest(t, "empty-org") + req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export?format=csv", nil) + rec := httptest.NewRecorder() + h.HandleExportVMInventory(rec, req.WithContext(orgScopeContext("empty-org"))) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "text/csv; charset=utf-8", rec.Header().Get("Content-Type")) + require.Contains(t, rec.Header().Get("Content-Disposition"), "attachment;") + rows, err := csv.NewReader(strings.NewReader(rec.Body.String())).ReadAll() + require.NoError(t, err) + require.Len(t, rows, 1, "a known empty fleet still has exactly its CSV header") + require.Equal(t, "Resource ID", rows[0][0]) +} + +func TestVMInventoryDownloadRecoversWhenItsMonitorBecomesAvailable(t *testing.T) { + available, _, ctx, _, _ := inventoryScheduleFixture(t) + h := NewReportingHandlers(nil, nil) + req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export?format=csv", nil).WithContext(ctx) + before := httptest.NewRecorder() + h.HandleExportVMInventory(before, req) + require.Equal(t, http.StatusServiceUnavailable, before.Code) + + h.SetMultiTenantMonitor(available.reportMonitorManager()) + after := httptest.NewRecorder() + h.HandleExportVMInventory(after, req) + require.Equal(t, http.StatusOK, after.Code) + require.Contains(t, after.Header().Get("Content-Disposition"), "attachment;") + rows, err := csv.NewReader(strings.NewReader(after.Body.String())).ReadAll() + require.NoError(t, err) + require.Len(t, rows, 62) + require.Contains(t, after.Body.String(), "inventory-vm-060") +} diff --git a/internal/api/reporting_inventory_handlers.go b/internal/api/reporting_inventory_handlers.go index 154bfd64b0..ed80d672d7 100644 --- a/internal/api/reporting_inventory_handlers.go +++ b/internal/api/reporting_inventory_handlers.go @@ -10,6 +10,8 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/reporting" ) +const vmInventoryUnavailableMessage = "VM inventory is unavailable. Check this organization's infrastructure connections before running again." + // HandleExportVMInventory exports the current VM inventory as spreadsheet-friendly CSV. func (h *ReportingHandlers) HandleExportVMInventory(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { @@ -27,11 +29,13 @@ func (h *ReportingHandlers) HandleExportVMInventory(w http.ResponseWriter, r *ht return } - snapshot := emptyReportingEnrichmentSnapshot() - if h != nil { - if liveSnapshot, ok := h.getReportingEnrichmentSnapshot(r.Context(), GetOrgID(r.Context())); ok { - snapshot = liveSnapshot - } + snapshot, ok := h.getReportingEnrichmentSnapshot(r.Context(), GetOrgID(r.Context())) + if !ok { + // Match scheduled inventory reports: missing source data is not an + // observed empty fleet, and must not become a successful download. + w.Header().Set("Cache-Control", "no-store") + writeErrorResponse(w, http.StatusServiceUnavailable, "inventory_unavailable", vmInventoryUnavailableMessage, nil) + return } generatedAt := time.Now().UTC() From b6eabcece8fdc3eeb3471e52b87c97b1a91ad568 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:54:26 +0000 Subject: [PATCH 2/3] Format the inventory availability regression Apply the locked formatter to the new consumer-side regression; product source and test assertions are unchanged. Change-source: pulse-maintainer --- .../Settings/__tests__/useReportingPanelState.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts index c3ec508f07..ad8813d059 100644 --- a/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/useReportingPanelState.test.ts @@ -191,7 +191,9 @@ describe('useReportingPanelState', () => { "VM inventory is unavailable. Check this organization's infrastructure connections before running again."; apiFetchMock.mockImplementation((url: string) => { if (url.startsWith('/api/admin/reports/inventory/vms/export?')) { - return Promise.resolve(jsonResponse({ code: 'inventory_unavailable', error: message }, 503)); + return Promise.resolve( + jsonResponse({ code: 'inventory_unavailable', error: message }, 503), + ); } return Promise.resolve( jsonResponse(url.endsWith('/schedules') ? schedulesPayload : catalogPayload), From 78397cf67eb78cc444e80efe5ad56e1b6d826e66 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:00:27 +0000 Subject: [PATCH 3/3] Keep the inventory CSV contract fixture available Use an available empty organization for the stable CSV-header contract. An absent monitor is no longer a successful inventory source; header, filename and column assertions are unchanged. Change-source: pulse-maintainer --- internal/api/contract_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 0a40eed941..0f79e11b21 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -5136,7 +5136,7 @@ func TestContract_AISettingsOllamaAuthJSONSnapshot(t *testing.T) { } func TestContract_VMInventoryExportCSVHeaders(t *testing.T) { - handler := NewReportingHandlers(nil, nil) + handler := newReportingHandlersWithInventoryForTest(t, "default") req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/inventory/vms/export?format=csv", nil) rec := httptest.NewRecorder()