Keep unavailable VM inventory out of successful downloads (#3239)

**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.

**Format the inventory availability regression**

Apply the locked formatter to the new consumer-side regression; product
source and test assertions are unchanged.

**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.

---
Fleet candidate `20261025T044640Z-web-product` from the `web-product`
lane. Reviewed tip `78397cf67eb78cc444e80efe5ad56e1b6d826e66` on `main`.

[How Pulse handles
triage](https://github.com/rcourtman/Pulse/blob/main/docs/AI_TRANSPARENCY.md)
This commit is contained in:
pulse-triage[bot]
2026-10-25 05:53:53 +00:00
committed by GitHub
6 changed files with 122 additions and 9 deletions
@@ -186,6 +186,34 @@ 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();
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -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()
@@ -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")
}
+9 -5
View File
@@ -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()