Compare commits

..
Author SHA1 Message Date
alejandrobailo 902c12edd1 fix(ui): address review findings on the interactive sample preview
Search the sample resource drawer, reopen copied resource links, accept only
positive page sizes, honour the date filter, reset the sample findings table
when its filters change, show the warming state and open ?section= on the
sample compliance detail, link the sample watchlist to the per-scan view,
hide the severity hint when points are not clickable, and process the
tracked scan before the completed-scan lookup. The radar tab test now waits
for the tab to be selected.
2026-11-03 18:44:41 +01:00
alejandrobailo 035607518b fix(ui): open sample findings from the Overview and the attack path 2026-11-03 17:51:19 +01:00
alejandrobailo 130557eaef feat(ui): open sample compliance frameworks with their requirements 2026-11-03 17:51:19 +01:00
alejandrobailo 06efb2f442 feat(ui): show sample resources until the first scan completes 2026-11-03 17:51:18 +01:00
alejandrobailo 7f84f18b5b fix(ui): apply every Findings filter to the sample and show the filter bar 2026-11-03 17:51:18 +01:00
56 changed files with 1867 additions and 699 deletions

No files matched your search

@@ -18,6 +18,7 @@ import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
import {
isSampleScanFilter,
pickSampleFindingFilters,
sampleFindingGroupResources,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
@@ -200,6 +201,7 @@ async function fetchFindingGroupResourcesEndpoint(
pageSize,
status: Array.isArray(status) ? status.join(",") : status,
search: Array.isArray(search) ? search[0] : search,
filters: pickSampleFindingFilters(filters),
});
}
@@ -1,4 +1,4 @@
import { isSampleId } from "@/lib/sample-preview/sample-records";
import { isSampleId } from "@/lib/sample-preview/sample-guards";
import { FINDING_STATUS, type FindingStatus } from "@/types/components";
import {
FINDING_TRIAGE_BILLING_HREF,
+16 -4
View File
@@ -33,11 +33,23 @@ export const getFindings = async ({
filters = {},
}) => {
if (isSampleScanFilter(filters)) {
return withFindingTriageSummaries(
sampleFindingsDocument(
sampleFindingsQuery(filters).slice(0, Number(pageSize) || 10),
),
const findings = sampleFindingsQuery(filters);
const size = Number(pageSize) || 10;
const current = Math.max(1, Number(page) || 1);
const document = sampleFindingsDocument(
findings.slice((current - 1) * size, current * size),
);
return withFindingTriageSummaries({
...document,
meta: {
...document.meta,
pagination: {
page: current,
pages: Math.max(1, Math.ceil(findings.length / size)),
count: findings.length,
},
},
});
}
const headers = await getAuthHeaders({ contentType: false });
+5
View File
@@ -14,6 +14,7 @@ import {
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
import {
isSampleId,
sampleResourceDrawerData,
sampleResourceEvents,
} from "@/lib/sample-preview/sample-records";
import { handleApiResponse } from "@/lib/server-actions-helper";
@@ -295,6 +296,10 @@ export const getResourceDrawerData = async ({
pageSize?: number;
query?: string;
}) => {
if (isSampleId(resourceId)) {
return sampleResourceDrawerData(resourceId, query);
}
const isCloudEnv = isCloud();
const [resourceData, findingsResponse, organizationsResponse] =
@@ -25,10 +25,14 @@ function getScoreColor(score: number): string {
}
interface RiskPlotClientProps {
disableNavigation?: boolean;
data: RiskPlotPoint[];
}
export function RiskPlotClient({ data }: RiskPlotClientProps) {
export function RiskPlotClient({
data,
disableNavigation = false,
}: RiskPlotClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [selectedPoint, setSelectedPoint] = useState<RiskPlotPoint | null>(
@@ -131,7 +135,7 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
</div>
<HorizontalBarChart
data={selectedPoint.severityData}
onBarClick={handleBarClick}
onBarClick={disableNavigation ? undefined : handleBarClick}
/>
</div>
) : (
@@ -13,10 +13,14 @@ import { SEVERITY_FILTER_MAP } from "@/types/severities";
import { CategorySelector } from "./category-selector";
interface RiskRadarViewClientProps {
disableNavigation?: boolean;
data: RadarDataPoint[];
}
export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
export function RiskRadarViewClient({
data,
disableNavigation = false,
}: RiskRadarViewClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [selectedPoint, setSelectedPoint] = useState<RadarDataPoint | null>(
@@ -99,7 +103,7 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
</div>
<HorizontalBarChart
data={selectedPoint.severityData}
onBarClick={handleBarClick}
onBarClick={disableNavigation ? undefined : handleBarClick}
/>
</div>
) : (
@@ -1,13 +1,19 @@
import { render, screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { SAMPLE_SERVICES_RESPONSE } from "@/lib/sample-preview/sample-overview";
import {
SAMPLE_SERVICES_RESPONSE,
SAMPLE_STATUS,
} from "@/lib/sample-preview/sample-overview";
import { OverviewSamplePreview } from "./overview-sample-preview";
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
// Card links import server actions; their auth chain has no place in jsdom.
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
useRouter: () => ({ push: pushMock, refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
@@ -15,39 +21,54 @@ vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
// The graph tabs have their own test; here they would only add chart noise.
vi.mock("./sample-graphs-tabs", () => ({
SampleGraphsTabs: () => <div data-testid="sample-graphs-tabs" />,
SampleGraphsTabs: () => null,
}));
describe("OverviewSamplePreview", () => {
it("fills the Overview with the demo account's numbers", () => {
it("renders the demo account's passing and failing totals", () => {
render(<OverviewSamplePreview showIssues={false} />);
expect(screen.getByText("Findings Severity Over Time")).toBeVisible();
expect(screen.getByText("Internet Exposed Resources")).toBeVisible();
expect(screen.getByTestId("sample-graphs-tabs")).toBeInTheDocument();
const card = screen
.getByText("Check Findings")
.closest('[data-slot="card"]')!;
expect(
within(card as HTMLElement).getByText(
SAMPLE_STATUS.fail.toLocaleString(),
),
).toBeVisible();
expect(
within(card as HTMLElement).getByText(
SAMPLE_STATUS.pass.toLocaleString(),
),
).toBeVisible();
});
it("only leaves links live where the target page shows the sample", () => {
const { container } = render(<OverviewSamplePreview showIssues={false} />);
const links = Array.from(container.querySelectorAll("a"));
const live = links.filter((link) => !link.closest("[inert]"));
expect(live.length).toBeGreaterThan(0);
expect(live.length).toBeLessThan(links.length);
for (const link of live) {
expect(link.getAttribute("href")).toMatch(/^\/(findings|issues)(\?|$)/);
expect(links.length).toBeGreaterThan(0);
for (const link of links) {
expect(link.getAttribute("href")).toMatch(
/^\/(findings|issues|compliance|resources)(\?|$)/,
);
}
});
it("lists every sample service in the watchlist", () => {
it("opens sample findings filtered by the selected service", async () => {
const user = userEvent.setup();
render(<OverviewSamplePreview showIssues={false} />);
// The riskiest service of the demo account leads the watchlist.
const riskiest = [...SAMPLE_SERVICES_RESPONSE.data].sort(
(left, right) => right.attributes.fail - left.attributes.fail,
)[0];
expect(
screen.getAllByText(new RegExp(riskiest.id, "i")).length,
).toBeGreaterThan(0);
await user.click(
screen.getByRole("button", { name: new RegExp(riskiest.id, "i") }),
);
const url = new URL(pushMock.mock.calls[0][0], "https://prowler.test");
expect(url.pathname).toBe("/findings");
expect(url.searchParams.get("filter[service__in]")).toBe(riskiest.id);
expect(url.searchParams.get("filter[status__in]")).toBe("FAIL");
});
});
@@ -1,9 +1,8 @@
import { type ReactNode } from "react";
import { adaptAttackSurfaceOverview } from "@/actions/overview/attack-surface/attack-surface.adapter";
import { adaptComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.adapter";
import type { ComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.types";
import { adaptResourceGroupOverview } from "@/actions/overview/resources-inventory/resources-inventory.adapter";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { FindingSeverityOverTimeCard } from "@/components/overview/finding-severity-over-time-card";
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { verdictCounts } from "@/lib/issues/verdicts";
@@ -35,30 +34,24 @@ import { ComplianceWatchlist, ServiceWatchlist } from "../watchlist";
import { SampleGraphsTabs } from "./sample-graphs-tabs";
// `contents` keeps each card a direct item of the grid's flex rows, while
// `inert` stops links into pages with no sample data (compliance details,
// resources). Cards that link to Findings or Issues stay live: those pages
// show the sample and apply the link's filters.
const Inert = ({ children }: { children: ReactNode }) => (
<div inert className="contents">
{children}
</div>
);
const NO_SCOPE: Record<string, string> = {};
interface OverviewSamplePreviewProps {
// Exploitability and Top issues only exist in Prowler Cloud, behind their flag.
showIssues: boolean;
/** Compliance totals derived from the sample requirements, when available. */
complianceWatchlist?: ComplianceWatchlistResponse["data"];
}
/** The Overview filled with the demo account, for tenants without a scan. */
export function OverviewSamplePreview({
showIssues,
complianceWatchlist = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data,
}: OverviewSamplePreviewProps) {
const complianceItems = adaptComplianceWatchlistResponse(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
).map((item) => ({
const complianceItems = adaptComplianceWatchlistResponse({
...SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
data: complianceWatchlist,
}).map((item) => ({
id: item.id,
framework: item.complianceId,
label: item.label,
@@ -69,14 +62,12 @@ export function OverviewSamplePreview({
return (
<OverviewGrid
threatScore={
<Inert>
<ThreatScore
score={SAMPLE_THREAT_SCORE.score}
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
/>
</Inert>
<ThreatScore
score={SAMPLE_THREAT_SCORE.score}
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
/>
}
exploitability={
showIssues ? (
@@ -106,16 +97,13 @@ export function OverviewSamplePreview({
}
riskSeverity={<RiskSeverityChart {...SAMPLE_FAILED_BY_SEVERITY} />}
resourcesInventory={
<Inert>
<ResourcesInventory
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
/>
</Inert>
<ResourcesInventory
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
/>
}
complianceWatchlist={
<Inert>
<ComplianceWatchlist items={complianceItems} hasWatchlist />
</Inert>
// The sample Compliance page is per scan: no multi-scan view to link.
<ComplianceWatchlist items={complianceItems} hasWatchlist={false} />
}
serviceWatchlist={
<ServiceWatchlist items={SAMPLE_SERVICES_RESPONSE.data} />
@@ -133,18 +121,9 @@ export function OverviewSamplePreview({
) : undefined
}
severityOverTime={
<Inert>
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Findings Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} />
</CardContent>
</Card>
</Inert>
<FindingSeverityOverTimeCard>
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} readOnly />
</FindingSeverityOverTimeCard>
}
graphsTabs={<SampleGraphsTabs />}
/>
@@ -0,0 +1,51 @@
import { expect, it } from "vitest";
import { render } from "@/__tests__/render-browser";
import {
SAMPLE_NEW_FINDINGS,
SAMPLE_RADAR_DATA,
} from "@/lib/sample-preview/sample-graphs";
import { SampleGraphsTabs } from "./sample-graphs-tabs";
it("offers each latest new failed finding as a drawer trigger", async () => {
const screen = await render(<SampleGraphsTabs />);
const [finding] = SAMPLE_NEW_FINDINGS;
// The trigger opens the finding drawer, which resolves sample ids locally.
await expect
.element(
screen
.getByRole("button", {
name: finding.attributes.check_metadata.checktitle,
})
.first(),
)
.toBeEnabled();
});
it("lets users explore sample categories without opening live findings", async () => {
const screen = await render(<SampleGraphsTabs />);
const category = SAMPLE_RADAR_DATA[0];
// A click that lands before the tabs hydrate is lost: retry until selected.
const radarTab = screen.getByRole("tab", { name: "Risk Radar" });
await expect
.poll(async () => {
await radarTab.click();
return radarTab.element().getAttribute("aria-selected");
})
.toBe("true");
await expect.element(screen.getByRole("combobox")).toBeVisible();
await screen.getByRole("combobox").click();
await screen
.getByRole("option", { name: category.category, exact: true })
.click();
await expect
.element(screen.getByText(`${category.value} Total Findings`))
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: /Critical/ }))
.not.toBeInTheDocument();
});
@@ -1,36 +0,0 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SAMPLE_CHECKS } from "@/lib/sample-preview/sample-dataset";
import { SampleGraphsTabs } from "./sample-graphs-tabs";
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
usePathname: () => "/",
useSearchParams: () => new URLSearchParams(),
}));
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
describe("SampleGraphsTabs", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
it("shows the sample new failed findings in the default tab without fetching", () => {
// Given
const firstNewCheck = SAMPLE_CHECKS.find((check) => check.newFailed > 0)!;
// When
render(<SampleGraphsTabs />);
// Then
expect(
screen.getByRole("tab", { name: "New Findings", selected: true }),
).toBeInTheDocument();
expect(screen.getAllByText(firstNewCheck.title).length).toBeGreaterThan(0);
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -18,16 +18,9 @@ import type { TabId } from "../graphs-tabs/_config/graphs-tabs-config";
import { RiskPlotClient } from "../graphs-tabs/risk-plot/risk-plot-client";
import { RiskRadarViewClient } from "../graphs-tabs/risk-radar-view/risk-radar-view-client";
// Inert so sample rows and charts never open drill-downs or detail panels.
const Inert = ({ children }: { children: ReactNode }) => (
<div inert className="flex w-full flex-1">
{children}
</div>
);
const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
findings: (
<Inert>
<div className="flex w-full flex-1">
<div className="flex w-full flex-col">
<LatestFindingsTable
data={SAMPLE_NEW_FINDINGS}
@@ -44,37 +37,42 @@ const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
}
/>
</div>
</Inert>
</div>
),
"threat-map": (
<Inert>
<div className="flex w-full flex-1">
<div className="w-full flex-1 overflow-hidden">
<ThreatMap data={SAMPLE_THREAT_MAP_DATA} height={460} />
<ThreatMap
disableNavigation
data={SAMPLE_THREAT_MAP_DATA}
height={460}
/>
</div>
</Inert>
</div>
),
"risk-radar": (
<Inert>
<RiskRadarViewClient data={SAMPLE_RADAR_DATA} />
</Inert>
<div className="flex w-full flex-1">
<RiskRadarViewClient disableNavigation data={SAMPLE_RADAR_DATA} />
</div>
),
"risk-pipeline": (
<Inert>
<div className="flex w-full flex-1">
<div className="w-full flex-1 overflow-visible">
<SankeyChart
disableNavigation
data={SAMPLE_SANKEY_DATA}
zeroDataProviders={SAMPLE_SANKEY_DATA.zeroDataProviders}
height={460}
/>
</div>
</Inert>
</div>
),
"risk-plot": (
<Inert>
<div className="flex w-full flex-1">
<div className="w-full flex-1 overflow-visible">
<RiskPlotClient data={SAMPLE_RISK_PLOT_POINTS} />
<RiskPlotClient disableNavigation data={SAMPLE_RISK_PLOT_POINTS} />
</div>
</Inert>
</div>
),
};
@@ -20,10 +20,12 @@ import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
interface FindingSeverityOverTimeProps {
data: LineDataPoint[];
readOnly?: boolean;
}
export const FindingSeverityOverTime = ({
data: initialData,
readOnly = false,
}: FindingSeverityOverTimeProps) => {
const router = useRouter();
const searchParams = useSearchParams();
@@ -131,13 +133,15 @@ export const FindingSeverityOverTime = ({
return (
<>
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
{!readOnly && (
<div className="mb-8 w-fit">
<TimeRangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
isLoading={isLoading}
/>
</div>
)}
{error ? (
<div
role="alert"
@@ -152,7 +156,7 @@ export const FindingSeverityOverTime = ({
lines={lines}
height={400}
xAxisInterval={getXAxisInterval()}
onPointClick={handlePointClick}
onPointClick={readOnly ? undefined : handlePointClick}
/>
</div>
)}
@@ -1,4 +1,5 @@
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
import { FindingSeverityOverTimeCard } from "@/components/overview/finding-severity-over-time-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
import { pickFilterParams } from "../_lib/filter-params";
@@ -42,16 +43,8 @@ export const FindingSeverityOverTimeSSR = async ({
}
return (
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Findings Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
<FindingSeverityOverTime data={result.data.data} />
</CardContent>
</Card>
<FindingSeverityOverTimeCard>
<FindingSeverityOverTime data={result.data.data} />
</FindingSeverityOverTimeCard>
);
};
@@ -29,6 +29,7 @@ interface ScanListTableProps {
scans: AttackPathScan[];
/** Overrides the `scanId` search param, e.g. for the sample scan. */
selectedScanId?: string;
readOnly?: boolean;
}
const DEFAULT_PAGE_SIZE = 10;
@@ -102,8 +103,10 @@ const buildMetadata = (
const getColumns = ({
selectedScanId,
readOnly,
}: {
selectedScanId: string | null;
readOnly: boolean;
}): ColumnDef<AttackPathScan>[] => [
{
id: "select",
@@ -117,10 +120,11 @@ const getColumns = ({
<RadioGroupItem
value={row.original.id}
checked={isSelected}
disabled={!canSelect}
disabled={readOnly || !canSelect}
className={cn(
"size-5",
canSelect &&
!readOnly &&
canSelect &&
!isSelected &&
"border-text-neutral-secondary cursor-pointer",
!canSelect && "disabled:opacity-70",
@@ -232,6 +236,7 @@ const getColumns = ({
export const ScanListTable = ({
scans,
selectedScanId: selectedScanIdOverride,
readOnly = false,
}: ScanListTableProps) => {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -283,23 +288,32 @@ export const ScanListTable = ({
return (
<RadioGroup
disabled={readOnly}
value={selectedScanId ?? ""}
onValueChange={handleSelectScan}
className="gap-0"
>
<DataTable
columns={getColumns({ selectedScanId })}
columns={getColumns({ selectedScanId, readOnly })}
data={paginatedScans}
metadata={buildMetadata(scans.length, currentPage, totalPages)}
metadata={
readOnly
? undefined
: buildMetadata(scans.length, currentPage, totalPages)
}
controlledPage={currentPage}
controlledPageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onRowClick={(row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
}
}}
onRowClick={
readOnly
? undefined
: (row) => {
if (row.original.attributes.graph_data_ready) {
handleSelectScan(row.original.id);
}
}
}
enableRowSelection
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
/>
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "@/__tests__/render-browser";
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
import { failedSampleFindings } from "@/lib/sample-preview/sample-records";
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
@@ -38,18 +39,28 @@ const SAMPLE_FIXTURE = {
queryResult: SAMPLE_ATTACK_PATHS.result,
};
const [BUCKET_FINDING] = failedSampleFindings("s3_bucket_public_access");
const [INSTANCE_FINDING] = failedSampleFindings("ec2_instance_imdsv2_enabled");
beforeEach(() => {
actions.getFindingById.mockReset();
useGraphStore.getState().reset();
resetLighthouseContextStore();
window.history.replaceState({}, "", "/attack-paths");
});
describe("Attack Paths sample preview", () => {
it("shows the sample graph and keeps it off the API and Lighthouse", async () => {
it("shows the sample graph, opens its findings and keeps it off the API and Lighthouse", async () => {
// Given
await render(<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />);
const screen = await render(
<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />,
);
const graph = new AttackPathPageHarness(SAMPLE_FIXTURE);
await expect
.element(screen.getByRole("radio", { name: "Selected scan" }))
.toBeDisabled();
// When
await vi.waitFor(
() => expect(graph.getNodeById("sample-s3-exports")).not.toBeNull(),
@@ -59,20 +70,26 @@ describe("Attack Paths sample preview", () => {
);
await graph.clickNode("sample-s3-exports");
await vi.waitFor(() =>
expect(graph.getNodeById("sample-finding-public-bucket")).not.toBeNull(),
expect(graph.getNodeById(BUCKET_FINDING.id)).not.toBeNull(),
);
await graph.clickNode(BUCKET_FINDING.id);
// The finding drawer resolves the sample record through the read action.
await vi.waitFor(() =>
expect(actions.getFindingById).toHaveBeenCalledWith(
BUCKET_FINDING.id,
expect.anything(),
),
);
await graph.clickNode("sample-finding-public-bucket");
await graph.executeQuery({ selectFirst: false });
// Then
expect(graph.showsNodeLabel("bastion-01")).toBe(true);
expect(graph.showsNodeLabel(INSTANCE_FINDING.resource.name)).toBe(true);
expect(
graph.showsNodeLabel(SAMPLE_ATTACK_PATHS.query.attributes.name),
).toBe(true);
expect(actions.getAttackPathScans).not.toHaveBeenCalled();
expect(actions.getAvailableQueries).not.toHaveBeenCalled();
expect(actions.executeQuery).not.toHaveBeenCalled();
expect(actions.getFindingById).not.toHaveBeenCalled();
expect(
useLighthouseContextStore.getState().contributions["attack-path-current"],
).toBeUndefined();
@@ -351,9 +351,6 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
// Open finding drawer directly, bypassing the node-details modal.
graphState.enterFilteredView(node.id);
graphState.selectNode(null); // clear so node-details modal doesn't open first
// Sample findings have no API record: focus the path, open no drawer.
if (sample) return;
findingNavigationInFlightRef.current = true;
void handleViewFinding(String(node.properties?.id || node.id));
return;
@@ -499,11 +496,13 @@ export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
) : (
<>
{/* The sample scan is the only one: nothing to pick or page. */}
<div inert={Boolean(sample)}>
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable scans={scans} selectedScanId={sample?.scan.id} />
</Suspense>
</div>
<Suspense fallback={<div>Loading scans...</div>}>
<ScanListTable
scans={scans}
selectedScanId={sample?.scan.id}
readOnly={Boolean(sample)}
/>
</Suspense>
{isViewingPreviousCycleData && (
<StatusAlert
@@ -14,9 +14,11 @@ const {
getComplianceAttributesMock,
getComplianceOverviewMetadataInfoMock,
getComplianceRequirementsMock,
getSamplePreviewStateMock,
getScanMock,
mapComplianceDataMock,
} = vi.hoisted(() => ({
getSamplePreviewStateMock: vi.fn(),
getComplianceAttributesMock: vi.fn(),
getComplianceOverviewMetadataInfoMock: vi.fn(),
getComplianceRequirementsMock: vi.fn(),
@@ -112,6 +114,14 @@ vi.mock("@/lib/shared/env", () => ({
isCloud: () => false,
}));
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
getSamplePreviewState: getSamplePreviewStateMock,
}));
vi.mock("../_components/compliance-detail-sample-preview", () => ({
ComplianceDetailSamplePreview: () => <div>Sample compliance detail</div>,
}));
vi.mock("../_components/cross-account-detail", () => ({
CrossAccountDetail: () => null,
}));
@@ -231,3 +241,46 @@ describe("Compliance detail task response", () => {
).toBeInTheDocument();
});
});
describe("Compliance detail for the sample scan", () => {
const renderSampleDetail = async () =>
ComplianceDetail({
params: Promise.resolve({ compliancetitle: "CIS" }),
searchParams: Promise.resolve({
complianceId: "cis_5.0_aws",
version: "5.0",
scanId: "sample-scan",
}),
});
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the sample detail without asking the API about the scan", async () => {
// Given
getSamplePreviewStateMock.mockResolvedValue({
active: true,
stage: "no_scan",
runningScan: null,
});
// When
render((await renderSampleDetail()) as ReactElement);
// Then
expect(screen.getByText("Sample compliance detail")).toBeInTheDocument();
expect(getScanMock).not.toHaveBeenCalled();
expect(getComplianceRequirementsMock).not.toHaveBeenCalled();
expect(getComplianceOverviewMetadataInfoMock).not.toHaveBeenCalled();
});
it("sends the sample scan back to Compliance once the preview is over", async () => {
// Given
getSamplePreviewStateMock.mockResolvedValue({ active: false });
// When / Then
await expect(renderSampleDetail()).rejects.toThrow("redirect");
expect(getScanMock).not.toHaveBeenCalled();
});
});
@@ -39,6 +39,8 @@ import {
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { isSampleId } from "@/lib/sample-preview/sample-guards";
import { isCloud } from "@/lib/shared/env";
import { cn } from "@/lib/utils";
import type { SearchParamsProps } from "@/types";
@@ -50,6 +52,7 @@ import {
import { isKnownProviderType } from "@/types/providers";
import { ScanEntity } from "@/types/scans";
import { ComplianceDetailSamplePreview } from "../_components/compliance-detail-sample-preview";
import { CrossAccountDetail } from "../_components/cross-account-detail";
import { CrossProviderDetail } from "../_components/cross-provider-detail";
import { buildSearchParamsKey } from "../_lib/search-params-key";
@@ -78,6 +81,25 @@ export default async function ComplianceDetail({
notFound();
}
// The sample scan never reaches the API: its detail is built from the catalog.
if (scanId && isSampleId(scanId)) {
const preview = await getSamplePreviewState();
if (!preview.active) redirect("/compliance");
return (
<ComplianceDetailSamplePreview
state={preview}
compliancetitle={compliancetitle}
complianceId={complianceId}
version={version}
filter={getSingleSearchParam(
resolvedSearchParams["filter[cis_profile_level]"],
)}
targetSection={section}
/>
);
}
const subscriptionOnlyPromise = isReportDownloadLocked();
// Cross-provider mode replaces the per-scan pipeline with the universal
@@ -0,0 +1,125 @@
import {
ClientAccordionWrapper,
ComplianceHeader,
ComplianceWarming,
RequirementsStatusCard,
TopFailedSectionsCard,
} from "@/components/compliance";
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
import { Card } from "@/components/shadcn/card/card";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
import { SAMPLE_SCAN } from "@/lib/sample-preview/sample-compliance";
import { getSampleComplianceDetail } from "@/lib/sample-preview/sample-compliance-requirements";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import type { Framework, RequirementsTotals } from "@/types/compliance";
interface ComplianceDetailSamplePreviewProps {
state: SamplePreviewActive;
compliancetitle: string;
complianceId: string;
version?: string;
/** CIS profile level, applied by the mapper like on the real page. */
filter?: string;
/** `?section=`: the accordion section to open, like on the real page. */
targetSection?: string;
}
/** A sample framework's detail: real catalog, sample statuses, no reports or Lighthouse. */
export async function ComplianceDetailSamplePreview({
state,
compliancetitle,
complianceId,
version,
filter,
targetSection,
}: ComplianceDetailSamplePreviewProps) {
const detail = await getSampleComplianceDetail(complianceId);
const formattedTitle = compliancetitle.split("-").join(" ");
const title = version ? `${formattedTitle} - ${version}` : formattedTitle;
// The catalog is still warming (or unavailable): same state as the real page.
if (!detail) {
return (
<ContentLayout title={title}>
<SamplePreviewBanner
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<ComplianceWarming />
</ContentLayout>
);
}
const mapper = getComplianceMapper(
detail.attributesData.data[0]?.attributes.framework,
);
const data: Framework[] = mapper.mapComplianceData(
detail.attributesData,
{ data: detail.requirements },
filter,
);
const totals = data.reduce<RequirementsTotals>(
(acc, framework) => ({
pass: acc.pass + framework.pass,
fail: acc.fail + framework.fail,
manual: acc.manual + framework.manual,
}),
{ pass: 0, fail: 0, manual: 0 },
);
const topFailed = mapper.getTopFailedSections(data);
const accordionItems = mapper.toAccordionItems(data, SAMPLE_SCAN.id);
// Mappers key sections as `${framework.name}-${section}`.
const sectionKeys = new Set(
data.map((framework) => `${framework.name}-${targetSection}`),
);
const initialExpandedKeys = targetSection
? accordionItems
.filter((item) => sectionKeys.has(item.key))
.map((item) => item.key)
.slice(0, 1)
: [];
return (
<ContentLayout title={title}>
<SamplePreviewBanner
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<Card variant="base" className="mb-6 w-full gap-4 p-4 md:p-5">
<ComplianceHeader
scans={[]}
uniqueRegions={[]}
showSearch={false}
framework={compliancetitle}
showProviders={false}
logoPath={getComplianceIcon(compliancetitle)}
complianceTitle={compliancetitle}
selectedScan={SAMPLE_SCAN}
/>
</Card>
<div className="flex flex-col gap-8">
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
<RequirementsStatusCard
pass={totals.pass}
fail={totals.fail}
manual={totals.manual}
/>
<TopFailedSectionsCard
sections={topFailed.items}
dataType={topFailed.type}
prepopulated={topFailed.prepopulated}
/>
</div>
{/* Requirement findings come from the sample records via the sample scan id. */}
<ClientAccordionWrapper
items={accordionItems}
defaultExpandedKeys={initialExpandedKeys}
scrollToKey={initialExpandedKeys[0]}
/>
</div>
</ContentLayout>
);
}
@@ -0,0 +1,36 @@
import { expect, it } from "vitest";
import { render } from "@/__tests__/render-browser";
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
import { ComplianceSamplePreview } from "./compliance-sample-preview";
it("opens sample framework details without offering report exports", async () => {
// Given / When
const screen = await render(
<ComplianceSamplePreview
state={{
active: true,
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
runningScan: null,
}}
/>,
);
// Then: frameworks open their sample detail; reports stay unavailable.
await expect
.element(screen.getByRole("heading", { name: "CIS - 5.0", exact: true }))
.toBeVisible();
await expect
.element(
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
)
.toBeEnabled();
await expect
.element(screen.getByRole("progressbar", { name: "ThreatScore progress" }))
.toHaveAttribute("aria-valuenow", String(SAMPLE_THREAT_SCORE.score));
await expect
.element(screen.getByRole("button", { name: /export/i }))
.not.toBeInTheDocument();
});
@@ -1,61 +0,0 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "@/lib/sample-preview/sample-overview";
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
import { ComplianceSamplePreview } from "./compliance-sample-preview";
const { fetchMock, getScanMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
getScanMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
usePathname: () => "/compliance",
}));
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
const STATE = {
active: true as const,
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
runningScan: null,
};
describe("ComplianceSamplePreview", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
});
it("renders the Overview watchlist frameworks as inert cards without fetching", () => {
render(<ComplianceSamplePreview state={STATE} />);
expect(
screen.getByText("You are exploring sample data"),
).toBeInTheDocument();
const cards = screen.getAllByRole("button", {
name: /compliance details$/,
});
expect(cards).toHaveLength(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.length,
);
const cis = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.find(
(item) => item.attributes.compliance_id === "cis_5.0_aws",
)!.attributes;
expect(
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
).toHaveTextContent(
`${cis.requirements_passed} / ${cis.total_requirements}`,
);
expect(cards[0].closest("[inert]")).not.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
expect(getScanMock).not.toHaveBeenCalled();
});
});
@@ -9,16 +9,20 @@ import {
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import type { ComplianceOverviewData } from "@/types/compliance";
import { ComplianceOverviewPanel } from "./compliance-overview-panel";
interface ComplianceSamplePreviewProps {
state: SamplePreviewActive;
/** Totals derived from the same requirements the detail page shows. */
overviews?: ComplianceOverviewData[];
}
/** Sample per-scan compliance: the Overview watchlist frameworks, inert. */
/** Sample per-scan compliance: cards open the sample detail; no reports. */
export function ComplianceSamplePreview({
state,
overviews = SAMPLE_COMPLIANCE_OVERVIEWS,
}: ComplianceSamplePreviewProps) {
return (
<>
@@ -26,37 +30,37 @@ export function ComplianceSamplePreview({
state={state}
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
/>
<div inert>
<div className="mb-6">
<ThreatScoreBadge
score={SAMPLE_THREAT_SCORE.score}
scanId={SAMPLE_SCAN.id}
provider={SAMPLE_SCAN.providerInfo.provider}
selectedScan={SAMPLE_SCAN}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
/>
</div>
<ComplianceOverviewPanel>
<ComplianceFrameworkGrid>
{SAMPLE_COMPLIANCE_OVERVIEWS.map(({ id, attributes }) => (
<div key={id} className="h-full [&>*]:h-full">
<ComplianceCard
title={attributes.framework}
version={attributes.version}
passingRequirements={attributes.requirements_passed}
totalRequirements={attributes.total_requirements}
prevPassingRequirements={attributes.requirements_passed}
prevTotalRequirements={attributes.total_requirements}
scanId={SAMPLE_SCAN.id}
complianceId={id}
id={id}
selectedScan={SAMPLE_SCAN}
/>
</div>
))}
</ComplianceFrameworkGrid>
</ComplianceOverviewPanel>
<div className="mb-6">
<ThreatScoreBadge
readOnly
score={SAMPLE_THREAT_SCORE.score}
scanId={SAMPLE_SCAN.id}
provider={SAMPLE_SCAN.providerInfo.provider}
selectedScan={SAMPLE_SCAN}
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
/>
</div>
<ComplianceOverviewPanel>
<ComplianceFrameworkGrid>
{overviews.map(({ id, attributes }) => (
<div key={id} className="h-full [&>*]:h-full">
<ComplianceCard
hideActions
title={attributes.framework}
version={attributes.version}
passingRequirements={attributes.requirements_passed}
totalRequirements={attributes.total_requirements}
prevPassingRequirements={attributes.requirements_passed}
prevTotalRequirements={attributes.total_requirements}
scanId={SAMPLE_SCAN.id}
complianceId={id}
id={id}
selectedScan={SAMPLE_SCAN}
/>
</div>
))}
</ComplianceFrameworkGrid>
</ComplianceOverviewPanel>
</>
);
}
@@ -65,6 +65,10 @@ vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
getSamplePreviewState: getSamplePreviewStateMock,
}));
vi.mock("@/lib/sample-preview/sample-compliance-requirements", () => ({
getSampleComplianceTotals: async () => [],
}));
vi.mock("./_components/compliance-sample-preview", () => ({
ComplianceSamplePreview: () => <div>Sample compliance preview</div>,
}));
+8 -1
View File
@@ -21,6 +21,8 @@ import { ContentLayout } from "@/components/shadcn/content-layout";
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
import { isReportDownloadLocked } from "@/lib/report-download-access";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { toSampleComplianceOverviews } from "@/lib/sample-preview/sample-compliance";
import { getSampleComplianceTotals } from "@/lib/sample-preview/sample-compliance-requirements";
import { isCloud } from "@/lib/shared/env";
import {
ExpandedScanData,
@@ -78,7 +80,12 @@ export default async function Compliance({
if (preview.active) {
return (
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
<ComplianceSamplePreview state={preview} />
<ComplianceSamplePreview
state={preview}
overviews={toSampleComplianceOverviews(
await getSampleComplianceTotals(),
)}
/>
</ContentLayout>
);
}
@@ -8,15 +8,18 @@ import { SAMPLE_FINDINGS } from "@/lib/sample-preview/sample-records";
import { FindingsSamplePreview } from "./findings-sample-preview";
const { fetchMock, getScanMock, resolveFindingIdsMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
getScanMock: vi.fn(),
resolveFindingIdsMock: vi.fn(),
}));
const { fetchMock, getScanMock, resolveFindingIdsMock, searchParams } =
vi.hoisted(() => ({
// One instance, like Next's hook: a new one per render loops the filter bar.
searchParams: new URLSearchParams(),
fetchMock: vi.fn(),
getScanMock: vi.fn(),
resolveFindingIdsMock: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
useSearchParams: () => searchParams,
usePathname: () => "/findings",
}));
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
@@ -37,6 +40,8 @@ const STATE = {
describe("FindingsSamplePreview", () => {
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
// jsdom has no scrollIntoView; the filter bar's command lists call it.
Element.prototype.scrollIntoView = vi.fn();
// jsdom has no IntersectionObserver; the drill-down's infinite scroll needs one.
vi.stubGlobal(
"IntersectionObserver",
@@ -114,7 +119,7 @@ describe("FindingsSamplePreview", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
it("applies the severity an Overview link carried, with a way back to all", () => {
it("applies the severity an Overview link carried", () => {
render(
<FindingsSamplePreview
state={STATE}
@@ -135,9 +140,17 @@ describe("FindingsSamplePreview", () => {
expect(screen.getByText(check.title)).toBeInTheDocument();
}
expect(screen.queryByText(high.title)).not.toBeInTheDocument();
expect(screen.getByRole("link", { name: "Show all" })).toHaveAttribute(
"href",
"/findings",
);
});
it("offers the real filter bar without fetching its options", () => {
render(<FindingsSamplePreview state={STATE} />);
expect(
screen.getByRole("combobox", { name: /^Filter by Provider\./ }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "More Filters" }),
).toBeInTheDocument();
expect(fetchMock).not.toHaveBeenCalled();
});
});
@@ -1,10 +1,13 @@
import Link from "next/link";
import { FindingsFilters } from "@/components/findings/findings-filters";
import { FindingsGroupTable } from "@/components/findings/table";
import { Button } from "@/components/shadcn";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { FilterTransitionWrapper } from "@/contexts";
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
import { filterSampleFindingGroups } from "@/lib/sample-preview/sample-findings";
import {
filterSampleFindingGroups,
SAMPLE_FILTER_PROVIDER,
SAMPLE_FINDING_FILTER_OPTIONS,
} from "@/lib/sample-preview/sample-findings";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import type { SearchParamsProps } from "@/types/components";
@@ -17,36 +20,43 @@ const SAMPLE_SCAN_FILTERS: Record<string, string> = {
interface FindingsSamplePreviewProps {
state: SamplePreviewActive;
/** Overview drill-downs and the table's search and sorting controls. */
/** The filter bar, Overview drill-downs and the table's search and sorting. */
searchParams?: SearchParamsProps;
}
/** Sample finding groups: no filter controls, no metadata (so no Lighthouse context). */
/** Sample finding groups with the real filter bar; options come from the records. */
export function FindingsSamplePreview({
state,
searchParams = {},
}: FindingsSamplePreviewProps) {
const { groups, filtered, resourceFilters } =
filterSampleFindingGroups(searchParams);
const { groups, resourceFilters } = filterSampleFindingGroups(searchParams);
return (
<>
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.FINDINGS} />
{filtered && (
<p className="text-text-neutral-tertiary mb-4 text-sm">
Showing {groups.length} sample checks matching your filters.{" "}
<Button variant="link" size="link-sm" asChild>
<Link href="/findings">Show all</Link>
</Button>
</p>
)}
<FindingsGroupTable
// Key resets client state (selection, drill-down) when data changes.
key={groups.map((group) => group.id).join(",")}
data={groups}
resolvedFilters={{ ...SAMPLE_SCAN_FILTERS, ...resourceFilters }}
hasHistoricalData
/>
<FilterTransitionWrapper>
<div className="mb-6">
{/* No scans or groups: only the sample account and its own values. */}
<FindingsFilters
providers={[SAMPLE_FILTER_PROVIDER]}
providerGroups={[]}
completedScanIds={[]}
scanDetails={[]}
uniqueRegions={SAMPLE_FINDING_FILTER_OPTIONS.regions}
uniqueServices={SAMPLE_FINDING_FILTER_OPTIONS.services}
uniqueResourceTypes={SAMPLE_FINDING_FILTER_OPTIONS.resourceTypes}
uniqueCategories={SAMPLE_FINDING_FILTER_OPTIONS.categories}
uniqueGroups={SAMPLE_FINDING_FILTER_OPTIONS.groups}
/>
</div>
<FindingsGroupTable
// Key resets client state (selection, drill-down) when data or filters change.
key={`${groups.map((group) => group.id).join(",")}|${JSON.stringify(resourceFilters)}`}
data={groups}
resolvedFilters={{ ...SAMPLE_SCAN_FILTERS, ...resourceFilters }}
hasHistoricalData
/>
</FilterTransitionWrapper>
</>
);
}
@@ -0,0 +1,34 @@
import { expect, it, vi } from "vitest";
import { render } from "@/__tests__/render-browser";
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
import { SAMPLE_ISSUES } from "@/lib/sample-preview/sample-issues";
import { IssuesList } from "./issues-list";
it("opens a sample issue and its findings without fetching tenant data", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
const issue = SAMPLE_ISSUES[0];
const content = await IssuesList({ searchParams: {}, sample: true });
const screen = await render(
<>
{content}
<GlobalSidePanel />
</>,
);
await screen
.getByRole("button", { name: issue.attributes.title, exact: true })
.click();
await screen.getByRole("tab", { name: "Findings", exact: true }).click();
await expect
.element(
screen.getByRole("button", {
name: "Ensure S3 buckets are not publicly accessible",
exact: true,
}),
)
.toBeVisible();
expect(fetchSpy).not.toHaveBeenCalled();
});
@@ -52,7 +52,7 @@ describe("IssuesList sample preview", () => {
);
});
it("renders the sample issues clickable, filtered by verdict, without the API", async () => {
it("filters sample issues by verdict without calling the API", async () => {
// When
render(
await IssuesList({
@@ -62,10 +62,11 @@ describe("IssuesList sample preview", () => {
);
// Then
const title = screen.getByRole("button", {
name: exploitable.attributes.title,
});
expect(title.closest("[inert]")).toBeNull();
expect(
screen.getByRole("button", {
name: exploitable.attributes.title,
}),
).toBeVisible();
expect(
screen.queryByText(notExploitable.attributes.title),
).not.toBeInTheDocument();
+5 -1
View File
@@ -16,6 +16,7 @@ import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-p
import { DOCS_URLS } from "@/lib/external-urls";
import { FEATURE_FLAG } from "@/lib/feature-flags";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { getSampleComplianceTotals } from "@/lib/sample-preview/sample-compliance-requirements";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import { isCloud } from "@/lib/shared/env";
import { SearchParamsProps } from "@/types";
@@ -87,7 +88,10 @@ export default async function Home({
state={samplePreview}
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
/>
<OverviewSamplePreview showIssues={isCloud()} />
<OverviewSamplePreview
showIssues={isCloud()}
complianceWatchlist={await getSampleComplianceTotals()}
/>
</ContentLayout>
);
}
@@ -0,0 +1,53 @@
import { ResourcesFilters } from "@/components/resources/resources-filters";
import { ResourcesTableWithSelection } from "@/components/resources/table";
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
import { FilterTransitionWrapper } from "@/contexts";
import { SAMPLE_FILTER_PROVIDER } from "@/lib/sample-preview/sample-findings";
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
import {
filterSampleResources,
getSampleResourceRow,
SAMPLE_RESOURCE_FILTER_OPTIONS,
} from "@/lib/sample-preview/sample-resources";
import type { SearchParamsProps } from "@/types";
interface ResourcesSamplePreviewProps {
state: SamplePreviewActive;
searchParams: SearchParamsProps;
}
/** Sample resources with the real filter bar, table and resource drawer. */
export function ResourcesSamplePreview({
state,
searchParams,
}: ResourcesSamplePreviewProps) {
const { data, metadata } = filterSampleResources(searchParams);
return (
<>
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.RESOURCES} />
<FilterTransitionWrapper>
<div className="mb-6">
<ResourcesFilters
providers={[SAMPLE_FILTER_PROVIDER]}
providerGroups={[]}
uniqueRegions={SAMPLE_RESOURCE_FILTER_OPTIONS.regions}
uniqueServices={SAMPLE_RESOURCE_FILTER_OPTIONS.services}
uniqueResourceTypes={SAMPLE_RESOURCE_FILTER_OPTIONS.types}
uniqueGroups={SAMPLE_RESOURCE_FILTER_OPTIONS.groups}
/>
</div>
<ResourcesTableWithSelection
data={data}
metadata={metadata}
// A copied resource link reopens its drawer, as on the real page.
initialResource={getSampleResourceRow(
searchParams.resourceId?.toString(),
)}
contributeLighthouseContext={false}
/>
</FilterTransitionWrapper>
</>
);
}
+17
View File
@@ -22,14 +22,31 @@ import {
hasDateOrScanFilter,
replaceFieldKey,
} from "@/lib";
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
import { ResourceProps, SearchParamsProps } from "@/types";
import { ResourcesSamplePreview } from "./_components/resources-sample-preview";
export default async function Resources({
searchParams,
}: {
searchParams: Promise<SearchParamsProps>;
}) {
const resolvedSearchParams = await searchParams;
// No completed scan yet: the demo account stands in for the tenant's resources.
const preview = await getSamplePreviewState();
if (preview.active) {
return (
<ContentLayout title="Resources" icon={<Warehouse />}>
<ResourcesSamplePreview
state={preview}
searchParams={resolvedSearchParams}
/>
</ContentLayout>
);
}
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
+27 -22
View File
@@ -52,6 +52,8 @@ interface ComplianceCardProps {
watchlistAction?: ReactNode;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
/** Hides report downloads and watchlist changes, e.g. for sample scans. */
hideActions?: boolean;
}
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
@@ -65,6 +67,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
isLatestCisForProvider = false,
watchlistAction,
subscriptionOnly = false,
hideActions = false,
}) => {
const searchParams = useSearchParams();
const router = useRouter();
@@ -159,28 +162,30 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
</div>
</CardContent>
</button>
<CardAction
className="absolute top-2 right-2 z-10 flex items-center gap-1"
role="group"
aria-label="Compliance actions"
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
subscriptionOnly={subscriptionOnly}
/>
{watchlistAction}
</CardAction>
{!hideActions && (
<CardAction
className="absolute top-2 right-2 z-10 flex items-center gap-1"
role="group"
aria-label="Compliance actions"
>
<ComplianceDownloadContainer
compact
orientation="column"
buttonWidth="icon"
presentation="dropdown"
scanId={scanId}
complianceId={complianceId}
reportType={getReportTypeForCompliance(
title,
complianceId,
isLatestCisForProvider,
)}
disabled={hasRegionFilter}
subscriptionOnly={subscriptionOnly}
/>
{watchlistAction}
</CardAction>
)}
</Card>
);
};
+53 -42
View File
@@ -38,6 +38,8 @@ interface ThreatScoreBadgeProps {
sectionScores?: SectionScores;
/** Prowler Cloud tenants without a paid plan cannot download reports. */
subscriptionOnly?: boolean;
/** Display scores without detail navigation or report downloads. */
readOnly?: boolean;
}
export const ThreatScoreBadge = ({
@@ -46,7 +48,9 @@ export const ThreatScoreBadge = ({
provider,
sectionScores,
subscriptionOnly = false,
readOnly = false,
}: ThreatScoreBadgeProps) => {
const DetailContainer = readOnly ? "div" : "button";
const router = useRouter();
const runReportDownload = useReportDownload(subscriptionOnly);
const searchParams = useSearchParams();
@@ -117,11 +121,11 @@ export const ThreatScoreBadge = ({
return (
<Card variant="base" padding="md" className="relative gap-4">
<CardContent className="flex flex-col gap-4 p-0 pr-14 lg:flex-row lg:items-start lg:gap-6">
{/* Clickable ThreatScore button */}
<button
className="border-border-neutral-secondary bg-bg-neutral-tertiary hover:border-border-neutral-primary hover:bg-bg-neutral-secondary flex shrink-0 cursor-pointer flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors lg:pr-3"
onClick={handleCardClick}
type="button"
{/* ThreatScore summary, with detail navigation when available. */}
<DetailContainer
className="border-border-neutral-secondary bg-bg-neutral-tertiary enabled:hover:border-border-neutral-primary enabled:hover:bg-bg-neutral-secondary flex shrink-0 flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors enabled:cursor-pointer lg:pr-3"
onClick={readOnly ? undefined : handleCardClick}
type={readOnly ? undefined : "button"}
>
<ThreatScoreLogo />
@@ -136,24 +140,29 @@ export const ThreatScoreBadge = ({
indicatorClassName={getScoreIndicatorClass(getScoreColor(score))}
/>
</div>
</button>
</DetailContainer>
{/* Pillar breakdown — always visible, in canonical order */}
{pillars.length > 0 && (
<div className="border-border-neutral-secondary flex-1 space-y-2 border-t pt-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
{pillars.map(({ name, score: sectionScore, hasData }) => (
<button
<DetailContainer
key={name}
type="button"
onClick={() => hasData && handlePillarClick(name)}
disabled={!hasData}
aria-disabled={!hasData}
aria-label={
hasData
? `Open ${name} details`
: `${name} (no data for this scan)`
type={readOnly ? undefined : "button"}
onClick={
readOnly
? undefined
: () => hasData && handlePillarClick(name)
}
className="hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
disabled={readOnly ? undefined : !hasData}
aria-label={
readOnly
? undefined
: hasData
? `Open ${name} details`
: `${name} (no data for this scan)`
}
className="enabled:hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
>
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
{name}
@@ -175,38 +184,40 @@ export const ThreatScoreBadge = ({
>
{hasData ? `${sectionScore.toFixed(1)}%` : "—"}
</span>
</button>
</DetailContainer>
))}
</div>
)}
</CardContent>
{/* ActionDropdown for downloads — top-right */}
<div className="absolute top-3 right-4">
<ActionDropdown
variant="bordered"
ariaLabel="Open compliance export actions"
>
<ActionDropdownItem
icon={
<FileTextIcon
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
}
label="Download CSV report"
onSelect={handleDownloadCsv}
/>
<ActionDropdownItem
icon={
<DownloadIcon
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
}
label="Download PDF report"
onSelect={handleDownloadPdf}
/>
</ActionDropdown>
</div>
{!readOnly && (
<div className="absolute top-3 right-4">
<ActionDropdown
variant="bordered"
ariaLabel="Open compliance export actions"
>
<ActionDropdownItem
icon={
<FileTextIcon
className={isDownloadingCsv ? "animate-download-icon" : ""}
/>
}
label="Download CSV report"
onSelect={handleDownloadCsv}
/>
<ActionDropdownItem
icon={
<DownloadIcon
className={isDownloadingPdf ? "animate-download-icon" : ""}
/>
}
label="Download PDF report"
onSelect={handleDownloadPdf}
/>
</ActionDropdown>
</div>
)}
</Card>
);
};
@@ -55,15 +55,7 @@ describe("RecheckResourceIconButton", () => {
expect(onRowClick).not.toHaveBeenCalled();
});
it("is always green and pulsing", () => {
render(<RecheckResourceIconButton target={target} />);
const button = screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL });
expect(button.className).toContain("text-button-primary");
expect(button.className).toContain("animate-pulse");
});
it("blocks sample re-checks and removes the pulse", async () => {
it("disables sample re-checks without opening the confirmation or triggering the row", async () => {
const user = userEvent.setup();
const onRowClick = vi.fn();
render(
@@ -75,8 +67,7 @@ describe("RecheckResourceIconButton", () => {
</div>,
);
const button = screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL });
expect(button).toHaveAttribute("aria-disabled", "true");
expect(button.className).not.toContain("animate-pulse");
expect(button).toBeDisabled();
await user.click(button);
expect(usePartialScanStore.getState().activeTarget).toBeNull();
expect(onRowClick).not.toHaveBeenCalled();
@@ -10,7 +10,6 @@ import {
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { usePartialScanTarget } from "@/hooks/use-partial-scan-target";
import { cn } from "@/lib/utils";
import { usePartialScanStore } from "@/store";
import type { PartialScanTarget } from "@/types/partial-scans";
@@ -22,8 +21,7 @@ interface RecheckResourceIconButtonProps {
disabledReason?: string;
}
// Beside "last seen": green and pulsing when a re-check is available.
// It is an action, not a notification, so it never settles into a seen state.
// Beside "last seen", using the shared icon-button and disabled states.
export function RecheckResourceIconButton({
target,
className,
@@ -44,23 +42,22 @@ export function RecheckResourceIconButton({
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="bare"
size="icon"
aria-label={RECHECK_RESOURCE_LABEL}
aria-disabled={Boolean(disabledReason)}
onClick={handleClick}
className={cn(
"size-5 shrink-0 rounded-md",
disabledReason
? "text-text-neutral-tertiary cursor-not-allowed"
: "text-button-primary hover:text-button-primary active:text-button-primary hover:[animation-play-state:paused] focus-visible:[animation-play-state:paused] motion-safe:animate-pulse",
className,
)}
<span
className={className}
tabIndex={disabledReason ? 0 : undefined}
onClick={(event) => event.stopPropagation()}
>
<RefreshCw className="size-3.5" aria-hidden />
</Button>
<Button
type="button"
variant="bare"
size="icon-xs"
aria-label={RECHECK_RESOURCE_LABEL}
disabled={Boolean(disabledReason)}
onClick={handleClick}
>
<RefreshCw aria-hidden />
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{disabledReason ?? RECHECK_RESOURCE_LABEL}
+5 -3
View File
@@ -289,9 +289,11 @@ export function LineChart({
</ChartContainer>
<div className="mt-4 flex flex-col items-start gap-2">
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by severity
</p>
{onPointClick && (
<p className="text-text-neutral-tertiary pl-2 text-xs">
Click to filter by severity
</p>
)}
<ChartLegend
items={legendItems}
selectedItem={selectedLine}
+10 -4
View File
@@ -43,6 +43,7 @@ interface ZeroDataProvider {
}
interface SankeyChartProps {
disableNavigation?: boolean;
data: {
nodes: SankeyNode[];
links: SankeyLink[];
@@ -168,7 +169,8 @@ export const CustomNode = ({
const color = colors[nodeName] || "var(--color-text-neutral-tertiary)";
const isHidden = nodeName === "";
const hasTooltip = !isHidden && payload.newFindings;
const isClickable = SEVERITY_FILTER_MAP[nodeName] !== undefined;
const isClickable =
Boolean(onNodeClick) && SEVERITY_FILTER_MAP[nodeName] !== undefined;
const handleMouseEnter = (e: React.MouseEvent) => {
if (!hasTooltip) return;
@@ -387,7 +389,10 @@ const CustomLink = ({
fill={color}
fillOpacity={getOpacity()}
stroke="none"
style={{ cursor: "pointer", transition: "fill-opacity 0.2s" }}
style={{
cursor: onLinkClick ? "pointer" : "default",
transition: "fill-opacity 0.2s",
}}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
@@ -401,6 +406,7 @@ export function SankeyChart({
data,
zeroDataProviders = [],
height = 400,
disableNavigation = false,
}: SankeyChartProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -510,7 +516,7 @@ export function SankeyChart({
onNodeHover={handleNodeHover}
onNodeMove={handleNodeMove}
onNodeLeave={handleNodeLeave}
onNodeClick={handleNodeClick}
onNodeClick={disableNavigation ? undefined : handleNodeClick}
/>
);
@@ -532,7 +538,7 @@ export function SankeyChart({
onLinkHover={handleLinkHover}
onLinkMove={handleLinkMove}
onLinkLeave={handleLinkLeave}
onLinkClick={handleLinkClick}
onLinkClick={disableNavigation ? undefined : handleLinkClick}
/>
);
+26 -17
View File
@@ -79,7 +79,7 @@ function LocationDetails({
onBarClick,
}: {
location: Pick<LocationPoint, "name" | "totalFindings" | "severityData">;
onBarClick: (dataPoint: BarDataPoint) => void;
onBarClick?: (dataPoint: BarDataPoint) => void;
}) {
return (
<div className="flex w-full flex-col">
@@ -109,6 +109,7 @@ function LocationDetails({
export function ThreatMap({
data,
height = MAP_CONFIG.defaultHeight,
disableNavigation = false,
}: ThreatMapProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -489,26 +490,34 @@ export function ThreatMap({
{selectedLocation ? (
<LocationDetails
location={selectedLocation}
onBarClick={(dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status && selectedLocation.providerType) {
navigateToFindings(
status,
selectedLocation.regionCode,
selectedLocation.providerType,
);
}
}}
onBarClick={
disableNavigation
? undefined
: (dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status && selectedLocation.providerType) {
navigateToFindings(
status,
selectedLocation.regionCode,
selectedLocation.providerType,
);
}
}
}
/>
) : isGlobalSelected && globalAggregatedData ? (
<LocationDetails
location={globalAggregatedData}
onBarClick={(dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status) {
navigateToFindings(status, "global");
}
}}
onBarClick={
disableNavigation
? undefined
: (dp) => {
const status = STATUS_FILTER_MAP[dp.name];
if (status) {
navigateToFindings(status, "global");
}
}
}
/>
) : (
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
+1
View File
@@ -36,6 +36,7 @@ export interface ThreatMapData {
}
export interface ThreatMapProps {
disableNavigation?: boolean;
data: ThreatMapData;
height?: number;
onLocationSelect?: (location: LocationPoint | null) => void;
@@ -0,0 +1,22 @@
import type { ReactNode } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
export function FindingSeverityOverTimeCard({
children,
}: {
children: ReactNode;
}) {
return (
<Card variant="base" className="flex h-full flex-1 flex-col">
<CardHeader className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<CardTitle>Findings Severity Over Time</CardTitle>
</div>
</CardHeader>
<CardContent className="flex flex-1 flex-col px-6">
{children}
</CardContent>
</Card>
);
}
@@ -14,12 +14,15 @@ interface ResourcesTableWithSelectionProps {
data: ResourceProps[];
metadata?: MetaDataProps;
initialResource?: ResourceProps | null;
/** Sample rows must not reach Lighthouse, not even as a count. */
contributeLighthouseContext?: boolean;
}
export function ResourcesTableWithSelection({
data,
metadata,
initialResource = null,
contributeLighthouseContext = true,
}: ResourcesTableWithSelectionProps) {
const safeData = data ?? [];
@@ -36,13 +39,14 @@ export function ResourcesTableWithSelection({
return (
<>
{metadata?.pagination.count !== undefined && (
<LighthouseContextContributor
key={`resources-summary-${metadata.pagination.count}`}
contributorId="resources-summary"
item={buildResourceSummaryContext(metadata.pagination.count)}
/>
)}
{contributeLighthouseContext &&
metadata?.pagination.count !== undefined && (
<LighthouseContextContributor
key={`resources-summary-${metadata.pagination.count}`}
contributorId="resources-summary"
item={buildResourceSummaryContext(metadata.pagination.count)}
/>
)}
<DataTable
columns={columns}
data={safeData}
@@ -14,12 +14,16 @@ import {
SamplePreviewBanner,
} from "./sample-preview-banner";
const { getScanMock, refreshMock } = vi.hoisted(() => ({
const { getScanMock, getCompletedScansMock, refreshMock } = vi.hoisted(() => ({
getScanMock: vi.fn(),
getCompletedScansMock: vi.fn(),
refreshMock: vi.fn(),
}));
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
vi.mock("@/actions/scans", () => ({
getScan: getScanMock,
getScansByState: getCompletedScansMock,
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: refreshMock }),
}));
@@ -42,6 +46,7 @@ const advancePoll = () =>
describe("SamplePreviewBanner", () => {
beforeEach(() => {
vi.useFakeTimers();
getCompletedScansMock.mockResolvedValue({ data: [] });
});
afterEach(() => {
@@ -108,6 +113,26 @@ describe("SamplePreviewBanner", () => {
window.removeEventListener(SAMPLE_PREVIEW_EVENT, listener);
});
it("exits when another scan completes before the tracked scan", async () => {
// Given: the newest scan is still running, but an earlier scan finished.
getScanMock.mockResolvedValue(scanResponse("executing", 30));
getCompletedScansMock.mockResolvedValue({ data: [{ id: "earlier-scan" }] });
render(
<SamplePreviewBanner
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
state={RUNNING}
/>,
);
// When
await advancePoll();
await advancePoll();
// Then: refresh once and stop polling the unfinished scan.
expect(refreshMock).toHaveBeenCalledTimes(1);
expect(getScanMock).toHaveBeenCalledTimes(1);
});
it("re-resolves the page when the tracked scan is gone", async () => {
getScanMock.mockResolvedValue({ error: "Not found", status: 404 });
render(
@@ -217,6 +242,26 @@ describe("SamplePreviewBanner", () => {
expect(screen.getByText("70%")).toBeVisible();
});
it("keeps progress updates and completion working when the completed-scan lookup fails", async () => {
getCompletedScansMock.mockRejectedValue(new Error("Network error"));
getScanMock
.mockResolvedValueOnce(scanResponse("executing", 70))
.mockResolvedValue(scanResponse("completed", 100));
render(
<SamplePreviewBanner
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
state={RUNNING}
/>,
);
await advancePoll();
expect(screen.getByText("70%")).toBeVisible();
expect(refreshMock).not.toHaveBeenCalled();
await advancePoll();
expect(refreshMock).toHaveBeenCalledTimes(1);
});
it("tells a running scan apart and opens the in-progress scans tab", () => {
getScanMock.mockReturnValue(new Promise(() => {}));
render(
@@ -233,4 +278,22 @@ describe("SamplePreviewBanner", () => {
screen.getByRole("link", { name: "View Running Scans" }),
).toHaveAttribute("href", "/scans?tab=active");
});
it("updates the tracked scan without waiting for the completed-scan lookup", async () => {
// Given: the completed-scan lookup never answers.
getCompletedScansMock.mockReturnValue(new Promise(() => {}));
getScanMock.mockResolvedValue(scanResponse("executing", 45));
render(
<SamplePreviewBanner
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
state={RUNNING}
/>,
);
// When
await advancePoll();
// Then
expect(screen.getByText("45%")).toBeVisible();
});
});
@@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { getScan } from "@/actions/scans";
import { getScan, getScansByState } from "@/actions/scans";
import {
Badge,
Button,
@@ -100,50 +100,50 @@ function SamplePreviewBannerContent({ state, page }: SamplePreviewBannerProps) {
let stopped = false;
let inFlight = false;
const exit = () => {
stop();
dispatchSamplePreview({ step: SAMPLE_PREVIEW_STEP.EXITED, page, stage });
toast({
title: "Your scan results are ready",
description: "The sample data has been replaced with your own.",
});
router.refresh();
};
const poll = async () => {
// A slow response must not overwrite a newer one.
if (inFlight) return;
inFlight = true;
// A failed request is a transient empty response: keep polling.
const response = await getScan(runningScan.id)
.catch(() => undefined)
.finally(() => {
inFlight = false;
});
if (stopped) return;
try {
// A failed request is a transient empty response: keep polling.
const response = await getScan(runningScan.id).catch(() => undefined);
if (stopped) return;
// The scan is gone (e.g. its provider was deleted): re-resolve the page.
if (response?.status === 404) {
stop();
router.refresh();
return;
const attributes = response?.data?.attributes;
if (attributes?.state === SCAN_STATES.COMPLETED) {
exit();
return;
}
// Failed/deleted scans need a new stage (or a different running scan).
if (
response?.status === 404 ||
attributes?.state === SCAN_STATES.FAILED
) {
stop();
router.refresh();
return;
}
if (attributes) setProgress(attributes.progress ?? 0);
// After the tracked scan, so a slow lookup never delays its progress.
// Another provider's scan may finish before the one whose progress we show.
const completedScans = await getScansByState().catch(() => undefined);
if (!stopped && completedScans?.data?.length > 0) exit();
} finally {
inFlight = false;
}
const attributes = response?.data?.attributes;
if (!attributes) return;
if (attributes.state === SCAN_STATES.COMPLETED) {
stop();
dispatchSamplePreview({
step: SAMPLE_PREVIEW_STEP.EXITED,
page,
stage,
});
toast({
title: "Your scan results are ready",
description: "The sample data has been replaced with your own.",
});
router.refresh();
return;
}
if (attributes.state === SCAN_STATES.FAILED) {
stop();
router.refresh();
return;
}
setProgress(attributes.progress ?? 0);
};
const intervalId = setInterval(poll, SAMPLE_PREVIEW_POLL_INTERVAL_MS);
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { SAMPLE_ATTACK_PATHS } from "./sample-attack-paths";
import { getSampleFinding } from "./sample-records";
const { nodes, relationships = [] } = SAMPLE_ATTACK_PATHS.result;
const findingNodes = nodes.filter((node) =>
node.labels.includes("ProwlerFinding"),
);
describe("sample attack paths", () => {
it("opens a sample finding record from every finding node", () => {
expect(findingNodes.length).toBeGreaterThan(0);
for (const node of findingNodes) {
expect(getSampleFinding(node.id)?.status).toBe("FAIL");
}
});
it("hangs each finding off the resource that record names", () => {
for (const node of findingNodes) {
const edge = relationships.find((rel) => rel.target === node.id);
const resource = nodes.find((candidate) => candidate.id === edge?.source);
expect(resource?.properties.name).toBe(
getSampleFinding(node.id)?.resource.name,
);
}
});
});
+49 -53
View File
@@ -6,7 +6,8 @@ import type {
QueryResultAttributes,
} from "@/types/attack-paths";
import { SAMPLE_ACCOUNT, SAMPLE_CHECKS } from "./sample-dataset";
import { SAMPLE_ACCOUNT } from "./sample-dataset";
import { failedSampleFindings, type SampleFinding } from "./sample-records";
export interface SampleAttackPaths {
scan: AttackPathScan;
@@ -14,35 +15,48 @@ export interface SampleAttackPaths {
result: QueryResultAttributes;
}
const SAMPLE_REGION = "us-east-1";
const SAMPLE_SCAN_COMPLETED_AT = SAMPLE_ACCOUNT.scannedAt;
const SAMPLE_SCAN_STARTED_AT = "2026-10-28T09:04:00Z";
const arn = (service: string, resource: string, region = SAMPLE_REGION) =>
`arn:aws:${service}:${region}:${SAMPLE_ACCOUNT.uid}:${resource}`;
const resource = (
id: string,
label: string,
properties: GraphNode["properties"],
): GraphNode => ({ id, labels: [label], properties: { id, ...properties } });
// Finding titles and severities come from SAMPLE_CHECKS, like the other pages.
const finding = (id: string, checkId: string): GraphNode => {
const check = SAMPLE_CHECKS.find((sample) => sample.checkId === checkId);
return {
id,
labels: ["ProwlerFinding"],
properties: {
id,
check_id: checkId,
check_title: check?.title ?? checkId,
severity: check?.severity ?? "high",
status: "FAIL",
},
};
// Each finding node is a sample record, so the finding drawer opens it.
const failingRecord = (checkId: string) => {
const [record] = failedSampleFindings(checkId);
if (!record) throw new Error(`No failing sample finding for ${checkId}`);
return record;
};
const SSH_FINDING = failingRecord(
"ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22",
);
const IMDSV2_FINDING = failingRecord("ec2_instance_imdsv2_enabled");
const PRIVESC_FINDING = failingRecord("iam_policy_allows_privilege_escalation");
const PUBLIC_BUCKET_FINDING = failingRecord("s3_bucket_public_access");
const finding = (record: SampleFinding): GraphNode => ({
id: record.id,
labels: ["ProwlerFinding"],
properties: {
id: record.id,
check_id: record.check.checkId,
check_title: record.check.title,
severity: record.check.severity,
status: "FAIL",
},
});
// Resource nodes reuse the records' names and ARNs, so graph and drawer agree.
const recordProperties = ({ resource }: SampleFinding) => ({
name: resource.name,
arn: resource.uid,
region: resource.region,
});
const rel = (
id: string,
source: string,
@@ -58,40 +72,31 @@ const SAMPLE_NODES: GraphNode[] = [
properties: { id: "internet", name: "Internet" },
},
resource("sample-sg-bastion", "EC2SecurityGroup", {
name: "bastion-ssh",
groupid: "sg-0a1b2c3d4e5f60718",
arn: arn("ec2", "security-group/sg-0a1b2c3d4e5f60718"),
region: SAMPLE_REGION,
...recordProperties(SSH_FINDING),
groupid: SSH_FINDING.resource.name,
}),
resource("sample-ec2-bastion", "EC2Instance", {
name: "bastion-01",
instanceid: "i-0f1e2d3c4b5a69788",
arn: arn("ec2", "instance/i-0f1e2d3c4b5a69788"),
...recordProperties(IMDSV2_FINDING),
instanceid: IMDSV2_FINDING.resource.name,
exposed_internet: true,
publicipaddress: "203.0.113.24",
region: SAMPLE_REGION,
}),
resource("sample-role-admin", "AWSRole", {
name: "app-admin-role",
arn: `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:role/app-admin-role`,
}),
resource("sample-policy-admin", "AWSPolicy", {
name: "app-admin-inline",
arn: `arn:aws:iam::${SAMPLE_ACCOUNT.uid}:policy/app-admin-inline`,
name: PRIVESC_FINDING.resource.name,
arn: PRIVESC_FINDING.resource.uid,
}),
resource("sample-s3-exports", "S3Bucket", {
name: "acme-customer-exports",
arn: "arn:aws:s3:::acme-customer-exports",
region: SAMPLE_REGION,
...recordProperties(PUBLIC_BUCKET_FINDING),
anonymous_access: true,
}),
finding(
"sample-finding-ssh",
"ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_22",
),
finding("sample-finding-imdsv2", "ec2_instance_imdsv2_enabled"),
finding("sample-finding-privesc", "iam_policy_allows_privilege_escalation"),
finding("sample-finding-public-bucket", "s3_bucket_public_access"),
finding(SSH_FINDING),
finding(IMDSV2_FINDING),
finding(PRIVESC_FINDING),
finding(PUBLIC_BUCKET_FINDING),
];
const SAMPLE_RELATIONSHIPS: GraphRelationship[] = [
@@ -106,23 +111,14 @@ const SAMPLE_RELATIONSHIPS: GraphRelationship[] = [
),
rel("sample-r4", "sample-role-admin", "sample-policy-admin", "POLICY"),
rel("sample-r5", "sample-policy-admin", "sample-s3-exports", "CAN_ACCESS"),
rel("sample-r6", "sample-sg-bastion", "sample-finding-ssh", "HAS_FINDING"),
rel(
"sample-r7",
"sample-ec2-bastion",
"sample-finding-imdsv2",
"HAS_FINDING",
),
rel(
"sample-r8",
"sample-role-admin",
"sample-finding-privesc",
"HAS_FINDING",
),
rel("sample-r6", "sample-sg-bastion", SSH_FINDING.id, "HAS_FINDING"),
rel("sample-r7", "sample-ec2-bastion", IMDSV2_FINDING.id, "HAS_FINDING"),
// The record's failing resource is the policy the role carries.
rel("sample-r8", "sample-policy-admin", PRIVESC_FINDING.id, "HAS_FINDING"),
rel(
"sample-r9",
"sample-s3-exports",
"sample-finding-public-bucket",
PUBLIC_BUCKET_FINDING.id,
"HAS_FINDING",
),
];
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { getComplianceAttributesMock } = vi.hoisted(() => ({
getComplianceAttributesMock: vi.fn(),
}));
vi.mock("@/actions/compliances", () => ({
getComplianceAttributes: getComplianceAttributesMock,
}));
import { REQUIREMENT_STATUS } from "@/types/compliance";
import {
getSampleComplianceDetail,
getSampleComplianceTotals,
} from "./sample-compliance-requirements";
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "./sample-overview";
import {
isSampleScanFilter,
SAMPLE_FINDINGS,
sampleFindingsQuery,
} from "./sample-records";
const FAILING = SAMPLE_FINDINGS.find((finding) => finding.status === "FAIL")!;
const requirement = (id: string, checkIds: string[]) => ({
type: "compliance-requirements-attributes",
id,
attributes: {
framework_description: "",
framework: "CIS",
version: "5.0",
description: `Requirement ${id}`,
attributes: { metadata: [], check_ids: checkIds },
},
});
describe("sample compliance requirements", () => {
beforeEach(() => {
getComplianceAttributesMock.mockReset();
});
it("derives each requirement's status from the sample findings", async () => {
// Given
getComplianceAttributesMock.mockResolvedValue({
data: [
requirement("1.1", [FAILING.check.checkId]),
requirement("1.2", ["check_outside_the_sample"]),
requirement("1.3", []),
],
});
// When
const detail = await getSampleComplianceDetail("cis_5.0_aws");
// Then: the catalog is read without a scan, so no tenant data is involved.
expect(getComplianceAttributesMock).toHaveBeenCalledWith("cis_5.0_aws");
expect(
detail?.requirements.map((item) => [item.id, item.attributes.status]),
).toEqual([
["1.1", REQUIREMENT_STATUS.FAIL],
["1.2", REQUIREMENT_STATUS.PASS],
["1.3", REQUIREMENT_STATUS.MANUAL],
]);
});
it("counts the cards from the same requirements the detail shows", async () => {
// Given
getComplianceAttributesMock.mockResolvedValue({
data: [
requirement("1.1", [FAILING.check.checkId]),
requirement("1.2", []),
],
});
// When
const totals = await getSampleComplianceTotals();
// Then
expect(totals).toHaveLength(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.length,
);
expect(totals[0].attributes).toMatchObject({
requirements_passed: 0,
requirements_failed: 1,
requirements_manual: 1,
total_requirements: 2,
});
});
it("keeps the fixed totals when the catalog is unavailable", async () => {
// Given
getComplianceAttributesMock.mockResolvedValue(undefined);
// When
const totals = await getSampleComplianceTotals();
// Then
expect(totals).toEqual(SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data);
});
it("answers a requirement's findings for the sample scan", () => {
// Given
const filters = {
"filter[scan__in]": "sample-scan",
"filter[check_id__in]": FAILING.check.checkId,
"filter[status__in]": "FAIL",
};
// When / Then
expect(isSampleScanFilter(filters)).toBe(true);
const findings = sampleFindingsQuery(filters);
expect(findings.length).toBeGreaterThan(0);
expect(
findings.every(
(finding) =>
finding.check.checkId === FAILING.check.checkId &&
finding.status === "FAIL",
),
).toBe(true);
});
});
@@ -0,0 +1,89 @@
import { cache } from "react";
import { getComplianceAttributes } from "@/actions/compliances";
import type { ComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.types";
import {
type AttributesData,
REQUIREMENT_STATUS,
type RequirementItemData,
type RequirementStatus,
} from "@/types/compliance";
import { SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE } from "./sample-overview";
import { SAMPLE_FINDINGS } from "./sample-records";
// Requirement statuses for the sample scan: the framework catalog (no tenant
// data) evaluated against the sample findings, the way a scan would.
const FAILING_CHECKS = new Set(
SAMPLE_FINDINGS.filter((finding) => finding.status === "FAIL").map(
(finding) => finding.check.checkId,
),
);
// Checks the sample does not evaluate count as passing: a clean account.
const requirementStatus = (checkIds: string[]): RequirementStatus => {
if (checkIds.length === 0) return REQUIREMENT_STATUS.MANUAL;
return checkIds.some((checkId) => FAILING_CHECKS.has(checkId))
? REQUIREMENT_STATUS.FAIL
: REQUIREMENT_STATUS.PASS;
};
export interface SampleComplianceDetail {
attributesData: AttributesData;
requirements: RequirementItemData[];
}
/** The framework's requirements with sample statuses; null if the catalog is unavailable. */
export const getSampleComplianceDetail = cache(
async (complianceId: string): Promise<SampleComplianceDetail | null> => {
// No scan id: the catalog of the provider that declares the framework.
const attributesData = await getComplianceAttributes(complianceId);
if (!attributesData || !("data" in attributesData)) return null;
if (!Array.isArray(attributesData.data)) return null;
const typed = attributesData as AttributesData;
return {
attributesData: typed,
requirements: typed.data.map((item) => ({
type: "compliance-requirements-details",
id: item.id,
attributes: {
framework: item.attributes.framework,
version: item.attributes.version,
description: item.attributes.description,
status: requirementStatus(item.attributes.attributes.check_ids ?? []),
},
})),
};
},
);
type WatchlistItem = ComplianceWatchlistResponse["data"][number];
/** Card totals from the same requirements; the fixed figures if a catalog is missing. */
export const getSampleComplianceTotals = async (): Promise<WatchlistItem[]> =>
Promise.all(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.map(async (item) => {
const detail = await getSampleComplianceDetail(item.id);
if (!detail) return item;
const count = (status: RequirementStatus) =>
detail.requirements.filter(
(requirement) => requirement.attributes.status === status,
).length;
const passed = count(REQUIREMENT_STATUS.PASS);
const failed = count(REQUIREMENT_STATUS.FAIL);
const manual = count(REQUIREMENT_STATUS.MANUAL);
return {
...item,
attributes: {
...item.attributes,
requirements_passed: passed,
requirements_failed: failed,
requirements_manual: manual,
total_requirements: passed + failed + manual,
},
};
}),
);
+12 -2
View File
@@ -1,3 +1,4 @@
import type { ComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.types";
import {
COMPLIANCE_OVERVIEW_TYPE,
ComplianceOverviewData,
@@ -25,8 +26,13 @@ const SAMPLE_FRAMEWORK_NAMES: Record<
soc2_aws: { framework: "SOC2", version: "" },
};
export const SAMPLE_COMPLIANCE_OVERVIEWS: ComplianceOverviewData[] =
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data.map(({ attributes }) => ({
type WatchlistItem = ComplianceWatchlistResponse["data"][number];
/** Per-scan compliance overviews for the given watchlist totals. */
export const toSampleComplianceOverviews = (
items: WatchlistItem[],
): ComplianceOverviewData[] =>
items.map(({ attributes }) => ({
type: COMPLIANCE_OVERVIEW_TYPE.OVERVIEW,
id: attributes.compliance_id,
attributes: {
@@ -41,6 +47,10 @@ export const SAMPLE_COMPLIANCE_OVERVIEWS: ComplianceOverviewData[] =
},
}));
export const SAMPLE_COMPLIANCE_OVERVIEWS = toSampleComplianceOverviews(
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data,
);
export const SAMPLE_SCAN: ScanEntity = {
id: SAMPLE_ACCOUNT.scanId,
providerInfo: {
@@ -7,6 +7,7 @@ import { getFindingGroupResources } from "@/actions/finding-groups";
import { SAMPLE_ACCOUNT, SAMPLE_CHECKS } from "./sample-dataset";
import { filterSampleFindingGroups } from "./sample-findings";
import { SAMPLE_STATUS } from "./sample-overview";
import { SAMPLE_FINDINGS } from "./sample-records";
describe("sample finding filters", () => {
it("shows passing sample findings reached from the Overview PASS segment", () => {
@@ -94,4 +95,100 @@ describe("sample finding filters", () => {
expect(result.data).toHaveLength(1);
expect(result.meta.pagination.count).toBe(1);
});
it("narrows to new failures when the Overview link asks for them", () => {
// Given
const newChecks = SAMPLE_CHECKS.filter((check) => check.newFailed > 0);
// When
const result = filterSampleFindingGroups({
"filter[status__in]": "FAIL",
"filter[delta]": "new",
});
// Then
expect(result.groups.map((group) => group.checkId).sort()).toEqual(
newChecks.map((check) => check.checkId).sort(),
);
expect(result.resourceFilters).toEqual({
"filter[status__in]": "FAIL",
"filter[delta]": "new",
});
});
it("counts only the resources in the selected region and type", () => {
// Given
const [{ resource }] = SAMPLE_FINDINGS;
const matching = SAMPLE_FINDINGS.filter(
(finding) =>
finding.resource.region === resource.region &&
finding.resource.type === resource.type,
);
// When
const result = filterSampleFindingGroups({
"filter[region__in]": resource.region,
"filter[resource_type__in]": resource.type,
});
// Then
expect(
result.groups.reduce(
(total, group) =>
total + (group.failCount ?? 0) + (group.passCount ?? 0),
0,
),
).toBe(matching.length);
});
it("keeps the sample account for its provider and drops muted-only views", () => {
expect(
filterSampleFindingGroups({ "filter[provider_type__in]": "aws" }).groups,
).toHaveLength(SAMPLE_CHECKS.length);
expect(
filterSampleFindingGroups({ "filter[provider_type__in]": "azure" })
.groups,
).toHaveLength(0);
expect(
filterSampleFindingGroups({ "filter[muted]": "true" }).groups,
).toHaveLength(0);
});
it("applies the group's region filter to its expanded resources", async () => {
// Given
const check = "s3_bucket_public_access";
const region = SAMPLE_FINDINGS.find(
(finding) => finding.check.checkId === check,
)!.resource.region;
// When
const result = await getFindingGroupResources({
checkId: check,
pageSize: 100,
filters: {
"filter[scan]": SAMPLE_ACCOUNT.scanId,
"filter[region__in]": region,
},
});
// Then
expect(result.data.length).toBeGreaterThan(0);
expect(
result.data.every(
(row: { attributes: { resource: { region: string } } }) =>
row.attributes.resource.region === region,
),
).toBe(true);
});
it("matches the date filter against the sample scan's day", () => {
const scanDay = SAMPLE_ACCOUNT.scannedAt.slice(0, 10);
expect(
filterSampleFindingGroups({ "filter[inserted_at]": scanDay }).groups,
).toHaveLength(SAMPLE_CHECKS.length);
expect(
filterSampleFindingGroups({ "filter[inserted_at]": "2020-01-01" }).groups,
).toHaveLength(0);
});
});
+85 -18
View File
@@ -6,13 +6,20 @@ import {
type FindingStatus,
SearchParamsProps,
} from "@/types/components";
import type { ProviderProps } from "@/types/providers";
import {
SAMPLE_ACCOUNT,
SAMPLE_CHECKS,
type SampleCheck,
} from "./sample-dataset";
import { SAMPLE_ID_PREFIX } from "./sample-records";
import {
matchesSampleFindingFilters,
pickSampleFindingFilters,
SAMPLE_FINDINGS,
SAMPLE_ID_PREFIX,
type SampleFinding,
} from "./sample-records";
// Finding groups derived from SAMPLE_CHECKS, in the API response shape so they
// run through the same adapter as real data.
@@ -44,14 +51,19 @@ interface SampleFindingGroupItem {
attributes: SampleFindingGroupAttributes;
}
// Counts come from the matching records, so a filtered group adds up.
const toFindingGroupItem = (
check: SampleCheck,
statuses: Set<FindingStatus> | null,
findings: SampleFinding[],
): SampleFindingGroupItem => {
const failed =
!statuses || statuses.has(FINDING_STATUS.FAIL) ? check.failed : 0;
const passed =
!statuses || statuses.has(FINDING_STATUS.PASS) ? check.passed : 0;
const failed = findings.filter(
(finding) => finding.status === FINDING_STATUS.FAIL,
).length;
const passed = findings.length - failed;
const newFailed = findings.filter(
(finding) =>
finding.status === FINDING_STATUS.FAIL && finding.delta === "new",
).length;
return {
type: "finding-groups",
// The sample prefix keeps the group out of Lighthouse context.
@@ -64,14 +76,14 @@ const toFindingGroupItem = (
status: failed > 0 ? FINDING_STATUS.FAIL : FINDING_STATUS.PASS,
muted: false,
impacted_providers: ["aws"],
resources_total: failed + passed,
resources_total: findings.length,
resources_fail: failed,
pass_count: passed,
fail_count: failed,
muted_count: 0,
new_count: failed > 0 ? check.newFailed : 0,
new_count: newFailed,
changed_count: 0,
new_fail_count: failed > 0 ? check.newFailed : 0,
new_fail_count: newFailed,
first_seen_at: SAMPLE_ACCOUNT.scannedAt,
last_seen_at: SAMPLE_ACCOUNT.scannedAt,
failing_since: SAMPLE_ACCOUNT.scannedAt,
@@ -88,9 +100,10 @@ const SAMPLE_GROUP_FILTERS: Record<
"filter[service__in]": (check) => check.service,
"filter[category__in]": (check) => check.attackSurface,
"filter[check_id__in]": (check) => check.checkId,
"filter[resource_groups__in]": (check) => check.resourceGroup,
};
/** The sample groups an Overview link asked for, and whether any filter applied. */
/** The sample groups the URL filters ask for, and the filters for their drill-downs. */
export const filterSampleFindingGroups = (searchParams: SearchParamsProps) => {
const statuses = getActiveStatusFilter(searchParams);
const query = searchParams["filter[search]"];
@@ -103,11 +116,18 @@ export const filterSampleFindingGroups = (searchParams: SearchParamsProps) => {
: [];
},
);
const findingFilters = pickSampleFindingFilters(searchParams);
const findingsByCheck = new Map<string, SampleFinding[]>();
for (const finding of SAMPLE_FINDINGS) {
if (statuses && !statuses.has(finding.status)) continue;
if (!matchesSampleFindingFilters(finding, findingFilters)) continue;
const checkFindings = findingsByCheck.get(finding.check.checkId) ?? [];
checkFindings.push(finding);
findingsByCheck.set(finding.check.checkId, checkFindings);
}
const checks = SAMPLE_CHECKS.filter(
(check) =>
(!statuses ||
(statuses.has(FINDING_STATUS.FAIL) && check.failed > 0) ||
(statuses.has(FINDING_STATUS.PASS) && check.passed > 0)) &&
findingsByCheck.has(check.checkId) &&
(!search ||
`${check.checkId} ${check.title}`.toLowerCase().includes(search)) &&
active.every(({ field, accepted }) => {
@@ -131,14 +151,61 @@ export const filterSampleFindingGroups = (searchParams: SearchParamsProps) => {
});
}
}
const resourceFilters: Record<string, string> = statuses
? { "filter[status__in]": Array.from(statuses).join(",") }
: {};
const resourceFilters: Record<string, string> = {
...(statuses
? { "filter[status__in]": Array.from(statuses).join(",") }
: {}),
...findingFilters,
};
return {
groups: adaptFindingGroupsResponse({
data: checks.map((check) => toFindingGroupItem(check, statuses)),
data: checks.map((check) =>
toFindingGroupItem(check, findingsByCheck.get(check.checkId) ?? []),
),
}),
filtered: active.length > 0 || statuses !== null || Boolean(search),
resourceFilters,
};
};
const unique = (values: (string | undefined)[]) =>
Array.from(
new Set(values.filter((value): value is string => Boolean(value))),
).sort();
/** Filter bar options, derived from the records the sample filters match. */
export const SAMPLE_FINDING_FILTER_OPTIONS = {
regions: unique(SAMPLE_FINDINGS.map((finding) => finding.resource.region)),
services: unique(SAMPLE_CHECKS.map((check) => check.service)),
resourceTypes: unique(
SAMPLE_FINDINGS.map((finding) => finding.resource.type),
),
categories: unique(SAMPLE_CHECKS.map((check) => check.attackSurface)),
groups: unique(SAMPLE_CHECKS.map((check) => check.resourceGroup)),
};
/** The sample account, so the provider filter can select it. */
export const SAMPLE_FILTER_PROVIDER: ProviderProps = {
id: SAMPLE_ACCOUNT.providerId,
type: "providers",
attributes: {
provider: "aws",
is_dynamic: false,
uid: SAMPLE_ACCOUNT.uid,
alias: SAMPLE_ACCOUNT.alias,
status: "completed",
resources: SAMPLE_FINDINGS.length,
connection: { connected: true, last_checked_at: SAMPLE_ACCOUNT.scannedAt },
scanner_args: {
only_logs: false,
excluded_checks: [],
aws_retries_max_attempts: 3,
},
inserted_at: SAMPLE_ACCOUNT.scannedAt,
updated_at: SAMPLE_ACCOUNT.scannedAt,
created_by: { object: "user", id: `${SAMPLE_ID_PREFIX}user` },
},
relationships: {
secret: { data: null },
provider_groups: { meta: { count: 0 }, data: [] },
},
};
@@ -7,6 +7,7 @@ import {
SAMPLE_THREAT_MAP_DATA,
} from "./sample-graphs";
import { SAMPLE_STATUS } from "./sample-overview";
import { getSampleFinding } from "./sample-records";
describe("sample graphs", () => {
it("agrees with the Overview failed and new totals", () => {
@@ -26,4 +27,17 @@ describe("sample graphs", () => {
Math.min(SAMPLE_STATUS.failNew, 10),
);
});
it("lists new failed findings that open the same sample record", () => {
for (const finding of SAMPLE_NEW_FINDINGS) {
const record = getSampleFinding(finding.id);
expect(record?.status).toBe("FAIL");
expect(record?.delta).toBe("new");
expect(finding.relationships.resource.attributes.name).toBe(
record?.resource.name,
);
expect(finding.relationships.provider.attributes.alias).toBeTruthy();
}
});
});
+14 -153
View File
@@ -12,12 +12,7 @@ import type { RiskPlotPoint } from "@/actions/overview/risk-plot/types/risk-plot
import { adaptCategoryOverviewToRadarData } from "@/actions/overview/risk-radar/risk-radar.adapter";
import type { CategoryOverviewResponse } from "@/actions/overview/risk-radar/types";
import type { RadarDataPoint } from "@/components/graphs/types";
import {
FINDING_DELTA,
FINDING_STATUS,
type FindingProps,
SEVERITY,
} from "@/types/components";
import { type FindingProps, SEVERITY } from "@/types/components";
import {
SAMPLE_ACCOUNT,
@@ -29,6 +24,7 @@ import {
SAMPLE_FAILED_BY_SEVERITY,
SAMPLE_THREAT_SCORE,
} from "./sample-overview";
import { SAMPLE_FINDINGS, sampleExpandedFindings } from "./sample-records";
// Graphs-tab payloads derived from SAMPLE_CHECKS, run through the real adapters.
@@ -47,9 +43,6 @@ const splitAcrossRegions = (count: number, offset: number): number[] =>
return base + (slot < count % SAMPLE_REGIONS.length ? 1 : 0);
});
const regionFor = (checkIndex: number, ordinal: number) =>
SAMPLE_REGIONS[(checkIndex + ordinal) % SAMPLE_REGIONS.length];
export const SAMPLE_SANKEY_DATA: SankeyData = adaptToSankeyData({
[PROVIDER_TYPE]: SAMPLE_FAILED_BY_SEVERITY,
});
@@ -167,138 +160,6 @@ const SAMPLE_CATEGORIES_RESPONSE: CategoryOverviewResponse = {
export const SAMPLE_RADAR_DATA: RadarDataPoint[] =
adaptCategoryOverviewToRadarData(SAMPLE_CATEGORIES_RESPONSE);
interface SampleResourceKind {
type: string;
prefix: string;
}
const RESOURCE_BY_SERVICE: Record<string, SampleResourceKind> = {
s3: { type: "AwsS3Bucket", prefix: "sample-exports-bucket" },
rds: { type: "AwsRdsDbInstance", prefix: "sample-orders-db" },
ec2: { type: "AwsEc2Instance", prefix: "sample-web-instance" },
iam: { type: "AwsIamUser", prefix: "sample-iam-user" },
};
const buildNewFinding = (
check: SampleCheck,
checkIndex: number,
ordinal: number,
): FindingProps => {
const region = regionFor(checkIndex, ordinal);
const kind = RESOURCE_BY_SERVICE[check.service] ?? {
type: "Other",
prefix: `sample-${check.service}-resource`,
};
const id = `sample-finding-${check.checkId}-${ordinal + 1}`;
const resourceId = `sample-resource-${check.checkId}-${ordinal + 1}`;
const resourceName = `${kind.prefix}-${ordinal + 1}`;
const timestamp = SAMPLE_ACCOUNT.scannedAt;
return {
type: "findings",
id,
attributes: {
uid: id,
delta: FINDING_DELTA.NEW,
status: FINDING_STATUS.FAIL,
status_extended: `${check.title}: ${resourceName} fails this check.`,
severity: check.severity,
check_id: check.checkId,
muted: false,
check_metadata: {
risk: "",
notes: "",
checkid: check.checkId,
provider: PROVIDER_TYPE,
severity: check.severity,
checktype: [],
dependson: [],
relatedto: [],
categories: categoriesOf(check),
checktitle: check.title,
compliance: null,
relatedurl: "",
description: check.title,
remediation: {
code: { cli: "", other: "", nativeiac: "", terraform: "" },
recommendation: { url: "", text: "" },
},
servicename: check.service,
checkaliases: [],
resourcetype: kind.type,
subservicename: "",
resourceidtemplate: "",
},
raw_result: null,
inserted_at: timestamp,
updated_at: timestamp,
first_seen_at: timestamp,
},
relationships: {
resources: { data: [{ type: "resources", id: resourceId }] },
scan: {
data: { type: "scans", id: SAMPLE_ACCOUNT.scanId },
attributes: {
name: "Sample scan",
trigger: "manual",
state: "completed",
unique_resource_count: 0,
progress: 100,
scanner_args: { checks_to_execute: [] },
duration: 0,
started_at: timestamp,
inserted_at: timestamp,
completed_at: timestamp,
scheduled_at: null,
next_scan_at: timestamp,
},
},
resource: {
data: [{ type: "resources", id: resourceId }],
id: resourceId,
attributes: {
uid: `arn:aws:${check.service}:${region}:${SAMPLE_ACCOUNT.uid}:${resourceName}`,
name: resourceName,
region,
service: check.service,
tags: {},
type: kind.type,
inserted_at: timestamp,
updated_at: timestamp,
details: null,
partition: "aws",
},
relationships: {
provider: {
data: { type: "providers", id: SAMPLE_ACCOUNT.providerId },
},
findings: {
meta: { count: 1 },
data: [{ type: "findings", id }],
},
},
links: { self: "" },
},
provider: {
data: { type: "providers", id: SAMPLE_ACCOUNT.providerId },
attributes: {
provider: PROVIDER_TYPE,
uid: SAMPLE_ACCOUNT.uid,
alias: SAMPLE_ACCOUNT.alias,
connection: { connected: true, last_checked_at: timestamp },
inserted_at: timestamp,
updated_at: timestamp,
},
relationships: {
secret: { data: { type: "provider-secrets", id: "sample-secret" } },
},
links: { self: "" },
},
},
links: { self: "" },
};
};
const SEVERITY_RANK = [
SEVERITY.CRITICAL,
SEVERITY.HIGH,
@@ -307,16 +168,16 @@ const SEVERITY_RANK = [
SEVERITY.INFORMATIONAL,
];
// Latest new failed findings, most severe first, like the real tab.
export const SAMPLE_NEW_FINDINGS: FindingProps[] = SAMPLE_CHECKS.flatMap(
(check, checkIndex) =>
Array.from({ length: check.newFailed }, (_, ordinal) =>
buildNewFinding(check, checkIndex, ordinal),
),
)
.sort(
(a, b) =>
SEVERITY_RANK.indexOf(a.attributes.severity) -
SEVERITY_RANK.indexOf(b.attributes.severity),
// Latest new failed findings, most severe first, like the real tab. They are
// the sample records, so a row opens the same finding the drawer resolves.
export const SAMPLE_NEW_FINDINGS = sampleExpandedFindings(
SAMPLE_FINDINGS.filter(
(finding) => finding.status === "FAIL" && finding.delta === "new",
)
.slice(0, NEW_FINDINGS_LIMIT);
.sort(
(a, b) =>
SEVERITY_RANK.indexOf(a.check.severity) -
SEVERITY_RANK.indexOf(b.check.severity),
)
.slice(0, NEW_FINDINGS_LIMIT),
) as unknown as FindingProps[];
@@ -18,6 +18,7 @@ export const SAMPLE_PREVIEW_PAGE = {
COMPLIANCE: "compliance",
ISSUES: "issues",
ATTACK_PATHS: "attack_paths",
RESOURCES: "resources",
} as const;
export type SamplePreviewPage =
+96 -6
View File
@@ -437,6 +437,17 @@ export const sampleFindingsDocument = (findings: SampleFinding[]) => {
};
};
/** Findings with their resource, scan and provider inlined, like the Overview tables. */
export const sampleExpandedFindings = (findings: SampleFinding[]) =>
findings.map((finding) => ({
...findingItem(finding),
relationships: {
scan: SCAN_ITEM,
resource: resourceItem(finding.resource),
provider: PROVIDER_ITEM,
},
}));
/** /findings?include=resources: the issue panel asks for nothing else. */
export const sampleIssueFindingsDocument = (findings: SampleFinding[]) => ({
data: findings.map(findingItem),
@@ -457,6 +468,53 @@ const paginate = <T>(items: T[], page: number, pageSize: number) => ({
},
});
type SampleFilterParams = Record<string, string | string[] | undefined>;
// Resource-level filters the Findings page sets, matched per sample finding.
const SAMPLE_FINDING_FILTERS: Record<
string,
(finding: SampleFinding) => string | null
> = {
"filter[region__in]": (finding) => finding.resource.region,
"filter[resource_type__in]": (finding) => finding.resource.type,
"filter[delta]": (finding) => finding.delta,
"filter[delta__in]": (finding) => finding.delta,
"filter[provider_type__in]": () => "aws",
"filter[provider_id__in]": () => SAMPLE_ACCOUNT.providerId,
// The date picker sends YYYY-MM-DD: every sample finding is from one scan.
"filter[inserted_at]": () => SAMPLE_ACCOUNT.scannedAt.slice(0, 10),
};
const MUTED_FILTER = "filter[muted]";
/** The resource-level filters present in a request, as plain strings. */
export const pickSampleFindingFilters = (
filters: SampleFilterParams,
): Record<string, string> =>
Object.fromEntries(
[...Object.keys(SAMPLE_FINDING_FILTERS), MUTED_FILTER].flatMap((key) => {
const raw = filters[key];
const value = Array.isArray(raw) ? raw.join(",") : raw;
// "false" is the page default: only asking for muted findings narrows.
if (key === MUTED_FILTER && value !== "true") return [];
return value ? [[key, value]] : [];
}),
);
/** Whether a sample finding passes the picked resource-level filters. */
export const matchesSampleFindingFilters = (
finding: SampleFinding,
filters: Record<string, string>,
) =>
// Sample findings are never muted.
filters[MUTED_FILTER] !== "true" &&
Object.entries(filters).every(([key, value]) => {
const field = SAMPLE_FINDING_FILTERS[key];
if (!field) return true;
const actual = field(finding);
return actual !== null && value.split(",").includes(actual);
});
/** /finding-groups/{check_id}/resources: failed first, like the API's sort. */
export const sampleFindingGroupResources = ({
checkId,
@@ -464,17 +522,20 @@ export const sampleFindingGroupResources = ({
pageSize,
status,
search,
filters = {},
}: {
checkId: string;
page: number;
pageSize: number;
status?: string;
search?: string;
filters?: Record<string, string>;
}) => {
const findings = SAMPLE_FINDINGS.filter(
(finding) =>
finding.check.checkId === checkId &&
(!status || status.split(",").includes(finding.status)) &&
matchesSampleFindingFilters(finding, filters) &&
(!search ||
finding.resource.name.toLowerCase().includes(search.toLowerCase())),
);
@@ -521,23 +582,52 @@ export const sampleFindingsQuery = (
const raw = filters[key];
return Array.isArray(raw) ? raw.join(",") : raw;
};
const list = (key: string) => value(key)?.split(",");
const resourceUid = value("filter[resource_uid]");
const checkId = value("filter[check_id]");
const status = value("filter[status]");
const uids = value("filter[uid__in]")?.split(",");
const checkIds = list("filter[check_id]") ?? list("filter[check_id__in]");
const statuses = list("filter[status]") ?? list("filter[status__in]");
const regions = list("filter[region__in]");
const uids = list("filter[uid__in]");
return SAMPLE_FINDINGS.filter(
(finding) =>
(!resourceUid || finding.resource.uid === resourceUid) &&
(!checkId || finding.check.checkId === checkId) &&
(!status || finding.status === status) &&
(!checkIds || checkIds.includes(finding.check.checkId)) &&
(!statuses || statuses.includes(finding.status)) &&
(!regions || regions.includes(finding.resource.region)) &&
(!uids || uids.includes(finding.uid)),
);
};
/** The resource drawer's data: the resource's failed findings, no API calls. */
export const sampleResourceDrawerData = (resourceId: string, query = "") => {
const search = query.trim().toLowerCase();
const findings = SAMPLE_FINDINGS.filter(
(finding) =>
finding.resource.id === resourceId &&
finding.status === "FAIL" &&
(!search ||
`${finding.check.checkId} ${finding.check.title}`
.toLowerCase()
.includes(search)),
);
return {
findings: findings.map(findingItem),
findingsMeta: {
pagination: { page: 1, pages: 1, count: findings.length },
version: "v1",
},
providerOrg: null,
resourceTags: {},
};
};
/** Whether a request is scoped to the sample scan. */
export const isSampleScanFilter = (
filters: Record<string, string | string[] | undefined> | undefined,
) => filters?.["filter[scan]"] === SAMPLE_ACCOUNT.scanId;
) =>
filters?.["filter[scan]"] === SAMPLE_ACCOUNT.scanId ||
// Compliance requirements ask by scan list, with the sample scan alone.
filters?.["filter[scan__in]"] === SAMPLE_ACCOUNT.scanId;
/** /findings/{id}/compliance-frameworks: the check's frameworks, all watched. */
export const sampleComplianceFrameworks = (
@@ -0,0 +1,136 @@
import { describe, expect, it, vi } from "vitest";
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
import { getResourceDrawerData } from "@/actions/resources";
import { SAMPLE_RESOURCE_GROUPS_RESPONSE } from "./sample-overview";
import { SAMPLE_FINDINGS, SAMPLE_RESOURCES } from "./sample-records";
import {
filterSampleResources,
getSampleResourceRow,
SAMPLE_RESOURCE_FILTER_OPTIONS,
} from "./sample-resources";
describe("sample resources", () => {
it("lists every sample resource with its failed findings count", () => {
// When
const { data, metadata } = filterSampleResources({ pageSize: "500" });
// Then
expect(metadata.pagination.count).toBe(SAMPLE_RESOURCES.length);
expect(
data.reduce(
(total, row) => total + row.attributes.failed_findings_count,
0,
),
).toBe(
SAMPLE_FINDINGS.filter((finding) => finding.status === "FAIL").length,
);
});
it("agrees with the Overview resource group counts", () => {
for (const group of SAMPLE_RESOURCE_GROUPS_RESPONSE.data) {
const { metadata } = filterSampleResources({
"filter[groups__in]": group.id,
});
expect(metadata.pagination.count).toBe(group.attributes.resources_count);
}
});
it("applies the filter bar, search, sort and paging", () => {
// Given
const [region] = SAMPLE_RESOURCE_FILTER_OPTIONS.regions;
const inRegion = SAMPLE_RESOURCES.filter(
(resource) => resource.region === region,
);
// When
const filtered = filterSampleResources({
"filter[region__in]": region,
sort: "-type",
pageSize: "500",
});
const searched = filterSampleResources({
"filter[search]": SAMPLE_RESOURCES[0].name.toUpperCase(),
});
const secondPage = filterSampleResources({ page: "2", pageSize: "5" });
// Then
expect(filtered.data).toHaveLength(inRegion.length);
const types = filtered.data.map((row) => row.attributes.type);
expect(types).toEqual([...types].sort().reverse());
expect(searched.data.map((row) => row.attributes.name)).toContain(
SAMPLE_RESOURCES[0].name,
);
expect(secondPage.data).toHaveLength(5);
expect(secondPage.metadata.pagination.page).toBe(2);
});
it("opens a sample resource drawer from its records, without the API", async () => {
// Given
const failing = SAMPLE_FINDINGS.find(
(finding) => finding.status === "FAIL",
)!;
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
// When
const drawer = await getResourceDrawerData({
resourceId: failing.resource.id,
resourceUid: failing.resource.uid,
providerId: "sample-provider",
providerType: "aws",
});
// Then
expect(
drawer.findings.map((finding: { id: string }) => finding.id),
).toContain(failing.id);
expect(drawer.providerOrg).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
vi.unstubAllGlobals();
});
it("falls back to the default page size for invalid values", () => {
for (const pageSize of ["-1", "0", "2.5", "abc"]) {
const { data } = filterSampleResources({ pageSize });
expect(data).toHaveLength(10);
}
});
it("reopens a copied sample resource link", () => {
const [resource] = SAMPLE_RESOURCES;
expect(getSampleResourceRow(resource.id)?.attributes.name).toBe(
resource.name,
);
expect(getSampleResourceRow("not-a-sample")).toBeNull();
});
it("searches the sample resource drawer's findings", async () => {
// Given
const failing = SAMPLE_FINDINGS.find(
(finding) => finding.status === "FAIL",
)!;
const request = {
resourceId: failing.resource.id,
resourceUid: failing.resource.uid,
providerId: "sample-provider",
providerType: "aws",
};
// When
const matching = await getResourceDrawerData({
...request,
query: failing.check.title.toUpperCase(),
});
const none = await getResourceDrawerData({ ...request, query: "zzz" });
// Then
expect(
matching.findings.map((finding: { id: string }) => finding.id),
).toContain(failing.id);
expect(none.findings).toHaveLength(0);
});
});
+157
View File
@@ -0,0 +1,157 @@
import type { MetaDataProps, ResourceProps, SearchParamsProps } from "@/types";
import { SAMPLE_ACCOUNT } from "./sample-dataset";
import {
SAMPLE_FINDINGS,
SAMPLE_RESOURCES,
type SampleResource,
} from "./sample-records";
// /resources rows built from the sample records, so counts, filters and the
// resource drawer agree with the Findings and Overview samples.
const DEFAULT_PAGE_SIZE = 10;
const PROVIDER_DATA: ResourceProps["relationships"]["provider"]["data"] = {
type: "providers",
id: SAMPLE_ACCOUNT.providerId,
attributes: {
inserted_at: SAMPLE_ACCOUNT.scannedAt,
updated_at: SAMPLE_ACCOUNT.scannedAt,
provider: "aws",
uid: SAMPLE_ACCOUNT.uid,
alias: SAMPLE_ACCOUNT.alias,
connection: { connected: true, last_checked_at: SAMPLE_ACCOUNT.scannedAt },
},
relationships: {
secret: { data: { type: "provider-secrets", id: "sample-secret" } },
},
links: { self: "" },
};
const toRow = (resource: SampleResource): ResourceProps => {
const findings = SAMPLE_FINDINGS.filter(
(finding) => finding.resource.id === resource.id,
);
const failed = findings.filter((finding) => finding.status === "FAIL");
return {
type: "resources",
id: resource.id,
attributes: {
inserted_at: SAMPLE_ACCOUNT.scannedAt,
updated_at: SAMPLE_ACCOUNT.scannedAt,
uid: resource.uid,
name: resource.name,
region: resource.region,
service: resource.service,
tags: {},
type: resource.type,
groups: Array.from(
new Set(findings.map((finding) => finding.check.resourceGroup)),
),
failed_findings_count: failed.length,
details: null,
partition: "aws",
metadata: null,
},
relationships: {
provider: { data: PROVIDER_DATA },
findings: { meta: { count: failed.length }, data: [] },
},
links: { self: "" },
};
};
const SAMPLE_RESOURCE_ROWS = SAMPLE_RESOURCES.map(toRow);
const unique = (values: string[]) => Array.from(new Set(values)).sort();
/** Filter bar options, derived from the rows the sample filters match. */
export const SAMPLE_RESOURCE_FILTER_OPTIONS = {
regions: unique(SAMPLE_RESOURCES.map((resource) => resource.region)),
services: unique(SAMPLE_RESOURCES.map((resource) => resource.service)),
types: unique(SAMPLE_RESOURCES.map((resource) => resource.type)),
groups: unique(
SAMPLE_RESOURCE_ROWS.flatMap((row) => row.attributes.groups ?? []),
),
};
// The filters the /resources filter bar and the Overview cards set.
const RESOURCE_FILTERS: Record<string, (row: ResourceProps) => string[]> = {
"filter[region__in]": (row) => [row.attributes.region],
"filter[service__in]": (row) => [row.attributes.service],
"filter[type__in]": (row) => [row.attributes.type],
"filter[groups__in]": (row) => row.attributes.groups ?? [],
"filter[provider_type__in]": () => ["aws"],
"filter[provider_id__in]": () => [SAMPLE_ACCOUNT.providerId],
"filter[provider__in]": () => [SAMPLE_ACCOUNT.providerId],
};
const SORTABLE_FIELDS = ["type", "region", "service"] as const;
type SortableField = (typeof SORTABLE_FIELDS)[number];
const isSortableField = (field: string): field is SortableField =>
(SORTABLE_FIELDS as readonly string[]).includes(field);
const param = (searchParams: SearchParamsProps, key: string) => {
const value = searchParams[key];
return Array.isArray(value) ? value.join(",") : value;
};
const positiveInteger = (value: string | undefined, fallback: number) => {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
};
/** The sample row a `?resourceId=` link opens, if it is one. */
export const getSampleResourceRow = (resourceId: string | undefined) =>
SAMPLE_RESOURCE_ROWS.find((row) => row.id === resourceId) ?? null;
/** One page of sample resources for the URL's filters, search and sort. */
export const filterSampleResources = (
searchParams: SearchParamsProps,
): { data: ResourceProps[]; metadata: MetaDataProps } => {
const search = param(searchParams, "filter[search]")?.trim().toLowerCase();
const active = Object.entries(RESOURCE_FILTERS).flatMap(([key, field]) => {
const value = param(searchParams, key);
return value ? [{ field, accepted: value.split(",") }] : [];
});
const rows = SAMPLE_RESOURCE_ROWS.filter(
(row) =>
(!search ||
`${row.attributes.name} ${row.attributes.uid}`
.toLowerCase()
.includes(search)) &&
active.every(({ field, accepted }) =>
field(row).some((value) => accepted.includes(value)),
),
);
const sort = param(searchParams, "sort");
const descending = sort?.startsWith("-") ?? false;
const sortField = sort?.replace(/^-/, "") ?? "";
if (isSortableField(sortField)) {
rows.sort((a, b) => {
const comparison = a.attributes[sortField].localeCompare(
b.attributes[sortField],
);
return descending ? -comparison : comparison;
});
}
const pageSize = positiveInteger(
param(searchParams, "pageSize"),
DEFAULT_PAGE_SIZE,
);
const pages = Math.max(1, Math.ceil(rows.length / pageSize));
const page = Math.min(positiveInteger(param(searchParams, "page"), 1), pages);
return {
data: rows.slice((page - 1) * pageSize, page * pageSize),
metadata: {
pagination: { page, pages, count: rows.length },
version: "v1",
},
};
};