Ungate reporting catalog metadata

This commit is contained in:
rcourtman 2026-03-25 23:24:12 +00:00
parent 9cc3ff11f8
commit 899466d13a
10 changed files with 77 additions and 5 deletions

View file

@ -181,6 +181,11 @@ plus a VM inventory definition route that own panel copy, performance report
options, export title, column schema, and filename prefixes. Lifecycle-
adjacent install and fleet surfaces may read those facts, but they must not
redefine reporting or inventory schema locally.
That catalog route is intentionally metadata-readable without the
`advanced_reporting` feature gate so locked admin reporting shells can stay on
the same API-owned definition before upsell; lifecycle-adjacent surfaces must
not treat that metadata visibility as permission to execute paid report/export
routes.
That same API-owned performance-report definition also governs transport-side
validation and attachment naming. Lifecycle-adjacent fleet surfaces may depend
on those downloads, but they must treat allowed formats, multi-resource caps,

View file

@ -242,6 +242,10 @@ consumers may validate or reject malformed payloads, but they must not invent
replacement report endpoints, filename prefixes, export routes, or default
range windows from frontend-local fallback constants once the catalog has been
accepted.
The catalog route itself is intentionally metadata-readable without the
`advanced_reporting` feature gate so locked admin surfaces can present the same
canonical reporting definition before upsell, while report generation and
inventory export remain feature-gated execution routes.
The `/api/resources` serializer now also refreshes canonical identity and
policy metadata through the shared unified-resource helper before it writes
the payload, so backend and frontend contract tests stay aligned on one

View file

@ -199,6 +199,9 @@ format/range selections from legacy frontend constants or invent fallback
report endpoints, filename prefixes, or range windows when the catalog is
present; the first valid selection and all request semantics must come from the
parsed backend definition.
That same settings shell must also read the reporting catalog for locked users,
not just entitled users, so the paywalled reporting panel does not drift onto a
separate frontend-owned title or description contract.
The shared updates settings owner also defines the user-facing framing for
rc-tagged builds. `frontend-modern/src/components/Settings/updatesSettingsModel.ts`
and `frontend-modern/src/utils/updatesPresentation.ts` must present that

View file

@ -227,6 +227,11 @@ plus a VM inventory definition route that own panel copy, stable column
schema, and filename prefix. Storage and recovery flows may read those facts
when they need fleet context, but they must not fork their own reporting or
inventory column contract.
That catalog route is intentionally metadata-readable without the
`advanced_reporting` feature gate so locked admin reporting shells can stay on
the same API-owned definition before upsell; storage- and recovery-adjacent
surfaces must not treat that metadata visibility as permission to execute paid
report/export routes.
That same API-owned performance-report definition also governs transport-side
validation and attachment naming. Storage and recovery flows may consume those
downloads, but they must treat allowed formats, multi-resource caps, optional

View file

@ -1097,6 +1097,8 @@ describe('Settings architecture guardrails', () => {
expect(reportingPanelStateSource).toContain('buildReportingRequest');
expect(reportingPanelStateSource).toContain('buildReportingCatalogRequest');
expect(reportingPanelStateSource).toContain('parseReportingCatalog');
expect(reportingPanelStateSource).toContain('!licenseLoaded()');
expect(reportingPanelStateSource).not.toContain('!isReportingEnabled()');
expect(reportingPanelStateSource).toContain('buildVMInventoryExportRequest');
expect(reportingPanelStateSource).toContain('getReportingGenerateSuccessMessage');
expect(reportingPanelStateSource).not.toContain('getTrialAlreadyUsedMessage()');

View file

@ -66,6 +66,7 @@ const catalogPayload = {
describe('useReportingPanelState', () => {
let useReportingPanelState: UseReportingPanelStateModule['useReportingPanelState'];
let apiFetchMock: ReturnType<typeof vi.fn>;
let hasReportingFeature: boolean;
let loadLicenseStatusMock: ReturnType<typeof vi.fn>;
beforeEach(async () => {
@ -74,6 +75,7 @@ describe('useReportingPanelState', () => {
apiFetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify(catalogPayload), { status: 200 }));
hasReportingFeature = true;
loadLicenseStatusMock = vi.fn();
vi.doMock('@/utils/apiClient', () => ({
@ -96,7 +98,7 @@ describe('useReportingPanelState', () => {
vi.doMock('@/stores/license', () => ({
entitlements: vi.fn(() => ({ trial_eligible: true })),
getUpgradeActionUrlOrFallback: vi.fn(() => '/pricing'),
hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting'),
hasFeature: vi.fn((feature: string) => feature === 'advanced_reporting' && hasReportingFeature),
licenseLoaded: vi.fn(() => true),
loadLicenseStatus: loadLicenseStatusMock,
}));
@ -135,4 +137,17 @@ describe('useReportingPanelState', () => {
dispose();
});
it('loads the reporting catalog even when the feature is locked', async () => {
hasReportingFeature = false;
const { hookState, dispose } = mountHook();
await flushAsync();
await flushAsync();
expect(apiFetchMock).toHaveBeenCalledWith('/api/admin/reports/catalog');
expect(hookState.reportingCatalog()?.title).toBe('Detailed Reporting');
dispose();
});
});

View file

@ -67,7 +67,7 @@ export const useReportingPanelState = () => {
createEffect(() => {
if (
!isReportingEnabled() ||
!licenseLoaded() ||
reportingCatalog() ||
reportingCatalogLoading() ||
reportingCatalogRequested()

View file

@ -499,6 +499,25 @@ func TestContract_PerformanceReportTransportUsesCatalogDefinition(t *testing.T)
}
}
func TestContract_ReportingCatalogRouteAccessibleWithoutReportingFeature(t *testing.T) {
rawToken := "reporting-catalog-contract-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/catalog", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for ungated reporting catalog route, got %d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
t.Fatalf("expected json content type, got %q", got)
}
}
func TestContract_PerformanceReportTransportUsesCatalogDefaultRange(t *testing.T) {
engine := &stubReportingEngine{data: []byte("report"), contentType: "application/pdf"}
original := reporting.GetEngine()

View file

@ -181,7 +181,7 @@ func (r *Router) registerOrgLicenseRoutesGroup(orgHandlers *OrgHandlers, rbacHan
if !ensureAdminSession(r.config, w, req) {
return
}
RequireLicenseFeature(r.licenseHandlers, featureAdvancedReportingValue, RequireScope(config.ScopeSettingsRead, reportingAdminEndpoints.HandleGetReportingCatalog))(w, req)
RequireScope(config.ScopeSettingsRead, reportingAdminEndpoints.HandleGetReportingCatalog)(w, req)
}))
r.mux.HandleFunc("/api/admin/reports/generate", RequirePermission(r.config, r.authorizer, auth.ActionRead, auth.ResourceNodes, func(w http.ResponseWriter, req *http.Request) {
if !ensureAdminSession(r.config, w, req) {

View file

@ -2893,20 +2893,39 @@ func TestAuditVerifyRequiresLicenseFeature(t *testing.T) {
}
}
func TestReportingEndpointsRequireLicenseFeature(t *testing.T) {
func TestReportingCatalogDoesNotRequireLicenseFeature(t *testing.T) {
rawToken := "reporting-license-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
req := httptest.NewRequest(http.MethodGet, "/api/admin/reports/catalog", nil)
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 for reporting catalog without reporting license, got %d", rec.Code)
}
}
func TestReportingExecutionEndpointsRequireLicenseFeature(t *testing.T) {
rawToken := "reporting-license-token-123.12345678"
record := newTokenRecord(t, rawToken, []string{config.ScopeSettingsRead}, nil)
cfg := newTestConfigWithTokens(t, record)
router := NewRouter(cfg, nil, nil, nil, nil, "1.0.0")
paths := []string{
"/api/admin/reports/catalog",
"/api/admin/reports/generate",
"/api/admin/reports/generate-multi",
"/api/admin/reports/inventory/vms/definition",
"/api/admin/reports/inventory/vms/export",
}
for _, path := range paths {
req := httptest.NewRequest(http.MethodGet, path, nil)
if path == "/api/admin/reports/generate-multi" {
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`))
}
req.Header.Set("X-API-Token", rawToken)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)