diff --git a/ui/app/(prowler)/_overview/_lib/lighthouse-provider-context.test.ts b/ui/app/(prowler)/_overview/_lib/lighthouse-provider-context.test.ts index af971b141d..cd0b21b24c 100644 --- a/ui/app/(prowler)/_overview/_lib/lighthouse-provider-context.test.ts +++ b/ui/app/(prowler)/_overview/_lib/lighthouse-provider-context.test.ts @@ -52,6 +52,18 @@ describe("buildOverviewProviderContextItems", () => { ]); }); + it("dedupes repeated provider ids before filling the bounded slots", () => { + const items = buildOverviewProviderContextItems({ + searchParams: { + "filter[provider_id__in]": "prov-1,prov-1,prov-1,prov-2,prov-3", + }, + providers, + groups, + }); + + expect(items.map((item) => item.id)).toEqual(["prov-1", "prov-2"]); + }); + it("resolves URL-filtered group ids to labeled group items", () => { const items = buildOverviewProviderContextItems({ searchParams: { "filter[provider_groups__in]": "group-1,unknown" }, diff --git a/ui/app/(prowler)/_overview/_lib/lighthouse-provider-context.ts b/ui/app/(prowler)/_overview/_lib/lighthouse-provider-context.ts index 0b388ca5dd..4fe3899a31 100644 --- a/ui/app/(prowler)/_overview/_lib/lighthouse-provider-context.ts +++ b/ui/app/(prowler)/_overview/_lib/lighthouse-provider-context.ts @@ -10,8 +10,8 @@ import type { ProviderProps } from "@/types/providers"; import { parseFilterIds } from "./provider-scope"; const OVERVIEW_PATHNAME = "/"; -// Bounded so provider items cannot crowd out the page, ThreatScore, and -// posture summaries within the shared context item budget. +// Bounded so provider items take a small share of the context item budget; +// under byte pressure the compiler additionally evicts provider items first. const MAX_PROVIDER_ITEMS = 2; const MAX_TOTAL_ITEMS = 3; @@ -26,8 +26,14 @@ export function buildOverviewProviderContextItems({ providers, groups, }: OverviewProviderContextInput): LighthouseProviderContextItem[] { - const providerIds = parseFilterIds(searchParams["filter[provider_id__in]"]); - const groupIds = parseFilterIds(searchParams["filter[provider_groups__in]"]); + // Dedupe before slicing so a repeated id cannot fill the bounded slots and + // silently push the remaining selected providers out of the context. + const providerIds = Array.from( + new Set(parseFilterIds(searchParams["filter[provider_id__in]"])), + ); + const groupIds = Array.from( + new Set(parseFilterIds(searchParams["filter[provider_groups__in]"])), + ); const providerItems = providerIds .map((id) => providers.find((provider) => provider.id === id)) diff --git a/ui/lib/lighthouse/context/compiler.ts b/ui/lib/lighthouse/context/compiler.ts index 973f26bc33..947a06597b 100644 --- a/ui/lib/lighthouse/context/compiler.ts +++ b/ui/lib/lighthouse/context/compiler.ts @@ -18,13 +18,29 @@ const LIGHTHOUSE_CONTEXT_MAX_BYTES = 4 * 1024; export function prepareLighthouseContext( value: unknown, ): LighthouseContextEnvelope | undefined { - const result = lighthouseContextEnvelopeSchema.safeParse(value); - if (!result.success) return undefined; + // Only the wrapper is checked here; compileLighthouseContext validates each + // item so a single malformed one drops alone instead of voiding the send. + if ( + typeof value !== "object" || + value === null || + !("items" in value) || + !Array.isArray(value.items) + ) { + return undefined; + } - const scopeKey = result.data.items[0]?.scopeKey; - return scopeKey - ? compileLighthouseContext(result.data.items, scopeKey) - : undefined; + const scopeKey = findCandidateScopeKey(value.items); + return scopeKey ? compileLighthouseContext(value.items, scopeKey) : undefined; +} + +function findCandidateScopeKey(candidates: unknown[]): string | undefined { + // Scope comes from the first item that survives validation — a malformed + // item carrying a foreign scopeKey must not decide the compiled scope. + for (const candidate of candidates) { + const result = lighthouseContextItemSchema.safeParse(candidate); + if (result.success) return result.data.scopeKey; + } + return undefined; } export function compileLighthouseContext( @@ -36,7 +52,9 @@ export function compileLighthouseContext( for (const candidate of candidates) { if (hasDifferentScope(candidate, scopeKey)) continue; const result = lighthouseContextItemSchema.safeParse(candidate); - if (!result.success) return undefined; + // A malformed candidate (a null in an optional field, or a kind this + // build doesn't know) drops alone instead of voiding the whole envelope. + if (!result.success) continue; parsedItems.push(result.data); } @@ -63,10 +81,24 @@ function hasDifferentScope(candidate: unknown, scopeKey: string): boolean { ); } +// Eviction drops items from the end, so automatic items rank by how much +// posture signal they carry: scores and summaries outlive provider labels. +const AUTOMATIC_KIND_ORDER: Record = { + [LIGHTHOUSE_CONTEXT_KIND.PAGE]: 0, + [LIGHTHOUSE_CONTEXT_KIND.COMPLIANCE]: 1, + [LIGHTHOUSE_CONTEXT_KIND.FINDING]: 2, + [LIGHTHOUSE_CONTEXT_KIND.ATTACK_PATH]: 3, + [LIGHTHOUSE_CONTEXT_KIND.RESOURCE]: 4, + [LIGHTHOUSE_CONTEXT_KIND.SCAN]: 5, + [LIGHTHOUSE_CONTEXT_KIND.ALERT]: 6, + [LIGHTHOUSE_CONTEXT_KIND.PROVIDER]: 7, +}; + function getItemOrder(item: LighthouseContextItem): number { if (item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE) return 0; if (item.source === LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED) return 1; - return item.source === LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC ? 3 : 2; + if (item.source !== LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC) return 2; + return 3 + AUTOMATIC_KIND_ORDER[item.kind]; } function buildEnvelopeWithinLimits( diff --git a/ui/lib/lighthouse/context/context.test.ts b/ui/lib/lighthouse/context/context.test.ts index 75faeb7aa0..5d407ae7df 100644 --- a/ui/lib/lighthouse/context/context.test.ts +++ b/ui/lib/lighthouse/context/context.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { compileLighthouseContext } from "./compiler"; +import { compileLighthouseContext, prepareLighthouseContext } from "./compiler"; import { buildComplianceContext, buildFilteredProviderContext, @@ -636,8 +636,9 @@ describe("compileLighthouseContext", () => { expect(context).toBeUndefined(); }); - it("should discard valid items together with an invalid same-scope item", () => { - // Given / When + it("should drop only the invalid item and keep the valid ones", () => { + // Given a valid page plus a finding whose optional field was + // normalized to null (e.g. by a backend or storage layer) const context = compileLighthouseContext( [ { @@ -653,14 +654,184 @@ describe("compileLighthouseContext", () => { id: "finding-1", source: "selection", scopeKey: "findings:/findings", - label: "Invalid finding without findingId", + label: "Selected finding", + findingId: "finding-1", + checkId: null, + }, + { + kind: "finding", + id: "finding-2", + source: "selection", + scopeKey: "findings:/findings", + label: "Selected finding", + findingId: "finding-2", + }, + ], + "findings:/findings", + ); + + // Then the null-carrying item drops alone + expect(context?.items.map((item) => item.id)).toEqual([ + "findings", + "finding-2", + ]); + }); + + it("should drop items of unknown kinds without voiding the envelope", () => { + // Given an item kind from a newer (or rolled-back) UI build + const context = compileLighthouseContext( + [ + { + kind: "page", + id: "findings", + source: "automatic", + scopeKey: "findings:/findings", + label: "Findings", + path: "/findings", + }, + { + kind: "future-widget", + id: "widget-1", + source: "automatic", + scopeKey: "findings:/findings", + label: "Unknown widget", }, ], "findings:/findings", ); // Then - expect(context).toBeUndefined(); + expect(context?.items.map((item) => item.id)).toEqual(["findings"]); + }); + }); + + describe("when automatic items compete for the byte budget", () => { + it("should evict provider labels before posture summaries", () => { + // Given a page, a ThreatScore summary, and enough oversized provider + // labels to exceed the byte budget regardless of arrival order + const scopeKey = "overview:/"; + const page = { + kind: "page", + id: "overview", + source: "automatic", + scopeKey, + label: "Overview", + path: "/", + }; + const threatScore = { + kind: "compliance", + id: "prowler-threat-score", + source: "automatic", + scopeKey, + label: "Prowler ThreatScore", + framework: "Prowler ThreatScore", + score: 62.4, + }; + const providers = Array.from({ length: 10 }, (_, index) => ({ + kind: "provider", + id: `provider-${index}`, + source: "automatic", + scopeKey, + label: `Provider ${index} ${"x".repeat(240)}`, + providerUid: `uid-${index}-${"y".repeat(240)}`, + })); + + // When the providers mount before the ThreatScore summary + const context = compileLighthouseContext( + [page, ...providers, threatScore], + scopeKey, + ); + + // Then the summary survives and only provider labels are evicted + expect(context?.items.map((item) => item.kind)).toContain("compliance"); + expect( + context?.items.filter((item) => item.kind === "provider").length, + ).toBeLessThan(providers.length); + expect(context?.items[1]?.id).toBe("prowler-threat-score"); }); }); }); + +describe("prepareLighthouseContext", () => { + it("should keep the valid items when one carries a malformed optional", () => { + // Given a stored envelope whose finding had checkId normalized to null + const context = prepareLighthouseContext({ + schemaVersion: 1, + transport: "inline", + items: [ + { + kind: "page", + id: "findings", + source: "automatic", + scopeKey: "findings:/findings", + label: "Findings", + path: "/findings", + }, + { + kind: "finding", + id: "finding-1", + source: "selection", + scopeKey: "findings:/findings", + label: "Selected finding", + findingId: "finding-1", + checkId: null, + }, + ], + }); + + // Then the malformed item drops alone instead of voiding the send + expect(context?.items.map((item) => item.id)).toEqual(["findings"]); + }); + + it("should scope from the first usable item when the leading one is malformed", () => { + // Given a leading item with no scopeKey at all + const context = prepareLighthouseContext({ + schemaVersion: 1, + transport: "inline", + items: [ + { kind: "finding", id: "broken" }, + { + kind: "page", + id: "findings", + source: "automatic", + scopeKey: "findings:/findings", + label: "Findings", + path: "/findings", + }, + ], + }); + + // Then the later valid page item still compiles + expect(context?.items.map((item) => item.id)).toEqual(["findings"]); + }); + + it("should not let a malformed foreign-scope item decide the scope", () => { + // Given a malformed leading item whose scopeKey points at another page + const context = prepareLighthouseContext({ + schemaVersion: 1, + transport: "inline", + items: [ + { kind: "resource", id: "broken", scopeKey: "resources:/resources" }, + { + kind: "page", + id: "findings", + source: "automatic", + scopeKey: "findings:/findings", + label: "Findings", + path: "/findings", + }, + ], + }); + + // Then the valid page defines the scope and compiles + expect(context?.items.map((item) => item.scopeKey)).toEqual([ + "findings:/findings", + ]); + }); + + it("should return no context for values without an item list", () => { + expect(prepareLighthouseContext(undefined)).toBeUndefined(); + expect(prepareLighthouseContext({ items: "not-a-list" })).toBeUndefined(); + expect(prepareLighthouseContext({ items: [] })).toBeUndefined(); + }); +});