mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-03 18:05:50 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902c12edd1 | ||
|
|
035607518b | ||
|
|
130557eaef | ||
|
|
06efb2f442 | ||
|
|
7f84f18b5b |
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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
+12
-8
@@ -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>
|
||||
);
|
||||
};
|
||||
+23
-9
@@ -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)}
|
||||
/>
|
||||
|
||||
+23
-6
@@ -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>
|
||||
);
|
||||
}
|
||||
+36
@@ -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>,
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user