mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 04:51:51 +00:00
feat(ui): add focused Lighthouse detail context
This commit is contained in:
@@ -105,6 +105,15 @@ describe("MessageBubble", () => {
|
||||
label: "Findings",
|
||||
path: "/findings",
|
||||
},
|
||||
{
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
scope_key: "findings:/findings",
|
||||
label: "Focused finding",
|
||||
finding_id: "finding-1",
|
||||
check_id: "aws_s3_bucket_public_access",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -119,7 +128,7 @@ describe("MessageBubble", () => {
|
||||
render(<MessageBubble message={userMessage} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("@ Findings")).toBeInTheDocument();
|
||||
expect(screen.getByText("@ Findings +1")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Remove Findings context/ }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -11,6 +11,11 @@ import type {
|
||||
LighthouseV2Session,
|
||||
LighthouseV2SupportedModel,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import {
|
||||
buildAttackPathContext,
|
||||
buildFocusedFindingContext,
|
||||
} from "@/lib/lighthouse/context/contributions";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
|
||||
import {
|
||||
LighthousePanelChat,
|
||||
@@ -101,6 +106,7 @@ describe("LighthousePanelChat", () => {
|
||||
stubEventSource();
|
||||
resetPanelChatStoreForTests();
|
||||
resetPanelChatConfigCacheForTests();
|
||||
useLighthouseContextStore.getState().resetContributions();
|
||||
|
||||
getConfigurationsMock.mockResolvedValue({ data: configurations });
|
||||
getSupportedProvidersMock.mockResolvedValue({
|
||||
@@ -243,6 +249,82 @@ describe("LighthousePanelChat", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends page, focused finding, and parent Attack Path context together", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
window.history.replaceState(null, "", "/attack-paths?scanId=scan-1");
|
||||
const contextStore = useLighthouseContextStore.getState();
|
||||
contextStore.registerContribution(
|
||||
"attack-path-current",
|
||||
buildAttackPathContext({
|
||||
pathname: "/attack-paths",
|
||||
scanId: "scan-1",
|
||||
queryId: "query-1",
|
||||
queryLabel: "Internet-exposed resources",
|
||||
}),
|
||||
);
|
||||
contextStore.setFocusedContext(
|
||||
1,
|
||||
buildFocusedFindingContext({
|
||||
pathname: "/attack-paths",
|
||||
findingId: "finding-1",
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
providerUid: "123456789012",
|
||||
resourceUid: "arn:aws:s3:::example",
|
||||
region: "eu-west-1",
|
||||
}),
|
||||
);
|
||||
createSessionMock.mockResolvedValue({
|
||||
data: session("session-context", "Explain this finding"),
|
||||
});
|
||||
sendMessageMock.mockResolvedValue({
|
||||
data: {
|
||||
task: {
|
||||
id: "task-context",
|
||||
name: "lighthouse-run",
|
||||
state: "executing",
|
||||
},
|
||||
},
|
||||
});
|
||||
render(<LighthousePanelChat />);
|
||||
const input = await screen.findByRole("textbox", { name: "Message" });
|
||||
expect(screen.getByText("@ Attack Paths +1")).toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.type(input, "Explain this finding{Enter}");
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
displayText: "Explain this finding",
|
||||
context: expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
kind: "page",
|
||||
id: "attack-paths",
|
||||
filters: { scanId: ["scan-1"] },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "attack_path",
|
||||
id: "current-query",
|
||||
scanId: "scan-1",
|
||||
queryId: "query-1",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a recent chat in place without navigating", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -162,9 +162,9 @@ describe("createLighthouseChatStore", () => {
|
||||
it("retries with the original context snapshot", async () => {
|
||||
// Given
|
||||
const store = makeStore();
|
||||
const context = findingsContext();
|
||||
const context = focusedFindingsContext();
|
||||
await store.getState().submitMessage("Prioritize findings", context);
|
||||
context.items[0].label = "Mutated after send";
|
||||
context.items[1].label = "Mutated after send";
|
||||
eventSources[0].fail(2 /* EventSource.CLOSED */);
|
||||
sendMessageMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
@@ -179,7 +179,7 @@ describe("createLighthouseChatStore", () => {
|
||||
expect(sendMessageMock).toHaveBeenNthCalledWith(2, {
|
||||
sessionId: "session-1",
|
||||
displayText: "Prioritize findings",
|
||||
context: findingsContext(),
|
||||
context: focusedFindingsContext(),
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
});
|
||||
@@ -596,6 +596,25 @@ function findingsContext(): LighthouseContextEnvelope {
|
||||
};
|
||||
}
|
||||
|
||||
function focusedFindingsContext(): LighthouseContextEnvelope {
|
||||
const context = findingsContext();
|
||||
return {
|
||||
...context,
|
||||
items: [
|
||||
...context.items,
|
||||
{
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Focused finding",
|
||||
findingId: "finding-1",
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function oversizedFindingsContext(): LighthouseContextEnvelope {
|
||||
const context = findingsContext();
|
||||
return {
|
||||
|
||||
@@ -1 +1 @@
|
||||
Lighthouse AI contextual messages with page-aware prompts, removable context chips, selected-resource metadata, and retry-safe historical badges
|
||||
Lighthouse AI contextual messages with page-aware prompts, focused side-panel details, selected-resource metadata, and retry-safe historical badges
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
|
||||
import { ResourceDetailDrawer } from "./resource-detail-drawer";
|
||||
|
||||
const { pathnameMock } = vi.hoisted(() => ({
|
||||
pathnameMock: vi.fn(() => "/findings"),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: pathnameMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/side-panel/detail-side-panel", () => ({
|
||||
DetailSidePanel: ({
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
context?: unknown;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
<output data-testid="focused-context">{JSON.stringify(context)}</output>
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./resource-detail-drawer-content", () => ({
|
||||
ResourceDetailDrawerContent: () => <div>Finding details</div>,
|
||||
}));
|
||||
|
||||
describe("ResourceDetailDrawer", () => {
|
||||
beforeEach(() => {
|
||||
pathnameMock.mockReturnValue("/findings");
|
||||
});
|
||||
|
||||
it("should update focused finding context when drawer navigation changes", () => {
|
||||
// Given
|
||||
const firstFinding = findingResource("finding-1", "bucket-1");
|
||||
const secondFinding = findingResource("finding-2", "bucket-2");
|
||||
const { rerender } = renderDrawer(firstFinding);
|
||||
expect(screen.getByTestId("focused-context")).toHaveTextContent(
|
||||
'"findingId":"finding-1"',
|
||||
);
|
||||
|
||||
// When
|
||||
rerender(drawer(secondFinding));
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("focused-context")).toHaveTextContent(
|
||||
'"findingId":"finding-2"',
|
||||
);
|
||||
expect(screen.getByTestId("focused-context")).toHaveTextContent(
|
||||
'"resourceUid":"bucket-2"',
|
||||
);
|
||||
expect(screen.getByTestId("focused-context")).toHaveTextContent(
|
||||
'"source":"focused"',
|
||||
);
|
||||
});
|
||||
|
||||
it("should scope a finding opened from an Attack Paths node", () => {
|
||||
// Given
|
||||
pathnameMock.mockReturnValue("/attack-paths");
|
||||
|
||||
// When
|
||||
renderDrawer(findingResource("finding-attack-path", "bucket-attack-path"));
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("focused-context")).toHaveTextContent(
|
||||
'"scopeKey":"attack-paths:/attack-paths"',
|
||||
);
|
||||
expect(screen.getByTestId("focused-context")).toHaveTextContent(
|
||||
'"findingId":"finding-attack-path"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderDrawer(currentResource: FindingResourceRow) {
|
||||
return render(drawer(currentResource));
|
||||
}
|
||||
|
||||
function drawer(currentResource: FindingResourceRow) {
|
||||
return (
|
||||
<ResourceDetailDrawer
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={null}
|
||||
currentIndex={0}
|
||||
totalResources={2}
|
||||
currentResource={currentResource}
|
||||
currentFinding={null}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function findingResource(
|
||||
findingId: string,
|
||||
resourceUid: string,
|
||||
): FindingResourceRow {
|
||||
return {
|
||||
id: findingId,
|
||||
rowType: "resource",
|
||||
findingId,
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
providerType: "aws",
|
||||
providerAlias: "Production",
|
||||
providerUid: "123456789012",
|
||||
resourceName: resourceUid,
|
||||
resourceType: "AwsS3Bucket",
|
||||
resourceGroup: "storage",
|
||||
resourceUid,
|
||||
service: "s3",
|
||||
region: "eu-west-1",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
isMuted: false,
|
||||
firstSeenAt: null,
|
||||
lastSeenAt: null,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
|
||||
import { buildFocusedFindingContext } from "@/lib/lighthouse/context/contributions";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
@@ -43,12 +46,27 @@ export function ResourceDetailDrawer({
|
||||
onMuteComplete,
|
||||
onTriageUpdate,
|
||||
}: ResourceDetailDrawerProps) {
|
||||
const pathname = usePathname();
|
||||
const context = currentResource
|
||||
? buildFocusedFindingContext({
|
||||
pathname,
|
||||
findingId: currentFinding?.id ?? currentResource.findingId,
|
||||
checkId: currentFinding?.checkId ?? currentResource.checkId,
|
||||
severity: currentFinding?.severity ?? currentResource.severity,
|
||||
status: currentFinding?.status ?? currentResource.status,
|
||||
providerUid: currentFinding?.providerUid ?? currentResource.providerUid,
|
||||
resourceUid: currentFinding?.resourceUid ?? currentResource.resourceUid,
|
||||
region: currentFinding?.resourceRegion ?? currentResource.region,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DetailSidePanel
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Resource Finding Details"
|
||||
description="View finding details for the selected resource"
|
||||
context={context}
|
||||
>
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -20,13 +20,32 @@ describe("LighthouseCurrentContextBadge", () => {
|
||||
await user.hover(contextBadge);
|
||||
|
||||
// Then
|
||||
expect(contextBadge).toHaveTextContent("@ Findings +1");
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Findings context/ }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(
|
||||
"Findings context will be included in your next message.",
|
||||
const tooltip = await screen.findByRole("tooltip");
|
||||
expect(contextBadge).toHaveTextContent("@ Findings +2");
|
||||
expect(tooltip).toHaveTextContent("Filters: severity: critical");
|
||||
expect(tooltip).toHaveTextContent("Finding: finding-focused");
|
||||
expect(tooltip).toHaveTextContent("Finding: finding-1");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["resource", resourceContext(), "Resource: resource-1 (bucket-1)"],
|
||||
["scan", scanContext(), "Scan: scan-1"],
|
||||
["Attack Path", attackPathContext(), "Attack Path: query-1 (scan scan-1)"],
|
||||
])("should identify included %s context", async (_, context, expected) => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<LighthouseCurrentContextBadge context={context} />);
|
||||
|
||||
// When
|
||||
await user.hover(
|
||||
screen.getByLabelText(`${context.items[0].label} context`),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(await screen.findByRole("tooltip")).toHaveTextContent(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,7 +55,7 @@ describe("LighthouseContextBadge", () => {
|
||||
render(<LighthouseContextBadge context={findingsContext()} />);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("@ Findings +1")).toBeInTheDocument();
|
||||
expect(screen.getByText("@ Findings +2")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Findings context/ }),
|
||||
).not.toBeInTheDocument();
|
||||
@@ -65,6 +84,94 @@ function findingsContext(): LighthouseContextEnvelope {
|
||||
label: "Selected finding",
|
||||
findingId: "finding-1",
|
||||
},
|
||||
{
|
||||
kind: "finding",
|
||||
id: "finding-focused",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Focused finding",
|
||||
findingId: "finding-focused",
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function resourceContext(): LighthouseContextEnvelope {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
{
|
||||
kind: "page",
|
||||
id: "resources",
|
||||
source: "automatic",
|
||||
scopeKey: "resources:/resources",
|
||||
label: "Resources",
|
||||
path: "/resources",
|
||||
},
|
||||
{
|
||||
kind: "resource",
|
||||
id: "resource-1",
|
||||
source: "focused",
|
||||
scopeKey: "resources:/resources",
|
||||
label: "Focused resource",
|
||||
resourceId: "resource-1",
|
||||
resourceUid: "bucket-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function scanContext(): LighthouseContextEnvelope {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
{
|
||||
kind: "page",
|
||||
id: "scans",
|
||||
source: "automatic",
|
||||
scopeKey: "scans:/scans",
|
||||
label: "Scans",
|
||||
path: "/scans",
|
||||
filters: { scanId: ["scan-1"] },
|
||||
},
|
||||
{
|
||||
kind: "scan",
|
||||
id: "scan-1",
|
||||
source: "selection",
|
||||
scopeKey: "scans:/scans",
|
||||
label: "Selected scan",
|
||||
scanId: "scan-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function attackPathContext(): LighthouseContextEnvelope {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transport: "inline",
|
||||
items: [
|
||||
{
|
||||
kind: "page",
|
||||
id: "attack-paths",
|
||||
source: "automatic",
|
||||
scopeKey: "attack-paths:/attack-paths",
|
||||
label: "Attack Paths",
|
||||
path: "/attack-paths",
|
||||
filters: { scanId: ["scan-1"] },
|
||||
},
|
||||
{
|
||||
kind: "attack_path",
|
||||
id: "current-query",
|
||||
source: "automatic",
|
||||
scopeKey: "attack-paths:/attack-paths",
|
||||
label: "Internet-exposed resources",
|
||||
scanId: "scan-1",
|
||||
queryId: "query-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LIGHTHOUSE_CONTEXT_KIND,
|
||||
LIGHTHOUSE_CONTEXT_SOURCE,
|
||||
type LighthouseContextEnvelope,
|
||||
type LighthouseContextItem,
|
||||
} from "@/types/lighthouse-context";
|
||||
|
||||
interface LighthouseCurrentContextBadgeProps {
|
||||
@@ -20,20 +21,18 @@ export function LighthouseCurrentContextBadge({
|
||||
context,
|
||||
}: LighthouseCurrentContextBadgeProps) {
|
||||
if (!context) return null;
|
||||
const { pageLabel, selectionCount } = getContextBadgeContent(context);
|
||||
const { pageLabel, additionalCount } = getContextBadgeContent(context);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge asChild variant="tag">
|
||||
<span tabIndex={0} aria-label={`${pageLabel} context`}>
|
||||
{buildContextLabel(pageLabel, selectionCount)}
|
||||
{buildContextLabel(pageLabel, additionalCount)}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{pageLabel} context will be included in your next message.
|
||||
</TooltipContent>
|
||||
<LighthouseContextTooltip context={context} />
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -43,14 +42,14 @@ export function LighthouseContextBadge({
|
||||
}: {
|
||||
context: LighthouseContextEnvelope;
|
||||
}) {
|
||||
const { pageLabel, selectionCount } = getContextBadgeContent(context);
|
||||
const { pageLabel, additionalCount } = getContextBadgeContent(context);
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge asChild variant="tag">
|
||||
<span tabIndex={0} aria-label={`Historical ${pageLabel} context`}>
|
||||
{buildContextLabel(pageLabel, selectionCount)}
|
||||
{buildContextLabel(pageLabel, additionalCount)}
|
||||
</span>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
@@ -64,11 +63,13 @@ function getContextBadgeContent(context: LighthouseContextEnvelope) {
|
||||
(item) => item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE,
|
||||
);
|
||||
const pageLabel = page?.label ?? "Context";
|
||||
const selectionCount = context.items.filter(
|
||||
(item) => item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
|
||||
const additionalCount = context.items.filter(
|
||||
(item) =>
|
||||
item.source === LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED ||
|
||||
item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
|
||||
).length;
|
||||
|
||||
return { pageLabel, selectionCount };
|
||||
return { pageLabel, additionalCount };
|
||||
}
|
||||
|
||||
function LighthouseContextTooltip({
|
||||
@@ -85,29 +86,79 @@ function LighthouseContextTooltip({
|
||||
.map(([key, values]) => `${key}: ${values.join(", ")}`)
|
||||
.join("; ")
|
||||
: "";
|
||||
const summaries = context.items
|
||||
.filter(
|
||||
(item) =>
|
||||
item.kind !== LIGHTHOUSE_CONTEXT_KIND.PAGE &&
|
||||
item.source === LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC,
|
||||
)
|
||||
.map((item) => item.label)
|
||||
.join(", ");
|
||||
const types = Array.from(
|
||||
new Set(context.items.map((item) => item.kind)),
|
||||
).join(", ");
|
||||
const itemDescriptions = context.items
|
||||
.map(getContextItemDescription)
|
||||
.filter((description) => description !== null);
|
||||
|
||||
return (
|
||||
<TooltipContent maxWidth="md">
|
||||
<div className="space-y-1">
|
||||
{page?.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE && (
|
||||
<p>Page: {page.path}</p>
|
||||
)}
|
||||
{filters && <p>Filters: {filters}</p>}
|
||||
{summaries && <p>Summaries: {summaries}</p>}
|
||||
<p>Included types: {types}</p>
|
||||
{itemDescriptions.map(({ id, text }) => (
|
||||
<p key={id}>{text}</p>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
);
|
||||
}
|
||||
|
||||
function buildContextLabel(pageLabel: string, selectionCount: number): string {
|
||||
return `@ ${pageLabel}${selectionCount > 0 ? ` +${selectionCount}` : ""}`;
|
||||
interface ContextItemDescription {
|
||||
id: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function getContextItemDescription(
|
||||
item: LighthouseContextItem,
|
||||
): ContextItemDescription | null {
|
||||
if (item.kind === LIGHTHOUSE_CONTEXT_KIND.PAGE) return null;
|
||||
if (
|
||||
item.source === LIGHTHOUSE_CONTEXT_SOURCE.AUTOMATIC &&
|
||||
item.id === "summary"
|
||||
) {
|
||||
return { id: `${item.kind}:${item.id}`, text: `Summary: ${item.label}` };
|
||||
}
|
||||
|
||||
switch (item.kind) {
|
||||
case LIGHTHOUSE_CONTEXT_KIND.FINDING:
|
||||
return {
|
||||
id: `${item.kind}:${item.id}`,
|
||||
text: `Finding: ${item.findingId}${item.checkId ? ` (${item.checkId})` : ""}`,
|
||||
};
|
||||
case LIGHTHOUSE_CONTEXT_KIND.RESOURCE:
|
||||
return {
|
||||
id: `${item.kind}:${item.id}`,
|
||||
text: `Resource: ${item.resourceId}${item.resourceUid ? ` (${item.resourceUid})` : ""}`,
|
||||
};
|
||||
case LIGHTHOUSE_CONTEXT_KIND.COMPLIANCE:
|
||||
return {
|
||||
id: `${item.kind}:${item.id}`,
|
||||
text: `Compliance: ${item.framework}${item.scanId ? ` (scan ${item.scanId})` : ""}`,
|
||||
};
|
||||
case LIGHTHOUSE_CONTEXT_KIND.ATTACK_PATH:
|
||||
return {
|
||||
id: `${item.kind}:${item.id}`,
|
||||
text: `Attack Path: ${item.queryId ?? item.id}${item.scanId ? ` (scan ${item.scanId})` : ""}`,
|
||||
};
|
||||
case LIGHTHOUSE_CONTEXT_KIND.SCAN:
|
||||
return {
|
||||
id: `${item.kind}:${item.id}`,
|
||||
text: `Scan: ${item.scanId ?? item.id}`,
|
||||
};
|
||||
case LIGHTHOUSE_CONTEXT_KIND.PROVIDER:
|
||||
return {
|
||||
id: `${item.kind}:${item.id}`,
|
||||
text: `Provider: ${item.providerUid ?? item.providerId ?? item.id}`,
|
||||
};
|
||||
default: {
|
||||
const exhaustiveItem: never = item;
|
||||
return exhaustiveItem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildContextLabel(pageLabel: string, additionalCount: number): string {
|
||||
return `@ ${pageLabel}${additionalCount > 0 ? ` +${additionalCount}` : ""}`;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { DetailSidePanel } from "@/components/side-panel/detail-side-panel";
|
||||
import { ResourceProps } from "@/types";
|
||||
import { buildFocusedResourceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import type { ResourceProps } from "@/types";
|
||||
|
||||
import { ResourceDetailContent } from "./table/resource-detail-content";
|
||||
|
||||
@@ -16,12 +19,21 @@ export const ResourceDetailsSheet = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: ResourceDetailsSheetProps) => {
|
||||
const pathname = usePathname();
|
||||
const context = buildFocusedResourceContext({
|
||||
pathname,
|
||||
id: resource.id,
|
||||
attributes: resource.attributes,
|
||||
providerUid: resource.relationships.provider.data.attributes.uid,
|
||||
});
|
||||
|
||||
return (
|
||||
<DetailSidePanel
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Resource Details"
|
||||
description="View the resource details"
|
||||
context={context}
|
||||
>
|
||||
<ResourceDetailContent key={resource.id} resourceDetails={resource} />
|
||||
</DetailSidePanel>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ResourceProps } from "@/types";
|
||||
@@ -19,8 +21,27 @@ vi.mock("@/components/shadcn/table", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/resources/resource-details-sheet", () => ({
|
||||
ResourceDetailsSheet: () => <div>Resource details</div>,
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/resources",
|
||||
}));
|
||||
|
||||
vi.mock("@/components/side-panel/detail-side-panel", () => ({
|
||||
DetailSidePanel: ({
|
||||
context,
|
||||
children,
|
||||
}: {
|
||||
context?: unknown;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<>
|
||||
<output data-testid="focused-context">{JSON.stringify(context)}</output>
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./resource-detail-content", () => ({
|
||||
ResourceDetailContent: () => <div>Resource details</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/lighthouse/context-contributor", () => ({
|
||||
@@ -38,21 +59,54 @@ vi.mock("@/components/lighthouse/context-contributor", () => ({
|
||||
}));
|
||||
|
||||
const resource = {
|
||||
type: "resources",
|
||||
id: "resource-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-07-22T10:00:00Z",
|
||||
updated_at: "2026-07-22T10:00:00Z",
|
||||
uid: "arn:aws:s3:::example",
|
||||
name: "example",
|
||||
service: "s3",
|
||||
region: "eu-west-1",
|
||||
type: "AwsS3Bucket",
|
||||
groups: ["storage"],
|
||||
failed_findings_count: 3,
|
||||
details: "full configuration must stay local",
|
||||
partition: "aws",
|
||||
tags: { owner: "security@example.com" },
|
||||
metadata: { secret: "do-not-send" },
|
||||
},
|
||||
relationships: {
|
||||
provider: { data: { attributes: { uid: "123456789012" } } },
|
||||
provider: {
|
||||
data: {
|
||||
type: "providers",
|
||||
id: "provider-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-07-22T10:00:00Z",
|
||||
updated_at: "2026-07-22T10:00:00Z",
|
||||
provider: "aws",
|
||||
uid: "123456789012",
|
||||
alias: "Production",
|
||||
connection: {
|
||||
connected: true,
|
||||
last_checked_at: "2026-07-22T10:00:00Z",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
secret: { data: { type: "provider-secrets", id: "secret-1" } },
|
||||
},
|
||||
links: { self: "/providers/provider-1" },
|
||||
},
|
||||
},
|
||||
findings: { meta: { count: 0 }, data: [] },
|
||||
},
|
||||
} as ResourceProps;
|
||||
links: { self: "/resources/resource-1" },
|
||||
} satisfies ResourceProps;
|
||||
|
||||
describe("ResourcesTableWithSelection", () => {
|
||||
it("publishes the loaded total and selected resource as context", async () => {
|
||||
it("publishes the loaded total and opens the selected resource detail", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ResourcesTableWithSelection
|
||||
data={[resource]}
|
||||
@@ -67,10 +121,28 @@ describe("ResourcesTableWithSelection", () => {
|
||||
'"total":17',
|
||||
);
|
||||
|
||||
screen.getByRole("button", { name: "Open resource" }).click();
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Open resource" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Resource details")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("focused-context")).toHaveTextContent(
|
||||
'"resourceId":"resource-1"',
|
||||
);
|
||||
expect(screen.getByTestId("focused-context")).toHaveTextContent(
|
||||
'"providerUid":"123456789012"',
|
||||
);
|
||||
expect(screen.getByTestId("focused-context")).not.toHaveTextContent(
|
||||
"full configuration must stay local",
|
||||
);
|
||||
expect(screen.getByTestId("focused-context")).not.toHaveTextContent(
|
||||
"security@example.com",
|
||||
);
|
||||
expect(screen.getByTestId("focused-context")).not.toHaveTextContent(
|
||||
"do-not-send",
|
||||
);
|
||||
expect(
|
||||
await screen.findByTestId("context-resource-resource-1"),
|
||||
).toHaveTextContent('"providerUid":"123456789012"');
|
||||
screen.queryByTestId("context-resource-resource-1"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,7 @@ import { useState } from "react";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { ResourceDetailsSheet } from "@/components/resources/resource-details-sheet";
|
||||
import { DataTable } from "@/components/shadcn/table";
|
||||
import {
|
||||
buildResourceContext,
|
||||
buildResourceSummaryContext,
|
||||
} from "@/lib/lighthouse/context/contributions";
|
||||
import { buildResourceSummaryContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { MetaDataProps, ResourceProps } from "@/types";
|
||||
|
||||
import { getColumnResources } from "./column-resources";
|
||||
@@ -46,18 +43,6 @@ export function ResourcesTableWithSelection({
|
||||
item={buildResourceSummaryContext(metadata.pagination.count)}
|
||||
/>
|
||||
)}
|
||||
{selectedResource && (
|
||||
<LighthouseContextContributor
|
||||
key={`resource-${selectedResource.id}`}
|
||||
contributorId={`resource-${selectedResource.id}`}
|
||||
item={buildResourceContext({
|
||||
id: selectedResource.id,
|
||||
attributes: selectedResource.attributes,
|
||||
providerUid:
|
||||
selectedResource.relationships.provider.data.attributes.uid,
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={safeData}
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
|
||||
import { DetailSidePanel } from "./detail-side-panel";
|
||||
@@ -39,6 +40,14 @@ function Host({ initialOpen = true }: { initialOpen?: boolean }) {
|
||||
onOpenChange={setOpen}
|
||||
title="Resource Details"
|
||||
description="View the resource details"
|
||||
context={{
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Focused finding",
|
||||
findingId: "finding-1",
|
||||
}}
|
||||
>
|
||||
<div data-testid="detail-content">detail body</div>
|
||||
</DetailSidePanel>
|
||||
@@ -61,10 +70,34 @@ function DualHost() {
|
||||
Open B
|
||||
</button>
|
||||
<GlobalSidePanel />
|
||||
<DetailSidePanel open={openA} onOpenChange={setOpenA} title="Finding A">
|
||||
<DetailSidePanel
|
||||
open={openA}
|
||||
onOpenChange={setOpenA}
|
||||
title="Finding A"
|
||||
context={{
|
||||
kind: "finding",
|
||||
id: "finding-a",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Focused finding A",
|
||||
findingId: "finding-a",
|
||||
}}
|
||||
>
|
||||
<div data-testid="detail-a">A body</div>
|
||||
</DetailSidePanel>
|
||||
<DetailSidePanel open={openB} onOpenChange={setOpenB} title="Finding B">
|
||||
<DetailSidePanel
|
||||
open={openB}
|
||||
onOpenChange={setOpenB}
|
||||
title="Finding B"
|
||||
context={{
|
||||
kind: "finding",
|
||||
id: "finding-b",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Focused finding B",
|
||||
findingId: "finding-b",
|
||||
}}
|
||||
>
|
||||
<div data-testid="detail-b">B body</div>
|
||||
</DetailSidePanel>
|
||||
<output data-testid="open-a">{String(openA)}</output>
|
||||
@@ -73,6 +106,34 @@ function DualHost() {
|
||||
);
|
||||
}
|
||||
|
||||
function NavigatingHost() {
|
||||
const [findingId, setFindingId] = useState("finding-1");
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setFindingId("finding-2")}>
|
||||
Next finding
|
||||
</button>
|
||||
<GlobalSidePanel />
|
||||
<DetailSidePanel
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
title="Finding"
|
||||
context={{
|
||||
kind: "finding",
|
||||
id: findingId,
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Focused finding",
|
||||
findingId,
|
||||
}}
|
||||
>
|
||||
<div data-testid="navigating-detail">{findingId}</div>
|
||||
</DetailSidePanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DetailSidePanel", () => {
|
||||
beforeEach(() => {
|
||||
isCloudMock.mockReturnValue(true);
|
||||
@@ -85,6 +146,7 @@ describe("DetailSidePanel", () => {
|
||||
contextOwnerToken: 0,
|
||||
contextOutlet: null,
|
||||
});
|
||||
useLighthouseContextStore.getState().resetContributions();
|
||||
});
|
||||
|
||||
it("portals the detail content into the global panel when open", async () => {
|
||||
@@ -131,6 +193,13 @@ describe("DetailSidePanel", () => {
|
||||
expect(await screen.findByTestId("panel-chat-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("detail-content")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("detail-content")).not.toBeVisible();
|
||||
expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-1");
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Close side panel" }));
|
||||
|
||||
// Then
|
||||
expect(useLighthouseContextStore.getState().focused).toBeNull();
|
||||
});
|
||||
|
||||
it("hands the panel to the newest detail view and closes the previous one", async () => {
|
||||
@@ -151,6 +220,7 @@ describe("DetailSidePanel", () => {
|
||||
expect(screen.queryByTestId("detail-a")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("open-a")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("open-b")).toHaveTextContent("true");
|
||||
expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-b");
|
||||
|
||||
// When: the panel is dismissed
|
||||
await user.click(screen.getByRole("button", { name: "Close side panel" }));
|
||||
@@ -159,6 +229,7 @@ describe("DetailSidePanel", () => {
|
||||
expect(screen.getByTestId("open-b")).toHaveTextContent("false");
|
||||
expect(screen.queryByTestId("detail-b")).not.toBeInTheDocument();
|
||||
expect(useSidePanelStore.getState().contextTab).toBeNull();
|
||||
expect(useLighthouseContextStore.getState().focused).toBeNull();
|
||||
});
|
||||
|
||||
it("registers nothing while closed and registers on open", async () => {
|
||||
@@ -174,4 +245,21 @@ describe("DetailSidePanel", () => {
|
||||
expect(await screen.findByTestId("detail-content")).toBeInTheDocument();
|
||||
expect(useSidePanelStore.getState().contextTab?.label).toBe("Details");
|
||||
});
|
||||
|
||||
it("updates focused context while navigating inside the current drawer", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<NavigatingHost />);
|
||||
await screen.findByText("finding-1");
|
||||
expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-1");
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Next finding" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("navigating-detail")).toHaveTextContent(
|
||||
"finding-2",
|
||||
);
|
||||
expect(useLighthouseContextStore.getState().focused?.id).toBe("finding-2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@ import { type ReactNode, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { useSidePanelStore } from "@/store/side-panel";
|
||||
import type { LighthouseContextItem } from "@/types/lighthouse-context";
|
||||
|
||||
interface DetailSidePanelProps {
|
||||
open: boolean;
|
||||
@@ -12,6 +14,7 @@ interface DetailSidePanelProps {
|
||||
// Screen-reader heading; the visible tab label is always "Details".
|
||||
title: string;
|
||||
description?: string;
|
||||
context?: LighthouseContextItem;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -34,6 +37,7 @@ function DetailSidePanelActive({
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
context,
|
||||
children,
|
||||
}: Omit<DetailSidePanelProps, "open">) {
|
||||
// Owner token from registration: several detail views can be mounted at
|
||||
@@ -47,20 +51,60 @@ function DetailSidePanelActive({
|
||||
// and every consumer's close path ends in stable setters.
|
||||
onRequestClose: () => onOpenChange(false),
|
||||
});
|
||||
useLighthouseContextStore
|
||||
.getState()
|
||||
.setFocusedContext(registered, context ?? null);
|
||||
setToken(registered);
|
||||
return () => useSidePanelStore.getState().unregisterContextTab(registered);
|
||||
return () => {
|
||||
useLighthouseContextStore.getState().clearFocusedContext(registered);
|
||||
useSidePanelStore.getState().unregisterContextTab(registered);
|
||||
};
|
||||
});
|
||||
|
||||
const ownerToken = useSidePanelStore((state) => state.contextOwnerToken);
|
||||
const outlet = useSidePanelStore((state) => state.contextOutlet);
|
||||
if (!outlet || token === null || token !== ownerToken) return null;
|
||||
const focusedRegistration =
|
||||
context && token !== null ? (
|
||||
<FocusedContextRegistration
|
||||
key={`${token}:${JSON.stringify(context)}`}
|
||||
ownerToken={token}
|
||||
context={context}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return createPortal(
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<h2 className="sr-only">{title}</h2>
|
||||
{description ? <p className="sr-only">{description}</p> : null}
|
||||
{children}
|
||||
</div>,
|
||||
outlet,
|
||||
if (!outlet || token === null || token !== ownerToken) {
|
||||
return focusedRegistration;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{focusedRegistration}
|
||||
{createPortal(
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<h2 className="sr-only">{title}</h2>
|
||||
{description ? <p className="sr-only">{description}</p> : null}
|
||||
{children}
|
||||
</div>,
|
||||
outlet,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface FocusedContextRegistrationProps {
|
||||
ownerToken: number;
|
||||
context: LighthouseContextItem;
|
||||
}
|
||||
|
||||
function FocusedContextRegistration({
|
||||
ownerToken,
|
||||
context,
|
||||
}: FocusedContextRegistrationProps) {
|
||||
useMountEffect(() => {
|
||||
useLighthouseContextStore.getState().setFocusedContext(ownerToken, context);
|
||||
return () =>
|
||||
useLighthouseContextStore.getState().clearFocusedContext(ownerToken);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -40,4 +40,46 @@ describe("buildCurrentLighthouseContext", () => {
|
||||
expect(current.page.label).toBe("Findings");
|
||||
expect(current.selectionCount).toBe(0);
|
||||
});
|
||||
|
||||
it("should combine the page, focused detail, and parent attack path", () => {
|
||||
// Given
|
||||
const parentContext = {
|
||||
kind: "attack_path" as const,
|
||||
id: "current-query",
|
||||
source: "automatic" as const,
|
||||
scopeKey: "attack-paths:/attack-paths",
|
||||
label: "Internet-exposed resources",
|
||||
scanId: "scan-1",
|
||||
queryId: "query-1",
|
||||
};
|
||||
const focusedContext = {
|
||||
kind: "finding" as const,
|
||||
id: "finding-1",
|
||||
source: "focused" as const,
|
||||
scopeKey: "attack-paths:/attack-paths",
|
||||
label: "Focused finding",
|
||||
findingId: "finding-1",
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
};
|
||||
|
||||
// When
|
||||
const current = buildCurrentLighthouseContext(
|
||||
"/attack-paths",
|
||||
new URLSearchParams("scanId=scan-1"),
|
||||
[parentContext],
|
||||
focusedContext,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(current.context?.items.map((item) => item.id)).toEqual([
|
||||
"attack-paths",
|
||||
"finding-1",
|
||||
"current-query",
|
||||
]);
|
||||
expect(current.context?.items[0]).toMatchObject({
|
||||
kind: "page",
|
||||
filters: { scanId: ["scan-1"] },
|
||||
});
|
||||
expect(current.selectionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,11 +29,13 @@ export function useLighthouseCurrentContext(): LighthouseCurrentContext {
|
||||
const contributions = useLighthouseContextStore(
|
||||
(state) => state.contributions,
|
||||
);
|
||||
const focused = useLighthouseContextStore((state) => state.focused);
|
||||
|
||||
return buildCurrentLighthouseContext(
|
||||
pathname,
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
Object.values(contributions),
|
||||
focused ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,6 +43,7 @@ export function buildCurrentLighthouseContext(
|
||||
pathname: string,
|
||||
searchParams: URLSearchParams,
|
||||
contributions: LighthouseContextItem[],
|
||||
focused?: LighthouseContextItem,
|
||||
): LighthouseCurrentContext {
|
||||
const page = resolveLighthousePage(pathname);
|
||||
const scopeKey = getLighthouseScopeKey(pathname);
|
||||
@@ -49,7 +52,7 @@ export function buildCurrentLighthouseContext(
|
||||
(item) => item.scopeKey === scopeKey,
|
||||
);
|
||||
const context = compileLighthouseContext(
|
||||
[pageContext, ...scopedContributions],
|
||||
[pageContext, ...(focused ? [focused] : []), ...scopedContributions],
|
||||
scopeKey,
|
||||
);
|
||||
|
||||
@@ -59,7 +62,9 @@ export function buildCurrentLighthouseContext(
|
||||
scopeKey,
|
||||
selectionCount:
|
||||
context?.items.filter(
|
||||
(item) => item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
|
||||
(item) =>
|
||||
item.source === LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED ||
|
||||
item.source === LIGHTHOUSE_CONTEXT_SOURCE.SELECTION,
|
||||
).length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
buildFindingGroupContext,
|
||||
buildFindingResourceContext,
|
||||
buildFindingSummaryContext,
|
||||
buildFocusedFindingContext,
|
||||
buildFocusedResourceContext,
|
||||
buildProviderContext,
|
||||
buildProviderSummaryContext,
|
||||
buildResourceContext,
|
||||
@@ -69,6 +71,36 @@ describe("Lighthouse page contributions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a focused finding for the owning page scope", () => {
|
||||
// Given / When
|
||||
const context = buildFocusedFindingContext({
|
||||
pathname: "/attack-paths",
|
||||
findingId: "finding-2",
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
providerUid: "123456789012",
|
||||
resourceUid: "arn:aws:s3:::example",
|
||||
region: "eu-west-1",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(context).toEqual({
|
||||
kind: "finding",
|
||||
id: "finding-2",
|
||||
source: "focused",
|
||||
scopeKey: "attack-paths:/attack-paths",
|
||||
label: "Focused finding",
|
||||
findingId: "finding-2",
|
||||
checkId: "aws_s3_bucket_public_access",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
providerUid: "123456789012",
|
||||
resourceUid: "arn:aws:s3:::example",
|
||||
region: "eu-west-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds resource summary and selected resource snapshots", () => {
|
||||
expect(buildResourceSummaryContext(17)).toMatchObject({
|
||||
kind: "resource",
|
||||
@@ -107,6 +139,38 @@ describe("Lighthouse page contributions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds a focused resource for the owning page scope", () => {
|
||||
// Given / When
|
||||
const context = buildFocusedResourceContext({
|
||||
pathname: "/resources",
|
||||
id: "resource-1",
|
||||
attributes: {
|
||||
uid: "arn:aws:s3:::example",
|
||||
service: "s3",
|
||||
region: "eu-west-1",
|
||||
type: "AwsS3Bucket",
|
||||
failed_findings_count: 3,
|
||||
},
|
||||
providerUid: "123456789012",
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(context).toEqual({
|
||||
kind: "resource",
|
||||
id: "resource-1",
|
||||
source: "focused",
|
||||
scopeKey: "resources:/resources",
|
||||
label: "Focused resource",
|
||||
resourceId: "resource-1",
|
||||
resourceUid: "arn:aws:s3:::example",
|
||||
providerUid: "123456789012",
|
||||
service: "s3",
|
||||
region: "eu-west-1",
|
||||
resourceType: "AwsS3Bucket",
|
||||
failedFindingsCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds compliance framework snapshots with score and totals", () => {
|
||||
expect(
|
||||
buildComplianceContext({
|
||||
|
||||
@@ -38,6 +38,10 @@ interface FindingResourceContextInput {
|
||||
region?: string;
|
||||
}
|
||||
|
||||
interface FocusedFindingContextInput extends FindingResourceContextInput {
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
interface ResourceContextInput {
|
||||
id: string;
|
||||
attributes: {
|
||||
@@ -50,6 +54,10 @@ interface ResourceContextInput {
|
||||
providerUid?: string;
|
||||
}
|
||||
|
||||
interface FocusedResourceContextInput extends ResourceContextInput {
|
||||
pathname: string;
|
||||
}
|
||||
|
||||
interface ComplianceContextInput {
|
||||
pathname: string;
|
||||
id: string;
|
||||
@@ -140,6 +148,26 @@ export function buildFindingResourceContext(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFocusedFindingContext(
|
||||
finding: FocusedFindingContextInput,
|
||||
): LighthouseFindingContextItem {
|
||||
const safeFindingId = toBoundedString(finding.findingId);
|
||||
return {
|
||||
kind: LIGHTHOUSE_CONTEXT_KIND.FINDING,
|
||||
id: safeFindingId,
|
||||
source: LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED,
|
||||
scopeKey: getLighthouseScopeKey(finding.pathname),
|
||||
label: "Focused finding",
|
||||
findingId: safeFindingId,
|
||||
checkId: optionalBoundedString(finding.checkId),
|
||||
severity: optionalBoundedString(finding.severity),
|
||||
status: optionalBoundedString(finding.status),
|
||||
providerUid: optionalBoundedString(finding.providerUid),
|
||||
resourceUid: optionalBoundedString(finding.resourceUid),
|
||||
region: optionalBoundedString(finding.region),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildResourceSummaryContext(
|
||||
total: number,
|
||||
): LighthouseResourceContextItem {
|
||||
@@ -174,6 +202,25 @@ export function buildResourceContext(
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFocusedResourceContext(
|
||||
resource: FocusedResourceContextInput,
|
||||
): LighthouseResourceContextItem {
|
||||
return {
|
||||
kind: LIGHTHOUSE_CONTEXT_KIND.RESOURCE,
|
||||
id: toBoundedString(resource.id),
|
||||
source: LIGHTHOUSE_CONTEXT_SOURCE.FOCUSED,
|
||||
scopeKey: getLighthouseScopeKey(resource.pathname),
|
||||
label: "Focused resource",
|
||||
resourceId: toBoundedString(resource.id),
|
||||
resourceUid: toBoundedString(resource.attributes.uid),
|
||||
providerUid: optionalBoundedString(resource.providerUid),
|
||||
service: toBoundedString(resource.attributes.service),
|
||||
region: toBoundedString(resource.attributes.region),
|
||||
resourceType: toBoundedString(resource.attributes.type),
|
||||
failedFindingsCount: toSafeCount(resource.attributes.failed_findings_count),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildComplianceContext(
|
||||
input: ComplianceContextInput,
|
||||
): LighthouseComplianceContextItem {
|
||||
|
||||
@@ -72,4 +72,38 @@ describe("useLighthouseContextStore", () => {
|
||||
).map((item) => item.id),
|
||||
).toEqual(["resource-2"]);
|
||||
});
|
||||
|
||||
it("should keep focused context owned by the latest detail panel", () => {
|
||||
// Given
|
||||
const { setFocusedContext, clearFocusedContext } =
|
||||
useLighthouseContextStore.getState();
|
||||
setFocusedContext(1, {
|
||||
kind: "finding",
|
||||
id: "finding-1",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Focused finding",
|
||||
findingId: "finding-1",
|
||||
});
|
||||
|
||||
// When
|
||||
setFocusedContext(2, {
|
||||
kind: "resource",
|
||||
id: "resource-2",
|
||||
source: "focused",
|
||||
scopeKey: "resources:/resources",
|
||||
label: "Focused resource",
|
||||
resourceId: "resource-2",
|
||||
});
|
||||
clearFocusedContext(1);
|
||||
|
||||
// Then
|
||||
expect(useLighthouseContextStore.getState().focused?.id).toBe("resource-2");
|
||||
|
||||
// When
|
||||
clearFocusedContext(2);
|
||||
|
||||
// Then
|
||||
expect(useLighthouseContextStore.getState().focused).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,17 +4,26 @@ import type { LighthouseContextItem } from "@/types/lighthouse-context";
|
||||
|
||||
export interface LighthouseContextStoreState {
|
||||
contributions: Record<string, LighthouseContextItem>;
|
||||
focused: LighthouseContextItem | null;
|
||||
focusedOwnerToken: number;
|
||||
registerContribution: (
|
||||
contributorId: string,
|
||||
item: LighthouseContextItem,
|
||||
) => void;
|
||||
removeContribution: (contributorId: string) => void;
|
||||
setFocusedContext: (
|
||||
ownerToken: number,
|
||||
item: LighthouseContextItem | null,
|
||||
) => void;
|
||||
clearFocusedContext: (ownerToken: number) => void;
|
||||
resetContributions: () => void;
|
||||
}
|
||||
|
||||
export const useLighthouseContextStore = create<LighthouseContextStoreState>(
|
||||
(set) => ({
|
||||
contributions: {},
|
||||
focused: null,
|
||||
focusedOwnerToken: 0,
|
||||
registerContribution: (contributorId, item) =>
|
||||
set((state) => ({
|
||||
contributions: {
|
||||
@@ -30,7 +39,16 @@ export const useLighthouseContextStore = create<LighthouseContextStoreState>(
|
||||
),
|
||||
),
|
||||
})),
|
||||
resetContributions: () => set({ contributions: {} }),
|
||||
setFocusedContext: (ownerToken, focused) =>
|
||||
set({ focused, focusedOwnerToken: ownerToken }),
|
||||
clearFocusedContext: (ownerToken) =>
|
||||
set((state) =>
|
||||
state.focusedOwnerToken === ownerToken
|
||||
? { focused: null, focusedOwnerToken: 0 }
|
||||
: state,
|
||||
),
|
||||
resetContributions: () =>
|
||||
set({ contributions: {}, focused: null, focusedOwnerToken: 0 }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user