mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user