From 1090ed59b712bf072c626b8399800650c0ceaf28 Mon Sep 17 00:00:00 2001 From: "Pablo Fernandez Guerra (PFE)" <148432447+pfe-nazaries@users.noreply.github.com> Date: Tue, 12 May 2026 16:33:29 +0200 Subject: [PATCH] feat(ui): replace D3+Dagre attack path graph with React Flow (#10686) Co-authored-by: Pablo F.G Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Alan Buscaglia --- .github/workflows/ui-tests.yml | 39 +- ui/.gitignore | 4 + ui/CHANGELOG.md | 1 + ui/__tests__/mockServiceWorker.test.ts | 18 + ui/__tests__/msw/handlers/attack-paths.ts | 231 +++ ui/__tests__/msw/handlers/index.ts | 13 + ui/__tests__/msw/worker.ts | 5 + ui/__tests__/render-browser.tsx | 25 + .../attack-paths/query-result.adapter.ts | 29 +- .../_components/graph/attack-path-graph.tsx | 1651 ++++++----------- .../_components/graph/graph-controls.test.tsx | 43 + .../_components/graph/graph-controls.tsx | 11 +- .../_components/graph/graph-legend.test.tsx | 186 ++ .../_components/graph/graph-legend.tsx | 999 +++++----- .../query-builder/_components/graph/index.ts | 2 +- .../graph/nodes/finding-node.test.tsx | 95 + .../_components/graph/nodes/finding-node.tsx | 157 ++ .../graph/nodes/hidden-handles.tsx | 35 + .../_components/graph/nodes/internet-node.tsx | 81 + .../graph/nodes/node-label-lines.ts | 48 + .../graph/nodes/resource-node.test.tsx | 88 + .../_components/graph/nodes/resource-node.tsx | 142 ++ .../_components/node-detail/index.ts | 1 - .../node-detail/node-relationships.tsx | 105 -- .../_hooks/use-graph-state.test.ts | 35 + .../query-builder/_hooks/use-graph-state.ts | 36 +- .../query-builder/_lib/export.test.ts | 156 ++ .../(workflow)/query-builder/_lib/export.ts | 611 +++++- .../(workflow)/query-builder/_lib/format.ts | 4 + .../query-builder/_lib/graph-colors.test.ts | 26 + .../query-builder/_lib/graph-colors.ts | 35 +- .../query-builder/_lib/graph-utils.ts | 78 +- .../(workflow)/query-builder/_lib/index.ts | 19 +- .../query-builder/_lib/layout.test.ts | 275 +++ .../(workflow)/query-builder/_lib/layout.ts | 161 ++ .../query-builder/_lib/node-visuals.test.ts | 474 +++++ .../query-builder/_lib/node-visuals.ts | 522 ++++++ .../attack-paths-page.browser.test.tsx | 620 +++++++ .../attack-paths-page.fixtures.ts | 279 +++ .../attack-paths-page.harness.browser.test.ts | 48 + .../attack-paths-page.harness.ts | 654 +++++++ .../query-builder/attack-paths-page.test.tsx | 19 + .../query-builder/attack-paths-page.tsx | 284 +-- ui/components/ui/nav-bar/navbar.tsx | 2 +- ui/dependency-log.json | 84 +- ui/package.json | 26 +- ui/pnpm-lock.yaml | 454 +++-- ui/pnpm-workspace.yaml | 2 + ui/public/mockServiceWorker.js | 354 ++++ ui/scripts/postinstall.js | 61 + ui/types/attack-paths.ts | 41 +- ui/vitest.browser.setup.ts | 94 + ui/vitest.config.ts | 205 +- 53 files changed, 7340 insertions(+), 2328 deletions(-) create mode 100644 ui/__tests__/mockServiceWorker.test.ts create mode 100644 ui/__tests__/msw/handlers/attack-paths.ts create mode 100644 ui/__tests__/msw/handlers/index.ts create mode 100644 ui/__tests__/msw/worker.ts create mode 100644 ui/__tests__/render-browser.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx delete mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.browser.test.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts create mode 100644 ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx create mode 100644 ui/public/mockServiceWorker.js create mode 100644 ui/vitest.browser.setup.ts diff --git a/.github/workflows/ui-tests.yml b/.github/workflows/ui-tests.yml index c11e90b8f4..4abbfa72af 100644 --- a/.github/workflows/ui-tests.yml +++ b/.github/workflows/ui-tests.yml @@ -1,14 +1,14 @@ -name: 'UI: Tests' +name: "UI: Tests" on: push: branches: - - 'master' - - 'v5.*' + - "master" + - "v5.*" pull_request: branches: - - 'master' - - 'v5.*' + - "master" + - "v5.*" concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -16,7 +16,7 @@ concurrency: env: UI_WORKING_DIR: ./ui - NODE_VERSION: '24.13.0' + NODE_VERSION: "24.13.0" permissions: {} @@ -42,6 +42,9 @@ jobs: fonts.gstatic.com:443 api.github.com:443 release-assets.githubusercontent.com:443 + cdn.playwright.dev:443 + objects.githubusercontent.com:443 + playwright.download.prss.microsoft.com:443 - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -133,7 +136,7 @@ jobs: if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed == 'true' run: | echo "Critical paths changed - running ALL unit tests" - pnpm run test:run + pnpm run test:unit - name: Run unit tests (related to changes only) if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files != '' @@ -142,7 +145,7 @@ jobs: echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" # Convert space-separated to vitest related format (remove ui/ prefix for relative paths) CHANGED_FILES=$(echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ') - pnpm exec vitest related $CHANGED_FILES --run + pnpm exec vitest related $CHANGED_FILES --run --project unit env: STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-source.outputs.all_changed_files }} @@ -150,7 +153,25 @@ jobs: if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files == '' run: | echo "Only test files changed - running ALL unit tests" - pnpm run test:run + pnpm run test:unit + + - name: Cache Playwright browsers + if: steps.check-changes.outputs.any_changed == 'true' + id: playwright-cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('ui/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-playwright-chromium- + + - name: Install Playwright Chromium browser + if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true' + run: pnpm exec playwright install chromium + + - name: Run browser tests + if: steps.check-changes.outputs.any_changed == 'true' + run: pnpm run test:browser - name: Build application if: steps.check-changes.outputs.any_changed == 'true' diff --git a/ui/.gitignore b/ui/.gitignore index 3b905e64d8..b6c86be41e 100644 --- a/ui/.gitignore +++ b/ui/.gitignore @@ -7,6 +7,7 @@ # testing /coverage +__screenshots__/ # next.js /.next/ @@ -28,6 +29,9 @@ yarn-error.log* .env*.local .env +# Claude Code local settings +.claude/ + # vercel .vercel diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index 991e26a888..9c50fcb00c 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **Prowler UI** are documented in this file. ### 🔄 Changed - Trimmed unused npm dependencies [(#11115)](https://github.com/prowler-cloud/prowler/pull/11115) +- Attack Paths graph now uses React Flow with improved layout, interactions, export, minimap, and browser test coverage [(#10686)](https://github.com/prowler-cloud/prowler/pull/10686) --- diff --git a/ui/__tests__/mockServiceWorker.test.ts b/ui/__tests__/mockServiceWorker.test.ts new file mode 100644 index 0000000000..e89265c8ac --- /dev/null +++ b/ui/__tests__/mockServiceWorker.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +describe("mock service worker message hardening", () => { + it("rejects messages from unexpected origins before handling client messages", () => { + const workerSource = readFileSync( + join(process.cwd(), "public/mockServiceWorker.js"), + "utf8", + ); + + expect(workerSource).toContain("event.origin !== self.location.origin"); + expect( + workerSource.indexOf("event.origin !== self.location.origin"), + ).toBeLessThan(workerSource.indexOf("const clientId = Reflect.get")); + }); +}); diff --git a/ui/__tests__/msw/handlers/attack-paths.ts b/ui/__tests__/msw/handlers/attack-paths.ts new file mode 100644 index 0000000000..b17c6c0596 --- /dev/null +++ b/ui/__tests__/msw/handlers/attack-paths.ts @@ -0,0 +1,231 @@ +import { http, HttpResponse } from "msw"; + +import type { PageFixture } from "@/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures"; +import type { + AttackPathQueriesResponse, + AttackPathQuery, + AttackPathQueryResult, + AttackPathScan, + AttackPathScansResponse, + QueryResultAttributes, +} from "@/types/attack-paths"; + +const API = process.env.NEXT_PUBLIC_API_BASE_URL!; + +type JsonApiErrorBody = { + errors: Array<{ detail: string; status: string }>; +}; + +const toScansApiResponse = ( + scans: AttackPathScan[], +): AttackPathScansResponse => ({ + data: scans, + links: { + first: `${API}/attack-paths-scans?page=1`, + last: `${API}/attack-paths-scans?page=1`, + next: null, + prev: null, + }, +}); + +const toQueriesApiResponse = ( + queries: AttackPathQuery[], +): AttackPathQueriesResponse => ({ + data: queries, +}); + +const toQueryResultApiResponse = ( + attrs: QueryResultAttributes, + queryId: string, +): AttackPathQueryResult => ({ + data: { + type: "attack-paths-query-run-requests", + id: queryId, + attributes: attrs, + }, +}); + +const toErrorBody = (detail: string, status: number): JsonApiErrorBody => ({ + errors: [{ detail, status: String(status) }], +}); + +const toFindingApiResponse = (fx: PageFixture, findingId: string) => { + const findingNode = fx.queryResult?.nodes.find( + (node) => node.id === findingId, + ); + const resourceNode = fx.queryResult?.nodes.find((node) => + fx.queryResult?.relationships?.some( + (rel) => + (rel.source === node.id && rel.target === findingId) || + (rel.target === node.id && rel.source === findingId), + ), + ); + const scan = fx.scans[0]; + const providerId = scan?.relationships?.provider?.data?.id ?? "provider-1"; + const resourceId = resourceNode?.id ?? "resource-1"; + + return { + data: { + type: "findings", + id: findingId, + attributes: { + uid: String(findingNode?.properties.id ?? findingId), + delta: null, + status: String(findingNode?.properties.status ?? "FAIL"), + status_extended: "Status extended", + severity: String(findingNode?.properties.severity ?? "critical"), + check_id: "attack_path_check", + muted: false, + muted_reason: null, + check_metadata: { + risk: "High", + notes: "", + checkid: "attack_path_check", + provider: "aws", + severity: String(findingNode?.properties.severity ?? "critical"), + checktype: [], + dependson: [], + relatedto: [], + categories: ["security"], + checktitle: String( + findingNode?.properties.check_title ?? "Attack path finding", + ), + compliance: null, + relatedurl: "", + description: "Attack path finding description", + remediation: { + code: { cli: "", other: "", nativeiac: "", terraform: "" }, + recommendation: { url: "", text: "Fix the finding" }, + }, + additionalurls: [], + servicename: String(resourceNode?.properties.service ?? "s3"), + checkaliases: [], + resourcetype: String(resourceNode?.labels[0] ?? "Resource"), + subservicename: "", + resourceidtemplate: "", + }, + raw_result: null, + inserted_at: "2026-04-21T10:00:00Z", + updated_at: "2026-04-21T10:05:00Z", + first_seen_at: null, + }, + relationships: { + resources: { data: [{ type: "resources", id: resourceId }] }, + scan: { data: { type: "scans", id: scan?.id ?? "scan-1" } }, + }, + }, + included: [ + { + type: "resources", + id: resourceId, + attributes: { + uid: String(resourceNode?.properties.arn ?? resourceId), + name: String(resourceNode?.properties.name ?? resourceId), + region: "us-east-1", + service: String(resourceNode?.properties.service ?? "s3"), + tags: {}, + type: String(resourceNode?.labels[0] ?? "Resource"), + inserted_at: "2026-04-21T10:00:00Z", + updated_at: "2026-04-21T10:05:00Z", + details: null, + partition: null, + }, + }, + { + type: "scans", + id: scan?.id ?? "scan-1", + attributes: { + name: "Attack path scan", + trigger: "manual", + state: scan?.attributes.state ?? "completed", + unique_resource_count: 1, + progress: scan?.attributes.progress ?? 100, + duration: scan?.attributes.duration ?? 0, + started_at: scan?.attributes.started_at ?? "2026-04-21T10:00:00Z", + inserted_at: scan?.attributes.inserted_at ?? "2026-04-21T10:00:00Z", + completed_at: scan?.attributes.completed_at ?? "2026-04-21T10:05:00Z", + scheduled_at: null, + next_scan_at: "", + }, + relationships: { + provider: { data: { type: "providers", id: providerId } }, + }, + }, + { + type: "providers", + id: providerId, + attributes: { + provider: scan?.attributes.provider_type ?? "aws", + uid: scan?.attributes.provider_uid ?? "123456789", + alias: scan?.attributes.provider_alias ?? "Provider", + connection: { + connected: true, + last_checked_at: "2026-04-21T10:00:00Z", + }, + inserted_at: "2026-04-21T10:00:00Z", + updated_at: "2026-04-21T10:05:00Z", + }, + }, + ], + }; +}; + +export const handlersForFixture = (fx: PageFixture) => [ + http.get(`${API}/attack-paths-scans`, () => + HttpResponse.json(toScansApiResponse(fx.scans)), + ), + + http.get<{ scanId: string }>( + `${API}/attack-paths-scans/:scanId/queries`, + () => + HttpResponse.json( + toQueriesApiResponse(fx.queries), + ), + ), + + http.post<{ scanId: string }>( + `${API}/attack-paths-scans/:scanId/queries/run`, + () => { + if (fx.queryError) { + return HttpResponse.json( + toErrorBody(fx.queryError.error, fx.queryError.status), + { status: fx.queryError.status }, + ); + } + if (!fx.queryResult) { + return HttpResponse.json( + toErrorBody("No data found", 404), + { status: 404 }, + ); + } + return HttpResponse.json( + toQueryResultApiResponse(fx.queryResult, fx.queryId), + ); + }, + ), + + http.post<{ scanId: string }>( + `${API}/attack-paths-scans/:scanId/queries/custom`, + () => { + if (fx.queryError) { + return HttpResponse.json( + toErrorBody(fx.queryError.error, fx.queryError.status), + { status: fx.queryError.status }, + ); + } + if (!fx.queryResult) { + return HttpResponse.json( + toErrorBody("No data found", 404), + { status: 404 }, + ); + } + return HttpResponse.json( + toQueryResultApiResponse(fx.queryResult, fx.queryId), + ); + }, + ), + + http.get<{ findingId: string }>(`${API}/findings/:findingId`, ({ params }) => + HttpResponse.json(toFindingApiResponse(fx, params.findingId)), + ), +]; diff --git a/ui/__tests__/msw/handlers/index.ts b/ui/__tests__/msw/handlers/index.ts new file mode 100644 index 0000000000..5859c31b8d --- /dev/null +++ b/ui/__tests__/msw/handlers/index.ts @@ -0,0 +1,13 @@ +import type { HttpHandler } from "msw"; + +/** + * Static handlers shared by every browser test — registered as defaults on + * the worker. Use this list for endpoints whose response doesn't change + * across tests (e.g. `/users/me`, `/tenants/current`, health checks). + * + * Per-domain dynamic handlers that depend on fixture data live in their own + * files alongside this index (e.g. `./attack-paths.ts`) and are imported + * directly by the tests that need them, then wired via + * `worker.use(...handlersForFixture(fx))`. + */ +export const handlers: HttpHandler[] = []; diff --git a/ui/__tests__/msw/worker.ts b/ui/__tests__/msw/worker.ts new file mode 100644 index 0000000000..318d6f4a03 --- /dev/null +++ b/ui/__tests__/msw/worker.ts @@ -0,0 +1,5 @@ +import { setupWorker } from "msw/browser"; + +import { handlers } from "./handlers"; + +export const worker = setupWorker(...handlers); diff --git a/ui/__tests__/render-browser.tsx b/ui/__tests__/render-browser.tsx new file mode 100644 index 0000000000..5ae56d4fec --- /dev/null +++ b/ui/__tests__/render-browser.tsx @@ -0,0 +1,25 @@ +import type { ComponentType, PropsWithChildren, ReactElement } from "react"; +import { render as vitestRender } from "vitest-browser-react"; + +const TestProviders = ({ children }: PropsWithChildren) => <>{children}; + +type RenderOptions = Parameters[1]; + +export function render(ui: ReactElement, options?: RenderOptions) { + const userWrapper = options?.wrapper as + | ComponentType + | undefined; + + const Wrapper = userWrapper + ? ({ children }: PropsWithChildren) => { + const Inner = userWrapper; + return ( + + {children} + + ); + } + : TestProviders; + + return vitestRender(ui, { ...options, wrapper: Wrapper }); +} diff --git a/ui/actions/attack-paths/query-result.adapter.ts b/ui/actions/attack-paths/query-result.adapter.ts index 65b33843af..93bf38aca0 100644 --- a/ui/actions/attack-paths/query-result.adapter.ts +++ b/ui/actions/attack-paths/query-result.adapter.ts @@ -131,27 +131,16 @@ export function adaptQueryResultToGraphData( // Populate findings and resources based on HAS_FINDING edges edges.forEach((edge) => { if (edge.type === "HAS_FINDING") { - const sourceId = - typeof edge.source === "string" - ? edge.source - : (edge.source as { id?: string })?.id; - const targetId = - typeof edge.target === "string" - ? edge.target - : (edge.target as { id?: string })?.id; + // Add finding to source node (resource -> finding) + const sourceNode = normalizedNodes.find((n) => n.id === edge.source); + if (sourceNode) { + sourceNode.findings.push(edge.target); + } - if (sourceId && targetId) { - // Add finding to source node (resource -> finding) - const sourceNode = normalizedNodes.find((n) => n.id === sourceId); - if (sourceNode) { - sourceNode.findings.push(targetId); - } - - // Add resource to target node (finding <- resource) - const targetNode = normalizedNodes.find((n) => n.id === targetId); - if (targetNode) { - targetNode.resources.push(sourceId); - } + // Add resource to target node (finding <- resource) + const targetNode = normalizedNodes.find((n) => n.id === edge.target); + if (targetNode) { + targetNode.resources.push(edge.source); } } }); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx index 10842492e8..6e9c608f64 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/attack-path-graph.tsx @@ -1,1173 +1,608 @@ "use client"; -import type { D3ZoomEvent, ZoomBehavior } from "d3"; -import { select, zoom, zoomIdentity } from "d3"; -import dagre from "dagre"; +import "@xyflow/react/dist/style.css"; + +import { + Background, + MiniMap, + type Node, + ReactFlow, + ReactFlowProvider, + type Rect, + useReactFlow, +} from "@xyflow/react"; import { useTheme } from "next-themes"; import { - forwardRef, + type MouseEvent, type Ref, useEffect, useImperativeHandle, + useLayoutEffect, useRef, useState, } from "react"; +import { cn } from "@/lib/utils"; import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; import { - formatNodeLabel, + computeFilteredSubgraph, getNodeBorderColor, getNodeColor, getPathEdges, - GRAPH_ALERT_BORDER_COLOR, - GRAPH_EDGE_COLOR_DARK, - GRAPH_EDGE_COLOR_LIGHT, GRAPH_EDGE_HIGHLIGHT_COLOR, + resolveHiddenFindingIds, } from "../../_lib"; +import { isFindingNode, layoutWithDagre } from "../../_lib/layout"; +import { FindingNode } from "./nodes/finding-node"; +import { InternetNode } from "./nodes/internet-node"; +import { ResourceNode } from "./nodes/resource-node"; -export interface AttackPathGraphRef { +// --- Types --- + +export interface GraphHandle { zoomIn: () => void; zoomOut: () => void; resetZoom: () => void; getZoomLevel: () => number; - getSVGElement: () => SVGSVGElement | null; + getContainerElement: () => HTMLDivElement | null; + getNodesBounds: () => Rect | null; } interface AttackPathGraphProps { data: AttackPathGraphData; - onNodeClick?: (node: GraphNode) => void; selectedNodeId?: string | null; isFilteredView?: boolean; - ref?: Ref; + initialNodeId?: string; + expandedResources?: Set; + onNodeClick?: (node: GraphNode) => void; + onInitialFilter?: (filteredData: AttackPathGraphData) => void; + ref?: Ref; + className?: string; } -/** - * Node data type used throughout the graph visualization - */ -type NodeData = { id: string; x: number; y: number; data: GraphNode }; +const EMPTY_EXPANDED: ReadonlySet = new Set(); -// Node dimensions - modern rounded pill style -const NODE_WIDTH = 180; -const NODE_HEIGHT = 50; -const NODE_RADIUS = 25; // Fully rounded ends for pill shape -const HEXAGON_WIDTH = 200; // Width for finding hexagons -const HEXAGON_HEIGHT = 55; // Height for finding hexagons +// --- Node type registry (stable reference) --- -/** - * D3 + Dagre hierarchical graph visualization for attack paths - * Renders rounded rectangle nodes with dashed edges - */ -const AttackPathGraphComponent = forwardRef< - AttackPathGraphRef, - AttackPathGraphProps ->(({ data, onNodeClick, selectedNodeId, isFilteredView = false }, ref) => { - const svgRef = useRef(null); - const [zoomLevel, setZoomLevel] = useState(1); +const NODE_TYPES = { + finding: FindingNode, + internet: InternetNode, + resource: ResourceNode, +} as const; + +// --- CSS for animated dashed edges, selected node pulse, and edge highlight --- + +const GRAPH_STYLES = ` + @keyframes dash { + to { stroke-dashoffset: -20; } + } + .react-flow .finding-edge .react-flow__edge-path { + stroke-dasharray: 8 6; + animation: dash 1s linear infinite; + } + @keyframes selectedPulse { + 0%, 100% { stroke-opacity: 1; } + 50% { stroke-opacity: 0.6; } + } + .selected-node { + animation: selectedPulse 1.2s ease-in-out infinite; + } + .react-flow .highlighted .react-flow__edge-path { + stroke: ${GRAPH_EDGE_HIGHLIGHT_COLOR}; + stroke-width: 3; + filter: drop-shadow(0 0 4px ${GRAPH_EDGE_HIGHLIGHT_COLOR}); + } +`; + +// --- SVG filter color constants --- + +const GRAPH_FINDING_GLOW_COLOR = "#ef4444"; +const GRAPH_SELECTED_GLOW_COLOR = GRAPH_EDGE_HIGHLIGHT_COLOR; + +// --- SVG filter defs (shared by all node components) --- + +const GraphDefs = () => ( + +); + +// --- Inner component: calls useReactFlow(), owns layout derivation --- + +type GraphCanvasProps = Omit; + +// Mask covers the area NOT currently in view; the cut-out rectangle is the +// viewport. To make the viewport rectangle stand out we darken the mask and +// give its border high contrast against the minimap background. +const MINIMAP_COLORS = { + light: { + bg: "#f8fafc", + mask: "rgba(15, 23, 42, 0.45)", + maskStroke: "#0f172a", + }, + dark: { + bg: "#0f172a", + mask: "rgba(0, 0, 0, 0.7)", + maskStroke: "#cbd5e1", + }, +} as const; + +const MINIMAP_VIEWPORT_STROKE_WIDTH = 3; + +// Animated re-fit shared by every auto-fit trigger. We deliberately do not +// cap maxZoom — for small subgraphs (e.g. a finding's filtered view with 3 +// nodes) the user expects the result to fill the canvas; an artificial cap +// looks like a layout error. +// +// Padding is asymmetric: the minimap sits in the bottom-right corner of the +// canvas (default 200×150 panel + offset), so a fit with uniform padding +// can drop nodes underneath it. Generous bottom/right padding keeps the +// fitted graph clear of the minimap. +const AUTO_FIT_OPTIONS = { + padding: { top: "32px", left: "32px", right: "240px", bottom: "180px" }, + duration: 300, +} as const; + +const MEASURED_FIT_MAX_ATTEMPTS = 30; + +const scheduleMeasuredFit = ( + isMeasured: () => boolean, + onMeasured: () => void, +) => { + let frame = 0; + let attempts = 0; + + const tryFit = () => { + if (isMeasured() || attempts >= MEASURED_FIT_MAX_ATTEMPTS) { + onMeasured(); + return; + } + + attempts += 1; + frame = requestAnimationFrame(tryFit); + }; + + frame = requestAnimationFrame(tryFit); + + return () => cancelAnimationFrame(frame); +}; + +const GraphCanvas = ({ + data, + selectedNodeId, + isFilteredView = false, + initialNodeId, + expandedResources, + onNodeClick, + onInitialFilter, + ref, +}: GraphCanvasProps) => { + const { zoomIn, zoomOut, fitView, getZoom, getNodes, getNodesBounds } = + useReactFlow(); const { resolvedTheme } = useTheme(); - const zoomBehaviorRef = useRef | null>( - null, - ); - const containerRef = useRef - > | null>(null); - const svgSelectionRef = useRef - > | null>(null); - const hiddenNodeIdsRef = useRef>(new Set()); - const onNodeClickRef = useRef(onNodeClick); - const nodeShapesRef = useRef - > | null>(null); - const linkElementsRef = useRef - > | null>(null); - const resourcesWithFindingsRef = useRef>(new Set()); - const selectedNodeIdRef = useRef(null); - const edgesDataRef = useRef< - Array<{ - sourceId: string; - targetId: string; - }> - >([]); + const containerRef = useRef(null); + const hasInitialized = useRef(false); - // Get edge color based on current theme - const edgeColor = - resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT; + const minimapColors = + resolvedTheme === "dark" ? MINIMAP_COLORS.dark : MINIMAP_COLORS.light; - // Keep selectedNodeIdRef in sync with selectedNodeId + // Path highlight state — local to the canvas, must reset on data swaps + // (otherwise we'd keep highlighting an edge that no longer exists). + const [hoveredNodeId, setHoveredNodeId] = useState(null); useEffect(() => { - selectedNodeIdRef.current = selectedNodeId ?? null; - }, [selectedNodeId]); + setHoveredNodeId(null); + }, [data]); - // Update ref when onNodeClick changes - useEffect(() => { - onNodeClickRef.current = onNodeClick; - }, [onNodeClick]); + // Tier 1 expansion is controlled by the parent (zustand store) so it + // survives the data-swaps that happen on filtered-view enter/exit. + const expanded = expandedResources ?? EMPTY_EXPANDED; - // Update selected node styling and edge highlighting without re-rendering - useEffect(() => { - if (nodeShapesRef.current) { - nodeShapesRef.current - .attr("stroke", (d: NodeData) => { - const isFinding = d.data.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const hasFindings = resourcesWithFindingsRef.current.has(d.id); + // --- initialNodeId: synchronous filtered-view derivation on first render --- + // Compute the effective data: if initialNodeId is set and valid, derive filtered subgraph + let effectiveData = data; + if ( + initialNodeId && + !hasInitialized.current && + data.nodes.some((n) => n.id === initialNodeId) + ) { + effectiveData = computeFilteredSubgraph(data, initialNodeId); + } - // Resources with findings always keep red border - if (!isFinding && hasFindings) { - return GRAPH_ALERT_BORDER_COLOR; - } - // Selected nodes get highlight color (orange) - if (d.id === selectedNodeId) { - return GRAPH_EDGE_HIGHLIGHT_COLOR; - } - // Default border color - return getNodeBorderColor(d.data.labels, d.data.properties); - }) - .attr("stroke-width", (d: NodeData) => { - const isFinding = d.data.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const hasFindings = resourcesWithFindingsRef.current.has(d.id); - const isSelected = d.id === selectedNodeId; - - if (isSelected) return 4; - if (!isFinding && hasFindings) return 2.5; - return isFinding ? 2 : 1.5; - }) - .attr("filter", (d: NodeData) => { - const isFinding = d.data.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const hasFindings = resourcesWithFindingsRef.current.has(d.id); - const isSelected = d.id === selectedNodeId; - - if (isSelected) return "url(#selectedGlow)"; - if (!isFinding && hasFindings) return "url(#redGlow)"; - return isFinding ? "url(#glow)" : null; - }) - .attr("class", (d: NodeData) => { - const isSelected = d.id === selectedNodeId; - return isSelected ? "node-shape selected-node" : "node-shape"; - }); + // Sync store flags via useLayoutEffect (runs before paint) + useLayoutEffect(() => { + if (hasInitialized.current) return; + hasInitialized.current = true; + if ( + initialNodeId && + data.nodes.some((n) => n.id === initialNodeId) && + onInitialFilter + ) { + onInitialFilter(effectiveData); } + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- one-time init - // Update edge highlighting for selected node - highlight entire path - if (linkElementsRef.current && edgesDataRef.current.length > 0) { - const pathEdges = selectedNodeId - ? getPathEdges(selectedNodeId, edgesDataRef.current) - : new Set(); - - linkElementsRef.current.each(function (edgeData: { - sourceId: string; - targetId: string; - }) { - const edgeId = `${edgeData.sourceId}-${edgeData.targetId}`; - const isInPath = pathEdges.has(edgeId); - select(this) - .attr("stroke", isInPath ? GRAPH_EDGE_HIGHLIGHT_COLOR : edgeColor) - .attr( - "marker-end", - isInPath ? "url(#arrowhead-highlight)" : "url(#arrowhead)", - ); - }); - } - }, [selectedNodeId, edgeColor]); - - useImperativeHandle(ref, () => ({ - zoomIn: () => { - if (svgSelectionRef.current && zoomBehaviorRef.current) { - svgSelectionRef.current - .transition() - .duration(300) - .call(zoomBehaviorRef.current.scaleBy, 1.3); - } - }, - zoomOut: () => { - if (svgSelectionRef.current && zoomBehaviorRef.current) { - svgSelectionRef.current - .transition() - .duration(300) - .call(zoomBehaviorRef.current.scaleBy, 0.77); - } - }, - resetZoom: () => { - if ( - svgSelectionRef.current && - zoomBehaviorRef.current && - containerRef.current - ) { - const bounds = containerRef.current.node()?.getBBox(); - if (!bounds) return; - - const fullWidth = svgRef.current?.clientWidth || 800; - const fullHeight = svgRef.current?.clientHeight || 500; - - const midX = bounds.x + bounds.width / 2; - const midY = bounds.y + bounds.height / 2; - const scale = - 0.8 / Math.max(bounds.width / fullWidth, bounds.height / fullHeight); - const tx = fullWidth / 2 - scale * midX; - const ty = fullHeight / 2 - scale * midY; - - svgSelectionRef.current - .transition() - .duration(300) - .call( - zoomBehaviorRef.current.transform, - zoomIdentity.translate(tx, ty).scale(scale), - ); - } - }, - getZoomLevel: () => zoomLevel, - getSVGElement: () => svgRef.current, - })); + // --- Auto-fit triggers --- + // + // Filter toggles (finding click → filtered view, "Back to Full View" → + // full graph) swap the data prop, which leaves the previous viewport + // pointing at coordinates that no longer contain the new layout. Re-fit + // once React Flow has applied the new layout (next animation frame). + // + // Resource expansion re-fits after newly revealed finding nodes have been + // measured. This restores the original Show findings behavior: expanding a + // resource should frame the selected resource plus its findings, not the + // entire graph. Collapsing re-fits the remaining visible graph so the user + // does not stay zoomed into empty space after hiding finding children. + const filteredFitInitRef = useRef(false); + const previousFilteredRef = useRef(isFilteredView); + const previousExpandedRef = useRef>(expanded); + // Captured every render so the expand-fit effect can resolve a resource + // ID to its connected finding IDs without recomputing the lookup. + // The map itself is built later in this render — see assignment below + // the layout-derivation block. + const resourceToFindingsRef = useRef>>(new Map()); useEffect(() => { - if (!svgRef.current || !data.nodes || data.nodes.length === 0) return; - - // Set dimensions based on container size - const width = svgRef.current.clientWidth || 800; - const height = svgRef.current.clientHeight || 500; - - // Clear previous content - select(svgRef.current).selectAll("*").remove(); - - // Create SVG - const svg = select(svgRef.current) - .attr("width", width) - .attr("height", height) - .attr("viewBox", [0, 0, width, height]); - - // Create container for zoom/pan - const container = svg.append("g") as unknown as ReturnType< - typeof select - >; - containerRef.current = container; - svgSelectionRef.current = svg as unknown as ReturnType< - typeof select - >; - - // Container relationships (reverse direction for layout purposes) - const containerRelations = new Set([ - "RUNS_IN", - "BELONGS_TO", - "LOCATED_IN", - "PART_OF", - ]); - - // Create dagre graph - const g = new dagre.graphlib.Graph(); - g.setGraph({ - rankdir: "LR", // Left to right - nodesep: 80, // Vertical spacing between nodes - ranksep: 150, // Horizontal spacing between ranks - marginx: 50, - marginy: 50, - }); - g.setDefaultEdgeLabel(() => ({})); - - // Initially hide finding nodes - they are shown when user clicks on a node - // In filtered view, show all nodes since they're already filtered to the selected path - const initialHiddenNodes = new Set(); - if (!isFilteredView) { - data.nodes.forEach((node) => { - const isFinding = node.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - if (isFinding) { - initialHiddenNodes.add(node.id); - } - }); + if (!filteredFitInitRef.current) { + filteredFitInitRef.current = true; + previousFilteredRef.current = isFilteredView; + return; } - hiddenNodeIdsRef.current = initialHiddenNodes; - - // Create a map to store original node data - const nodeDataMap = new Map(data.nodes.map((node) => [node.id, node])); - - // Add nodes to dagre graph with appropriate sizes - data.nodes.forEach((node) => { - const isFinding = node.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - g.setNode(node.id, { - label: node.id, - width: isFinding ? HEXAGON_WIDTH : NODE_WIDTH, - height: isFinding ? HEXAGON_HEIGHT : NODE_HEIGHT, - }); - }); - - // Add edges to dagre graph - if (data.edges && Array.isArray(data.edges)) { - data.edges.forEach((edge) => { - const source = edge.source; - const target = edge.target; - let sourceId = - typeof source === "string" - ? source - : typeof source === "object" && source !== null - ? (source as GraphNode).id - : ""; - let targetId = - typeof target === "string" - ? target - : typeof target === "object" && target !== null - ? (target as GraphNode).id - : ""; - - // Reverse container relationships for proper hierarchy - if (containerRelations.has(edge.type)) { - [sourceId, targetId] = [targetId, sourceId]; - } - - if (sourceId && targetId) { - g.setEdge(sourceId, targetId, { - originalSource: - typeof edge.source === "string" - ? edge.source - : (edge.source as GraphNode).id, - originalTarget: - typeof edge.target === "string" - ? edge.target - : (edge.target as GraphNode).id, - }); - } - }); - } - - // Run dagre layout - dagre.layout(g); - - // Draw edges - const edgesData: Array<{ - source: { x: number; y: number }; - target: { x: number; y: number }; - id: string; - sourceId: string; - targetId: string; - }> = []; - g.edges().forEach((e) => { - const sourceNode = g.node(e.v); - const targetNode = g.node(e.w); - - edgesData.push({ - source: { x: sourceNode.x, y: sourceNode.y }, - target: { x: targetNode.x, y: targetNode.y }, - id: `${e.v}-${e.w}`, - sourceId: e.v, - targetId: e.w, - }); - }); - - // Store edges data in ref for path highlighting - edgesDataRef.current = edgesData.map((e) => ({ - sourceId: e.sourceId, - targetId: e.targetId, - })); - - // Add defs for filters and markers FIRST (before using them) - const defs = svg.append("defs"); - - // Glow filter for nodes - const glowFilter = defs.append("filter").attr("id", "glow"); - glowFilter - .append("feGaussianBlur") - .attr("stdDeviation", "3") - .attr("result", "coloredBlur"); - const feMerge = glowFilter.append("feMerge"); - feMerge.append("feMergeNode").attr("in", "coloredBlur"); - feMerge.append("feMergeNode").attr("in", "SourceGraphic"); - - // Edge glow filter - const edgeGlowFilter = defs.append("filter").attr("id", "edgeGlow"); - edgeGlowFilter - .append("feGaussianBlur") - .attr("stdDeviation", "2") - .attr("result", "coloredBlur"); - const edgeFeMerge = edgeGlowFilter.append("feMerge"); - edgeFeMerge.append("feMergeNode").attr("in", "coloredBlur"); - edgeFeMerge.append("feMergeNode").attr("in", "SourceGraphic"); - - // Red glow filter for resources with findings - const redGlowFilter = defs.append("filter").attr("id", "redGlow"); - redGlowFilter - .append("feDropShadow") - .attr("dx", "0") - .attr("dy", "0") - .attr("stdDeviation", "4") - .attr("flood-color", GRAPH_ALERT_BORDER_COLOR) - .attr("flood-opacity", "0.6"); - - // Orange glow filter for selected/filtered node - const selectedGlowFilter = defs.append("filter").attr("id", "selectedGlow"); - selectedGlowFilter - .append("feDropShadow") - .attr("dx", "0") - .attr("dy", "0") - .attr("stdDeviation", "6") - .attr("flood-color", GRAPH_EDGE_HIGHLIGHT_COLOR) - .attr("flood-opacity", "0.8"); - - // Arrow marker (theme-aware) - refX=10 places the arrow tip exactly at the line endpoint - defs - .append("marker") - .attr("id", "arrowhead") - .attr("viewBox", "0 0 10 10") - .attr("refX", 10) - .attr("refY", 5) - .attr("markerWidth", 6) - .attr("markerHeight", 6) - .attr("orient", "auto") - .append("path") - .attr("d", "M 0 0 L 10 5 L 0 10 z") - .attr("fill", edgeColor); - - // Arrow marker (highlighted orange) for hover state - defs - .append("marker") - .attr("id", "arrowhead-highlight") - .attr("viewBox", "0 0 10 10") - .attr("refX", 10) - .attr("refY", 5) - .attr("markerWidth", 6) - .attr("markerHeight", 6) - .attr("orient", "auto") - .append("path") - .attr("d", "M 0 0 L 10 5 L 0 10 z") - .attr("fill", GRAPH_EDGE_HIGHLIGHT_COLOR); - - // Add CSS animation for dashed lines, resource edge styles, and selected node pulse - svg.append("style").text(` - @keyframes dash { - to { - stroke-dashoffset: -20; - } - } - .animated-edge { - animation: dash 1s linear infinite; - } - .resource-edge { - stroke-opacity: 1; - } - @keyframes selectedPulse { - 0%, 100% { - stroke-opacity: 1; - stroke-width: 4px; - } - 50% { - stroke-opacity: 0.6; - stroke-width: 6px; - } - } - .selected-node { - animation: selectedPulse 1.2s ease-in-out infinite; - filter: url(#selectedGlow); - } - `); - - const linkGroup = container.append("g").attr("class", "links"); - - // Calculate edge endpoints based on node shape - const getEdgePoints = ( - sourceId: string, - targetId: string, - source: { x: number; y: number }, - target: { x: number; y: number }, - ) => { - const sourceNode = nodeDataMap.get(sourceId); - const targetNode = nodeDataMap.get(targetId); - - const sourceIsFinding = sourceNode?.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const targetIsFinding = targetNode?.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const sourceIsInternet = sourceNode?.labels.some( - (label) => label.toLowerCase() === "internet", - ); - const targetIsInternet = targetNode?.labels.some( - (label) => label.toLowerCase() === "internet", - ); - - // Get appropriate widths based on node type - // Internet nodes are circles with radius = NODE_HEIGHT * 0.8 - const sourceHalfWidth = sourceIsInternet - ? NODE_HEIGHT * 0.8 - : sourceIsFinding - ? HEXAGON_WIDTH / 2 - : NODE_WIDTH / 2; - const targetHalfWidth = targetIsInternet - ? NODE_HEIGHT * 0.8 - : targetIsFinding - ? HEXAGON_WIDTH / 2 - : NODE_WIDTH / 2; - - // Source exits from right side - const x1 = source.x + sourceHalfWidth; - const y1 = source.y; - - // Target enters from left side - line ends at node edge, arrow extends from there - const x2 = target.x - targetHalfWidth; - const y2 = target.y; - - return { x1, y1, x2, y2 }; - }; - - // Helper to check if a node is a finding - const isNodeFinding = (nodeId: string) => { - const node = nodeDataMap.get(nodeId); - return node?.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - }; - - const linkElements = linkGroup - .selectAll("line") - .data(edgesData) - .enter() - .append("line") - .attr( - "x1", - (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).x1, - ) - .attr( - "y1", - (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).y1, - ) - .attr( - "x2", - (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).x2, - ) - .attr( - "y2", - (d) => getEdgePoints(d.sourceId, d.targetId, d.source, d.target).y2, - ) - .attr("stroke", edgeColor) - .attr("stroke-width", 3) - .attr("stroke-linecap", "round") - .attr("stroke-dasharray", (d) => { - // Dashed lines only for edges connected to findings - const hasFinding = - isNodeFinding(d.sourceId) || isNodeFinding(d.targetId); - return hasFinding ? "8,6" : null; - }) - .attr("class", (d) => { - // Animate dashed lines - const hasFinding = - isNodeFinding(d.sourceId) || isNodeFinding(d.targetId); - return hasFinding ? "animated-edge" : "resource-edge"; - }) - .attr("marker-end", "url(#arrowhead)") - .style("visibility", (d) => { - const sourceIsFinding = isNodeFinding(d.sourceId); - const targetIsFinding = isNodeFinding(d.targetId); - - // Hide edges connected to findings in full view (shown when user clicks on a node or in filtered view) - if (!isFilteredView && (sourceIsFinding || targetIsFinding)) { - return "hidden"; - } - return "visible"; - }); - - // Store linkElements reference for hover interactions - // D3 selection types don't match our ref type exactly; safe cast for internal use - linkElementsRef.current = linkElements as unknown as ReturnType< - typeof select - >; - - // Draw nodes - const nodesData = g.nodes().map((v) => { - const node = g.node(v); - return { - id: v, - x: node.x, - y: node.y, - data: nodeDataMap.get(v)!, - }; - }); - - const nodeGroup = container.append("g").attr("class", "nodes"); - - const nodeElements = nodeGroup - .selectAll("g.node") - .data(nodesData) - .enter() - .append("g") - .attr("class", "node") - .attr("transform", (d) => `translate(${d.x},${d.y})`) - .attr("cursor", "pointer") - .style("display", (d) => { - // Hide findings in full view (they are shown when user clicks on a node or in filtered view) - return hiddenNodeIdsRef.current.has(d.id) ? "none" : null; - }) - .on("mouseenter", function (_event: PointerEvent, d) { - // Highlight entire path from this node - const pathEdges = getPathEdges(d.id, edgesData); - linkElements.each(function (edgeData) { - const edgeId = `${edgeData.sourceId}-${edgeData.targetId}`; - if (pathEdges.has(edgeId)) { - select(this) - .attr("stroke", GRAPH_EDGE_HIGHLIGHT_COLOR) - .attr("marker-end", "url(#arrowhead-highlight)"); + if (previousFilteredRef.current === isFilteredView) return; + const wasFilteredView = previousFilteredRef.current; + previousFilteredRef.current = isFilteredView; + // React Flow measures node sizes asynchronously via ResizeObserver after + // the data swap. A single rAF runs while measured.width is still 0, so + // fitView computes a degenerate bbox and the viewport keeps the user's + // previous zoom — most visibly when leaving a filtered view in which the + // user had zoomed in. Poll until visible nodes are measured (or give up + // after ~500ms so we never block on a stuck observer). + return scheduleMeasuredFit( + () => { + const visibleNodes = getNodes().filter((n) => !n.hidden); + return ( + visibleNodes.length > 0 && + visibleNodes.every((n) => (n.measured?.width ?? 0) > 0) + ); + }, + () => { + if (wasFilteredView && !isFilteredView && expanded.size > 0) { + const contextualNodeIds = new Set(); + for (const resourceId of Array.from(expanded)) { + contextualNodeIds.add(resourceId); + resourceToFindingsRef.current + .get(resourceId) + ?.forEach((findingId) => contextualNodeIds.add(findingId)); } - }); - // Change node border to highlight color on hover - const nodeGroup = select(this); - const nodeShape = nodeGroup.select(".node-shape"); - const isFinding = d.data.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const hasFindings = resourcesWithFindings.has(d.id); + const contextualNodes = getNodes().filter( + (node) => !node.hidden && contextualNodeIds.has(node.id), + ); - // Don't change border for resources with findings (keep red) - if (!hasFindings || isFinding) { - nodeShape.attr("stroke", GRAPH_EDGE_HIGHLIGHT_COLOR); - } - }) - .on("mouseleave", function (_event: PointerEvent, d) { - const selectedId = selectedNodeIdRef.current; - - // Reset edges: keep selected node's path highlighted - const selectedPathEdges = selectedId - ? getPathEdges(selectedId, edgesData) - : new Set(); - - linkElements.each(function (edgeData) { - const edgeId = `${edgeData.sourceId}-${edgeData.targetId}`; - if (selectedPathEdges.has(edgeId)) { - select(this) - .attr("stroke", GRAPH_EDGE_HIGHLIGHT_COLOR) - .attr("marker-end", "url(#arrowhead-highlight)"); - } else { - select(this) - .attr("stroke", edgeColor) - .attr("marker-end", "url(#arrowhead)"); + if (contextualNodes.length > 0) { + fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes }); + return; } - }); - - // Reset node border - const nodeGroup = select(this); - const nodeShape = nodeGroup.select(".node-shape"); - const isFinding = d.data.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const hasFindings = resourcesWithFindings.has(d.id); - - // Determine the correct border color - if (!isFinding && hasFindings) { - nodeShape.attr("stroke", GRAPH_ALERT_BORDER_COLOR); - } else if (d.id === selectedId) { - nodeShape.attr("stroke", GRAPH_EDGE_HIGHLIGHT_COLOR); - } else { - nodeShape.attr( - "stroke", - getNodeBorderColor(d.data.labels, d.data.properties), - ); - } - }) - .on("click", function (event: PointerEvent, d) { - event.stopPropagation(); - - // Toggle visibility of connected finding nodes - const node = d.data; - const isFinding = node.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - - if (!isFinding) { - // Find connected findings for THIS node - const connectedFindings = new Set(); - data.edges?.forEach((edge) => { - const sourceId = - typeof edge.source === "string" - ? edge.source - : (edge.source as GraphNode).id; - const targetId = - typeof edge.target === "string" - ? edge.target - : (edge.target as GraphNode).id; - - if (sourceId === node.id || targetId === node.id) { - const otherId = sourceId === node.id ? targetId : sourceId; - const otherNode = data.nodes.find((n) => n.id === otherId); - if ( - otherNode?.labels.some((label) => - label.toLowerCase().includes("finding"), - ) - ) { - connectedFindings.add(otherId); - } - } - }); - - // Clear hidden nodes and hide ALL findings - hiddenNodeIdsRef.current.clear(); - data.nodes.forEach((n) => { - const isNodeFinding = n.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - if (isNodeFinding) { - hiddenNodeIdsRef.current.add(n.id); - } - }); - - // Show ONLY the findings connected to the clicked node - connectedFindings.forEach((findingId) => { - hiddenNodeIdsRef.current.delete(findingId); - }); - - // Update node visibility - nodeElements.style( - "display", - function (nodeData: { - id: string; - x: number; - y: number; - data: GraphNode; - }) { - return hiddenNodeIdsRef.current.has(nodeData.id) ? "none" : null; - }, - ); - - // Update edge visibility - linkElements.style( - "visibility", - function (edgeData: { - source: { x: number; y: number }; - target: { x: number; y: number }; - id: string; - sourceId: string; - targetId: string; - }) { - // Resource-to-resource edges are ALWAYS visible - const sourceIsFinding = isNodeFinding(edgeData.sourceId); - const targetIsFinding = isNodeFinding(edgeData.targetId); - - if (!sourceIsFinding && !targetIsFinding) { - return "visible"; - } - - // Finding edges only visible when finding is not hidden - return hiddenNodeIdsRef.current.has(edgeData.sourceId) || - hiddenNodeIdsRef.current.has(edgeData.targetId) - ? "hidden" - : "visible"; - }, - ); - - // Auto-adjust view to show the selected node and its findings - setTimeout(() => { - if ( - svgSelectionRef.current && - zoomBehaviorRef.current && - containerRef.current && - svgRef.current - ) { - // Calculate bounding box of visible nodes (clicked node + its findings) - const visibleNodeIds = new Set([ - node.id, - ...Array.from(connectedFindings), - ]); - const visibleNodesData = nodesData.filter((n) => - visibleNodeIds.has(n.id), - ); - - if (visibleNodesData.length > 0) { - // Find min/max coordinates of visible nodes - let minX = Infinity, - maxX = -Infinity, - minY = Infinity, - maxY = -Infinity; - visibleNodesData.forEach((n) => { - minX = Math.min(minX, n.x - NODE_WIDTH / 2); - maxX = Math.max(maxX, n.x + NODE_WIDTH / 2); - minY = Math.min(minY, n.y - NODE_HEIGHT / 2); - maxY = Math.max(maxY, n.y + NODE_HEIGHT / 2); - }); - - // Add padding - const padding = 80; - minX -= padding; - maxX += padding; - minY -= padding; - maxY += padding; - - // Get actual SVG dimensions from the DOM - const svgRect = svgRef.current.getBoundingClientRect(); - const fullWidth = svgRect.width; - const fullHeight = svgRect.height; - - const boxWidth = maxX - minX; - const boxHeight = maxY - minY; - const midX = minX + boxWidth / 2; - const midY = minY + boxHeight / 2; - - // Calculate scale to fit all visible nodes - const scale = - 0.9 / Math.max(boxWidth / fullWidth, boxHeight / fullHeight); - const tx = fullWidth / 2 - scale * midX; - const ty = fullHeight / 2 - scale * midY; - - svgSelectionRef.current - .transition() - .duration(500) - .call( - zoomBehaviorRef.current.transform, - zoomIdentity.translate(tx, ty).scale(scale), - ); - } - } - }, 50); } - onNodeClickRef.current?.(d.data); - }); - - // Add tooltip - nodeElements.append("title").text((d: (typeof nodesData)[0]): string => { - const isFinding = d.data.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const label = - d.data.labels && d.data.labels.length > 0 - ? formatNodeLabel(d.data.labels[0]) - : d.id; - - if (isFinding) { - return `${label}\nClick to view finding details`; - } else { - return `${label}\nClick to view related findings`; - } - }); - - // Build a set of resource nodes that have findings connected to them - const resourcesWithFindings = new Set(); - data.edges?.forEach((edge) => { - const sourceId = - typeof edge.source === "string" - ? edge.source - : (edge.source as GraphNode).id; - const targetId = - typeof edge.target === "string" - ? edge.target - : (edge.target as GraphNode).id; - - const sourceNode = nodeDataMap.get(sourceId); - const targetNode = nodeDataMap.get(targetId); - - const sourceIsFinding = sourceNode?.labels.some((l) => - l.toLowerCase().includes("finding"), - ); - const targetIsFinding = targetNode?.labels.some((l) => - l.toLowerCase().includes("finding"), - ); - - // If one end is a finding, the other is a resource with findings - if (sourceIsFinding && !targetIsFinding) { - resourcesWithFindings.add(targetId); - } - if (targetIsFinding && !sourceIsFinding) { - resourcesWithFindings.add(sourceId); - } - }); - - // Store in ref for use in selection updates - resourcesWithFindingsRef.current = resourcesWithFindings; - - // Add shapes - hexagons for findings, rounded pill shapes for resources - nodeElements.each(function (d) { - const group = select(this); - const isFinding = d.data.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - const nodeColor = getNodeColor(d.data.labels, d.data.properties); - const borderColor = getNodeBorderColor(d.data.labels, d.data.properties); - const hasFindings = resourcesWithFindings.has(d.id); - - if (isFinding) { - // Hexagon for findings - always has glow - const w = HEXAGON_WIDTH; - const h = HEXAGON_HEIGHT; - const sideInset = w * 0.15; - const hexPath = ` - M ${-w / 2 + sideInset} ${-h / 2} - L ${w / 2 - sideInset} ${-h / 2} - L ${w / 2} 0 - L ${w / 2 - sideInset} ${h / 2} - L ${-w / 2 + sideInset} ${h / 2} - L ${-w / 2} 0 - Z - `; - const isSelected = d.id === selectedNodeId; - group - .append("path") - .attr("d", hexPath) - .attr("fill", nodeColor) - .attr("fill-opacity", 0.85) - .attr("stroke", isSelected ? GRAPH_EDGE_HIGHLIGHT_COLOR : borderColor) - .attr("stroke-width", isSelected ? 4 : 2) - .attr("filter", isSelected ? "url(#selectedGlow)" : "url(#glow)") - .attr( - "class", - isSelected ? "node-shape selected-node" : "node-shape", - ); - } else { - // Check if this is an Internet node - const isInternet = d.data.labels.some( - (label) => label.toLowerCase() === "internet", - ); - - const isSelected = d.id === selectedNodeId; - - // Resources with findings get red border and red glow (even when selected) - // Selected nodes get orange border - const strokeColor = hasFindings - ? GRAPH_ALERT_BORDER_COLOR - : isSelected - ? GRAPH_EDGE_HIGHLIGHT_COLOR - : borderColor; - - // Determine filter: selected takes priority, then hasFindings, then default - const nodeFilter = isSelected - ? "url(#selectedGlow)" - : hasFindings - ? "url(#redGlow)" - : "url(#glow)"; - - const nodeClass = isSelected - ? "node-shape selected-node" - : "node-shape"; - - if (isInternet) { - // Globe shape for Internet nodes - larger than regular nodes - const radius = NODE_HEIGHT * 0.8; - - // Main circle - group - .append("circle") - .attr("cx", 0) - .attr("cy", 0) - .attr("r", radius) - .attr("fill", nodeColor) - .attr("fill-opacity", 0.85) - .attr("stroke", strokeColor) - .attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5) - .attr("filter", nodeFilter) - .attr("class", nodeClass); - - // Horizontal ellipse (equator) - group - .append("ellipse") - .attr("cx", 0) - .attr("cy", 0) - .attr("rx", radius) - .attr("ry", radius * 0.35) - .attr("fill", "none") - .attr("stroke", strokeColor) - .attr("stroke-width", 1) - .attr("stroke-opacity", 0.5); - - // Vertical ellipse (meridian) - group - .append("ellipse") - .attr("cx", 0) - .attr("cy", 0) - .attr("rx", radius * 0.35) - .attr("ry", radius) - .attr("fill", "none") - .attr("stroke", strokeColor) - .attr("stroke-width", 1) - .attr("stroke-opacity", 0.5); - } else { - // Rounded pill shape for other resources - group - .append("rect") - .attr("x", -NODE_WIDTH / 2) - .attr("y", -NODE_HEIGHT / 2) - .attr("width", NODE_WIDTH) - .attr("height", NODE_HEIGHT) - .attr("rx", NODE_RADIUS) - .attr("ry", NODE_RADIUS) - .attr("fill", nodeColor) - .attr("fill-opacity", 0.85) - .attr("stroke", strokeColor) - .attr("stroke-width", isSelected ? 4 : hasFindings ? 2.5 : 1.5) - .attr("filter", nodeFilter) - .attr("class", nodeClass); - } - } - }); - - // Store references for updating selection later - const nodeShapes = nodeElements.selectAll(".node-shape"); - nodeShapesRef.current = nodeShapes as unknown as ReturnType< - typeof select - >; - - // Add label text - white text on all nodes (backgrounds are dark enough) - nodeElements.each(function (d) { - const group = select(this); - const isFinding = d.data.labels.some((label) => - label.toLowerCase().includes("finding"), - ); - - // Create text container - white text with shadow for readability - const textGroup = group - .append("text") - .attr("pointer-events", "none") - .attr("text-anchor", "middle") - .attr("dominant-baseline", "middle") - .attr("fill", "#ffffff") - .style("text-shadow", "0 1px 2px rgba(0,0,0,0.5)"); - - if (isFinding) { - // For findings: show check_title/name (severity is shown by color) - const title = String( - d.data.properties?.check_title || - d.data.properties?.name || - d.data.properties?.id || - "Finding", - ); - const maxChars = 24; - const displayTitle = - title.length > maxChars - ? title.substring(0, maxChars) + "..." - : title; - - textGroup - .append("tspan") - .attr("x", 0) - .attr("font-size", "11px") - .attr("font-weight", "600") - .text(displayTitle); - } else { - // For resources: show name with type below - const name = String( - d.data.properties?.name || - d.data.properties?.id || - (d.data.labels && d.data.labels.length > 0 - ? formatNodeLabel(d.data.labels[0]) - : "Unknown"), - ); - const maxChars = 22; - const displayName = - name.length > maxChars ? name.substring(0, maxChars) + "..." : name; - - // Name - textGroup - .append("tspan") - .attr("x", 0) - .attr("dy", "-0.3em") - .attr("font-size", "11px") - .attr("font-weight", "600") - .text(displayName); - - // Type label - slightly transparent white - const type = - d.data.labels && d.data.labels.length > 0 - ? formatNodeLabel(d.data.labels[0]) - : ""; - if (type) { - textGroup - .append("tspan") - .attr("x", 0) - .attr("dy", "1.3em") - .attr("font-size", "9px") - .attr("fill", "rgba(255,255,255,0.8)") - .text(type); - } - } - }); - - // Add zoom behavior - const zoomBehavior = zoom().on( - "zoom", - (event: D3ZoomEvent) => { - const transform = event.transform; - container.attr("transform", transform.toString()); - setZoomLevel(transform.k); + fitView(AUTO_FIT_OPTIONS); }, ); - zoomBehaviorRef.current = zoomBehavior; + }, [expanded, isFilteredView, fitView, getNodes]); - svg.call(zoomBehavior); + useEffect(() => { + const previous = previousExpandedRef.current; + previousExpandedRef.current = expanded; + if (previous === expanded) return; + const newResourceIds = Array.from(expanded).filter( + (id) => !previous.has(id), + ); + const collapsedResourceIds = Array.from(previous).filter( + (id) => !expanded.has(id), + ); - // Enable Ctrl + mouse wheel zoom only (disable regular scroll zoom) - svg.on("wheel.zoom", null); - svg.on("dblclick.zoom", null); + if (newResourceIds.length === 0) { + if (collapsedResourceIds.length === 0) return; - // Custom wheel handler that only zooms when Ctrl is pressed - svg.on("wheel", function (event: WheelEvent) { - if (event.ctrlKey || event.metaKey) { - event.preventDefault(); - const currentTransform = container.attr("transform"); - const k = currentTransform - ? parseFloat(currentTransform.match(/scale\(([^)]+)\)/)?.[1] || "1") - : 1; - const scaleFactor = event.deltaY > 0 ? 0.75 : 1.35; - const newK = Math.max(0.1, Math.min(10, k * scaleFactor)); + return scheduleMeasuredFit( + () => { + const visibleNodes = getNodes().filter((n) => !n.hidden); + return ( + visibleNodes.length > 0 && + visibleNodes.every((n) => (n.measured?.width ?? 0) > 0) + ); + }, + () => fitView(AUTO_FIT_OPTIONS), + ); + } - if (zoomBehaviorRef.current && svgSelectionRef.current) { - const svgNode = svgRef.current; - if (svgNode) { - const rect = svgNode.getBoundingClientRect(); - const mouseX = event.clientX - rect.left; - const mouseY = event.clientY - rect.top; + const contextualFitNodeIds = new Set(newResourceIds); + const newFindingIds = new Set(); + for (const resourceId of newResourceIds) { + const findings = resourceToFindingsRef.current.get(resourceId); + if (!findings) continue; + findings.forEach((id) => { + newFindingIds.add(id); + contextualFitNodeIds.add(id); + }); + } + if (newFindingIds.size === 0) return; - svgSelectionRef.current - .transition() - .duration(100) - .call(zoomBehaviorRef.current.scaleTo, newK, [mouseX, mouseY]); - } - } - } - }); - - // Auto-fit to screen - setTimeout(() => { - if ( - svgSelectionRef.current && - zoomBehaviorRef.current && - containerRef.current - ) { - const bounds = containerRef.current.node()?.getBBox(); - if (!bounds) return; - - const fullWidth = svgRef.current?.clientWidth || 800; - const fullHeight = svgRef.current?.clientHeight || 500; - - const midX = bounds.x + bounds.width / 2; - const midY = bounds.y + bounds.height / 2; - const scale = - 0.8 / Math.max(bounds.width / fullWidth, bounds.height / fullHeight); - const tx = fullWidth / 2 - scale * midX; - const ty = fullHeight / 2 - scale * midY; - - svgSelectionRef.current.call( - zoomBehaviorRef.current.transform, - zoomIdentity.translate(tx, ty).scale(scale), + // Findings transition from hidden to visible on expand, and React Flow + // measures them asynchronously. Poll before fitting so fitView uses real + // dimensions instead of the zero-size hidden-node measurements. + return scheduleMeasuredFit( + () => { + const targets = getNodes().filter((n) => newFindingIds.has(n.id)); + return ( + targets.length === newFindingIds.size && + targets.every((n) => (n.measured?.width ?? 0) > 0) ); - } - }, 100); - // D3's imperative rendering model requires controlled re-renders. - // We intentionally only re-render on data/view changes, not on callback refs - // (onNodeClick, selectedNodeId) which would cause unnecessary D3 re-renders. - // edgeColor is included to re-render when theme changes. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, isFilteredView, edgeColor]); + }, + () => { + const contextualNodes = getNodes().filter((n) => + contextualFitNodeIds.has(n.id), + ); + fitView({ ...AUTO_FIT_OPTIONS, nodes: contextualNodes }); + }, + ); + }, [expanded, fitView, getNodes]); + + const nodes = effectiveData.nodes ?? []; + const edges = effectiveData.edges ?? []; + + // Pre-compute which resources have findings connected (O(n+e)) + const findingNodeIds = new Set(); + const resourceToFindings = new Map>(); + const findingToResources = new Map>(); + + nodes.forEach((n) => { + if (isFindingNode(n.labels)) findingNodeIds.add(n.id); + }); + + const resourcesWithFindings = new Set(); + edges.forEach((edge) => { + const sourceIsFinding = findingNodeIds.has(edge.source); + const targetIsFinding = findingNodeIds.has(edge.target); + + if (sourceIsFinding) { + resourcesWithFindings.add(edge.target); + // Map resource → its findings + const findings = resourceToFindings.get(edge.target) ?? new Set(); + findings.add(edge.source); + resourceToFindings.set(edge.target, findings); + // Map finding → its resources + const resources = findingToResources.get(edge.source) ?? new Set(); + resources.add(edge.target); + findingToResources.set(edge.source, resources); + } + if (targetIsFinding) { + resourcesWithFindings.add(edge.source); + const findings = resourceToFindings.get(edge.source) ?? new Set(); + findings.add(edge.target); + resourceToFindings.set(edge.source, findings); + const resources = findingToResources.get(edge.target) ?? new Set(); + resources.add(edge.source); + findingToResources.set(edge.target, resources); + } + }); + + // Refresh the expand-fit effect's lookup with the latest mapping. + resourceToFindingsRef.current = resourceToFindings; + + // Tier 1: compute which finding nodes are hidden (not expanded) + const hiddenFindingIds = resolveHiddenFindingIds({ + expandedResources: expanded, + findingNodeIds, + findingToResources, + isFilteredView, + }); + + const layoutNodeIds = new Set( + nodes + .filter((node) => !hiddenFindingIds.has(node.id)) + .map((node) => node.id), + ); + const layoutNodes = nodes.filter((node) => layoutNodeIds.has(node.id)); + const layoutEdges = edges.filter( + (edge) => layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target), + ); + + // Derive RF nodes and edges from visible data only. Hidden finding nodes are + // excluded before Dagre runs so the initial tier-1 graph does not reserve + // empty space for findings that the user has not expanded yet. + const { rfNodes, rfEdges } = layoutWithDagre(layoutNodes, layoutEdges); + + // Path highlight: compute highlighted edge IDs + const activeHighlightNodeId = hoveredNodeId ?? selectedNodeId; + const highlightedEdgeIds = activeHighlightNodeId + ? getPathEdges( + activeHighlightNodeId, + rfEdges.map((e) => ({ sourceId: e.source, targetId: e.target })), + ) + : new Set(); + + // Enrich nodes with selection, hasFindings, and hidden state + const enrichedNodes = rfNodes.map((node) => ({ + ...node, + selected: node.id === selectedNodeId, + hidden: hiddenFindingIds.has(node.id), + className: cn( + node.className, + isFindingNode(node.data.graphNode.labels) || + resourcesWithFindings.has(node.id) + ? "cursor-pointer" + : "cursor-default", + ), + data: { + ...node.data, + hasFindings: resourcesWithFindings.has(node.id), + }, + })); + + // Enrich edges with hidden state (hide edges to hidden findings) and highlight + const enrichedEdges = rfEdges.map((edge) => { + const sourceHidden = hiddenFindingIds.has(edge.source); + const targetHidden = hiddenFindingIds.has(edge.target); + const pathKey = + typeof edge.data === "object" && edge.data && "pathKey" in edge.data + ? String((edge.data as { pathKey?: string }).pathKey) + : `${edge.source}-${edge.target}`; + const isHighlighted = + highlightedEdgeIds.has(edge.id) || highlightedEdgeIds.has(pathKey); + + return { + ...edge, + hidden: sourceHidden || targetHidden, + className: cn(edge.className, isHighlighted && "highlighted"), + }; + }); + + useImperativeHandle(ref, () => ({ + zoomIn: () => zoomIn({ duration: 300 }), + zoomOut: () => zoomOut({ duration: 300 }), + resetZoom: () => fitView({ duration: 300 }), + getZoomLevel: () => getZoom(), + getContainerElement: () => containerRef.current, + getNodesBounds: () => { + const rfNodes = getNodes(); + if (rfNodes.length === 0) return null; + return getNodesBounds(rfNodes); + }, + })); + + const handleNodeClick = (_event: MouseEvent, node: Node) => { + const graphNode = (node.data as { graphNode: GraphNode }).graphNode; + + // Always fire parent callback (handles selection + Tier 2 filtered view) + onNodeClick?.(graphNode); + }; + + // Path highlight on hover + const handleNodeMouseEnter = (_event: MouseEvent, node: Node) => { + setHoveredNodeId(node.id); + }; + + const handleNodeMouseLeave = () => { + setHoveredNodeId(null); + }; return ( - +
+ + + { + const graphNode = (node.data as { graphNode?: GraphNode }) + .graphNode; + if (!graphNode) return MINIMAP_COLORS.light.maskStroke; + return getNodeColor(graphNode.labels, graphNode.properties); + }} + nodeStrokeColor={(node) => { + const graphNode = (node.data as { graphNode?: GraphNode }) + .graphNode; + if (!graphNode) return "transparent"; + return getNodeBorderColor(graphNode.labels, graphNode.properties); + }} + /> + +
); -}); +}; -AttackPathGraphComponent.displayName = "AttackPathGraph"; +// --- Outer component: renders ReactFlowProvider --- -export const AttackPathGraph = AttackPathGraphComponent; +export const AttackPathGraph = ({ + data, + selectedNodeId, + isFilteredView, + initialNodeId, + expandedResources, + onNodeClick, + onInitialFilter, + ref, + className, +}: AttackPathGraphProps) => { + return ( +
+ + + + + +
+ ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx new file mode 100644 index 0000000000..13e254c4f4 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { GraphControls } from "./graph-controls"; + +const baseProps = { + onZoomIn: vi.fn(), + onZoomOut: vi.fn(), + onFitToScreen: vi.fn(), +}; + +describe("GraphControls", () => { + it("disables the export button and surfaces the unavailable message when no onExport is provided", () => { + render(); + + const exportButton = screen.getByRole("button", { + name: /export available soon/i, + }); + + expect(exportButton).toBeDisabled(); + expect( + screen.queryByRole("button", { name: /^export graph$/i }), + ).not.toBeInTheDocument(); + }); + + it("enables the export button and invokes the callback when onExport is provided", async () => { + const user = userEvent.setup(); + const onExport = vi.fn(); + + render(); + + const exportButton = screen.getByRole("button", { + name: /^export graph$/i, + }); + + expect(exportButton).toBeEnabled(); + + await user.click(exportButton); + + expect(onExport).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx index 872cd57445..ebb9704f7c 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-controls.tsx @@ -14,7 +14,7 @@ interface GraphControlsProps { onZoomIn: () => void; onZoomOut: () => void; onFitToScreen: () => void; - onExport: () => void; + onExport?: () => void; } /** @@ -38,6 +38,7 @@ export const GraphControls = ({ size="sm" onClick={onZoomIn} className="h-8 w-8 p-0" + aria-label="Zoom in" > @@ -52,6 +53,7 @@ export const GraphControls = ({ size="sm" onClick={onZoomOut} className="h-8 w-8 p-0" + aria-label="Zoom out" > @@ -66,6 +68,7 @@ export const GraphControls = ({ size="sm" onClick={onFitToScreen} className="h-8 w-8 p-0" + aria-label="Fit graph to view" > @@ -79,12 +82,16 @@ export const GraphControls = ({ variant="ghost" size="sm" onClick={onExport} + disabled={!onExport} className="h-8 w-8 p-0" + aria-label={onExport ? "Export graph" : "Export available soon"} > - Export graph + + {onExport ? "Export graph" : "Export available soon"} + diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx new file mode 100644 index 0000000000..7dbb55a054 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.test.tsx @@ -0,0 +1,186 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { AttackPathGraphData } from "@/types/attack-paths"; + +import { GraphLegend } from "./graph-legend"; + +vi.mock("next-themes", () => ({ + useTheme: () => ({ resolvedTheme: "dark" }), +})); + +const graphData: AttackPathGraphData = { + nodes: [ + { + id: "aws-account", + labels: ["AWSAccount"], + properties: { name: "Production" }, + }, + { id: "bucket", labels: ["S3Bucket"], properties: { name: "logs" } }, + { id: "vpc", labels: ["VPC"], properties: { name: "main" } }, + { + id: "finding", + labels: ["ProwlerFinding"], + properties: { check_title: "Public bucket", severity: "critical" }, + }, + ], + relationships: [ + { id: "r1", source: "aws-account", target: "bucket", label: "HAS" }, + { id: "r2", source: "bucket", target: "vpc", label: "CAN_ACCESS" }, + { id: "r3", source: "bucket", target: "finding", label: "HAS_FINDING" }, + ], +}; + +describe("GraphLegend", () => { + it("should explain concrete visible node types without generic categories", () => { + // Given - A graph with provider, resource, and finding nodes + + // When + render( + , + ); + + // Then + expect( + screen.getByRole("heading", { name: /provider roots/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /node types/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /findings by risk/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole("heading", { name: /states/i }), + ).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /edges/i })).toBeInTheDocument(); + + expect(screen.getByText("Provider")).toBeInTheDocument(); + expect(screen.getByText("S3 Bucket")).toBeInTheDocument(); + expect(screen.getByText("VPC")).toBeInTheDocument(); + expect(screen.queryByText("Storage")).not.toBeInTheDocument(); + expect(screen.queryByText("Network")).not.toBeInTheDocument(); + expect(screen.queryByText("Compute")).not.toBeInTheDocument(); + expect(screen.queryByText("Identity")).not.toBeInTheDocument(); + expect(screen.queryByText("Secret / misc")).not.toBeInTheDocument(); + + expect(screen.getByText("Critical")).toBeInTheDocument(); + expect(screen.queryByText("High")).not.toBeInTheDocument(); + expect(screen.queryByText("Medium")).not.toBeInTheDocument(); + expect(screen.queryByText("Low / Info")).not.toBeInTheDocument(); + + expect(screen.getByText("Selected node")).toBeInTheDocument(); + expect(screen.getByText("Node with findings")).toBeInTheDocument(); + expect(screen.getByText("Normal edge")).toBeInTheDocument(); + expect(screen.getByText("Finding edge")).toBeInTheDocument(); + expect(screen.getByText("Highlighted path")).toBeInTheDocument(); + expect( + screen.getByRole("img", { + name: /highlighted path: prowler green path/i, + }), + ).toBeInTheDocument(); + expect(screen.queryByText(/orange path/i)).not.toBeInTheDocument(); + + expect(screen.queryByText(/ctrl/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/scroll to zoom/i)).not.toBeInTheDocument(); + }); + + it("should hide finding legend items when finding nodes are hidden", () => { + // Given - A resource has related findings, but it is not expanded yet + + // When + render(); + + // Then + expect( + screen.queryByRole("heading", { name: /findings by risk/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByText("Finding edge")).not.toBeInTheDocument(); + expect(screen.getByText("Node with findings")).toBeInTheDocument(); + }); + + it("should keep unattached findings visible in the legend", () => { + // Given - Findings have no connected resource and stay visible in the full graph + const findingsOnlyGraphData: AttackPathGraphData = { + nodes: [ + { + id: "finding-critical", + labels: ["ProwlerFinding"], + properties: { check_title: "Critical finding", severity: "critical" }, + }, + { + id: "finding-high", + labels: ["ProwlerFinding"], + properties: { check_title: "High finding", severity: "high" }, + }, + ], + relationships: [], + }; + + // When + render(); + + // Then + expect( + screen.getByRole("heading", { name: /findings by risk/i }), + ).toBeInTheDocument(); + expect(screen.getByText("Critical")).toBeInTheDocument(); + expect(screen.getByText("High")).toBeInTheDocument(); + }); + + it("should list policy and role node types separately", () => { + // Given - A graph whose visible nodes are all identity-related, but distinct + const identityGraphData: AttackPathGraphData = { + nodes: [ + { + id: "aws-account", + labels: ["AWSAccount"], + properties: { name: "Production" }, + }, + { + id: "role", + labels: ["PermissionRole"], + properties: { name: "prowler-pro-dev-gha-role" }, + }, + { + id: "policy", + labels: ["AWSPolicy"], + properties: { name: "IAMPermissions" }, + }, + { + id: "statement", + labels: ["AWSPolicyStatement"], + properties: { name: "policy statement" }, + }, + ], + relationships: [ + { id: "r1", source: "aws-account", target: "role", label: "HAS" }, + { id: "r2", source: "role", target: "policy", label: "HAS" }, + { id: "r3", source: "policy", target: "statement", label: "HAS" }, + ], + }; + + // When + render(); + + // Then + expect(screen.getByText("Permission Role")).toBeInTheDocument(); + expect(screen.getByText("AWS Policy")).toBeInTheDocument(); + expect(screen.getByText("AWS Policy Statement")).toBeInTheDocument(); + expect(screen.queryByText("Identity")).not.toBeInTheDocument(); + }); + + it("should stay hidden until graph nodes are available", () => { + // Given - No graph nodes have been loaded yet + const emptyGraphData: AttackPathGraphData = { nodes: [] }; + + // When + const { container } = render(); + + // Then + expect(container).toBeEmptyDOMElement(); + expect( + screen.queryByRole("heading", { name: /provider roots/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx index 98fb5c6c7c..03e17cfd3e 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/graph-legend.tsx @@ -1,6 +1,7 @@ "use client"; import { useTheme } from "next-themes"; +import type { ElementType, ReactNode } from "react"; import { Card, CardContent } from "@/components/shadcn"; import { @@ -9,513 +10,571 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/shadcn/tooltip"; -import type { AttackPathGraphData } from "@/types/attack-paths"; +import type { AttackPathGraphData, GraphNode } from "@/types/attack-paths"; import { getNodeBorderColor, getNodeColor, + GRAPH_ALERT_BORDER_COLOR, GRAPH_EDGE_COLOR_DARK, GRAPH_EDGE_COLOR_LIGHT, + GRAPH_EDGE_HIGHLIGHT_COLOR, GRAPH_NODE_BORDER_COLORS, GRAPH_NODE_COLORS, } from "../../_lib/graph-colors"; +import { resolveHiddenFindingIds } from "../../_lib/graph-utils"; +import { NODE_CATEGORY, resolveNodeVisual } from "../../_lib/node-visuals"; -interface LegendItem { +const LEGEND_PREVIEW = { + BADGE_RADIUS: 16, + BADGE_CENTER: 18, + ICON_SIZE: 20, + ICON_OFFSET: 8, + SVG_SIZE: 36, +} as const; + +const EDGE_VARIANT = { + NORMAL: "normal", + FINDING: "finding", + HIGHLIGHTED: "highlighted", +} as const; + +type EdgeVariant = (typeof EDGE_VARIANT)[keyof typeof EDGE_VARIANT]; + +interface LegendVisualItem { label: string; - color: string; - borderColor: string; description: string; - shape: "rectangle" | "hexagon" | "cloud"; + Icon: ElementType; + fillColor: string; + borderColor: string; + glow?: boolean; } -// Map node labels to human-readable names and descriptions -const nodeTypeDescriptions: Record< - string, - { name: string; description: string } -> = { - // Findings - ProwlerFinding: { - name: "Finding", - description: "Security findings from Prowler scans", - }, - // AWS Account - AWSAccount: { - name: "AWS Account", - description: "AWS account root node", - }, - // Compute - EC2Instance: { - name: "EC2 Instance", - description: "Elastic Compute Cloud instance", - }, - LambdaFunction: { - name: "Lambda Function", - description: "AWS Lambda serverless function", - }, - // Storage - S3Bucket: { - name: "S3 Bucket", - description: "Simple Storage Service bucket", - }, - // IAM - IAMRole: { - name: "IAM Role", - description: "Identity and Access Management role", - }, - IAMPolicy: { - name: "IAM Policy", - description: "Identity and Access Management policy", - }, - AWSRole: { - name: "AWS Role", - description: "AWS IAM role", - }, - AWSPolicy: { - name: "AWS Policy", - description: "AWS IAM policy", - }, - AWSInlinePolicy: { - name: "AWS Inline Policy", - description: "AWS IAM inline policy", - }, - AWSPolicyStatement: { - name: "AWS Policy Statement", - description: "AWS IAM policy statement", - }, - AWSPrincipal: { - name: "AWS Principal", - description: "AWS IAM principal entity", - }, - // Networking - SecurityGroup: { - name: "Security Group", - description: "AWS security group for network access control", - }, - EC2SecurityGroup: { - name: "EC2 Security Group", - description: "EC2 security group for network access control", - }, - IpPermissionInbound: { - name: "IP Permission Inbound", - description: "Inbound IP permission rule", - }, - IpRule: { - name: "IP Rule", - description: "IP address rule", - }, - Internet: { - name: "Internet", - description: "Internet gateway or public access", - }, - // Tags - AWSTag: { - name: "AWS Tag", - description: "AWS resource tag", - }, - Tag: { - name: "Tag", - description: "Resource tag", - }, -}; - -/** - * Extract unique node types from graph data - */ -function extractNodeTypes( - nodes: AttackPathGraphData["nodes"] | undefined, -): string[] { - if (!nodes) return []; - - const nodeTypes = new Set(); - nodes.forEach((node) => { - node.labels.forEach((label) => { - nodeTypes.add(label); - }); - }); - - return Array.from(nodeTypes).sort(); +interface LegendStateItem { + label: string; + description: string; + fillColor: string; + borderColor: string; + strokeWidth: number; + glowColor?: string; } -/** - * Severity legend items - colors work in both light and dark themes - */ -const severityLegendItems: LegendItem[] = [ - { - label: "Critical", - color: GRAPH_NODE_COLORS.critical, - borderColor: GRAPH_NODE_BORDER_COLORS.critical, - description: "Critical severity finding", - shape: "hexagon", - }, - { - label: "High", - color: GRAPH_NODE_COLORS.high, - borderColor: GRAPH_NODE_BORDER_COLORS.high, - description: "High severity finding", - shape: "hexagon", - }, - { - label: "Medium", - color: GRAPH_NODE_COLORS.medium, - borderColor: GRAPH_NODE_BORDER_COLORS.medium, - description: "Medium severity finding", - shape: "hexagon", - }, - { - label: "Low", - color: GRAPH_NODE_COLORS.low, - borderColor: GRAPH_NODE_BORDER_COLORS.low, - description: "Low severity finding", - shape: "hexagon", - }, -]; - -/** - * Generate legend items from graph data - */ -function generateLegendItems( - nodeTypes: string[], - hasFindings: boolean, -): LegendItem[] { - const items: LegendItem[] = []; - const seenTypes = new Set(); - - // Add severity items if there are findings - if (hasFindings) { - items.push(...severityLegendItems); - } - - // Helper to format unknown node types (e.g., "AWSPolicyStatement" -> "AWS Policy Statement") - const formatNodeTypeName = (nodeType: string): string => { - return nodeType - .replace(/([A-Z])/g, " $1") // Add space before capitals - .replace(/^ /, "") // Remove leading space - .replace(/AWS /g, "AWS ") // Keep AWS together - .replace(/EC2 /g, "EC2 ") // Keep EC2 together - .replace(/S3 /g, "S3 ") // Keep S3 together - .replace(/IAM /g, "IAM ") // Keep IAM together - .replace(/IP /g, "IP ") // Keep IP together - .trim(); - }; - - nodeTypes.forEach((nodeType) => { - if (seenTypes.has(nodeType)) return; - seenTypes.add(nodeType); - - // Skip findings - we show severity colors instead - const isFinding = nodeType.toLowerCase().includes("finding"); - if (isFinding) return; - - const description = nodeTypeDescriptions[nodeType]; - - // Determine shape based on node type - const isInternet = nodeType.toLowerCase() === "internet"; - const shape: "rectangle" | "hexagon" | "cloud" = isInternet - ? "cloud" - : "rectangle"; - - if (description) { - items.push({ - label: description.name, - color: getNodeColor([nodeType]), - borderColor: getNodeBorderColor([nodeType]), - description: description.description, - shape, - }); - } else { - // Format unknown node types nicely - const formattedName = formatNodeTypeName(nodeType); - items.push({ - label: formattedName, - color: getNodeColor([nodeType]), - borderColor: getNodeBorderColor([nodeType]), - description: `${formattedName} node`, - shape, - }); - } - }); - - return items; +interface LegendEdgeItem { + label: string; + description: string; + variant: EdgeVariant; } -/** - * Hexagon shape component for legend - */ -const HexagonShape = ({ - color, - borderColor, -}: { - color: string; - borderColor: string; -}) => ( - -); +interface LegendSectionProps { + title: string; + children: ReactNode; +} -/** - * Pill shape component for legend - */ -const PillShape = ({ - color, - borderColor, -}: { - color: string; - borderColor: string; -}) => ( - -); - -/** - * Globe shape component for legend (used for Internet nodes) - */ -const GlobeShape = ({ - color, - borderColor, -}: { - color: string; - borderColor: string; -}) => ( - -); - -/** - * Edge line component for legend - */ -const EdgeLine = ({ - dashed, - edgeColor, -}: { - dashed: boolean; - edgeColor: string; -}) => ( - -); +interface LegendItemProps { + label: string; + description: string; + children: ReactNode; +} interface GraphLegendProps { data?: AttackPathGraphData; + expandedResources?: ReadonlySet; + isFilteredView?: boolean; } -/** - * Legend for attack path graph node types and edge styles - */ -export const GraphLegend = ({ data }: GraphLegendProps) => { - const { resolvedTheme } = useTheme(); - const nodeTypes = extractNodeTypes(data?.nodes); +interface GraphLegendState { + visibleNodes: GraphNode[]; + visibleNodeIds: Set; + visibleFindingIds: Set; + visibleEdges: Array<{ source: string; target: string }>; + resourcesWithFindings: Set; +} - // Get edge color based on current theme - const edgeColor = - resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT; +const buildNode = ( + labels: string[], + properties: GraphNode["properties"] = {}, +): GraphNode => ({ + id: labels[0] ?? "legend-node", + labels, + properties, +}); - // Check if there are any findings in the data - const hasFindings = nodeTypes.some((type) => - type.toLowerCase().includes("finding"), +const buildVisualItem = ( + label: string, + description: string, + node: GraphNode, + fillColor: string, + borderColor: string, + glow = false, +): LegendVisualItem => ({ + label, + description, + Icon: resolveNodeVisual(node).Icon, + fillColor, + borderColor, + glow, +}); + +const providerRootItem = buildVisualItem( + "Provider", + "Cloud account, tenant, project, organization, or cluster entry point.", + buildNode(["AWSAccount"], { name: "Provider root" }), + GRAPH_NODE_COLORS.awsAccount, + GRAPH_NODE_BORDER_COLORS.awsAccount, +); + +const findingRiskItems: LegendVisualItem[] = [ + buildVisualItem( + "Critical", + "Highest-risk finding node with severity-colored badge and glow.", + buildNode(["ProwlerFinding"], { severity: "critical" }), + GRAPH_NODE_COLORS.critical, + GRAPH_NODE_BORDER_COLORS.critical, + true, + ), + buildVisualItem( + "High", + "High-risk finding node with severity-colored badge and glow.", + buildNode(["ProwlerFinding"], { severity: "high" }), + GRAPH_NODE_COLORS.high, + GRAPH_NODE_BORDER_COLORS.high, + true, + ), + buildVisualItem( + "Medium", + "Medium-risk finding node with severity-colored badge and glow.", + buildNode(["ProwlerFinding"], { severity: "medium" }), + GRAPH_NODE_COLORS.medium, + GRAPH_NODE_BORDER_COLORS.medium, + true, + ), + buildVisualItem( + "Low / Info", + "Lower-risk informational findings use the info-style risk icon.", + buildNode(["ProwlerFinding"], { severity: "info" }), + GRAPH_NODE_COLORS.info, + GRAPH_NODE_BORDER_COLORS.info, + true, + ), +]; + +const stateItems: LegendStateItem[] = [ + { + label: "Selected node", + description: "Active node with a stronger animated selection ring.", + fillColor: GRAPH_NODE_COLORS.default, + borderColor: GRAPH_EDGE_HIGHLIGHT_COLOR, + strokeWidth: 4, + glowColor: GRAPH_EDGE_HIGHLIGHT_COLOR, + }, + { + label: "Node with findings", + description: "Resource node linked to one or more findings.", + fillColor: GRAPH_NODE_COLORS.default, + borderColor: GRAPH_ALERT_BORDER_COLOR, + strokeWidth: 3, + glowColor: GRAPH_ALERT_BORDER_COLOR, + }, +]; + +const edgeItems: LegendEdgeItem[] = [ + { + label: "Normal edge", + description: "Relationship between resources in the attack path.", + variant: EDGE_VARIANT.NORMAL, + }, + { + label: "Finding edge", + description: "Animated dashed edge that connects a resource to a finding.", + variant: EDGE_VARIANT.FINDING, + }, + { + label: "Highlighted path", + description: + "Prowler green path shown when hovering or selecting related graph nodes.", + variant: EDGE_VARIANT.HIGHLIGHTED, + }, +]; + +const isFindingNode = (node: GraphNode): boolean => + node.labels.some((label) => label.toLowerCase().includes("finding")); + +const getGraphEdges = ( + data: AttackPathGraphData, +): Array<{ source: string; target: string }> => + data.relationships ?? data.edges ?? []; + +const resolveLegendState = ( + data: AttackPathGraphData, + expandedResources: ReadonlySet, + isFilteredView: boolean, +): GraphLegendState => { + const findingNodeIds = new Set( + data.nodes.filter(isFindingNode).map((node) => node.id), + ); + const findingToResources = new Map>(); + const resourcesWithFindings = new Set(); + const graphEdges = getGraphEdges(data); + + for (const edge of graphEdges) { + const sourceIsFinding = findingNodeIds.has(edge.source); + const targetIsFinding = findingNodeIds.has(edge.target); + + if (sourceIsFinding) { + resourcesWithFindings.add(edge.target); + const resources = findingToResources.get(edge.source) ?? new Set(); + resources.add(edge.target); + findingToResources.set(edge.source, resources); + } + + if (targetIsFinding) { + resourcesWithFindings.add(edge.source); + const resources = findingToResources.get(edge.target) ?? new Set(); + resources.add(edge.source); + findingToResources.set(edge.target, resources); + } + } + + const hiddenFindingIds = resolveHiddenFindingIds({ + expandedResources, + findingNodeIds, + findingToResources, + isFilteredView, + }); + + const visibleNodes = data.nodes.filter( + (node) => !hiddenFindingIds.has(node.id), + ); + const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); + const visibleFindingIds = new Set( + visibleNodes.filter(isFindingNode).map((node) => node.id), + ); + const visibleEdges = graphEdges.filter( + (edge) => + visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target), ); - const legendItems = generateLegendItems(nodeTypes, hasFindings); + return { + visibleNodes, + visibleNodeIds, + visibleFindingIds, + visibleEdges, + resourcesWithFindings, + }; +}; - if (legendItems.length === 0) { +const resolveNodeTypeItems = ( + visibleNodes: GraphNode[], +): LegendVisualItem[] => { + const itemsByType = new Map(); + + for (const node of visibleNodes) { + if (isFindingNode(node)) continue; + + const visual = resolveNodeVisual(node); + if (visual.category === NODE_CATEGORY.ACCOUNT) continue; + + const key = `${visual.category}:${visual.description}`; + + if (!itemsByType.has(key)) { + itemsByType.set(key, { + label: visual.description, + description: `${visual.description} node`, + Icon: visual.Icon, + fillColor: getNodeColor(node.labels), + borderColor: getNodeBorderColor(node.labels), + }); + } + } + + return Array.from(itemsByType.values()); +}; + +const resolveFindingRiskItems = ( + visibleNodes: GraphNode[], +): LegendVisualItem[] => { + const visibleSeverities = new Set( + visibleNodes + .filter(isFindingNode) + .map((node) => String(node.properties.severity ?? "").toLowerCase()), + ); + + return findingRiskItems.filter((item) => { + if (item.label === "Low / Info") { + return ( + visibleSeverities.has("low") || + visibleSeverities.has("info") || + visibleSeverities.has("informational") + ); + } + + return visibleSeverities.has(item.label.toLowerCase()); + }); +}; + +const LegendSection = ({ title, children }: LegendSectionProps) => ( +
+

+ {title} +

+
+ {children} +
+
+); + +const LegendItem = ({ label, description, children }: LegendItemProps) => ( + + +
+ {children} + + {label} + +
+
+ {description} +
+); + +const BadgePreview = ({ + Icon, + fillColor, + borderColor, + glow, +}: LegendVisualItem) => ( + +); + +const StatePreview = ({ + fillColor, + borderColor, + strokeWidth, + glowColor, +}: LegendStateItem) => ( + +); + +const EdgePreview = ({ + variant, + edgeColor, +}: { + variant: EdgeVariant; + edgeColor: string; +}) => { + const isFindingEdge = variant === EDGE_VARIANT.FINDING; + const isHighlightedPath = variant === EDGE_VARIANT.HIGHLIGHTED; + const strokeColor = isHighlightedPath + ? GRAPH_EDGE_HIGHLIGHT_COLOR + : edgeColor; + + return ( + + ); +}; + +/** + * Compact semantic legend for the Attack Paths graph visual language. + */ +export const GraphLegend = ({ + data, + expandedResources = new Set(), + isFilteredView = false, +}: GraphLegendProps) => { + const { resolvedTheme } = useTheme(); + + if (!data || data.nodes.length === 0) { return null; } + const legendState = resolveLegendState( + data, + expandedResources, + isFilteredView, + ); + const providerItem = legendState.visibleNodes.some( + (node) => resolveNodeVisual(node).category === NODE_CATEGORY.ACCOUNT, + ) + ? providerRootItem + : null; + const visibleNodeTypeItems = resolveNodeTypeItems(legendState.visibleNodes); + const visibleFindingRiskItems = resolveFindingRiskItems( + legendState.visibleNodes, + ); + const visibleStateItems = stateItems.filter( + (item) => + item.label === "Selected node" || + Array.from(legendState.resourcesWithFindings).some((resourceId) => + legendState.visibleNodeIds.has(resourceId), + ), + ); + const visibleEdgeItems = edgeItems.filter((item) => { + if (item.variant === EDGE_VARIANT.FINDING) { + return legendState.visibleEdges.some( + (edge) => + legendState.visibleFindingIds.has(edge.source) || + legendState.visibleFindingIds.has(edge.target), + ); + } + + return legendState.visibleEdges.length > 0; + }); + + if ( + !providerItem && + visibleNodeTypeItems.length === 0 && + visibleFindingRiskItems.length === 0 && + visibleStateItems.length === 0 && + visibleEdgeItems.length === 0 + ) { + return null; + } + + const edgeColor = + resolvedTheme === "dark" ? GRAPH_EDGE_COLOR_DARK : GRAPH_EDGE_COLOR_LIGHT; + return ( - - -
- {/* Node types section */} -
- - {legendItems.map((item) => ( - - -
- {item.shape === "hexagon" ? ( - - ) : item.shape === "cloud" ? ( - - ) : ( - - )} - - {item.label} - -
-
- {item.description} -
- ))} -
-
+ + + +
+ {providerItem && ( + + + + + + )} - {/* Edge types section */} -
- - - -
- - - Resource Connection - -
-
- - Connection between infrastructure resources - -
+ {visibleNodeTypeItems.length > 0 && ( + + {visibleNodeTypeItems.map((item) => ( + + + + ))} + + )} - {hasFindings && ( - - -
- - - Finding Connection - -
-
- - Connection to a security finding - -
- )} -
-
+ {visibleFindingRiskItems.length > 0 && ( + + {visibleFindingRiskItems.map((item) => ( + + + + ))} + + )} - {/* Zoom control hint */} -
- - Ctrl - - + - - Scroll to zoom - + {visibleStateItems.length > 0 && ( + + {visibleStateItems.map((item) => ( + + + + ))} + + )} + + {visibleEdgeItems.length > 0 && ( + + {visibleEdgeItems.map((item) => ( + + + + ))} + + )}
-
+
); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts index ae529f31cd..52b316d510 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/index.ts @@ -1,4 +1,4 @@ -export type { AttackPathGraphRef } from "./attack-path-graph"; +export type { GraphHandle } from "./attack-path-graph"; export { AttackPathGraph } from "./attack-path-graph"; export { GraphControls } from "./graph-controls"; export { GraphLegend } from "./graph-legend"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx new file mode 100644 index 0000000000..74a1de7e5d --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from "@testing-library/react"; +import { type NodeProps, Position } from "@xyflow/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { GraphNode } from "@/types/attack-paths"; + +import { FindingNode } from "./finding-node"; + +const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null)); + +vi.mock("./hidden-handles", () => ({ + HiddenHandles: hiddenHandlesMock, +})); + +const buildFindingNode = (severity: string, title: string): GraphNode => ({ + id: `${severity}-finding`, + labels: ["ProwlerFinding"], + properties: { check_title: title, id: `${severity}-finding`, severity }, +}); + +const buildNodeProps = (graphNode: GraphNode): NodeProps => + ({ + id: graphNode.id, + type: "finding", + data: { graphNode }, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: false, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }) as unknown as NodeProps; + +describe("FindingNode", () => { + it("positions graph handles for horizontal left-to-right edges", () => { + // Given + const props = buildNodeProps( + buildFindingNode("critical", "Root key exposed"), + ); + + // When + render(); + + // Then + expect(hiddenHandlesMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourcePosition: Position.Right, + sourceStyle: { left: 97, top: 26 }, + targetPosition: Position.Left, + targetStyle: { left: 53, top: 26 }, + }), + undefined, + ); + }); + + describe("severity visuals", () => { + it("should render the critical finding risk icon with readable text", () => { + // Given + const props = buildNodeProps( + buildFindingNode("critical", "Root key exposed"), + ); + + // When + render(); + + // Then + expect( + screen.getByTestId("attack-path-finding-icon-critical"), + ).toHaveAccessibleName("Critical finding risk icon"); + expect(screen.getByText("Root key exposed")).toBeInTheDocument(); + expect(screen.getByText("critical")).toBeInTheDocument(); + }); + + it("should render a distinct medium finding risk icon with readable text", () => { + // Given + const props = buildNodeProps( + buildFindingNode("medium", "Bucket lacks logging"), + ); + + // When + render(); + + // Then + expect( + screen.getByTestId("attack-path-finding-icon-medium"), + ).toHaveAccessibleName("Medium finding risk icon"); + expect( + screen.queryByTestId("attack-path-finding-icon-critical"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Bucket lacks")).toBeInTheDocument(); + expect(screen.getByText("logging")).toBeInTheDocument(); + expect(screen.getByText("medium")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx new file mode 100644 index 0000000000..986617def7 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/finding-node.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { type NodeProps, Position } from "@xyflow/react"; + +import type { GraphNode } from "@/types/attack-paths"; + +import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; +import { HiddenHandles } from "./hidden-handles"; +import { getNodeLabelLines } from "./node-label-lines"; + +interface FindingNodeData { + graphNode: GraphNode; + [key: string]: unknown; +} + +const NODE_WIDTH = 150; +const NODE_HEIGHT = 112; +const TITLE_MAX_CHARS = 18; +const TITLE_MAX_LINES = 2; +const BADGE_SIZE = 44; +const BADGE_RADIUS = BADGE_SIZE / 2; +const BADGE_CENTER_X = NODE_WIDTH / 2; +const BADGE_CENTER_Y = 26; +const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS; +const BADGE_RIGHT_X = BADGE_CENTER_X + BADGE_RADIUS; +const ICON_SIZE = 28; +const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2; +const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2; +const TEXT_X = BADGE_CENTER_X; +const TITLE_Y = 66; +const TITLE_LINE_HEIGHT = 13; +const SEVERITY_Y = 94; + +const severityLabel = (severity: unknown): string | undefined => { + if (!severity) return undefined; + const rawSeverity = Array.isArray(severity) ? severity[0] : severity; + return String(rawSeverity).toLowerCase(); +}; + +const toFindingIconTestId = (severity: string | undefined): string => + `attack-path-finding-icon-${severity ?? "unknown"}`; + +const toAccessibleSeverity = (severity: string | undefined): string => + severity + ? `${severity.charAt(0).toUpperCase()}${severity.slice(1)}` + : "Unknown"; + +export const FindingNode = ({ data, selected }: NodeProps) => { + const { graphNode } = data as FindingNodeData; + const { fillColor, borderColor } = resolveNodeColors({ + labels: graphNode.labels, + properties: graphNode.properties, + selected, + }); + + const title = String( + graphNode.properties?.check_title || + graphNode.properties?.name || + graphNode.properties?.id || + "Finding", + ); + const displayTitleLines = getNodeLabelLines( + title, + TITLE_MAX_CHARS, + TITLE_MAX_LINES, + ); + const visual = resolveNodeVisual(graphNode); + const Icon = visual.Icon; + const severity = severityLabel(graphNode.properties?.severity); + const iconLabel = `${toAccessibleSeverity(severity)} finding risk icon`; + + const badgeStrokeWidth = selected ? 4 : 2.5; + const glowRadius = selected ? 32 : 30; + const glowOpacity = selected ? 0.34 : 0.28; + + return ( + <> + + + + + + + + + {displayTitleLines.map((line, index) => ( + + {line} + + ))} + {severity && ( + + {severity} + + )} + + + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx new file mode 100644 index 0000000000..a551afee0b --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/hidden-handles.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { Handle, Position } from "@xyflow/react"; +import type { CSSProperties } from "react"; + +interface HiddenHandlesProps { + sourcePosition?: Position; + style?: CSSProperties; + targetPosition?: Position; + sourceStyle?: CSSProperties; + targetStyle?: CSSProperties; +} + +export const HiddenHandles = ({ + sourcePosition = Position.Right, + sourceStyle, + style, + targetPosition = Position.Left, + targetStyle, +}: HiddenHandlesProps) => ( + <> + + + +); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx new file mode 100644 index 0000000000..e2009f71c9 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/internet-node.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { type NodeProps } from "@xyflow/react"; + +import type { GraphNode } from "@/types/attack-paths"; + +import { resolveNodeColors } from "../../../_lib"; +import { HiddenHandles } from "./hidden-handles"; + +interface InternetNodeData { + graphNode: GraphNode; + [key: string]: unknown; +} + +const RADIUS = 40; // NODE_HEIGHT * 0.8 +const DIAMETER = RADIUS * 2; + +export const InternetNode = ({ data, selected }: NodeProps) => { + const { graphNode } = data as InternetNodeData; + const { fillColor, borderColor } = resolveNodeColors({ + labels: graphNode.labels, + properties: graphNode.properties, + selected, + }); + const strokeWidth = selected ? 4 : 1.5; + + return ( + <> + + + {/* Main circle */} + + {/* Horizontal ellipse (equator) */} + + {/* Vertical ellipse (meridian) */} + + {/* Label */} + + Internet + + + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts new file mode 100644 index 0000000000..a6ed74e70a --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/node-label-lines.ts @@ -0,0 +1,48 @@ +const splitByMaxChars = (text: string, maxChars: number): string[] => { + const words = text.trim().split(/\s+/).filter(Boolean); + const lines: string[] = []; + let currentLine = ""; + + for (const word of words) { + if (!currentLine) { + currentLine = word; + continue; + } + + const nextLine = `${currentLine} ${word}`; + if (nextLine.length <= maxChars) { + currentLine = nextLine; + continue; + } + + lines.push(currentLine); + currentLine = word; + } + + if (currentLine) lines.push(currentLine); + return lines; +}; + +const splitLongToken = (text: string, maxChars: number): string[] => { + const lines: string[] = []; + + for (let index = 0; index < text.length; index += maxChars) { + lines.push(text.slice(index, index + maxChars)); + } + + return lines; +}; + +export const getNodeLabelLines = ( + text: string, + maxChars: number, + maxLines: number, +): string[] => { + if (!text.trim()) return []; + + const rawLines = text.includes(" ") + ? splitByMaxChars(text, maxChars) + : splitLongToken(text, maxChars); + + return rawLines.slice(0, maxLines); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx new file mode 100644 index 0000000000..937e6cb2ac --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; +import { type NodeProps, Position } from "@xyflow/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { GraphNode } from "@/types/attack-paths"; + +import { ResourceNode } from "./resource-node"; + +const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null)); + +vi.mock("./hidden-handles", () => ({ + HiddenHandles: hiddenHandlesMock, +})); + +const buildGraphNode = (label: string, name: string): GraphNode => ({ + id: `${label}-${name}`, + labels: [label], + properties: { id: `${label}-${name}`, name }, +}); + +const buildNodeProps = (graphNode: GraphNode): NodeProps => + ({ + id: graphNode.id, + type: "resource", + data: { graphNode }, + selected: false, + dragging: false, + zIndex: 0, + isConnectable: false, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }) as unknown as NodeProps; + +describe("ResourceNode", () => { + it("positions graph handles for horizontal left-to-right edges", () => { + // Given + const props = buildNodeProps(buildGraphNode("S3Bucket", "logs")); + + // When + render(); + + // Then + expect(hiddenHandlesMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourcePosition: Position.Right, + sourceStyle: { left: 90, top: 26 }, + targetPosition: Position.Left, + targetStyle: { left: 46, top: 26 }, + }), + undefined, + ); + }); + + describe("node visual icons", () => { + it("should render the S3 bucket icon with the resource label", () => { + // Given + const props = buildNodeProps(buildGraphNode("S3Bucket", "logs")); + + // When + render(); + + // Then + expect( + screen.getByTestId("attack-path-node-icon-s3-bucket"), + ).toHaveAccessibleName("S3 Bucket icon"); + expect(screen.getByText("logs")).toBeInTheDocument(); + expect(screen.getByText("S3 Bucket")).toBeInTheDocument(); + }); + + it("should render a distinct VPC icon with the resource label", () => { + // Given + const props = buildNodeProps(buildGraphNode("VPC", "main-vpc")); + + // When + render(); + + // Then + expect( + screen.getByTestId("attack-path-node-icon-vpc"), + ).toHaveAccessibleName("VPC icon"); + expect( + screen.queryByTestId("attack-path-node-icon-s3-bucket"), + ).not.toBeInTheDocument(); + expect(screen.getByText("main-vpc")).toBeInTheDocument(); + expect(screen.getByText("VPC")).toBeInTheDocument(); + }); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx new file mode 100644 index 0000000000..324d3aa4b9 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/graph/nodes/resource-node.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { type NodeProps, Position } from "@xyflow/react"; + +import type { GraphNode } from "@/types/attack-paths"; + +import { resolveNodeColors, resolveNodeVisual } from "../../../_lib"; +import { HiddenHandles } from "./hidden-handles"; +import { getNodeLabelLines } from "./node-label-lines"; + +interface ResourceNodeData { + graphNode: GraphNode; + hasFindings?: boolean; + [key: string]: unknown; +} + +const NODE_WIDTH = 136; +const NODE_HEIGHT = 112; +const NAME_MAX_CHARS = 16; +const NAME_MAX_LINES = 2; +const BADGE_SIZE = 44; +const BADGE_RADIUS = BADGE_SIZE / 2; +const BADGE_CENTER_X = NODE_WIDTH / 2; +const BADGE_CENTER_Y = 26; +const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS; +const BADGE_RIGHT_X = BADGE_CENTER_X + BADGE_RADIUS; +const ICON_SIZE = 28; +const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2; +const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2; +const TEXT_X = BADGE_CENTER_X; +const NAME_Y = 66; +const NAME_LINE_HEIGHT = 13; +const TYPE_Y = 94; + +const toIconTestId = (description: string): string => + `attack-path-node-icon-${description + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "")}`; + +export const ResourceNode = ({ data, selected }: NodeProps) => { + const { graphNode, hasFindings } = data as ResourceNodeData; + const { fillColor, borderColor } = resolveNodeColors({ + labels: graphNode.labels, + properties: graphNode.properties, + selected, + hasFindings, + }); + const badgeStrokeWidth = selected ? 4 : hasFindings ? 3 : 1.5; + const glowRadius = selected ? 31 : hasFindings ? 29 : 0; + const glowOpacity = selected ? 0.32 : hasFindings ? 0.26 : 0; + const visual = resolveNodeVisual(graphNode); + const Icon = visual.Icon; + + const displayNameLines = getNodeLabelLines( + visual.displayName, + NAME_MAX_CHARS, + NAME_MAX_LINES, + ); + const typeLabel = visual.description; + const iconLabel = `${visual.description} icon`; + + return ( + <> + + + {glowRadius > 0 && ( + + )} + + + + + + {displayNameLines.map((line, index) => ( + + {line} + + ))} + {typeLabel && ( + + {typeLabel} + + )} + + + + ); +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts index c5895fe51f..0abb3c8fae 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/index.ts @@ -1,4 +1,3 @@ export { NodeDetailContent, NodeDetailPanel } from "./node-detail-panel"; export { NodeOverview } from "./node-overview"; -export { NodeRelationships } from "./node-relationships"; export { NodeRemediation } from "./node-remediation"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx deleted file mode 100644 index 7370204990..0000000000 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_components/node-detail/node-relationships.tsx +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; - -import { cn } from "@/lib/utils"; -import type { GraphEdge } from "@/types/attack-paths"; - -interface NodeRelationshipsProps { - incomingEdges: GraphEdge[]; - outgoingEdges: GraphEdge[]; -} - -/** - * Format edge type to human-readable label - * e.g., "HAS_FINDING" -> "Has Finding" - */ -function formatEdgeType(edgeType: string): string { - return edgeType - .split("_") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(" "); -} - -interface EdgeItemProps { - edge: GraphEdge; - isOutgoing: boolean; -} - -/** - * Reusable edge item component - */ -function EdgeItem({ edge, isOutgoing }: EdgeItemProps) { - const targetId = - typeof edge.target === "string" ? edge.target : String(edge.target); - const sourceId = - typeof edge.source === "string" ? edge.source : String(edge.source); - const displayId = (isOutgoing ? targetId : sourceId).substring(0, 30); - - return ( -
- - {displayId} - - - {formatEdgeType(edge.type)} - -
- ); -} - -/** - * Node relationships section showing incoming and outgoing edges - */ -export const NodeRelationships = ({ - incomingEdges, - outgoingEdges, -}: NodeRelationshipsProps) => { - return ( -
- {/* Outgoing Relationships */} -
-

- Outgoing Relationships ({outgoingEdges.length}) -

- {outgoingEdges.length > 0 ? ( -
- {outgoingEdges.map((edge) => ( - - ))} -
- ) : ( -

- No outgoing relationships -

- )} -
- - {/* Incoming Relationships */} -
-

- Incoming Relationships ({incomingEdges.length}) -

- {incomingEdges.length > 0 ? ( -
- {incomingEdges.map((edge) => ( - - ))} -
- ) : ( -

- No incoming relationships -

- )} -
-
- ); -}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.test.ts new file mode 100644 index 0000000000..611ccf9d27 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { useGraphStore } from "./use-graph-state"; + +describe("useGraphStore", () => { + beforeEach(() => { + useGraphStore.getState().reset(); + }); + + it("keeps only one expanded findings resource open at a time", () => { + // Given + const store = useGraphStore.getState(); + + // When + store.toggleExpandedResource("resource-a"); + useGraphStore.getState().toggleExpandedResource("resource-b"); + + // Then + expect(Array.from(useGraphStore.getState().expandedResources)).toEqual([ + "resource-b", + ]); + }); + + it("closes the expanded findings resource when toggled again", () => { + // Given + const store = useGraphStore.getState(); + + // When + store.toggleExpandedResource("resource-a"); + useGraphStore.getState().toggleExpandedResource("resource-a"); + + // Then + expect(useGraphStore.getState().expandedResources.size).toBe(0); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts index 8af36bbcfb..6484a33843 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_hooks/use-graph-state.ts @@ -14,6 +14,11 @@ interface FilteredViewState { isFilteredView: boolean; filteredNodeId: string | null; fullData: AttackPathGraphData | null; // Original data before filtering + // Tier 1 expansion state: which resource nodes have their findings revealed. + // Lives in the store (not local component state) so it survives the data + // swaps that happen when entering/exiting filtered view. Reset only on + // fresh data loads (new query / scan) — see `setGraphData`. + expandedResources: Set; } interface GraphStore extends GraphState, FilteredViewState { @@ -21,14 +26,13 @@ interface GraphStore extends GraphState, FilteredViewState { setSelectedNodeId: (nodeId: string | null) => void; setLoading: (loading: boolean) => void; setError: (error: string | null) => void; - setZoom: (zoomLevel: number) => void; - setPan: (panX: number, panY: number) => void; setFilteredView: ( isFiltered: boolean, nodeId: string | null, filteredData: AttackPathGraphData | null, fullData: AttackPathGraphData | null, ) => void; + toggleExpandedResource: (resourceId: string) => void; reset: () => void; } @@ -37,15 +41,13 @@ const initialState: GraphState & FilteredViewState = { selectedNodeId: null, loading: false, error: null, - zoomLevel: 1, - panX: 0, - panY: 0, isFilteredView: false, filteredNodeId: null, fullData: null, + expandedResources: new Set(), }; -const useGraphStore = create((set) => ({ +export const useGraphStore = create((set) => ({ ...initialState, setGraphData: (data) => set({ @@ -54,12 +56,12 @@ const useGraphStore = create((set) => ({ error: null, isFilteredView: false, filteredNodeId: null, + // Fresh data → drop any stale expansion from the previous graph. + expandedResources: new Set(), }), setSelectedNodeId: (nodeId) => set({ selectedNodeId: nodeId }), setLoading: (loading) => set({ loading }), setError: (error) => set({ error }), - setZoom: (zoomLevel) => set({ zoomLevel }), - setPan: (panX, panY) => set({ panX, panY }), setFilteredView: (isFiltered, nodeId, filteredData, fullData) => set({ isFilteredView: isFiltered, @@ -68,6 +70,13 @@ const useGraphStore = create((set) => ({ fullData, selectedNodeId: nodeId, }), + toggleExpandedResource: (resourceId) => + set((state) => { + const next = state.expandedResources.has(resourceId) + ? new Set() + : new Set([resourceId]); + return { expandedResources: next }; + }), reset: () => set(initialState), })); @@ -106,11 +115,6 @@ export const useGraphState = () => { store.setError(error); }; - const updateZoomAndPan = (zoomLevel: number, panX: number, panY: number) => { - store.setZoom(zoomLevel); - store.setPan(panX, panY); - }; - const resetGraph = () => { store.reset(); }; @@ -162,18 +166,16 @@ export const useGraphState = () => { selectedNode: getSelectedNode(), loading: store.loading, error: store.error, - zoomLevel: store.zoomLevel, - panX: store.panX, - panY: store.panY, isFilteredView: store.isFilteredView, filteredNodeId: store.filteredNodeId, filteredNode: getFilteredNode(), + expandedResources: store.expandedResources, + toggleExpandedResource: store.toggleExpandedResource, updateGraphData, selectNode, startLoading, stopLoading, setError, - updateZoomAndPan, resetGraph, clearGraph, enterFilteredView, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts new file mode 100644 index 0000000000..5f4131e695 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.test.ts @@ -0,0 +1,156 @@ +import type { Rect } from "@xyflow/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AttackPathGraphData } from "@/types/attack-paths"; + +import { exportGraphAsJSON, exportGraphAsPNG } from "./export"; + +const bounds: Rect = { x: 0, y: 0, width: 100, height: 100 }; + +const graphData: AttackPathGraphData = { + nodes: [ + { id: "internet", labels: ["Internet"], properties: { name: "Internet" } }, + { + id: "ec2-1", + labels: ["EC2Instance"], + properties: { name: "api-server-01" }, + }, + ], + edges: [ + { id: "edge-1", source: "internet", target: "ec2-1", type: "CAN_REACH" }, + ], +}; + +const buildContainerWithViewport = () => { + const container = document.createElement("div"); + const reactFlow = document.createElement("div"); + reactFlow.className = "react-flow"; + const viewport = document.createElement("div"); + viewport.className = "react-flow__viewport"; + reactFlow.appendChild(viewport); + container.appendChild(reactFlow); + return container; +}; + +class TestImage { + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + decoding = "async"; + + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } +} + +describe("exportGraphAsPNG", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.stubGlobal("Image", TestImage); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:graph-export"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {}); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({ + arc: vi.fn(), + beginPath: vi.fn(), + bezierCurveTo: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + fillRect: vi.fn(), + fillText: vi.fn(), + lineTo: vi.fn(), + moveTo: vi.fn(), + quadraticCurveTo: vi.fn(), + restore: vi.fn(), + save: vi.fn(), + setLineDash: vi.fn(), + stroke: vi.fn(), + } as unknown as CanvasRenderingContext2D); + vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue( + "data:image/png;base64,AAAA", + ); + }); + + it("throws when the container is not mounted", async () => { + await expect( + exportGraphAsPNG(null, bounds, undefined, graphData), + ).rejects.toThrow("Graph container not mounted"); + }); + + it("throws when the React Flow root is missing inside the container", async () => { + const container = document.createElement("div"); + + await expect( + exportGraphAsPNG(container, bounds, undefined, graphData), + ).rejects.toThrow("React Flow root not found in container"); + }); + + it("throws when the React Flow viewport is missing inside the container", async () => { + const container = document.createElement("div"); + const reactFlow = document.createElement("div"); + reactFlow.className = "react-flow"; + container.appendChild(reactFlow); + + await expect( + exportGraphAsPNG(container, bounds, undefined, graphData), + ).rejects.toThrow("React Flow viewport not found in container"); + }); + + it("throws when bounds are null (no nodes to export)", async () => { + const container = buildContainerWithViewport(); + + await expect( + exportGraphAsPNG(container, null, undefined, graphData), + ).rejects.toThrow("No nodes to export"); + }); + + it("throws when graph data has no nodes", async () => { + const container = buildContainerWithViewport(); + + await expect( + exportGraphAsPNG(container, bounds, undefined, { nodes: [] }), + ).rejects.toThrow("No nodes to export"); + }); + + it("renders graph data to a PNG download", async () => { + const container = buildContainerWithViewport(); + const appendChild = vi.spyOn(document.body, "appendChild"); + + await exportGraphAsPNG(container, bounds, "graph.png", graphData); + + expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith( + "image/png", + ); + const link = appendChild.mock.calls.find( + ([element]) => element instanceof HTMLAnchorElement, + )?.[0] as HTMLAnchorElement | undefined; + expect(link?.download).toBe("graph.png"); + expect(link?.href).toBe("data:image/png;base64,AAAA"); + }); + + it("re-throws a generic export error when canvas is unavailable", async () => { + const container = buildContainerWithViewport(); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + await expect( + exportGraphAsPNG(container, bounds, undefined, graphData), + ).rejects.toThrow("Failed to export graph"); + expect(consoleError).toHaveBeenCalled(); + }); +}); + +describe("exportGraphAsJSON", () => { + it("re-throws a generic export error when serialization fails", () => { + const circular: Record = {}; + circular.self = circular; + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + + expect(() => exportGraphAsJSON(circular)).toThrow("Failed to export graph"); + expect(consoleError).toHaveBeenCalled(); + + consoleError.mockRestore(); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts index fd04b6a31e..71926ad8c3 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/export.ts @@ -1,13 +1,49 @@ /** - * Export utilities for attack path graphs - * Handles exporting graph visualization to various formats + * Export utilities for attack path graphs. + * + * React Flow DOM screenshotting proved unreliable in-app, so PNG export draws a + * deterministic canvas from the same graph data + Dagre layout used by the UI. */ -/** - * Helper function to download a blob as a file - * @param blob The blob to download - * @param filename The name of the file - */ +import type { Rect } from "@xyflow/react"; + +import type { AttackPathGraphData, GraphEdge } from "@/types/attack-paths"; + +import { truncateLabel } from "./format"; +import { + getNodeBorderColor, + getNodeColor, + GRAPH_ALERT_BORDER_COLOR, + GRAPH_EDGE_COLOR_DARK, +} from "./graph-colors"; +import { layoutWithDagre } from "./layout"; +import { resolveNodeVisual } from "./node-visuals"; + +interface ExportGraphOptions { + expandedResources?: ReadonlySet; + isFilteredView?: boolean; + selectedNodeId?: string | null; +} + +interface Point { + x: number; + y: number; +} + +const EXPORT_IMAGE_WIDTH = 1920; +const EXPORT_IMAGE_HEIGHT = 1080; +const EXPORT_BACKGROUND = "#1c1917"; +const EXPORT_PADDING = 96; +const DOT_SPACING = 32; +const BADGE_RADIUS = 22; +const BADGE_CENTER_Y = 26; +const GLOW_RADIUS = 30; +const LABEL_Y = 66; +const LABEL_LINE_HEIGHT = 13; +const TYPE_Y = 94; +const RESOURCE_NAME_MAX_CHARS = 18; +const RESOURCE_NAME_MAX_LINES = 2; + const downloadBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); const link = document.createElement("a"); @@ -19,106 +55,507 @@ const downloadBlob = (blob: Blob, filename: string) => { URL.revokeObjectURL(url); }; -/** - * Export graph as SVG image - * @param svgElement The SVG element to export - * @param filename The name of the file to download - */ -export const exportGraphAsSVG = ( - svgElement: SVGSVGElement | null, - filename: string = "attack-path-graph.svg", +const downloadDataUrl = (dataUrl: string, filename: string) => { + const link = document.createElement("a"); + link.href = dataUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; + +const isFindingNode = (labels: string[]) => + labels.some((label) => label.toLowerCase().includes("finding")); + +const getGraphEdges = (graphData: AttackPathGraphData): GraphEdge[] => { + if (graphData.edges?.length) return graphData.edges; + + return (graphData.relationships ?? []).map((relationship) => ({ + id: relationship.id, + source: relationship.source, + target: relationship.target, + type: relationship.label, + properties: relationship.properties, + })); +}; + +const getVisibleGraphData = ( + graphData: AttackPathGraphData, + options: ExportGraphOptions = {}, +): AttackPathGraphData => { + if (options.isFilteredView) return graphData; + + const edges = getGraphEdges(graphData); + const expandedResources = options.expandedResources ?? new Set(); + const nodeById = new Map(graphData.nodes.map((node) => [node.id, node])); + const hiddenFindingIds = new Set(); + + graphData.nodes.forEach((node) => { + if (!isFindingNode(node.labels)) return; + + const connectedResources = edges + .flatMap((edge) => { + if (edge.source === node.id) return [edge.target]; + if (edge.target === node.id) return [edge.source]; + return []; + }) + .filter((id) => { + const connectedNode = nodeById.get(id); + return connectedNode && !isFindingNode(connectedNode.labels); + }); + + const hasExpandedResource = connectedResources.some((resourceId) => + expandedResources.has(resourceId), + ); + if (connectedResources.length > 0 && !hasExpandedResource) { + hiddenFindingIds.add(node.id); + } + }); + + return { + nodes: graphData.nodes.filter((node) => !hiddenFindingIds.has(node.id)), + edges: edges.filter( + (edge) => + !hiddenFindingIds.has(edge.source) && + !hiddenFindingIds.has(edge.target), + ), + }; +}; + +const getResourcesWithFindings = ( + sourceGraphData: AttackPathGraphData, + visibleGraphData: AttackPathGraphData, ) => { - if (!svgElement) return; + const visibleNodeIds = new Set(visibleGraphData.nodes.map((node) => node.id)); + const sourceNodeById = new Map( + sourceGraphData.nodes.map((node) => [node.id, node]), + ); + const findingNodeIds = new Set( + sourceGraphData.nodes + .filter((node) => isFindingNode(node.labels)) + .map((node) => node.id), + ); + const resourcesWithFindings = new Set(); - try { - // Clone the SVG element to avoid modifying the original - const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement; + getGraphEdges(sourceGraphData).forEach((edge) => { + const sourceIsFinding = findingNodeIds.has(edge.source); + const targetIsFinding = findingNodeIds.has(edge.target); + const sourceNode = sourceNodeById.get(edge.source); + const targetNode = sourceNodeById.get(edge.target); - // Find the main container group (first g element with transform) - const containerGroup = clonedSvg.querySelector("g"); - if (!containerGroup) { - throw new Error("Could not find graph container"); + if ( + sourceIsFinding && + targetNode && + !isFindingNode(targetNode.labels) && + visibleNodeIds.has(edge.target) + ) { + resourcesWithFindings.add(edge.target); } - // Get the bounding box of the actual graph content - // We need to get it from the original SVG since cloned elements don't have computed geometry - const originalContainer = svgElement.querySelector("g"); - if (!originalContainer) { - throw new Error("Could not find original graph container"); + if ( + targetIsFinding && + sourceNode && + !isFindingNode(sourceNode.labels) && + visibleNodeIds.has(edge.source) + ) { + resourcesWithFindings.add(edge.source); } + }); - const bbox = originalContainer.getBBox(); + return resourcesWithFindings; +}; - // Add padding around the content - const padding = 50; - const contentWidth = bbox.width + padding * 2; - const contentHeight = bbox.height + padding * 2; +const getLabelLines = (label: string, maxChars: number, maxLines: number) => { + const words = label.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let current = ""; - // Set the SVG dimensions to fit the content - clonedSvg.setAttribute("width", `${contentWidth}`); - clonedSvg.setAttribute("height", `${contentHeight}`); - clonedSvg.setAttribute( - "viewBox", - `${bbox.x - padding} ${bbox.y - padding} ${contentWidth} ${contentHeight}`, - ); + words.forEach((word) => { + const next = current ? `${current} ${word}` : word; + if (next.length <= maxChars) { + current = next; + return; + } + if (current) lines.push(current); + current = word; + }); - // Remove the zoom transform from the container - the viewBox now handles positioning - containerGroup.removeAttribute("transform"); + if (current) lines.push(current); + if (lines.length === 0) lines.push(label); - // Add white background for better visibility - const bgRect = document.createElementNS( - "http://www.w3.org/2000/svg", - "rect", - ); - bgRect.setAttribute("x", `${bbox.x - padding}`); - bgRect.setAttribute("y", `${bbox.y - padding}`); - bgRect.setAttribute("width", `${contentWidth}`); - bgRect.setAttribute("height", `${contentHeight}`); - bgRect.setAttribute("fill", "#1c1917"); // Dark background matching the app - clonedSvg.insertBefore(bgRect, clonedSvg.firstChild); + const visibleLines = lines.slice(0, maxLines); + if (lines.length > maxLines && visibleLines.length > 0) { + const lastIndex = visibleLines.length - 1; + visibleLines[lastIndex] = truncateLabel(visibleLines[lastIndex], maxChars); + } - const svgData = new XMLSerializer().serializeToString(clonedSvg); - const blob = new Blob([svgData], { type: "image/svg+xml" }); - downloadBlob(blob, filename); - } catch (error) { - console.error("Failed to export graph as SVG:", error); - throw new Error("Failed to export graph"); + return visibleLines; +}; + +const getFittedLayout = (graphData: AttackPathGraphData) => { + const { rfNodes, rfEdges } = layoutWithDagre( + graphData.nodes, + getGraphEdges(graphData), + ); + + if (rfNodes.length === 0) { + throw new Error("No nodes to export"); + } + + const minX = Math.min(...rfNodes.map((node) => node.position.x)); + const minY = Math.min(...rfNodes.map((node) => node.position.y)); + const maxX = Math.max( + ...rfNodes.map((node) => node.position.x + (node.width ?? 0)), + ); + const maxY = Math.max( + ...rfNodes.map((node) => node.position.y + (node.height ?? 0)), + ); + const graphWidth = Math.max(maxX - minX, 1); + const graphHeight = Math.max(maxY - minY, 1); + const scale = Math.min( + (EXPORT_IMAGE_WIDTH - EXPORT_PADDING * 2) / graphWidth, + (EXPORT_IMAGE_HEIGHT - EXPORT_PADDING * 2) / graphHeight, + 2, + ); + const offsetX = (EXPORT_IMAGE_WIDTH - graphWidth * scale) / 2 - minX * scale; + const offsetY = + (EXPORT_IMAGE_HEIGHT - graphHeight * scale) / 2 - minY * scale; + + const toExportPoint = (x: number, y: number): Point => ({ + x: x * scale + offsetX, + y: y * scale + offsetY, + }); + + return { rfNodes, rfEdges, toExportPoint }; +}; + +const drawBackground = (context: CanvasRenderingContext2D) => { + context.fillStyle = EXPORT_BACKGROUND; + context.fillRect(0, 0, EXPORT_IMAGE_WIDTH, EXPORT_IMAGE_HEIGHT); + + context.fillStyle = "rgba(68, 64, 60, 0.55)"; + for (let x = 2; x < EXPORT_IMAGE_WIDTH; x += DOT_SPACING) { + for (let y = 2; y < EXPORT_IMAGE_HEIGHT; y += DOT_SPACING) { + context.beginPath(); + context.arc(x, y, 1.4, 0, Math.PI * 2); + context.fill(); + } } }; +const movePointToward = (from: Point, to: Point, distance: number): Point => { + const dx = to.x - from.x; + const dy = to.y - from.y; + const length = Math.hypot(dx, dy); + if (length === 0) return from; + + return { + x: from.x + (dx / length) * distance, + y: from.y + (dy / length) * distance, + }; +}; + +const drawArrowHead = ( + context: CanvasRenderingContext2D, + from: Point, + to: Point, +) => { + const angle = Math.atan2(to.y - from.y, to.x - from.x); + const size = 10; + + context.beginPath(); + context.moveTo(to.x, to.y); + context.lineTo( + to.x - size * Math.cos(angle - Math.PI / 6), + to.y - size * Math.sin(angle - Math.PI / 6), + ); + context.lineTo( + to.x - size * Math.cos(angle + Math.PI / 6), + to.y - size * Math.sin(angle + Math.PI / 6), + ); + context.closePath(); + context.fill(); +}; + +const drawEdges = ( + context: CanvasRenderingContext2D, + edges: ReturnType["rfEdges"], + getNodeCenter: (id: string) => Point | null, +) => { + context.strokeStyle = GRAPH_EDGE_COLOR_DARK; + context.fillStyle = GRAPH_EDGE_COLOR_DARK; + context.globalAlpha = 0.72; + context.lineWidth = 2; + + edges.forEach((edge) => { + const source = getNodeCenter(edge.source); + const target = getNodeCenter(edge.target); + if (!source || !target) return; + + const start = movePointToward(source, target, BADGE_RADIUS); + const end = movePointToward(target, source, BADGE_RADIUS + 8); + const midX = (start.x + end.x) / 2; + context.setLineDash( + edge.className?.includes("finding-edge") ? [10, 8] : [], + ); + context.beginPath(); + context.moveTo(start.x, start.y); + context.bezierCurveTo(midX, start.y, midX, end.y, end.x, end.y); + context.stroke(); + context.setLineDash([]); + drawArrowHead(context, start, end); + }); + + context.globalAlpha = 1; +}; + +const drawShieldIcon = ( + context: CanvasRenderingContext2D, + x: number, + y: number, +) => { + context.beginPath(); + context.moveTo(x, y - 12); + context.quadraticCurveTo(x + 8, y - 8, x + 12, y - 8); + context.lineTo(x + 10, y + 3); + context.quadraticCurveTo(x + 8, y + 11, x, y + 14); + context.quadraticCurveTo(x - 8, y + 11, x - 10, y + 3); + context.lineTo(x - 12, y - 8); + context.quadraticCurveTo(x - 8, y - 8, x, y - 12); + context.stroke(); + context.beginPath(); + context.moveTo(x - 5, y); + context.lineTo(x - 1, y + 4); + context.lineTo(x + 7, y - 5); + context.stroke(); +}; + +const drawKeyIcon = ( + context: CanvasRenderingContext2D, + x: number, + y: number, +) => { + context.beginPath(); + context.arc(x - 6, y - 2, 5, 0, Math.PI * 2); + context.moveTo(x - 1, y - 2); + context.lineTo(x + 12, y - 2); + context.moveTo(x + 8, y - 2); + context.lineTo(x + 8, y + 5); + context.moveTo(x + 12, y - 2); + context.lineTo(x + 12, y + 3); + context.stroke(); +}; + +const drawFindingIcon = ( + context: CanvasRenderingContext2D, + x: number, + y: number, +) => { + context.beginPath(); + context.moveTo(x, y - 13); + context.lineTo(x + 13, y + 10); + context.lineTo(x - 13, y + 10); + context.closePath(); + context.stroke(); + context.font = "700 18px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText("!", x, y + 3); +}; + +const drawNodeIcon = ( + context: CanvasRenderingContext2D, + x: number, + y: number, + category: string, + description: string, +) => { + const lowerDescription = description.toLowerCase(); + context.save(); + context.strokeStyle = "#ffffff"; + context.fillStyle = "#ffffff"; + context.lineWidth = 2.2; + context.lineCap = "round"; + context.lineJoin = "round"; + + if (category === "finding") { + drawFindingIcon(context, x, y); + } else if (lowerDescription.includes("policy statement")) { + context.font = "700 24px monospace"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText("{}", x, y + 1); + } else if (lowerDescription.includes("policy")) { + drawKeyIcon(context, x, y); + } else if (category === "identity" || lowerDescription.includes("role")) { + drawShieldIcon(context, x, y); + } else if (category === "account") { + context.font = "700 11px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText("AWS", x, y + 1); + } else { + context.font = "700 14px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText( + description + .split(" ") + .map((word) => word[0]) + .join("") + .slice(0, 2) + .toUpperCase(), + x, + y + 1, + ); + } + + context.restore(); +}; + +const drawNode = ( + context: CanvasRenderingContext2D, + graphNode: AttackPathGraphData["nodes"][number], + center: Point, + options: { hasFindings: boolean; selected: boolean }, +) => { + const isFinding = isFindingNode(graphNode.labels); + const visual = resolveNodeVisual(graphNode); + const fill = getNodeColor(graphNode.labels, graphNode.properties); + const stroke = options.hasFindings + ? GRAPH_ALERT_BORDER_COLOR + : getNodeBorderColor(graphNode.labels, graphNode.properties); + const glowOpacity = options.selected + ? 0.34 + : isFinding + ? 0.28 + : options.hasFindings + ? 0.26 + : 0; + const strokeWidth = options.selected + ? 4 + : options.hasFindings + ? 3 + : isFinding + ? 2.5 + : 1.5; + + if (glowOpacity > 0) { + context.fillStyle = stroke; + context.globalAlpha = glowOpacity / 2; + context.beginPath(); + context.arc(center.x, center.y, GLOW_RADIUS, 0, Math.PI * 2); + context.fill(); + context.globalAlpha = 1; + } + + context.fillStyle = fill; + context.strokeStyle = stroke; + context.lineWidth = strokeWidth; + context.beginPath(); + context.arc(center.x, center.y, BADGE_RADIUS, 0, Math.PI * 2); + context.fill(); + context.stroke(); + + const typeLabel = truncateLabel(visual.description, 22); + drawNodeIcon(context, center.x, center.y, visual.category, typeLabel); + + context.fillStyle = "#ffffff"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.font = "600 11px sans-serif"; + getLabelLines( + visual.displayName, + RESOURCE_NAME_MAX_CHARS, + RESOURCE_NAME_MAX_LINES, + ).forEach((line, index) => { + context.fillText( + line, + center.x, + center.y + (LABEL_Y - BADGE_CENTER_Y) + index * LABEL_LINE_HEIGHT, + 150, + ); + }); + + context.fillStyle = "rgba(255,255,255,0.82)"; + context.font = "9px sans-serif"; + context.fillText( + typeLabel, + center.x, + center.y + (TYPE_Y - BADGE_CENTER_Y), + 150, + ); +}; + +const renderGraphToPngDataUrl = ( + graphData: AttackPathGraphData, + options?: ExportGraphOptions, +) => { + const canvas = document.createElement("canvas"); + canvas.width = EXPORT_IMAGE_WIDTH; + canvas.height = EXPORT_IMAGE_HEIGHT; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas not available"); + + const visibleGraphData = getVisibleGraphData(graphData, options); + const resourcesWithFindings = getResourcesWithFindings( + graphData, + visibleGraphData, + ); + const { rfNodes, rfEdges, toExportPoint } = getFittedLayout(visibleGraphData); + const nodeById = new Map(rfNodes.map((node) => [node.id, node])); + const getNodeCenter = (id: string) => { + const node = nodeById.get(id); + if (!node) return null; + return toExportPoint( + node.position.x + (node.width ?? 0) / 2, + node.position.y + BADGE_CENTER_Y, + ); + }; + + drawBackground(context); + drawEdges(context, rfEdges, getNodeCenter); + rfNodes.forEach((rfNode) => { + const center = getNodeCenter(rfNode.id); + if (!center) return; + + drawNode(context, rfNode.data.graphNode, center, { + hasFindings: resourcesWithFindings.has(rfNode.id), + selected: options?.selectedNodeId === rfNode.id, + }); + }); + + return canvas.toDataURL("image/png"); +}; + /** - * Export graph as PNG image - * @param svgElement The SVG element to export - * @param filename The name of the file to download + * Export graph as PNG using graph data instead of DOM rasterization. */ export const exportGraphAsPNG = async ( - svgElement: SVGSVGElement | null, + containerElement: HTMLDivElement | null, + bounds: Rect | null, filename: string = "attack-path-graph.png", + graphData?: AttackPathGraphData | null, + options?: ExportGraphOptions, ) => { - if (!svgElement) return; + if (!containerElement) { + throw new Error("Graph container not mounted"); + } + + if (!containerElement.querySelector(".react-flow")) { + throw new Error("React Flow root not found in container"); + } + + if (!containerElement.querySelector(".react-flow__viewport")) { + throw new Error("React Flow viewport not found in container"); + } + + if (!bounds || !graphData?.nodes.length) { + throw new Error("No nodes to export"); + } try { - const svgData = new XMLSerializer().serializeToString(svgElement); - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; - - if (!ctx) throw new Error("Could not get canvas context"); - - const svg = new Image(); - svg.onload = () => { - canvas.width = svg.width; - canvas.height = svg.height; - ctx.drawImage(svg, 0, 0); - canvas.toBlob((blob) => { - if (blob) { - downloadBlob(blob, filename); - } - }); - }; - svg.onerror = () => { - throw new Error("Failed to load SVG for PNG conversion"); - }; - svg.src = `data:image/svg+xml;base64,${btoa(svgData)}`; + downloadDataUrl(renderGraphToPngDataUrl(graphData, options), filename); } catch (error) { console.error("Failed to export graph as PNG:", error); throw new Error("Failed to export graph"); @@ -126,9 +563,7 @@ export const exportGraphAsPNG = async ( }; /** - * Export graph data as JSON - * @param graphData The graph data to export - * @param filename The name of the file to download + * Export graph data as JSON (format-agnostic — does not depend on DOM rendering). */ export const exportGraphAsJSON = ( graphData: Record, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts index 02871e270e..0876613259 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/format.ts @@ -23,3 +23,7 @@ export function formatNodeLabel(label: string): string { export function formatNodeLabels(labels: string[]): string { return labels.map(formatNodeLabel).join(", "); } + +export function truncateLabel(text: string, maxChars: number): string { + return text.length > maxChars ? `${text.substring(0, maxChars)}...` : text; +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts new file mode 100644 index 0000000000..47ba73311f --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { + GRAPH_ALERT_BORDER_COLOR, + GRAPH_EDGE_HIGHLIGHT_COLOR, + resolveNodeColors, +} from "./graph-colors"; + +describe("resolveNodeColors", () => { + it("prioritizes selected state over hasFindings for the border color", () => { + const selectedColors = resolveNodeColors({ + labels: ["EC2Instance"], + selected: true, + hasFindings: true, + }); + + const alertOnlyColors = resolveNodeColors({ + labels: ["EC2Instance"], + selected: false, + hasFindings: true, + }); + + expect(selectedColors.borderColor).toBe(GRAPH_EDGE_HIGHLIGHT_COLOR); + expect(alertOnlyColors.borderColor).toBe(GRAPH_ALERT_BORDER_COLOR); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts index b49b058850..6c79d6c60a 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-colors.ts @@ -54,8 +54,8 @@ export const GRAPH_NODE_BORDER_COLORS = { // Edge colors per theme export const GRAPH_EDGE_COLOR_DARK = "#ffffff"; // White for dark theme export const GRAPH_EDGE_COLOR_LIGHT = "#1e293b"; // Slate 800 for light theme -export const GRAPH_EDGE_HIGHLIGHT_COLOR = "#f97316"; // Orange 500 (on hover) -export const GRAPH_EDGE_GLOW_COLOR = "#fb923c"; +export const GRAPH_EDGE_HIGHLIGHT_COLOR = "#34d399"; // Prowler green (hover/selection) +export const GRAPH_EDGE_GLOW_COLOR = "#6ee7b7"; export const GRAPH_SELECTION_COLOR = "#ffffff"; export const GRAPH_BORDER_COLOR = "#374151"; export const GRAPH_ALERT_BORDER_COLOR = "#ef4444"; // Red 500 - for resources with findings @@ -128,6 +128,37 @@ export const getNodeBorderColor = ( return GRAPH_NODE_BORDER_COLORS.default; }; +interface ResolveNodeColorsParams { + labels: string[]; + properties?: Record; + selected?: boolean; + hasFindings?: boolean; +} + +interface NodeColorResult { + fillColor: string; + borderColor: string; +} + +/** + * Resolve fill and border colors for a graph node, layering selection and + * finding-alert state on top of the label/severity defaults. + */ +export const resolveNodeColors = ({ + labels, + properties, + selected, + hasFindings, +}: ResolveNodeColorsParams): NodeColorResult => { + const fillColor = getNodeColor(labels, properties); + const borderColor = selected + ? GRAPH_EDGE_HIGHLIGHT_COLOR + : hasFindings + ? GRAPH_ALERT_BORDER_COLOR + : getNodeBorderColor(labels, properties); + return { fillColor, borderColor }; +}; + /** * Check if a background color is light (for determining text color) */ diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts index ac5c219bfb..e77126039f 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/graph-utils.ts @@ -4,21 +4,40 @@ import type { AttackPathGraphData } from "@/types/attack-paths"; -/** - * Type for edge node reference - can be a string ID or an object with id property - * Note: We use `object` to match GraphEdge type from attack-paths.ts - */ -export type EdgeNodeRef = string | object; +export const resolveHiddenFindingIds = ({ + expandedResources, + findingNodeIds, + findingToResources, + isFilteredView, +}: { + expandedResources: ReadonlySet; + findingNodeIds: ReadonlySet; + findingToResources: ReadonlyMap>; + isFilteredView: boolean; +}): Set => { + const hiddenFindingIds = new Set(); -/** - * Helper to get edge source/target ID from string or object - */ -export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => { - if (typeof nodeRef === "string") { - return nodeRef; + if (isFilteredView) { + return hiddenFindingIds; } - // Edge node references are objects with an id property - return (nodeRef as { id: string }).id; + + findingNodeIds.forEach((findingId) => { + const connectedResources = findingToResources.get(findingId); + + if (!connectedResources) { + return; + } + + const anyExpanded = Array.from(connectedResources).some((resourceId) => + expandedResources.has(resourceId), + ); + + if (!anyExpanded) { + hiddenFindingIds.add(findingId); + } + }); + + return hiddenFindingIds; }; /** @@ -44,10 +63,8 @@ export const computeFilteredSubgraph = ( }); edges.forEach((edge) => { - const sourceId = getEdgeNodeId(edge.source); - const targetId = getEdgeNodeId(edge.target); - forwardEdges.get(sourceId)?.add(targetId); - backwardEdges.get(targetId)?.add(sourceId); + forwardEdges.get(edge.source)?.add(edge.target); + backwardEdges.get(edge.target)?.add(edge.source); }); const visibleNodeIds = new Set(); @@ -84,35 +101,30 @@ export const computeFilteredSubgraph = ( traverseDownstream(targetNodeId); // Also include findings directly connected to the selected node + const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels])); edges.forEach((edge) => { - const sourceId = getEdgeNodeId(edge.source); - const targetId = getEdgeNodeId(edge.target); - const sourceNode = nodes.find((n) => n.id === sourceId); - const targetNode = nodes.find((n) => n.id === targetId); - - const sourceIsFinding = sourceNode?.labels.some((l) => + const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) => l.toLowerCase().includes("finding"), ); - const targetIsFinding = targetNode?.labels.some((l) => + const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) => l.toLowerCase().includes("finding"), ); // Include findings connected to the selected node - if (sourceId === targetNodeId && targetIsFinding) { - visibleNodeIds.add(targetId); + if (edge.source === targetNodeId && targetIsFinding) { + visibleNodeIds.add(edge.target); } - if (targetId === targetNodeId && sourceIsFinding) { - visibleNodeIds.add(sourceId); + if (edge.target === targetNodeId && sourceIsFinding) { + visibleNodeIds.add(edge.source); } }); // Filter nodes and edges to only include visible ones const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id)); - const filteredEdges = edges.filter((edge) => { - const sourceId = getEdgeNodeId(edge.source); - const targetId = getEdgeNodeId(edge.target); - return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId); - }); + const filteredEdges = edges.filter( + (edge) => + visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target), + ); return { nodes: filteredNodes, diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts index abecf7a3c2..261a27cf8d 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/index.ts @@ -1,9 +1,5 @@ -export { - exportGraphAsJSON, - exportGraphAsPNG, - exportGraphAsSVG, -} from "./export"; -export { formatNodeLabel, formatNodeLabels } from "./format"; +export { exportGraphAsJSON, exportGraphAsPNG } from "./export"; +export { formatNodeLabel, formatNodeLabels, truncateLabel } from "./format"; export { getNodeBorderColor, getNodeColor, @@ -14,10 +10,17 @@ export { GRAPH_NODE_BORDER_COLORS, GRAPH_NODE_COLORS, GRAPH_SELECTION_COLOR, + resolveNodeColors, } from "./graph-colors"; export { computeFilteredSubgraph, - type EdgeNodeRef, - getEdgeNodeId, getPathEdges, + resolveHiddenFindingIds, } from "./graph-utils"; +export { layoutWithDagre } from "./layout"; +export { + NODE_CATEGORY, + type NodeCategory, + type NodeVisual, + resolveNodeVisual, +} from "./node-visuals"; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts new file mode 100644 index 0000000000..eb972c37be --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.test.ts @@ -0,0 +1,275 @@ +import { Position } from "@xyflow/react"; +import { describe, expect, it } from "vitest"; + +import type { GraphEdge, GraphNode } from "@/types/attack-paths"; + +import { layoutWithDagre } from "./layout"; + +const findingNode: GraphNode = { + id: "finding-1", + labels: ["ProwlerFinding"], + properties: { check_title: "Open S3 bucket", severity: "high" }, +}; + +const resourceNode: GraphNode = { + id: "resource-1", + labels: ["S3Bucket"], + properties: { name: "bucket-1" }, +}; + +const internetNode: GraphNode = { + id: "internet-1", + labels: ["Internet"], + properties: {}, +}; + +describe("layoutWithDagre", () => { + it("returns empty arrays for empty input", () => { + const result = layoutWithDagre([], []); + expect(result.rfNodes).toEqual([]); + expect(result.rfEdges).toEqual([]); + }); + it("assigns node types and dimensions from labels", () => { + const { rfNodes } = layoutWithDagre( + [findingNode, resourceNode, internetNode], + [], + ); + + const byId = new Map(rfNodes.map((n) => [n.id, n])); + + expect(byId.get("finding-1")).toMatchObject({ + type: "finding", + width: 150, + height: 112, + }); + expect(byId.get("resource-1")).toMatchObject({ + type: "resource", + width: 136, + height: 112, + }); + expect(byId.get("internet-1")).toMatchObject({ + type: "internet", + width: 80, + height: 80, + }); + }); + + it("is deterministic: same input produces equal output across runs", () => { + const nodes = [findingNode, resourceNode]; + const edges: GraphEdge[] = [ + { + id: "e1", + source: "resource-1", + target: "finding-1", + type: "HAS_FINDING", + }, + ]; + + const a = layoutWithDagre(nodes, edges); + const b = layoutWithDagre(nodes, edges); + + expect(a).toEqual(b); + }); + + it("places connected children to the right and stacks siblings within the horizontal rank", () => { + const rootNode: GraphNode = { + id: "root", + labels: ["AWSAccount"], + properties: { name: "account" }, + }; + const siblingNodes: GraphNode[] = [ + { + id: "bucket", + labels: ["S3Bucket"], + properties: { name: "bucket" }, + }, + { + id: "lambda", + labels: ["AWSLambda"], + properties: { name: "function" }, + }, + { + id: "database", + labels: ["RDSInstance"], + properties: { name: "database" }, + }, + ]; + + const { rfNodes } = layoutWithDagre( + [rootNode, ...siblingNodes], + siblingNodes.map((node) => ({ + id: `root-${node.id}`, + source: "root", + target: node.id, + type: "CONNECTS_TO", + })), + ); + + const rootPosition = rfNodes.find( + (candidate) => candidate.id === "root", + )?.position; + const siblingPositions = siblingNodes.map((node) => { + const rfNode = rfNodes.find((candidate) => candidate.id === node.id); + + expect(rfNode).toBeDefined(); + + return rfNode?.position ?? { x: 0, y: 0 }; + }); + + const xSpread = + Math.max(...siblingPositions.map((position) => position.x)) - + Math.min(...siblingPositions.map((position) => position.x)); + const ySpread = + Math.max(...siblingPositions.map((position) => position.y)) - + Math.min(...siblingPositions.map((position) => position.y)); + + expect(rootPosition).toBeDefined(); + siblingPositions.forEach((position) => { + expect(position.x).toBeGreaterThan(rootPosition?.x ?? 0); + }); + expect(ySpread).toBeGreaterThan(xSpread); + }); + + it("connects edges through right and left node sides for horizontal layout", () => { + const { rfNodes } = layoutWithDagre( + [findingNode, resourceNode], + [ + { + id: "e1", + source: "resource-1", + target: "finding-1", + type: "HAS_FINDING", + }, + ], + ); + + rfNodes.forEach((node) => { + expect(node.sourcePosition).toBe(Position.Right); + expect(node.targetPosition).toBe(Position.Left); + }); + }); + + it("offsets dagre center positions by half of the node dimensions (top-left)", () => { + const { rfNodes } = layoutWithDagre([findingNode, resourceNode], []); + + rfNodes.forEach((node) => { + expect(Number.isFinite(node.position.x)).toBe(true); + expect(Number.isFinite(node.position.y)).toBe(true); + }); + + // Different node types must end up with different sizes — confirms the + // dimension-aware offset is wired up. + const findingDims = rfNodes.find((n) => n.id === "finding-1"); + const resourceDims = rfNodes.find((n) => n.id === "resource-1"); + expect(findingDims?.width).not.toEqual(resourceDims?.width); + }); + + it("reverses container relationships while preserving original endpoints in edge data", () => { + const containerNode: GraphNode = { + id: "container", + labels: ["AWSAccount"], + properties: { name: "acct" }, + }; + const childNode: GraphNode = { + id: "child", + labels: ["S3Bucket"], + properties: { name: "bucket" }, + }; + + const { rfEdges } = layoutWithDagre( + [containerNode, childNode], + [ + { + id: "e1", + source: "container", + target: "child", + type: "RUNS_IN", + }, + ], + ); + + expect(rfEdges).toHaveLength(1); + expect(rfEdges[0]).toMatchObject({ + source: "child", + target: "container", + data: { originalSource: "container", originalTarget: "child" }, + }); + }); + + it("animates edges that touch a finding node and tags them with finding-edge", () => { + const { rfEdges } = layoutWithDagre( + [findingNode, resourceNode, internetNode], + [ + { + id: "e1", + source: "resource-1", + target: "finding-1", + type: "HAS_FINDING", + }, + { + id: "e2", + source: "internet-1", + target: "resource-1", + type: "CONNECTS_TO", + }, + ], + ); + + const findingEdge = rfEdges.find( + (e) => e.source === "resource-1" && e.target === "finding-1", + ); + const plainEdge = rfEdges.find( + (e) => e.source === "internet-1" && e.target === "resource-1", + ); + + expect(findingEdge).toMatchObject({ + animated: true, + className: "finding-edge", + }); + expect(plainEdge).toMatchObject({ + animated: false, + className: "resource-edge", + }); + }); + + it("preserves the original edge id when the graph has a single edge", () => { + const { rfEdges } = layoutWithDagre( + [findingNode, resourceNode], + [ + { + id: "ignored-by-rf", + source: "resource-1", + target: "finding-1", + type: "HAS_FINDING", + }, + ], + ); + + expect(rfEdges[0]?.id).toBe("ignored-by-rf"); + }); + + it("preserves parallel edges between the same nodes with unique ids", () => { + const { rfEdges } = layoutWithDagre( + [resourceNode, findingNode], + [ + { + id: "edge-1", + source: "resource-1", + target: "finding-1", + type: "HAS_FINDING", + }, + { + id: "edge-2", + source: "resource-1", + target: "finding-1", + type: "HAS_FINDING", + }, + ], + ); + + expect(rfEdges).toHaveLength(2); + expect(rfEdges.map((edge) => edge.id)).toEqual(["edge-1", "edge-2"]); + expect(rfEdges.every((edge) => edge.source === "resource-1")).toBe(true); + expect(rfEdges.every((edge) => edge.target === "finding-1")).toBe(true); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts new file mode 100644 index 0000000000..d3df3dfd46 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/layout.ts @@ -0,0 +1,161 @@ +/** + * Pure Dagre layout adapter for React Flow + * Converts normalized GraphNode[] + GraphEdge[] to positioned RF nodes + */ + +import { Graph, layout as dagreLayout } from "@dagrejs/dagre"; +import { type Edge, type Node, Position } from "@xyflow/react"; + +import type { GraphEdge, GraphNode } from "@/types/attack-paths"; + +// Node dimensions matching the rendered React Flow custom nodes. +const RESOURCE_NODE_WIDTH = 136; +const RESOURCE_NODE_HEIGHT = 112; +const FINDING_NODE_WIDTH = 150; +const FINDING_NODE_HEIGHT = 112; +const INTERNET_DIAMETER = 80; // NODE_HEIGHT * 0.8 * 2 + +// Container relationships that get reversed for proper hierarchy +const CONTAINER_RELATIONS = new Set([ + "RUNS_IN", + "BELONGS_TO", + "LOCATED_IN", + "PART_OF", +]); + +interface NodeData extends Record { + graphNode: GraphNode; +} + +const NODE_TYPE = { + FINDING: "finding", + INTERNET: "internet", + RESOURCE: "resource", +} as const; + +type NodeType = (typeof NODE_TYPE)[keyof typeof NODE_TYPE]; + +export const isFindingNode = (labels: string[]): boolean => + labels.some((l) => l.toLowerCase().includes("finding")); + +const getNodeType = (labels: string[]): NodeType => { + if (isFindingNode(labels)) return NODE_TYPE.FINDING; + if (labels.some((l) => l.toLowerCase() === "internet")) + return NODE_TYPE.INTERNET; + return NODE_TYPE.RESOURCE; +}; + +const getNodeDimensions = ( + type: NodeType, +): { width: number; height: number } => { + if (type === NODE_TYPE.FINDING) + return { width: FINDING_NODE_WIDTH, height: FINDING_NODE_HEIGHT }; + if (type === NODE_TYPE.INTERNET) + return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER }; + return { width: RESOURCE_NODE_WIDTH, height: RESOURCE_NODE_HEIGHT }; +}; + +/** + * Pure layout function: computes positioned React Flow nodes from graph data. + * Deterministic — same inputs always produce same outputs. + */ +export const layoutWithDagre = ( + nodes: GraphNode[], + edges: GraphEdge[], +): { rfNodes: Node[]; rfEdges: Edge[] } => { + const g = new Graph({ multigraph: true }); + g.setGraph({ + rankdir: "LR", + nodesep: 80, + ranksep: 150, + marginx: 50, + marginy: 50, + }); + g.setDefaultEdgeLabel(() => ({})); + + // Add nodes with type-based dimensions + nodes.forEach((node) => { + const type = getNodeType(node.labels); + const { width, height } = getNodeDimensions(type); + g.setNode(node.id, { label: node.id, width, height }); + }); + + // Add edges, reversing container relationships for proper hierarchy + edges.forEach((edge) => { + let sourceId = edge.source; + let targetId = edge.target; + + if (CONTAINER_RELATIONS.has(edge.type)) { + [sourceId, targetId] = [targetId, sourceId]; + } + + if (sourceId && targetId) { + g.setEdge( + sourceId, + targetId, + { + id: edge.id, + originalSource: edge.source, + originalTarget: edge.target, + }, + edge.id, + ); + } + }); + + dagreLayout(g); + + // Build RF nodes from layout + const rfNodes: Node[] = nodes.map((node) => { + const dagreNode = g.node(node.id); + const type = getNodeType(node.labels); + const { width, height } = getNodeDimensions(type); + + return { + id: node.id, + type, + position: { + x: dagreNode.x - width / 2, + y: dagreNode.y - height / 2, + }, + sourcePosition: Position.Right, + targetPosition: Position.Left, + data: { graphNode: node }, + width, + height, + }; + }); + + // Build RF edges from dagre edges (using layout order, not original) + const rfEdges: Edge[] = g + .edges() + .map((e: { v: string; w: string; name?: string }) => { + const edgeData = g.edge(e) as { + id?: string; + originalSource: string; + originalTarget: string; + }; + + // Check if either end is a finding node + const sourceNode = nodes.find((n) => n.id === e.v); + const targetNode = nodes.find((n) => n.id === e.w); + const hasFinding = + isFindingNode(sourceNode?.labels ?? []) || + isFindingNode(targetNode?.labels ?? []); + + return { + id: edgeData.id ?? e.name ?? `${e.v}-${e.w}`, + source: e.v, + target: e.w, + animated: hasFinding, + className: hasFinding ? "finding-edge" : "resource-edge", + data: { + pathKey: `${e.v}-${e.w}`, + originalSource: edgeData.originalSource, + originalTarget: edgeData.originalTarget, + }, + }; + }); + + return { rfNodes, rfEdges }; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts new file mode 100644 index 0000000000..cb5f70f06e --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.test.ts @@ -0,0 +1,474 @@ +import { + AlertTriangle, + Bot, + Braces, + CircleAlert, + FileKey2, + Globe2, + Info, + KeyRound, + Route, + Server, + Shield, + ShieldCheck, + Siren, + Tags, + UserCog, + Users, + Waypoints, +} from "lucide-react"; +import { describe, expect, it } from "vitest"; + +import { + AWSProviderBadge, + AzureProviderBadge, + GCPProviderBadge, + KS8ProviderBadge, +} from "@/components/icons/providers-badge"; +import { + AmazonEC2Icon, + AmazonRDSIcon, + AmazonS3Icon, + AmazonVPCIcon, + AWSIAMIcon, + AWSLambdaIcon, +} from "@/components/icons/services/IconServices"; +import type { GraphNode } from "@/types/attack-paths"; + +import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals"; + +const buildNode = (labels: string[], properties = {}): GraphNode => ({ + id: labels[0] ?? "unknown-node", + labels, + properties, +}); + +describe("resolveNodeVisual", () => { + describe("exact label mappings", () => { + it("should resolve AWSAccount nodes to account metadata", () => { + // Given + const node = buildNode(["AWSAccount"], { name: "Production" }); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.ACCOUNT, + displayName: "Production", + description: "AWS Account", + fallbackUsed: false, + }); + expect(visual.Icon).toBe(AWSProviderBadge); + }); + + it("should resolve cloud provider root nodes to provider badges", () => { + // Given + const providerNodes = [ + { + label: "AzureTenant", + description: "Azure Tenant", + Icon: AzureProviderBadge, + }, + { + label: "GCPProject", + description: "Google Cloud Project", + Icon: GCPProviderBadge, + }, + { + label: "KubernetesCluster", + description: "Kubernetes Cluster", + Icon: KS8ProviderBadge, + }, + ]; + + for (const providerNode of providerNodes) { + // When + const visual = resolveNodeVisual( + buildNode([providerNode.label], { name: providerNode.description }), + ); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.ACCOUNT, + displayName: providerNode.description, + description: providerNode.description, + fallbackUsed: false, + }); + expect(visual.Icon).toBe(providerNode.Icon); + } + }); + + it("should resolve S3Bucket nodes to storage metadata", () => { + // Given + const node = buildNode(["S3Bucket"], { name: "public-assets" }); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.STORAGE, + displayName: "public-assets", + description: "S3 Bucket", + fallbackUsed: false, + }); + expect(visual.Icon).toBe(AmazonS3Icon); + }); + + it.each([ + ["AWSAccount", NODE_CATEGORY.ACCOUNT, "AWS Account", AWSProviderBadge], + ["S3Bucket", NODE_CATEGORY.STORAGE, "S3 Bucket", AmazonS3Icon], + ["S3", NODE_CATEGORY.STORAGE, "S3", AmazonS3Icon], + ["VPC", NODE_CATEGORY.NETWORK, "VPC", AmazonVPCIcon], + ["Subnet", NODE_CATEGORY.NETWORK, "Subnet", Waypoints], + ["SecurityGroup", NODE_CATEGORY.NETWORK, "Security Group", Shield], + ["InternetGateway", NODE_CATEGORY.NETWORK, "Internet Gateway", Route], + ["DefaultGateway", NODE_CATEGORY.NETWORK, "Default Gateway", Route], + ["EC2Instance", NODE_CATEGORY.COMPUTE, "EC2 Instance", AmazonEC2Icon], + [ + "VirtualMachine", + NODE_CATEGORY.COMPUTE, + "Virtual Machine", + AmazonEC2Icon, + ], + ["Compute", NODE_CATEGORY.COMPUTE, "Compute", Server], + ["NIC", NODE_CATEGORY.COMPUTE, "NIC", Server], + ["IAMUser", NODE_CATEGORY.IDENTITY, "IAM User", AWSIAMIcon], + ["IAMRole", NODE_CATEGORY.IDENTITY, "IAM Role", AWSIAMIcon], + ["ServiceAccount", NODE_CATEGORY.IDENTITY, "Service Account", Bot], + ["AccessKey", NODE_CATEGORY.SECRET, "Access Key", KeyRound], + ["Secret", NODE_CATEGORY.SECRET, "Secret", KeyRound], + ] as const)( + "should map %s to %s with the expected icon", + (label, category, description, Icon) => { + // Given + const node = buildNode([label]); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual).toMatchObject({ + category, + description, + fallbackUsed: false, + }); + expect(visual.Icon).toBe(Icon); + }, + ); + + it("should resolve VPC nodes to network metadata", () => { + // Given + const node = buildNode(["VPC"], { name: "main-vpc" }); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.NETWORK, + displayName: "main-vpc", + description: "VPC", + fallbackUsed: false, + }); + expect(visual.Icon).toBe(AmazonVPCIcon); + }); + + it("should resolve ProwlerFinding nodes to finding metadata", () => { + // Given + const node = buildNode(["ProwlerFinding"], { + check_title: "S3 bucket is public", + }); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.FINDING, + displayName: "S3 bucket is public", + description: "Prowler Finding", + fallbackUsed: false, + }); + }); + + it("should resolve finding icons from severity", () => { + // Given + const findingNodes = [ + { severity: "critical", Icon: Siren }, + { severity: "high", Icon: AlertTriangle }, + { severity: "medium", Icon: CircleAlert }, + { severity: "low", Icon: Info }, + { severity: "informational", Icon: Info }, + ]; + + for (const findingNode of findingNodes) { + // When + const visual = resolveNodeVisual( + buildNode(["ProwlerFinding"], { + check_title: `${findingNode.severity} finding`, + severity: findingNode.severity, + }), + ); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.FINDING, + description: "Prowler Finding", + fallbackUsed: false, + }); + expect(visual.Icon).toBe(findingNode.Icon); + } + }); + + it("should use the generic alert icon when finding severity is unknown", () => { + // Given + const node = buildNode(["ProwlerFinding"], { + check_title: "Unknown risk", + severity: "unknown", + }); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual.Icon).toBe(AlertTriangle); + }); + + it("should resolve Internet nodes to internet metadata", () => { + // Given + const node = buildNode(["Internet"]); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.INTERNET, + displayName: "Internet", + description: "Internet", + fallbackUsed: false, + }); + }); + }); + + describe("alias and normalized mappings", () => { + it("should resolve IAMUser nodes to identity metadata with the AWS IAM icon", () => { + // Given + const node = buildNode(["IAMUser"], { name: "alice" }); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.IDENTITY, + displayName: "alice", + description: "IAM User", + fallbackUsed: false, + }); + expect(visual.Icon).toBe(AWSIAMIcon); + }); + + it("should resolve case-insensitive AccessKey labels to secret metadata", () => { + // Given + const node = buildNode(["access_key"], { id: "AKIA123" }); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.SECRET, + displayName: "AKIA123", + description: "Access Key", + fallbackUsed: false, + }); + }); + + it("should resolve AWS identity and policy labels to distinct icons", () => { + // Given + const identityNodes = [ + { + label: "AWSUser", + description: "AWS User", + Icon: UserCog, + }, + { + label: "AWSManagedPolicy", + description: "AWS Managed Policy", + Icon: FileKey2, + }, + { + label: "AWSPolicyStatement", + description: "AWS Policy Statement", + Icon: Braces, + }, + { + label: "PermissionRole", + description: "Permission Role", + Icon: ShieldCheck, + }, + ]; + + for (const identityNode of identityNodes) { + // When + const visual = resolveNodeVisual( + buildNode([identityNode.label], { name: identityNode.description }), + ); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.IDENTITY, + displayName: identityNode.description, + description: identityNode.description, + fallbackUsed: false, + }); + expect(visual.Icon).toBe(identityNode.Icon); + } + }); + + it("should resolve all AWS labels used by predefined Attack Paths queries", () => { + // Given + const awsQueryNodes = [ + { + label: "AWSTag", + category: NODE_CATEGORY.MISC, + description: "AWS Tag", + Icon: Tags, + }, + { + label: "EC2SecurityGroup", + category: NODE_CATEGORY.NETWORK, + description: "EC2 Security Group", + Icon: Shield, + }, + { + label: "IpPermissionInbound", + category: NODE_CATEGORY.NETWORK, + description: "Inbound IP Permission", + Icon: Shield, + }, + { + label: "IpRange", + category: NODE_CATEGORY.NETWORK, + description: "IP Range", + Icon: Globe2, + }, + { + label: "AWSPrincipal", + category: NODE_CATEGORY.IDENTITY, + description: "AWS Principal", + Icon: ShieldCheck, + }, + { + label: "AWSGroup", + category: NODE_CATEGORY.IDENTITY, + description: "AWS Group", + Icon: Users, + }, + { + label: "RDSInstance", + category: NODE_CATEGORY.STORAGE, + description: "RDS Instance", + Icon: AmazonRDSIcon, + }, + { + label: "LoadBalancer", + category: NODE_CATEGORY.NETWORK, + description: "Load Balancer", + Icon: Route, + }, + { + label: "ELBListener", + category: NODE_CATEGORY.NETWORK, + description: "ELB Listener", + Icon: Route, + }, + { + label: "LoadBalancerV2", + category: NODE_CATEGORY.NETWORK, + description: "Load Balancer V2", + Icon: Route, + }, + { + label: "ELBV2Listener", + category: NODE_CATEGORY.NETWORK, + description: "ELB V2 Listener", + Icon: Route, + }, + { + label: "ElasticIPAddress", + category: NODE_CATEGORY.NETWORK, + description: "Elastic IP Address", + Icon: Globe2, + }, + { + label: "EC2PrivateIp", + category: NODE_CATEGORY.NETWORK, + description: "EC2 Private IP", + Icon: Waypoints, + }, + { + label: "NetworkInterface", + category: NODE_CATEGORY.NETWORK, + description: "Network Interface", + Icon: Waypoints, + }, + { + label: "LaunchTemplate", + category: NODE_CATEGORY.COMPUTE, + description: "Launch Template", + Icon: Server, + }, + { + label: "AWSLambda", + category: NODE_CATEGORY.COMPUTE, + description: "AWS Lambda", + Icon: AWSLambdaIcon, + }, + { + label: "AWSSageMakerNotebookInstance", + category: NODE_CATEGORY.COMPUTE, + description: "SageMaker Notebook Instance", + Icon: Bot, + }, + ]; + + for (const awsQueryNode of awsQueryNodes) { + // When + const visual = resolveNodeVisual( + buildNode([awsQueryNode.label], { name: awsQueryNode.description }), + ); + + // Then + expect(visual).toMatchObject({ + category: awsQueryNode.category, + displayName: awsQueryNode.description, + description: awsQueryNode.description, + fallbackUsed: false, + }); + expect(visual.Icon).toBe(awsQueryNode.Icon); + } + }); + }); + + describe("fallback behavior", () => { + it("should use formatted labels for unknown nodes and mark the fallback", () => { + // Given + const node = buildNode(["CustomGraphNode"]); + + // When + const visual = resolveNodeVisual(node); + + // Then + expect(visual).toMatchObject({ + category: NODE_CATEGORY.MISC, + displayName: "Custom Graph Node", + description: "Custom Graph Node", + fallbackUsed: true, + }); + }); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts new file mode 100644 index 0000000000..34c7b15450 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/_lib/node-visuals.ts @@ -0,0 +1,522 @@ +import { + AlertTriangle, + Bot, + Box, + Braces, + CircleAlert, + FileKey2, + Globe2, + Info, + KeyRound, + Route, + Server, + Shield, + ShieldCheck, + Siren, + Tags, + UserCog, + Users, + Waypoints, +} from "lucide-react"; +import type { ElementType } from "react"; + +import { + AlibabaCloudProviderBadge, + AWSProviderBadge, + AzureProviderBadge, + CloudflareProviderBadge, + GCPProviderBadge, + GitHubProviderBadge, + GoogleWorkspaceProviderBadge, + IacProviderBadge, + ImageProviderBadge, + KS8ProviderBadge, + M365ProviderBadge, + MongoDBAtlasProviderBadge, + OpenStackProviderBadge, + OracleCloudProviderBadge, + VercelProviderBadge, +} from "@/components/icons/providers-badge"; +import { + AmazonEC2Icon, + AmazonRDSIcon, + AmazonS3Icon, + AmazonVPCIcon, + AWSIAMIcon, + AWSLambdaIcon, +} from "@/components/icons/services/IconServices"; +import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths"; + +import { formatNodeLabel } from "./format"; + +export const NODE_CATEGORY = { + FINDING: "finding", + INTERNET: "internet", + ACCOUNT: "account", + STORAGE: "storage", + NETWORK: "network", + COMPUTE: "compute", + IDENTITY: "identity", + SECRET: "secret", + MISC: "misc", +} as const; + +export type NodeCategory = (typeof NODE_CATEGORY)[keyof typeof NODE_CATEGORY]; + +interface KnownNodeVisualMapping { + category: NodeCategory; + description: string; + Icon: ElementType; +} + +export interface NodeVisual extends KnownNodeVisualMapping { + displayName: string; + fallbackUsed: boolean; +} + +const KNOWN_NODE_VISUALS = { + awsaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "AWS Account", + Icon: AWSProviderBadge, + }, + azureaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "Azure Account", + Icon: AzureProviderBadge, + }, + azuretenant: { + category: NODE_CATEGORY.ACCOUNT, + description: "Azure Tenant", + Icon: AzureProviderBadge, + }, + gcpaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "Google Cloud Account", + Icon: GCPProviderBadge, + }, + gcpproject: { + category: NODE_CATEGORY.ACCOUNT, + description: "Google Cloud Project", + Icon: GCPProviderBadge, + }, + googlecloudaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "Google Cloud Account", + Icon: GCPProviderBadge, + }, + kubernetescluster: { + category: NODE_CATEGORY.ACCOUNT, + description: "Kubernetes Cluster", + Icon: KS8ProviderBadge, + }, + k8scluster: { + category: NODE_CATEGORY.ACCOUNT, + description: "Kubernetes Cluster", + Icon: KS8ProviderBadge, + }, + githubaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "GitHub Account", + Icon: GitHubProviderBadge, + }, + githuborganization: { + category: NODE_CATEGORY.ACCOUNT, + description: "GitHub Organization", + Icon: GitHubProviderBadge, + }, + m365tenant: { + category: NODE_CATEGORY.ACCOUNT, + description: "Microsoft 365 Tenant", + Icon: M365ProviderBadge, + }, + googleworkspace: { + category: NODE_CATEGORY.ACCOUNT, + description: "Google Workspace", + Icon: GoogleWorkspaceProviderBadge, + }, + iac: { + category: NODE_CATEGORY.ACCOUNT, + description: "Infrastructure as Code", + Icon: IacProviderBadge, + }, + containerregistry: { + category: NODE_CATEGORY.ACCOUNT, + description: "Container Registry", + Icon: ImageProviderBadge, + }, + oraclecloudaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "Oracle Cloud Account", + Icon: OracleCloudProviderBadge, + }, + mongodbatlas: { + category: NODE_CATEGORY.ACCOUNT, + description: "MongoDB Atlas", + Icon: MongoDBAtlasProviderBadge, + }, + alibabacloudaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "Alibaba Cloud Account", + Icon: AlibabaCloudProviderBadge, + }, + cloudflareaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "Cloudflare Account", + Icon: CloudflareProviderBadge, + }, + openstackaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "OpenStack Account", + Icon: OpenStackProviderBadge, + }, + vercelaccount: { + category: NODE_CATEGORY.ACCOUNT, + description: "Vercel Account", + Icon: VercelProviderBadge, + }, + s3bucket: { + category: NODE_CATEGORY.STORAGE, + description: "S3 Bucket", + Icon: AmazonS3Icon, + }, + s3: { + category: NODE_CATEGORY.STORAGE, + description: "S3", + Icon: AmazonS3Icon, + }, + vpc: { + category: NODE_CATEGORY.NETWORK, + description: "VPC", + Icon: AmazonVPCIcon, + }, + subnet: { + category: NODE_CATEGORY.NETWORK, + description: "Subnet", + Icon: Waypoints, + }, + securitygroup: { + category: NODE_CATEGORY.NETWORK, + description: "Security Group", + Icon: Shield, + }, + ec2securitygroup: { + category: NODE_CATEGORY.NETWORK, + description: "EC2 Security Group", + Icon: Shield, + }, + ippermissioninbound: { + category: NODE_CATEGORY.NETWORK, + description: "Inbound IP Permission", + Icon: Shield, + }, + iprange: { + category: NODE_CATEGORY.NETWORK, + description: "IP Range", + Icon: Globe2, + }, + elasticipaddress: { + category: NODE_CATEGORY.NETWORK, + description: "Elastic IP Address", + Icon: Globe2, + }, + ec2privateip: { + category: NODE_CATEGORY.NETWORK, + description: "EC2 Private IP", + Icon: Waypoints, + }, + networkinterface: { + category: NODE_CATEGORY.NETWORK, + description: "Network Interface", + Icon: Waypoints, + }, + internetgateway: { + category: NODE_CATEGORY.NETWORK, + description: "Internet Gateway", + Icon: Route, + }, + defaultgateway: { + category: NODE_CATEGORY.NETWORK, + description: "Default Gateway", + Icon: Route, + }, + loadbalancer: { + category: NODE_CATEGORY.NETWORK, + description: "Load Balancer", + Icon: Route, + }, + loadbalancerv2: { + category: NODE_CATEGORY.NETWORK, + description: "Load Balancer V2", + Icon: Route, + }, + elblistener: { + category: NODE_CATEGORY.NETWORK, + description: "ELB Listener", + Icon: Route, + }, + elbv2listener: { + category: NODE_CATEGORY.NETWORK, + description: "ELB V2 Listener", + Icon: Route, + }, + ec2instance: { + category: NODE_CATEGORY.COMPUTE, + description: "EC2 Instance", + Icon: AmazonEC2Icon, + }, + launchtemplate: { + category: NODE_CATEGORY.COMPUTE, + description: "Launch Template", + Icon: Server, + }, + awslambda: { + category: NODE_CATEGORY.COMPUTE, + description: "AWS Lambda", + Icon: AWSLambdaIcon, + }, + awssagemakernotebookinstance: { + category: NODE_CATEGORY.COMPUTE, + description: "SageMaker Notebook Instance", + Icon: Bot, + }, + virtualmachine: { + category: NODE_CATEGORY.COMPUTE, + description: "Virtual Machine", + Icon: AmazonEC2Icon, + }, + rdsinstance: { + category: NODE_CATEGORY.STORAGE, + description: "RDS Instance", + Icon: AmazonRDSIcon, + }, + compute: { + category: NODE_CATEGORY.COMPUTE, + description: "Compute", + Icon: Server, + }, + nic: { + category: NODE_CATEGORY.COMPUTE, + description: "NIC", + Icon: Server, + }, + iamuser: { + category: NODE_CATEGORY.IDENTITY, + description: "IAM User", + Icon: AWSIAMIcon, + }, + awsuser: { + category: NODE_CATEGORY.IDENTITY, + description: "AWS User", + Icon: UserCog, + }, + awsgroup: { + category: NODE_CATEGORY.IDENTITY, + description: "AWS Group", + Icon: Users, + }, + awsprincipal: { + category: NODE_CATEGORY.IDENTITY, + description: "AWS Principal", + Icon: ShieldCheck, + }, + iamrole: { + category: NODE_CATEGORY.IDENTITY, + description: "IAM Role", + Icon: AWSIAMIcon, + }, + awsrole: { + category: NODE_CATEGORY.IDENTITY, + description: "AWS Role", + Icon: ShieldCheck, + }, + permissionrole: { + category: NODE_CATEGORY.IDENTITY, + description: "Permission Role", + Icon: ShieldCheck, + }, + awsmanagedpolicy: { + category: NODE_CATEGORY.IDENTITY, + description: "AWS Managed Policy", + Icon: FileKey2, + }, + awspolicy: { + category: NODE_CATEGORY.IDENTITY, + description: "AWS Policy", + Icon: FileKey2, + }, + policy: { + category: NODE_CATEGORY.IDENTITY, + description: "Policy", + Icon: FileKey2, + }, + awspolicystatement: { + category: NODE_CATEGORY.IDENTITY, + description: "AWS Policy Statement", + Icon: Braces, + }, + policystatement: { + category: NODE_CATEGORY.IDENTITY, + description: "Policy Statement", + Icon: Braces, + }, + accesskey: { + category: NODE_CATEGORY.SECRET, + description: "Access Key", + Icon: KeyRound, + }, + secret: { + category: NODE_CATEGORY.SECRET, + description: "Secret", + Icon: KeyRound, + }, + serviceaccount: { + category: NODE_CATEGORY.IDENTITY, + description: "Service Account", + Icon: Bot, + }, + awstag: { + category: NODE_CATEGORY.MISC, + description: "AWS Tag", + Icon: Tags, + }, +} as const satisfies Record; + +type KnownNodeLabel = keyof typeof KNOWN_NODE_VISUALS; + +const normalizeLabel = (label: string): string => + label.toLowerCase().replace(/[^a-z0-9]/g, ""); + +const isKnownNodeLabel = (label: string): label is KnownNodeLabel => + label in KNOWN_NODE_VISUALS; + +const isFindingLabel = (label: string): boolean => + normalizeLabel(label).includes("finding"); + +const isInternetLabel = (label: string): boolean => + normalizeLabel(label) === "internet"; + +const FINDING_SEVERITY = { + CRITICAL: "critical", + HIGH: "high", + MEDIUM: "medium", + LOW: "low", + INFO: "info", + INFORMATIONAL: "informational", +} as const; + +type FindingSeverity = (typeof FINDING_SEVERITY)[keyof typeof FINDING_SEVERITY]; + +const FINDING_SEVERITY_ICONS = { + [FINDING_SEVERITY.CRITICAL]: Siren, + [FINDING_SEVERITY.HIGH]: AlertTriangle, + [FINDING_SEVERITY.MEDIUM]: CircleAlert, + [FINDING_SEVERITY.LOW]: Info, + [FINDING_SEVERITY.INFO]: Info, + [FINDING_SEVERITY.INFORMATIONAL]: Info, +} as const satisfies Record; + +const stringifyProperty = ( + value: GraphNodePropertyValue, +): string | undefined => { + if (value === null || value === undefined) return undefined; + if (Array.isArray(value)) return value.join(", "); + return String(value); +}; + +const firstDefinedProperty = ( + node: GraphNode, + keys: string[], +): string | undefined => { + for (const key of keys) { + const value = stringifyProperty(node.properties[key]); + if (value) return value; + } + + return undefined; +}; + +const getPrimaryFormattedLabel = (node: GraphNode): string => { + const primaryLabel = node.labels[0]; + if (!primaryLabel) return "Unknown"; + return formatNodeLabel(primaryLabel.replace(/[_-]/g, " ")); +}; + +const resolveDisplayName = (node: GraphNode): string => + firstDefinedProperty(node, ["name", "display_name", "title", "id"]) ?? + getPrimaryFormattedLabel(node); + +const resolveFindingDisplayName = (node: GraphNode): string => + firstDefinedProperty(node, ["check_title", "title", "name", "id"]) ?? + getPrimaryFormattedLabel(node); + +const resolveFindingSeverity = ( + node: GraphNode, +): FindingSeverity | undefined => { + const severity = firstDefinedProperty(node, ["severity"]); + if (!severity) return undefined; + + const normalizedSeverity = severity.toLowerCase(); + return normalizedSeverity in FINDING_SEVERITY_ICONS + ? (normalizedSeverity as FindingSeverity) + : undefined; +}; + +const resolveFindingIcon = (node: GraphNode): ElementType => { + const severity = resolveFindingSeverity(node); + return severity ? FINDING_SEVERITY_ICONS[severity] : AlertTriangle; +}; + +const resolveKnownMapping = ( + labels: string[], +): KnownNodeVisualMapping | undefined => { + for (const label of labels) { + const normalizedLabel = normalizeLabel(label); + if (isKnownNodeLabel(normalizedLabel)) { + return KNOWN_NODE_VISUALS[normalizedLabel]; + } + } + + return undefined; +}; + +export const resolveNodeVisual = (node: GraphNode): NodeVisual => { + if (node.labels.some(isFindingLabel)) { + return { + category: NODE_CATEGORY.FINDING, + displayName: resolveFindingDisplayName(node), + description: "Prowler Finding", + Icon: resolveFindingIcon(node), + fallbackUsed: false, + }; + } + + if (node.labels.some(isInternetLabel)) { + return { + category: NODE_CATEGORY.INTERNET, + displayName: "Internet", + description: "Internet", + Icon: Globe2, + fallbackUsed: false, + }; + } + + const knownMapping = resolveKnownMapping(node.labels); + if (knownMapping) { + return { + ...knownMapping, + displayName: resolveDisplayName(node), + fallbackUsed: false, + }; + } + + const fallbackLabel = getPrimaryFormattedLabel(node); + + return { + category: NODE_CATEGORY.MISC, + displayName: resolveDisplayName(node), + description: fallbackLabel, + Icon: Box, + fallbackUsed: true, + }; +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx new file mode 100644 index 0000000000..1d9ea021ca --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.browser.test.tsx @@ -0,0 +1,620 @@ +/** + * Browser-mode tests for . + * + * Tests are grouped by user-perceived flow, not by internal spec taxonomy. Each + * test interacts with the page ONLY through `AttackPathPageHarness`. Each test: + * 1. picks a fixture + * 2. calls `mountWith(fx)` — wires MSW handlers, sets the URL, mounts the page + * 3. drives the harness + * + * If you find yourself reaching for a DOM query in a test, push it into the harness. + */ + +import { beforeEach, describe, expect, test as base, vi } from "vitest"; + +import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths"; +import { worker } from "@/__tests__/msw/worker"; +import { render } from "@/__tests__/render-browser"; + +const { getFindingByIdMock } = vi.hoisted(() => ({ + getFindingByIdMock: vi.fn(), +})); + +vi.mock("@/actions/findings", async () => { + const actual = + await vi.importActual( + "@/actions/findings", + ); + + getFindingByIdMock.mockImplementation(actual.getFindingById); + + return { + ...actual, + getFindingById: getFindingByIdMock, + }; +}); + +import { useGraphStore } from "./_hooks/use-graph-state"; +import { getPathEdges } from "./_lib"; +import { isFindingNode, layoutWithDagre } from "./_lib/layout"; +import AttackPathsPage from "./attack-paths-page"; +import { fixtures, type PageFixture } from "./attack-paths-page.fixtures"; +import { AttackPathPageHarness } from "./attack-paths-page.harness"; + +interface Fixtures { + mountWith: (fx?: PageFixture) => Promise; +} + +// The graph store is module-scoped, so it survives across tests in the same +// file. Reset it before each test so no test sees stale state from a previous +// one (selection, filtered view, expanded resources, etc.). +beforeEach(() => { + useGraphStore.getState().reset(); + getFindingByIdMock.mockClear(); +}); + +const test = base.extend({ + mountWith: async ({}, use) => { + // `use` is Vitest's fixture-injection callback, not React's `use` hook. + // eslint-disable-next-line react-hooks/rules-of-hooks + await use(async (fx = fixtures.typical()) => { + worker.use(...handlersForFixture(fx)); + window.history.replaceState({}, "", `/attack-paths?scanId=${fx.scanId}`); + await render(); + return new AttackPathPageHarness(fx); + }); + }, +}); + +describe("loading the page", () => { + test("an account with no scans shows the empty state", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.emptyScans()); + expect(await graph.emptyStateMessage()).toMatch(/No scans available/i); + }); +}); + +describe("running a query", () => { + test("the graph renders with a background, a minimap, and a viewport", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + expect(graph.background).toBeTruthy(); + expect(graph.minimap).toBeTruthy(); + expect(graph.viewport).toBeTruthy(); + }); + + test("nodes are laid out at distinct positions", async ({ mountWith }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + const positions = graph.nodePositions; + expect(positions.some((p) => p.x !== 0 || p.y !== 0)).toBe(true); + }); + + test("the toolbar exposes zoom, fit, and export controls", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(1); + + expect(graph.toolbar.zoomInButton).toBeTruthy(); + expect(graph.toolbar.zoomOutButton).toBeTruthy(); + expect(graph.toolbar.fitButton).toBeTruthy(); + expect(graph.toolbar.exportButton).toBeTruthy(); + }); + + test("finding, resource, and internet nodes all render", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + + expect(graph.findingNodes.length).toBeGreaterThan(0); + expect(graph.resourceNodes.length).toBeGreaterThan(0); + expect(graph.internetNodes.length).toBeGreaterThan(0); + }); + + test("only edges connected to a finding are animated", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + + expect(graph.findingEdges.length).toBeGreaterThan(0); + expect(graph.resourceEdges.length).toBeGreaterThan(0); + }); + + test("edges connect string source and target ids", async ({ mountWith }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(2); + + const edgeIds = graph.renderedEdgeIds; + expect(edgeIds.length).toBeGreaterThan(0); + expect(new Set(edgeIds).size).toBe(edgeIds.length); + for (const id of edgeIds) { + expect(id.length).toBeGreaterThan(0); + } + }); + + test("a query that returns one node renders just that node", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.singleNode()); + await graph.executeQuery(); + await graph.waitForLayoutStable(1); + expect(graph.nodes).toHaveLength(1); + }); + + test("a query that returns no graph data surfaces an error without crashing", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.emptyGraph()); + try { + await graph.executeQuery(); + } catch { + /* expected: layout never stabilizes */ + } + expect(graph.nodes).toHaveLength(0); + }); + + test("a 200-node graph finishes laying out within 5s", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.large(200)); + const start = performance.now(); + await graph.executeQuery(); + await graph.waitForLayoutStable(1); + const elapsed = performance.now() - start; + expect(elapsed).toBeLessThan(5000); + }); + + test("disconnected components are both visible", async ({ mountWith }) => { + const graph = await mountWith(fixtures.disconnected()); + await graph.executeQuery(); + await graph.waitForLayoutStable(4); + expect(graph.nodes.length).toBe(4); + }); + + test("a query that returns only resources renders no findings", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.resourcesOnly()); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + expect(graph.findingNodes.length).toBe(0); + expect(graph.resourceNodes.length).toBe(3); + }); + + test("findings without a connected resource stay visible in the full graph", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.findingsOnly()); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + expect(graph.findingNodes.length).toBe(3); + expect(graph.resourceNodes.length).toBe(0); + }); + + test("hidden findings do not reserve layout space until expanded", async ({ + mountWith, + }) => { + // Given - a graph whose findings are hidden in the initial tier-1 view. + // The initial rendered positions should match a layout computed from only + // visible resources/edges, not from hidden finding nodes. + const fixture = fixtures.typical(); + if (!fixture.queryResult) throw new Error("Expected graph fixture data"); + + const visibleNodes = fixture.queryResult.nodes.filter( + (node) => !isFindingNode(node.labels), + ); + const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); + const visibleEdges = (fixture.queryResult.relationships ?? []) + .filter( + (edge) => + visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target), + ) + .map((edge) => ({ + ...edge, + type: edge.label, + })); + const expectedPositions = Object.fromEntries( + layoutWithDagre(visibleNodes, visibleEdges).rfNodes.map((node) => [ + node.id, + node.position, + ]), + ); + + const graph = await mountWith(fixture); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + // Then - hidden findings do not influence initial resource coordinates. + for (const node of visibleNodes) { + expect(graph.nodePositionsById[node.id]).toEqual( + expectedPositions[node.id], + ); + } + }); + + test("self-loops, cycles, long labels, unicode, and duplicate edges all render", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.edgeCases()); + await graph.executeQuery(); + await graph.waitForLayoutStable(5); + + expect(graph.nodes.length).toBe(7); + expect(graph.containsText(/🔒-secure-bucket-日本語/)).toBe(true); + }); +}); + +describe("exploring the graph", () => { + test("clicking a finding opens the filtered view and finding details", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + + expect(graph.isInFilteredView).toBe(false); + await graph.clickFirstFindingNode(); + + expect(graph.isInFilteredView).toBe(true); + expect(getFindingByIdMock).toHaveBeenCalledTimes(1); + expect(graph.hasNodeDetailsModal).toBe(false); + expect(graph.hasNodeActionDialog).toBe(false); + }); + + test("clicking a resource with findings directly reveals related finding nodes", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + expect(graph.findingNodes.length).toBe(0); + expect(graph.hasNodeDetailsModal).toBe(false); + + await graph.clickFirstResourceNode(); + + expect(graph.findingNodes.length).toBeGreaterThan(0); + expect(graph.hasNodeDetailsModal).toBe(false); + expect(graph.hasNodeActionDialog).toBe(false); + }); + + test("clicking an expanded resource with findings hides its related finding nodes", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + await graph.clickFirstResourceNode(); + expect(graph.findingNodes.length).toBeGreaterThan(0); + + await graph.clickFirstResourceNode(); + + expect(graph.findingNodes.length).toBe(0); + expect(graph.hasNodeDetailsModal).toBe(false); + expect(graph.hasNodeActionDialog).toBe(false); + }); + + test("clicking a resource with findings re-fits around the resource and its findings", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + const initialViewport = graph.viewportTransform; + + await graph.clickFirstResourceNode(); + + expect(graph.findingNodes.length).toBeGreaterThan(0); + await graph.waitFor( + () => graph.viewportTransform !== initialViewport, + 2000, + ); + + const contextualViewport = graph.viewportTransform; + + await graph.fit(); + + await graph.waitFor( + () => graph.viewportTransform !== contextualViewport, + 2000, + ); + }); + test("clicking an expanded resource re-fits the remaining visible graph", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + await graph.clickFirstResourceNode(); + expect(graph.findingNodes.length).toBeGreaterThan(0); + await graph.waitForTransition(); + + const expandedViewport = graph.viewportTransform; + + await graph.clickFirstResourceNode(); + + expect(graph.findingNodes.length).toBe(0); + await graph.waitFor( + () => graph.viewportTransform !== expandedViewport, + 2000, + ); + }); + + test("returning from a finding keeps the expanded findings context fitted", async ({ + mountWith, + }) => { + const graph = await mountWith(fixtures.large(20)); + await graph.executeQuery(); + await graph.waitForLayoutStable(16); + + await graph.clickFirstResourceNode(); + expect(graph.findingNodes.length).toBeGreaterThan(0); + await graph.waitForTransition(); + + await graph.clickFirstFindingNode(); + expect(graph.isInFilteredView).toBe(true); + + await graph.exitFilteredView(); + + expect(graph.isInFilteredView).toBe(false); + await graph.waitForTransition(); + + expect(graph.findingNodes.length).toBeGreaterThan(0); + expect(graph.viewportTransform).toBeTruthy(); + }); + test("clicking a resource without findings does nothing", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + expect(graph.hasNodeDetailsModal).toBe(false); + expect(graph.hasNodeActionDialog).toBe(false); + expect(graph.findingNodes.length).toBe(0); + + await graph.clickFirstResourceNodeWithoutFindings(); + + expect(graph.findingNodes.length).toBe(0); + expect(graph.hasNodeDetailsModal).toBe(false); + expect(graph.hasNodeActionDialog).toBe(false); + }); + + test("exiting the filtered view restores the full graph", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + + const fullNodes = graph.nodes.length; + await graph.clickFirstFindingNode(); + await graph.exitFilteredView(); + await graph.waitForLayoutStable(fullNodes); + expect(graph.isInFilteredView).toBe(false); + }); + + test("hovering a node highlights its path edges", async ({ mountWith }) => { + const fixture = fixtures.typical(); + const graph = await mountWith(fixture); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + const hoveredNodeId = graph.resourceNodes[0]?.getAttribute("data-id"); + expect(hoveredNodeId).toBeTruthy(); + + const findingIds = new Set( + (fixture.queryResult?.nodes ?? []) + .filter((node) => isFindingNode(node.labels)) + .map((node) => node.id), + ); + const visibleEdges = (fixture.queryResult?.relationships ?? []) + .filter( + (edge) => !findingIds.has(edge.source) && !findingIds.has(edge.target), + ) + .map((edge) => ({ sourceId: edge.source, targetId: edge.target })); + const expectedPathKeys = getPathEdges(hoveredNodeId ?? "", visibleEdges); + const expectedHighlightedIds = (fixture.queryResult?.relationships ?? []) + .filter((edge) => expectedPathKeys.has(`${edge.source}-${edge.target}`)) + .map((edge) => edge.id) + .sort(); + + await graph.hoverFirstResourceNode(); + await graph.waitForTransition(120); + + expect( + graph.highlightedEdges.map((edge) => edge.dataset.id ?? "").sort(), + ).toEqual(expectedHighlightedIds); + + await graph.unhoverNodes(); + await graph.waitForTransition(120); + expect(graph.highlightedEdges.length).toBe(0); + }); + + test("selecting a node keeps its path edges highlighted", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + await graph.clickFirstResourceNodeWithoutFindings(); + + expect(graph.highlightedEdges.length).toBeGreaterThan(0); + }); + + test("clicking the empty canvas keeps the full graph", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + await graph.clickEmptyCanvas(); + expect(graph.isInFilteredView).toBe(false); + }); + + test("rapid clicks on a finding don't duplicate the filtered view", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + + await graph.rapidlyClickFirstFindingNode(2); + + expect(graph.isInFilteredView).toBe(true); + expect(getFindingByIdMock).toHaveBeenCalledTimes(1); + }); + + test("double-clicking a node doesn't break state", async ({ mountWith }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + await graph.dblClickFirstResourceNode(); + expect(graph.nodes.length).toBeGreaterThan(0); + }); +}); + +describe("auto-fitting the viewport", () => { + test("the minimap viewport indicator has a visible border", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + expect(graph.minimapMaskStrokeWidth).toBeGreaterThan(0); + }); + + test("expanding resources re-fits the viewport when revealed findings fall off-screen", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + // Given - zoom into the current overview so newly revealed findings can + // sit entirely outside the current frame. The expand auto-fit should then + // recover the user instead of leaving them hunting off-screen. + for (let i = 0; i < 5; i++) { + await graph.zoomIn(); + await graph.waitForTransition(80); + } + // Hidden findings are not measured by the initial declarative fit, so + // their positions can sit outside the framed viewport. Expanding the + // resources should re-fit so the user does not have to hunt for the + // newly visible findings off-screen. + const before = graph.viewportTransform; + expect(before).toBeTruthy(); + + await graph.expandAllFindings(); + await graph.waitForTransition(); + + expect(graph.viewportTransform).not.toBe(before); + }); + + test("clicking a finding re-fits the viewport for the filtered subgraph", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + + const beforeFilter = graph.viewportTransform; + expect(beforeFilter).toBeTruthy(); + + await graph.clickFirstFindingNode(); + expect(graph.isInFilteredView).toBe(true); + await graph.waitForTransition(); + + expect(graph.viewportTransform).not.toBe(beforeFilter); + }); + + test("Back to Full View re-fits the viewport for the full graph", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + await graph.clickFirstFindingNode(); + expect(graph.isInFilteredView).toBe(true); + await graph.waitForTransition(); + const filterT = graph.viewportTransform; + + await graph.exitFilteredView(); + await graph.waitForLayoutStable(3); + await graph.waitForTransition(); + + expect(graph.viewportTransform).not.toBe(filterT); + }); +}); + +describe("exporting the graph", () => { + test("the export button is enabled when a graph is rendered", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + + expect(graph.toolbar.isExportButtonEnabled).toBe(true); + }); + + test("clicking export downloads a PNG sized to the configured export canvas", async ({ + mountWith, + }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + + const png = await graph.captureExportPNG(); + + expect(png.filename).toBe("attack-path-graph.png"); + expect(png.mimeType).toBe("image/png"); + expect(png.width).toBe(1920); + expect(png.height).toBe(1080); + }); +}); + +describe("running a different query", () => { + test("the previous filtered view is cleared", async ({ mountWith }) => { + const graph = await mountWith(); + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + await graph.expandAllFindings(); + await graph.clickFirstFindingNode(); + expect(graph.isInFilteredView).toBe(true); + + await graph.executeQuery(); + await graph.waitForLayoutStable(3); + expect(graph.isInFilteredView).toBe(false); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts new file mode 100644 index 0000000000..37e2170355 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures.ts @@ -0,0 +1,279 @@ +/** + * Typed fixture builders for browser tests. + * + * Each builder returns a self-contained snapshot of the API surface the page + * exercises: scans list, available queries, and query execution result. The + * MSW handler factory in the harness turns a fixture into HTTP mocks. + */ + +import type { + AttackPathQuery, + AttackPathScan, + GraphNode, + GraphRelationship, + QueryResultAttributes, +} from "@/types/attack-paths"; + +export interface PageFixture { + scans: AttackPathScan[]; + scanId: string; + queries: AttackPathQuery[]; + queryId: string; + queryResult: QueryResultAttributes | null; + queryError?: { status: number; error: string }; +} + +const TYPICAL_SCAN_ID = "11111111-1111-4111-8111-111111111111"; +const SECOND_SCAN_ID = "22222222-2222-4222-8222-222222222222"; + +const DEFAULT_QUERY_ID = "aws-public-s3-buckets"; + +const buildScan = ( + id: string, + overrides: Partial = {}, +): AttackPathScan => ({ + type: "attack-paths-scans", + id, + attributes: { + state: "completed", + progress: 100, + graph_data_ready: true, + provider_alias: `Provider ${id.slice(0, 4)}`, + provider_type: "aws", + provider_uid: `123456789${id.slice(0, 3)}`, + inserted_at: "2026-04-21T10:00:00Z", + started_at: "2026-04-21T10:00:00Z", + completed_at: "2026-04-21T10:05:00Z", + duration: 300, + ...overrides, + }, + relationships: { + provider: { data: { type: "providers", id: `provider-${id}` } }, + scan: { data: { type: "scans", id: `base-scan-${id}` } }, + task: { data: { type: "tasks", id: `task-${id}` } }, + }, +}); + +const buildQuery = ( + id: string, + name: string, + overrides: Partial = {}, +): AttackPathQuery => ({ + type: "attack-paths-scans", + id, + attributes: { + name, + short_description: `Run the ${name} query`, + description: `Detailed description for ${name}.`, + provider: "aws", + parameters: [], + attribution: null, + documentation_link: null, + ...overrides, + }, +}); + +const buildResourceNode = ( + id: string, + label: string, + name: string, + extraLabels: string[] = [], +): GraphNode => ({ + id, + labels: [label, ...extraLabels], + properties: { id, name, arn: `arn:aws:example::${id}` }, +}); + +const buildFindingNode = ( + id: string, + title: string, + severity = "high", +): GraphNode => ({ + id, + labels: ["ProwlerFinding"], + properties: { id, check_title: title, severity, status: "FAIL" }, +}); + +const buildInternetNode = (): GraphNode => ({ + id: "internet", + labels: ["Internet"], + properties: { id: "internet", name: "Internet" }, +}); + +const buildRel = ( + id: string, + source: string, + target: string, + label: string, +): GraphRelationship => ({ id, source, target, label }); + +export const typical = (): PageFixture => { + const nodes: GraphNode[] = [ + buildInternetNode(), + buildResourceNode("ec2-1", "EC2Instance", "api-server-01"), + buildResourceNode("s3-1", "S3Bucket", "private-data-bucket"), + buildResourceNode("iam-1", "IAMRole", "AppRole"), + buildFindingNode("f-1", "S3 bucket is public", "critical"), + buildFindingNode("f-2", "EC2 exposed to internet", "high"), + ]; + const relationships: GraphRelationship[] = [ + buildRel("r1", "internet", "ec2-1", "CAN_REACH"), + buildRel("r2", "ec2-1", "s3-1", "CAN_ACCESS"), + buildRel("r3", "ec2-1", "iam-1", "ASSUMES"), + buildRel("r4", "s3-1", "f-1", "HAS_FINDING"), + buildRel("r5", "ec2-1", "f-2", "HAS_FINDING"), + ]; + return { + scans: [buildScan(TYPICAL_SCAN_ID), buildScan(SECOND_SCAN_ID)], + scanId: TYPICAL_SCAN_ID, + queries: [ + buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets"), + buildQuery("aws-open-security-groups", "Open security groups"), + ], + queryId: DEFAULT_QUERY_ID, + queryResult: { nodes, relationships }, + }; +}; + +export const emptyScans = (): PageFixture => ({ + scans: [], + scanId: TYPICAL_SCAN_ID, + queries: [], + queryId: DEFAULT_QUERY_ID, + queryResult: null, +}); + +export const emptyGraph = (): PageFixture => ({ + scans: [buildScan(TYPICAL_SCAN_ID)], + scanId: TYPICAL_SCAN_ID, + queries: [buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets")], + queryId: DEFAULT_QUERY_ID, + queryResult: null, + queryError: { status: 404, error: "No data found" }, +}); + +export const singleNode = (): PageFixture => ({ + scans: [buildScan(TYPICAL_SCAN_ID)], + scanId: TYPICAL_SCAN_ID, + queries: [buildQuery(DEFAULT_QUERY_ID, "Public S3 buckets")], + queryId: DEFAULT_QUERY_ID, + queryResult: { + nodes: [buildResourceNode("only-1", "S3Bucket", "solitary-bucket")], + relationships: [], + }, +}); + +export const findingsOnly = (): PageFixture => ({ + scans: [buildScan(TYPICAL_SCAN_ID)], + scanId: TYPICAL_SCAN_ID, + queries: [buildQuery(DEFAULT_QUERY_ID, "Findings only")], + queryId: DEFAULT_QUERY_ID, + queryResult: { + nodes: [ + buildFindingNode("f-1", "Finding A", "critical"), + buildFindingNode("f-2", "Finding B", "high"), + buildFindingNode("f-3", "Finding C", "medium"), + ], + relationships: [], + }, +}); + +export const resourcesOnly = (): PageFixture => ({ + scans: [buildScan(TYPICAL_SCAN_ID)], + scanId: TYPICAL_SCAN_ID, + queries: [buildQuery(DEFAULT_QUERY_ID, "Resources only")], + queryId: DEFAULT_QUERY_ID, + queryResult: { + nodes: [ + buildResourceNode("ec2-1", "EC2Instance", "web-1"), + buildResourceNode("ec2-2", "EC2Instance", "web-2"), + buildResourceNode("s3-1", "S3Bucket", "logs"), + ], + relationships: [ + buildRel("r1", "ec2-1", "s3-1", "CAN_ACCESS"), + buildRel("r2", "ec2-2", "s3-1", "CAN_ACCESS"), + ], + }, +}); + +export const disconnected = (): PageFixture => ({ + scans: [buildScan(TYPICAL_SCAN_ID)], + scanId: TYPICAL_SCAN_ID, + queries: [buildQuery(DEFAULT_QUERY_ID, "Disconnected components")], + queryId: DEFAULT_QUERY_ID, + queryResult: { + nodes: [ + buildResourceNode("a-1", "EC2Instance", "alpha-ec2"), + buildResourceNode("a-2", "S3Bucket", "alpha-s3"), + buildResourceNode("b-1", "EC2Instance", "beta-ec2"), + buildResourceNode("b-2", "S3Bucket", "beta-s3"), + ], + relationships: [ + buildRel("r1", "a-1", "a-2", "CAN_ACCESS"), + buildRel("r2", "b-1", "b-2", "CAN_ACCESS"), + ], + }, +}); + +export const large = (count = 200): PageFixture => { + const nodes: GraphNode[] = []; + const relationships: GraphRelationship[] = []; + for (let i = 0; i < count; i++) { + const id = `n-${i}`; + if (i % 5 === 0) { + nodes.push(buildFindingNode(id, `Finding ${i}`, "high")); + } else { + nodes.push(buildResourceNode(id, "EC2Instance", `instance-${i}`)); + } + if (i > 0) { + relationships.push(buildRel(`r-${i}`, `n-${i - 1}`, id, "CAN_REACH")); + } + } + return { + scans: [buildScan(TYPICAL_SCAN_ID)], + scanId: TYPICAL_SCAN_ID, + queries: [buildQuery(DEFAULT_QUERY_ID, "Large graph")], + queryId: DEFAULT_QUERY_ID, + queryResult: { nodes, relationships }, + }; +}; + +export const edgeCases = (): PageFixture => { + const longLabel = + "a very long resource name that should be truncated ".repeat(4); + const nodes: GraphNode[] = [ + buildResourceNode("self-1", "EC2Instance", "self-loop"), + buildResourceNode("cy-1", "EC2Instance", "cycle-a"), + buildResourceNode("cy-2", "EC2Instance", "cycle-b"), + buildResourceNode("long-1", "EC2Instance", longLabel), + buildResourceNode("emoji-1", "S3Bucket", "🔒-secure-bucket-日本語"), + buildResourceNode("dup-a", "EC2Instance", "dup-source"), + buildResourceNode("dup-b", "S3Bucket", "dup-target"), + ]; + const relationships: GraphRelationship[] = [ + buildRel("self-edge", "self-1", "self-1", "REFERS_TO"), + buildRel("cy-a", "cy-1", "cy-2", "CAN_REACH"), + buildRel("cy-b", "cy-2", "cy-1", "CAN_REACH"), + buildRel("dup-1", "dup-a", "dup-b", "CAN_ACCESS"), + buildRel("dup-2", "dup-a", "dup-b", "CAN_ACCESS"), + ]; + return { + scans: [buildScan(TYPICAL_SCAN_ID)], + scanId: TYPICAL_SCAN_ID, + queries: [buildQuery(DEFAULT_QUERY_ID, "Edge cases")], + queryId: DEFAULT_QUERY_ID, + queryResult: { nodes, relationships }, + }; +}; + +export const fixtures = { + typical, + emptyScans, + emptyGraph, + singleNode, + findingsOnly, + resourcesOnly, + disconnected, + large, + edgeCases, +}; diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.browser.test.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.browser.test.ts new file mode 100644 index 0000000000..7b051403d0 --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.browser.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; + +import { fixtures } from "./attack-paths-page.fixtures"; +import { AttackPathPageHarness } from "./attack-paths-page.harness"; + +describe("AttackPathPageHarness", () => { + it("should fail graph node clicks when browser pointer interaction fails", async () => { + // Given - A rendered graph node whose pointer interaction fails + const harness = new AttackPathPageHarness(fixtures.typical()); + const node = document.createElement("div"); + node.className = "react-flow__node react-flow__node-resource"; + node.setAttribute("data-id", "ec2-1"); + document.body.append(node); + + const pointerError = new Error("pointer intercepted"); + const clickSpy = vi + .spyOn(harness.user, "click") + .mockRejectedValue(pointerError); + const domClickSpy = vi.spyOn(node, "click"); + + // When / Then + await expect(harness.clickNode("ec2-1")).rejects.toThrow( + "pointer intercepted", + ); + expect(clickSpy).toHaveBeenCalledWith(node); + expect(domClickSpy).not.toHaveBeenCalled(); + }); + + it("should dispatch rapid finding clicks synchronously for race tests", async () => { + // Given - A rendered finding node and a harness rapid-click helper + const harness = new AttackPathPageHarness(fixtures.typical()); + const finding = document.createElement("button"); + finding.className = "react-flow__node react-flow__node-finding"; + finding.setAttribute("data-id", "f-1"); + document.body.append(finding); + + const userClickSpy = vi.spyOn(harness.user, "click"); + const domClickSpy = vi.spyOn(finding, "click"); + vi.spyOn(harness, "waitForTransition").mockResolvedValue(); + + // When + await harness.rapidlyClickFirstFindingNode(2); + + // Then + expect(userClickSpy).not.toHaveBeenCalled(); + expect(domClickSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts new file mode 100644 index 0000000000..86ba2bf56b --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.harness.ts @@ -0,0 +1,654 @@ +/** + * Test harness for browser-mode tests. + * + * Selectors + flows only. Mounting and MSW setup live in the test file. + */ + +import { vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import type { PageFixture } from "./attack-paths-page.fixtures"; + +export class AttackPathPageHarness { + private static readonly NODE_SEL = ".react-flow__node"; + private static readonly EDGE_SEL = ".react-flow__edge"; + private static readonly VIEWPORT_SEL = ".react-flow__viewport"; + private static readonly MINIMAP_SEL = ".react-flow__minimap"; + private static readonly BACKGROUND_SEL = ".react-flow__background"; + + private static isFindingElement(el: Element): boolean { + return ( + el.classList.contains("react-flow__node-finding") || + el.getAttribute("data-nodetype") === "finding" + ); + } + + private static isResourceElement(el: Element): boolean { + return ( + el.classList.contains("react-flow__node-resource") || + el.getAttribute("data-nodetype") === "resource" + ); + } + + private static isInternetElement(el: Element): boolean { + return ( + el.classList.contains("react-flow__node-internet") || + el.getAttribute("data-nodetype") === "internet" + ); + } + + readonly user = userEvent; + + constructor(readonly fixture: PageFixture) {} + + // --- Container --- + + get container(): HTMLElement { + return document.body; + } + + // --- Collections --- + + get nodes(): HTMLElement[] { + return Array.from( + this.container.querySelectorAll( + AttackPathPageHarness.NODE_SEL, + ), + ); + } + + get edges(): HTMLElement[] { + return Array.from( + this.container.querySelectorAll( + AttackPathPageHarness.EDGE_SEL, + ), + ); + } + + get findingNodes(): HTMLElement[] { + return this.nodes.filter(AttackPathPageHarness.isFindingElement); + } + + get resourceNodes(): HTMLElement[] { + return this.nodes.filter(AttackPathPageHarness.isResourceElement); + } + + get internetNodes(): HTMLElement[] { + return this.nodes.filter(AttackPathPageHarness.isInternetElement); + } + + get findingEdges(): HTMLElement[] { + return this.edges.filter((e) => e.classList.contains("finding-edge")); + } + + get resourceEdges(): HTMLElement[] { + return this.edges.filter((e) => e.classList.contains("resource-edge")); + } + + get highlightedEdges(): HTMLElement[] { + return this.edges.filter((e) => e.classList.contains("highlighted")); + } + + get renderedNodeIds(): string[] { + return this.nodes.map((el) => el.getAttribute("data-id") ?? ""); + } + + get renderedEdgeIds(): string[] { + return this.edges.map((el) => el.getAttribute("data-id") ?? ""); + } + + /** + * Parsed `translate(x, y)` from each rendered node's transform style. + * Useful for asserting layout actually placed nodes (non-zero, distinct, …). + */ + get nodePositions(): Array<{ x: number; y: number }> { + return this.nodes.map((el) => { + const match = /translate\(\s*([-\d.]+)px?\s*,\s*([-\d.]+)px?\s*\)/.exec( + el.style.transform, + ); + return match + ? { x: Number(match[1]), y: Number(match[2]) } + : { x: 0, y: 0 }; + }); + } + + get nodePositionsById(): Record { + return Object.fromEntries( + this.nodes.map((el) => { + const id = el.getAttribute("data-id") ?? ""; + const match = /translate\(\s*([-\d.]+)px?\s*,\s*([-\d.]+)px?\s*\)/.exec( + el.style.transform, + ); + + return [ + id, + match ? { x: Number(match[1]), y: Number(match[2]) } : { x: 0, y: 0 }, + ]; + }), + ); + } + + // --- Predicates --- + + get isInFilteredView(): boolean { + return !!this.container.querySelector( + '[aria-label="Return to full graph view"]', + ); + } + + isNodeSelected(nodeId: string): boolean { + const el = this.getNodeById(nodeId); + return !!el && el.classList.contains("selected"); + } + + isEdgeHighlighted(edgeId: string): boolean { + const el = this.getEdgeById(edgeId); + return !!el && el.classList.contains("highlighted"); + } + + isNodeHidden(nodeId: string): boolean { + return !this.getNodeById(nodeId); + } + + // --- Lookups --- + + getNodeById(id: string): HTMLElement | null { + return this.container.querySelector( + `${AttackPathPageHarness.NODE_SEL}[data-id="${id}"]`, + ); + } + + getEdgeById(id: string): HTMLElement | null { + return this.container.querySelector( + `${AttackPathPageHarness.EDGE_SEL}[data-id="${id}"]`, + ); + } + + // --- Handles --- + + private q(selector: string): HTMLElement | null { + return this.container.querySelector(selector); + } + + get toolbar() { + const exportButton = + this.q('button[aria-label="Export graph"]') ?? + this.q('button[aria-label="Export available soon"]'); + return { + zoomInButton: this.q('button[aria-label="Zoom in"]'), + zoomOutButton: this.q('button[aria-label="Zoom out"]'), + fitButton: this.q('button[aria-label="Fit graph to view"]'), + exportButton, + isExportButtonEnabled: + !!exportButton && !(exportButton as HTMLButtonElement).disabled, + backToFullViewButton: this.q( + 'button[aria-label="Return to full graph view"]', + ), + fullscreenButton: this.q('button[aria-label="Fullscreen"]'), + }; + } + + // --- Page-level surface (alerts, raw text) --- + + /** Wait for a `[role="alert"]` to appear and return its text. */ + async emptyStateMessage(timeoutMs = 2000): Promise { + const alert = await this.waitFor( + () => this.container.querySelector('[role="alert"]'), + timeoutMs, + ); + return alert.textContent ?? ""; + } + + /** True when the rendered page contains text matching `pattern`. */ + containsText(pattern: RegExp): boolean { + return pattern.test(this.container.textContent ?? ""); + } + + get minimap(): HTMLElement | null { + return this.q(AttackPathPageHarness.MINIMAP_SEL); + } + + get background(): HTMLElement | null { + return this.q(AttackPathPageHarness.BACKGROUND_SEL); + } + + get viewport(): HTMLElement | null { + return this.q(AttackPathPageHarness.VIEWPORT_SEL); + } + + /** + * Inline `transform` of the React Flow viewport element. This is the + * pan/zoom matrix React Flow rewrites on every fit/zoom/pan, so comparing + * it before vs. after a user action is enough to assert that the viewport + * actually moved (or stayed put). + */ + get viewportTransform(): string { + return this.viewport?.style.transform ?? ""; + } + + /** + * `stroke-width` of the minimap mask SVG. The mask cuts out the area + * currently in view; the cut-out's border is what indicates the viewport + * inside the minimap. A non-zero stroke-width means the indicator has a + * visible border (rather than blending into the dark theme background). + */ + get minimapMaskStrokeWidth(): number { + const mask = this.minimap?.querySelector( + ".react-flow__minimap-mask", + ); + if (!mask) return 0; + const inline = mask.getAttribute("stroke-width"); + if (inline) return Number.parseFloat(inline); + const computed = getComputedStyle(mask).strokeWidth; + return Number.parseFloat(computed); + } + + get fullscreenDialog(): HTMLElement | null { + return document.querySelector('[role="dialog"]'); + } + + get nodeDetailsHeading(): HTMLElement | null { + return ( + Array.from( + document.querySelectorAll("[role='dialog'] h2"), + ).find((heading) => /^Node Details$/i.test(heading.textContent ?? "")) ?? + null + ); + } + + get hasNodeDetailsModal(): boolean { + return !!this.nodeDetailsHeading; + } + + get nodeActionHeading(): HTMLElement | null { + return ( + Array.from( + document.querySelectorAll("[role='dialog'] h2"), + ).find((heading) => + /^Choose node action$/i.test(heading.textContent ?? ""), + ) ?? null + ); + } + + get hasNodeActionDialog(): boolean { + return !!this.nodeActionHeading; + } + + // --- Sync helpers --- + + /** Wait until React Flow has rendered at least `expected` node elements. */ + async waitForLayoutStable(expected = 1, timeoutMs = 3000): Promise { + await vi.waitFor( + () => { + if (this.nodes.length < expected) { + throw new Error( + `expected ${expected} nodes, got ${this.nodes.length}`, + ); + } + }, + { timeout: timeoutMs, interval: 16 }, + ); + } + + /** Wait until the predicate returns truthy and return that value. */ + async waitFor( + fn: () => T | null | undefined | false, + timeoutMs = 3000, + ): Promise { + return vi.waitFor( + () => { + const v = fn(); + if (!v) throw new Error("waitFor predicate not yet truthy"); + return v; + }, + { timeout: timeoutMs, interval: 16 }, + ) as Promise; + } + + async waitForTransition(ms = 350): Promise { + await new Promise((r) => setTimeout(r, ms)); + } + + // --- Action methods --- + + private async clickElement( + element: HTMLElement, + options?: { fallbackToDomClick?: boolean }, + ): Promise { + try { + await this.user.click(element); + } catch (error) { + if (!options?.fallbackToDomClick) throw error; + element.click(); + } + } + + private async clickGraphElement(element: HTMLElement): Promise { + await this.closeFindingDrawerIfOpen(); + await this.user.click(element); + } + + async closeFindingDrawerIfOpen(): Promise { + const drawer = Array.from( + document.querySelectorAll('[role="dialog"]'), + ).find((dialog) => + /resource finding details|finding overview/i.test( + dialog.textContent ?? "", + ), + ); + + if (!drawer) return; + + const closeButton = Array.from( + drawer.querySelectorAll("button"), + ).find((button) => /^close$/i.test(button.textContent?.trim() ?? "")); + + if (closeButton) { + await this.clickElement(closeButton); + await this.waitForTransition(); + } + } + + async selectQuery(queryId?: string): Promise { + await this.closeFindingDrawerIfOpen(); + + const trigger = await this.waitFor( + () => + this.container.querySelector( + 'button[role="combobox"]', + ), + 10000, + ); + await this.user.click(trigger); + + const targetId = queryId ?? this.fixture.queryId; + const targetName = this.fixture.queries.find((q) => q.id === targetId) + ?.attributes.name; + + const option = await this.waitFor( + () => + document.querySelector( + `[role="option"][data-value="${targetId}"]`, + ) ?? + Array.from( + document.querySelectorAll('[role="option"]'), + ).find((el) => targetName && el.textContent?.includes(targetName)), + 10000, + ); + await this.user.click(option); + await this.waitForTransition(); + } + + async executeQuery(options: { selectFirst?: boolean } = {}): Promise { + if (options.selectFirst !== false) { + await this.selectQuery(); + } + + const button = await this.waitFor( + () => + Array.from( + this.container.querySelectorAll("button"), + ).find( + (b) => + !b.disabled && + /execute query/i.test(b.textContent ?? "") && + !/executing/i.test(b.textContent ?? ""), + ), + 10000, + ); + await this.user.click(button); + await this.waitForLayoutStable(1, 10000); + } + + async clickNode(nodeId: string): Promise { + const el = this.getNodeById(nodeId); + if (!el) throw new Error(`clickNode: node "${nodeId}" not found`); + await this.clickGraphElement(el); + await this.waitForTransition(); + } + + async clickFirstFindingNode(): Promise { + const [finding] = this.findingNodes; + if (!finding) throw new Error("clickFirstFindingNode: no finding rendered"); + await this.clickGraphElement(finding); + await this.waitForTransition(); + return finding; + } + + async clickFirstResourceNode(): Promise { + const [resource] = this.resourceNodes; + if (!resource) + throw new Error("clickFirstResourceNode: no resource rendered"); + await this.clickGraphElement(resource); + await this.waitForTransition(); + return resource; + } + + async clickFirstResourceNodeWithoutFindings(): Promise { + const findingIds = new Set( + (this.fixture.queryResult?.nodes ?? []) + .filter((n) => + n.labels.some((l) => l.toLowerCase().includes("finding")), + ) + .map((n) => n.id), + ); + const resourceWithFindingIds = new Set(); + for (const rel of this.fixture.queryResult?.relationships ?? []) { + if (findingIds.has(rel.source)) resourceWithFindingIds.add(rel.target); + if (findingIds.has(rel.target)) resourceWithFindingIds.add(rel.source); + } + const resource = this.resourceNodes.find((node) => { + const id = node.getAttribute("data-id"); + return id && !resourceWithFindingIds.has(id); + }); + if (!resource) { + throw new Error( + "clickFirstResourceNodeWithoutFindings: no resource without findings rendered", + ); + } + await this.clickGraphElement(resource); + await this.waitForTransition(); + return resource; + } + + /** + * Dispatch same-tick clicks against the first finding node. This models the + * duplicate-click race before the drawer overlay can intercept later pointer + * actions, so it intentionally uses native DOM clicks instead of awaited + * user-event pointer interactions. + */ + async rapidlyClickFirstFindingNode(times = 2): Promise { + const [finding] = this.findingNodes; + if (!finding) + throw new Error("rapidlyClickFirstFindingNode: no finding rendered"); + await this.closeFindingDrawerIfOpen(); + for (let i = 0; i < times; i++) { + finding.click(); + } + await this.waitForTransition(); + return finding; + } + + async dblClickFirstResourceNode(): Promise { + const [resource] = this.resourceNodes; + if (!resource) + throw new Error("dblClickFirstResourceNode: no resource rendered"); + await this.user.dblClick(resource); + await this.waitForTransition(); + return resource; + } + + /** + * Click the React Flow background pane (anywhere not on a node/edge), used + * to verify that empty-canvas clicks don't open the filtered view. + */ + async clickEmptyCanvas(): Promise { + const pane = this.q(".react-flow__pane") ?? this.q(".react-flow__renderer"); + if (!pane) throw new Error("clickEmptyCanvas: pane not rendered"); + await this.user.click(pane); + await this.waitForTransition(); + } + + /** + * Click every resource that the fixture's relationships connect to a finding. + * Findings are hidden by default in the full graph view (Tier 1) — clicking + * their adjacent resources reveals them. + */ + async expandAllFindings(): Promise { + const findingIds = new Set( + (this.fixture.queryResult?.nodes ?? []) + .filter((n) => + n.labels.some((l) => l.toLowerCase().includes("finding")), + ) + .map((n) => n.id), + ); + const resourceWithFindingIds = new Set(); + for (const rel of this.fixture.queryResult?.relationships ?? []) { + if (findingIds.has(rel.source)) resourceWithFindingIds.add(rel.target); + if (findingIds.has(rel.target)) resourceWithFindingIds.add(rel.source); + } + for (const id of Array.from(resourceWithFindingIds)) { + const el = this.getNodeById(id); + if (el) { + await this.user.click(el); + await this.waitForTransition(50); + } + } + } + + async hoverNode(nodeId: string): Promise { + const el = this.getNodeById(nodeId); + if (!el) throw new Error(`hoverNode: node "${nodeId}" not found`); + await this.user.hover(el); + await this.waitForTransition(80); + } + + async hoverFirstResourceNode(): Promise { + const [resource] = this.resourceNodes; + if (!resource) + throw new Error("hoverFirstResourceNode: no resource rendered"); + await this.user.hover(resource); + await this.waitForTransition(80); + return resource; + } + + async unhoverNodes(): Promise { + const canvas = + this.q(".react-flow__pane") ?? this.q(".react-flow__renderer"); + if (canvas) await this.user.hover(canvas); + await this.waitForTransition(80); + } + + async zoomIn(): Promise { + const btn = this.toolbar.zoomInButton; + if (!btn) throw new Error("zoomIn: toolbar not rendered"); + await this.user.click(btn); + } + + async zoomOut(): Promise { + const btn = this.toolbar.zoomOutButton; + if (!btn) throw new Error("zoomOut: toolbar not rendered"); + await this.user.click(btn); + } + + async fit(): Promise { + const btn = this.toolbar.fitButton; + if (!btn) throw new Error("fit: toolbar not rendered"); + await this.user.click(btn); + } + + async exitFilteredView(): Promise { + await this.closeFindingDrawerIfOpen(); + + const btn = this.toolbar.backToFullViewButton; + if (!btn) throw new Error("exitFilteredView: not in filtered view"); + await this.clickElement(btn); + await this.waitForTransition(); + } + + async openFullscreen(): Promise { + const btn = this.toolbar.fullscreenButton; + if (!btn) throw new Error("openFullscreen: button not found"); + await this.user.click(btn); + await this.waitFor(() => this.fullscreenDialog, 3000); + await this.waitForTransition(); + } + + async closeFullscreen(): Promise { + const dialog = this.fullscreenDialog; + if (!dialog) return; + const close = dialog.querySelector( + 'button[aria-label="Close"]', + ); + if (close) await this.user.click(close); + else await this.user.keyboard("{Escape}"); + await this.waitForTransition(); + } + + async exportAsPNG(target: "main" | "fullscreen" = "main"): Promise { + const scope = + target === "fullscreen" ? this.fullscreenDialog : this.container; + if (!scope) throw new Error("exportAsPNG: target scope missing"); + const btn = scope.querySelector( + 'button[aria-label="Export graph"]', + ); + if (!btn) throw new Error("exportAsPNG: export button disabled or missing"); + await this.user.click(btn); + await this.waitForTransition(300); + } + + /** + * Trigger an export and capture the resulting download as a structured + * record. Intercepts `HTMLAnchorElement.prototype.click` so the test + * environment doesn't actually navigate, and parses width/height from the + * PNG IHDR chunk so callers can assert on canvas size. + */ + async captureExportPNG( + target: "main" | "fullscreen" = "main", + timeoutMs = 10000, + ): Promise<{ + filename: string; + href: string; + mimeType: string; + width: number; + height: number; + }> { + const downloads: Array<{ href: string; download: string }> = []; + const originalClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function () { + if (this.download) { + downloads.push({ href: this.href, download: this.download }); + return; + } + originalClick.call(this); + }; + + try { + await this.exportAsPNG(target); + await this.waitFor(() => downloads.length > 0, timeoutMs); + } finally { + HTMLAnchorElement.prototype.click = originalClick; + } + + const [download] = downloads; + const [meta, base64 = ""] = download.href.split(","); + const mimeType = /^data:([^;]+)/.exec(meta ?? "")?.[1] ?? ""; + + // PNG IHDR chunk: bytes 16-19 = width (uint32 BE), 20-23 = height. + const bytes = atob(base64); + const u32BE = (offset: number) => + ((bytes.charCodeAt(offset) << 24) | + (bytes.charCodeAt(offset + 1) << 16) | + (bytes.charCodeAt(offset + 2) << 8) | + bytes.charCodeAt(offset + 3)) >>> + 0; + + return { + filename: download.download, + href: download.href, + mimeType, + width: u32BE(16), + height: u32BE(20), + }; + } +} diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx new file mode 100644 index 0000000000..8b91c3fb6e --- /dev/null +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.test.tsx @@ -0,0 +1,19 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +describe("AttackPathsPage", () => { + const currentDir = path.dirname(fileURLToPath(import.meta.url)); + const filePath = path.join(currentDir, "attack-paths-page.tsx"); + const source = readFileSync(filePath, "utf8"); + + it("keeps the page description without rendering a duplicate Attack Paths heading", () => { + // Then + expect(source).not.toContain(">\n Attack Paths\n "); + expect(source).toContain( + "Select a scan, build a query, and visualize Attack Paths in your", + ); + }); +}); diff --git a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx index 4fbd48c793..992fdee5ef 100644 --- a/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx +++ b/ui/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowLeft, Info, Maximize2, X } from "lucide-react"; +import { ArrowLeft, Info, Maximize2 } from "lucide-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useRef, useState } from "react"; @@ -22,17 +22,15 @@ import { AlertDescription, AlertTitle, Button, - Card, - CardContent, } from "@/components/shadcn"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/shadcn/dialog"; -import { Spinner } from "@/components/shadcn/spinner/spinner"; import { useToast } from "@/components/ui"; import type { AttackPathQuery, @@ -48,17 +46,16 @@ import { GraphControls, GraphLegend, GraphLoading, - NodeDetailContent, QueryDescription, QueryExecutionError, QueryParametersForm, QuerySelector, ScanListTable, } from "./_components"; -import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph"; +import type { GraphHandle } from "./_components/graph/attack-path-graph"; import { useGraphState } from "./_hooks/use-graph-state"; import { useQueryBuilder } from "./_hooks/use-query-builder"; -import { exportGraphAsSVG, formatNodeLabel } from "./_lib"; +import { exportGraphAsPNG } from "./_lib"; /** * Attack Paths @@ -76,10 +73,10 @@ export default function AttackPathsPage() { const [queriesLoading, setQueriesLoading] = useState(true); const [queriesError, setQueriesError] = useState(null); const [isFullscreenOpen, setIsFullscreenOpen] = useState(false); - const graphRef = useRef(null); - const fullscreenGraphRef = useRef(null); + const graphRef = useRef(null); + const fullscreenGraphRef = useRef(null); + const findingNavigationInFlightRef = useRef(false); const hasResetRef = useRef(false); - const nodeDetailsRef = useRef(null); const graphContainerRef = useRef(null); const [queries, setQueries] = useState([]); @@ -95,6 +92,11 @@ export default function AttackPathsPage() { } }, [graphState]); + // Reset graph state when scan changes + useEffect(() => { + graphState.resetGraph(); + }, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only + // Load available scans on mount useEffect(() => { const loadScans = async () => { @@ -189,10 +191,6 @@ export default function AttackPathsPage() { loadQueries(); }, [scanId, toast]); - const handleQueryChange = (queryId: string) => { - queryBuilder.handleQueryChange(queryId); - }; - const showErrorToast = (title: string, description: string) => { toast({ title, @@ -289,21 +287,39 @@ export default function AttackPathsPage() { }; const handleNodeClick = (node: GraphNode) => { - // Enter filtered view showing only paths containing this node - graphState.enterFilteredView(node.id); - - // For findings, also scroll to the details section const isFinding = node.labels.some((label) => label.toLowerCase().includes("finding"), ); if (isFinding) { - setTimeout(() => { - nodeDetailsRef.current?.scrollIntoView({ - behavior: "smooth", - block: "nearest", - }); - }, 100); + if (findingNavigationInFlightRef.current) { + return; + } + + findingNavigationInFlightRef.current = true; + // Findings skip the intermediate node-details modal. The finding drawer + // is the useful destination, so open it directly from the graph click. + graphState.enterFilteredView(node.id); + // enterFilteredView stores the filtered node as selected so the graph can + // highlight it. Clear the selection right after for findings so the node + // details modal does not open before the finding drawer. + graphState.selectNode(null); + void handleViewFinding(String(node.properties?.id || node.id)); + return; + } + + const sourceData = graphState.fullData || graphState.data; + const hasFindings = sourceData?.edges?.some((edge) => { + if (edge.source !== node.id && edge.target !== node.id) return false; + const otherId = edge.source === node.id ? edge.target : edge.source; + const otherNode = sourceData.nodes?.find(({ id }) => id === otherId); + return otherNode?.labels.some((label) => + label.toLowerCase().includes("finding"), + ); + }); + + if (hasFindings) { + graphState.toggleExpandedResource(node.id); } }; @@ -311,37 +327,42 @@ export default function AttackPathsPage() { graphState.exitFilteredView(); }; - const handleCloseDetails = () => { - graphState.selectNode(null); - }; - - const getFindingId = (node: GraphNode | null) => - node ? String(node.properties?.id || node.id) : ""; - - const handleViewFinding = (findingId: string) => { + const handleViewFinding = async (findingId: string) => { if (!findingId) return; - void finding.navigateToFinding(findingId); + + try { + await finding.navigateToFinding(findingId); + } finally { + findingNavigationInFlightRef.current = false; + } }; - const handleGraphExport = (svgElement: SVGSVGElement | null) => { + const handleGraphExport = async (target: "main" | "fullscreen") => { + const ref = target === "fullscreen" ? fullscreenGraphRef : graphRef; + const handle = ref.current; + if (!handle) return; + try { - if (svgElement) { - exportGraphAsSVG(svgElement, "attack-path-graph.svg"); - toast({ - title: "Success", - description: "Graph exported as SVG", - variant: "default", - }); - } else { - throw new Error("Could not find graph element"); - } - } catch (error) { + await exportGraphAsPNG( + handle.getContainerElement(), + handle.getNodesBounds(), + "attack-path-graph.png", + graphState.data, + { + expandedResources: graphState.expandedResources, + isFilteredView: graphState.isFilteredView, + selectedNodeId: graphState.selectedNodeId, + }, + ); toast({ - title: "Error", - description: - error instanceof Error ? error.message : "Failed to export graph", - variant: "destructive", + title: "Success", + description: "Graph exported", + variant: "default", }); + } catch (error) { + const description = + error instanceof Error ? error.message : "Failed to export graph"; + showErrorToast("Export failed", description); } }; @@ -353,12 +374,9 @@ export default function AttackPathsPage() { onRefresh={refreshScans} /> - {/* Header */} + {/* Page introduction */}
-

- Attack Paths -

-

+

Select a scan, build a query, and visualize Attack Paths in your infrastructure.

@@ -423,7 +441,7 @@ export default function AttackPathsPage() { {queryBuilder.selectedQueryData && ( @@ -512,8 +530,9 @@ export default function AttackPathsPage() { 💡 - Click on any node to filter and view its connected - paths + Click a finding to focus its connected path, or click + a resource with findings to show or hide its related + findings
)} @@ -524,11 +543,7 @@ export default function AttackPathsPage() { onZoomIn={() => graphRef.current?.zoomIn()} onZoomOut={() => graphRef.current?.zoomOut()} onFitToScreen={() => graphRef.current?.resetZoom()} - onExport={() => - handleGraphExport( - graphRef.current?.getSVGElement() || null, - ) - } + onExport={() => handleGraphExport("main")} /> {/* Fullscreen button */} @@ -550,6 +565,10 @@ export default function AttackPathsPage() { Fullscreen graph view + + Explore the attack path graph at full size. Use + the toolbar to zoom, fit, or export the graph. +
fullscreenGraphRef.current?.resetZoom() } - onExport={() => - handleGraphExport( - fullscreenGraphRef.current?.getSVGElement() || - null, - ) - } + onExport={() => handleGraphExport("fullscreen")} />
-
+
- {/* Node Detail Panel - Side by side */} - {graphState.selectedNode && ( -
- - -
-

- Node Details -

- -
-

- {graphState.selectedNode?.labels.some( - (label) => - label - .toLowerCase() - .includes("finding"), - ) - ? graphState.selectedNode?.properties - ?.check_title || - graphState.selectedNode?.properties - ?.id || - "Unknown Finding" - : graphState.selectedNode?.properties - ?.name || - graphState.selectedNode?.properties - ?.id || - "Unknown Resource"} -

-
-
-

- Type -

-

- {graphState.selectedNode?.labels - .map(formatNodeLabel) - .join(", ")} -

-
-
-
-
-
- )}
@@ -654,82 +615,23 @@ export default function AttackPathsPage() { onNodeClick={handleNodeClick} selectedNodeId={graphState.selectedNodeId} isFilteredView={graphState.isFilteredView} + expandedResources={graphState.expandedResources} />
{/* Legend below */} -
- +
+
) : null}
)} - {/* Node Detail Panel - Below Graph */} - {graphState.selectedNode && graphState.data && ( -
-
-
-

Node Details

-

- {String( - graphState.selectedNode.labels.some((label) => - label.toLowerCase().includes("finding"), - ) - ? graphState.selectedNode.properties?.check_title || - graphState.selectedNode.properties?.id || - "Unknown Finding" - : graphState.selectedNode.properties?.name || - graphState.selectedNode.properties?.id || - "Unknown Resource", - )} -

-
-
- {graphState.selectedNode.labels.some((label) => - label.toLowerCase().includes("finding"), - ) && ( - - )} - -
-
- - -
- )} - {finding.findingDetails && ( }> + }> } diff --git a/ui/dependency-log.json b/ui/dependency-log.json index 0793e19206..1ebaf58658 100644 --- a/ui/dependency-log.json +++ b/ui/dependency-log.json @@ -39,6 +39,14 @@ "strategy": "installed", "generatedAt": "2026-03-26T08:39:34.728Z" }, + { + "section": "dependencies", + "name": "@dagrejs/dagre", + "from": "3.0.0", + "to": "3.0.0", + "strategy": "installed", + "generatedAt": "2026-04-16T12:15:51.357Z" + }, { "section": "dependencies", "name": "@extractus/feed-extractor", @@ -327,14 +335,6 @@ "strategy": "installed", "generatedAt": "2025-10-22T12:36:37.962Z" }, - { - "section": "dependencies", - "name": "@types/dagre", - "from": "0.7.53", - "to": "0.7.53", - "strategy": "installed", - "generatedAt": "2025-11-27T11:47:22.908Z" - }, { "section": "dependencies", "name": "@types/js-yaml", @@ -351,6 +351,14 @@ "strategy": "installed", "generatedAt": "2026-03-26T08:39:34.728Z" }, + { + "section": "dependencies", + "name": "@xyflow/react", + "from": "12.10.2", + "to": "12.10.2", + "strategy": "installed", + "generatedAt": "2026-04-16T12:15:51.357Z" + }, { "section": "dependencies", "name": "ai", @@ -391,14 +399,6 @@ "strategy": "installed", "generatedAt": "2025-10-22T12:36:37.962Z" }, - { - "section": "dependencies", - "name": "dagre", - "from": "0.8.5", - "to": "0.8.5", - "strategy": "installed", - "generatedAt": "2025-11-27T11:47:22.908Z" - }, { "section": "dependencies", "name": "date-fns", @@ -455,6 +455,22 @@ "strategy": "installed", "generatedAt": "2025-10-22T12:36:37.962Z" }, + { + "section": "dependencies", + "name": "marked", + "from": "15.0.12", + "to": "15.0.12", + "strategy": "installed", + "generatedAt": "2025-10-22T12:36:37.962Z" + }, + { + "section": "dependencies", + "name": "modern-screenshot", + "from": "4.7.0", + "to": "4.7.0", + "strategy": "installed", + "generatedAt": "2026-04-21T12:43:22.528Z" + }, { "section": "dependencies", "name": "nanoid", @@ -759,6 +775,22 @@ "strategy": "installed", "generatedAt": "2026-01-29T16:42:27.795Z" }, + { + "section": "devDependencies", + "name": "@vitest/browser", + "from": "4.0.18", + "to": "4.0.18", + "strategy": "installed", + "generatedAt": "2026-04-30T13:13:39.682Z" + }, + { + "section": "devDependencies", + "name": "@vitest/browser-playwright", + "from": "4.0.18", + "to": "4.0.18", + "strategy": "installed", + "generatedAt": "2026-04-30T13:13:39.682Z" + }, { "section": "devDependencies", "name": "@vitest/coverage-v8", @@ -779,9 +811,9 @@ "section": "devDependencies", "name": "dotenv", "from": "16.6.1", - "to": "16.6.1", + "to": null, "strategy": "installed", - "generatedAt": "2026-05-11T15:12:01.207Z" + "generatedAt": "2026-05-12T10:13:36.469Z" }, { "section": "devDependencies", @@ -887,6 +919,14 @@ "strategy": "installed", "generatedAt": "2026-04-10T11:55:26.693Z" }, + { + "section": "devDependencies", + "name": "msw", + "from": "2.13.4", + "to": "2.13.4", + "strategy": "installed", + "generatedAt": "2026-04-30T13:13:39.682Z" + }, { "section": "devDependencies", "name": "postcss", @@ -934,5 +974,13 @@ "to": "4.0.18", "strategy": "installed", "generatedAt": "2026-01-29T16:42:27.795Z" + }, + { + "section": "devDependencies", + "name": "vitest-browser-react", + "from": "2.0.4", + "to": "2.0.4", + "strategy": "installed", + "generatedAt": "2026-04-30T13:23:20.132Z" } ] diff --git a/ui/package.json b/ui/package.json index 817260f0a8..c8b4d88810 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,8 +16,11 @@ "lint:knip:fix": "knip --fix --max-issues 494", "format:check": "./node_modules/.bin/prettier --check ./app", "format:write": "./node_modules/.bin/prettier --config .prettierrc.json --write ./app", - "test": "vitest", - "test:run": "vitest run", + "test": "vitest run", + "test:watch": "vitest", + "test:unit": "vitest run --project unit", + "test:browser": "vitest run --project browser", + "test:browser:watch": "vitest --project browser", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans", "test:e2e:ui": "playwright test --project=auth --project=sign-up --project=providers --project=invitations --project=scans --ui", @@ -33,6 +36,7 @@ "@codemirror/language": "6.12.2", "@codemirror/state": "6.6.0", "@codemirror/view": "6.40.0", + "@dagrejs/dagre": "3.0.0", "@extractus/feed-extractor": "7.1.7", "@heroui/react": "2.8.4", "@hookform/resolvers": "5.2.2", @@ -69,15 +73,14 @@ "@tailwindcss/postcss": "4.1.18", "@tailwindcss/typography": "0.5.16", "@tanstack/react-table": "8.21.3", - "@types/dagre": "0.7.53", "@types/js-yaml": "4.0.9", "@uiw/react-codemirror": "4.25.8", + "@xyflow/react": "12.10.2", "ai": "5.0.109", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "d3": "7.9.0", - "dagre": "0.8.5", "date-fns": "4.1.0", "framer-motion": "11.18.2", "import-in-the-middle": "2.0.0", @@ -85,6 +88,8 @@ "jwt-decode": "4.0.0", "langchain": "1.2.10", "lucide-react": "0.543.0", + "marked": "15.0.12", + "modern-screenshot": "4.7.0", "nanoid": "5.1.6", "next": "16.2.3", "next-auth": "5.0.0-beta.30", @@ -125,6 +130,8 @@ "@typescript-eslint/eslint-plugin": "8.53.0", "@typescript-eslint/parser": "8.53.0", "@vitejs/plugin-react": "5.1.2", + "@vitest/browser": "4.0.18", + "@vitest/browser-playwright": "4.0.18", "@vitest/coverage-v8": "4.0.18", "babel-plugin-react-compiler": "1.0.0", "dotenv": "16.6.1", @@ -141,12 +148,14 @@ "globals": "17.0.0", "jsdom": "27.4.0", "knip": "6.3.1", + "msw": "2.13.4", "postcss": "8.4.38", "prettier": "3.6.2", "prettier-plugin-tailwindcss": "0.6.14", "tailwindcss": "4.1.18", "typescript": "5.5.4", - "vitest": "4.0.18" + "vitest": "4.0.18", + "vitest-browser-react": "2.0.4" }, "pnpm": { "overrides": { @@ -173,5 +182,10 @@ } }, "version": "0.0.1", - "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" + "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", + "msw": { + "workerDirectory": [ + "public" + ] + } } diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 6f2e9da8fa..d5d22248f8 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@codemirror/view': specifier: 6.40.0 version: 6.40.0 + '@dagrejs/dagre': + specifier: 3.0.0 + version: 3.0.0 '@extractus/feed-extractor': specifier: 7.1.7 version: 7.1.7 @@ -153,15 +156,15 @@ importers: '@tanstack/react-table': specifier: 8.21.3 version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@types/dagre': - specifier: 0.7.53 - version: 0.7.53 '@types/js-yaml': specifier: 4.0.9 version: 4.0.9 '@uiw/react-codemirror': specifier: 4.25.8 version: 4.25.8(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.1)(@codemirror/language@6.12.2)(@codemirror/lint@6.9.5)(@codemirror/search@6.6.0)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.40.0)(codemirror@6.0.2)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@xyflow/react': + specifier: 12.10.2 + version: 12.10.2(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) ai: specifier: 5.0.109 version: 5.0.109(zod@4.1.11) @@ -177,9 +180,6 @@ importers: d3: specifier: 7.9.0 version: 7.9.0 - dagre: - specifier: 0.8.5 - version: 0.8.5 date-fns: specifier: 4.1.0 version: 4.1.0 @@ -201,6 +201,12 @@ importers: lucide-react: specifier: 0.543.0 version: 0.543.0(react@19.2.5) + marked: + specifier: 15.0.12 + version: 15.0.12 + modern-screenshot: + specifier: 4.7.0 + version: 4.7.0 nanoid: specifier: 5.1.6 version: 5.1.6 @@ -316,9 +322,15 @@ importers: '@vitejs/plugin-react': specifier: 5.1.2 version: 5.1.2(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/browser': + specifier: 4.0.18 + version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser-playwright': + specifier: 4.0.18 + version: 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/coverage-v8': specifier: 4.0.18 - version: 4.0.18(@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) + version: 4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) babel-plugin-react-compiler: specifier: 1.0.0 version: 1.0.0 @@ -364,6 +376,9 @@ importers: knip: specifier: 6.3.1 version: 6.3.1(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1) + msw: + specifier: 2.13.4 + version: 2.13.4(@types/node@24.10.8)(typescript@5.5.4) postcss: specifier: 8.4.38 version: 8.4.38 @@ -381,7 +396,10 @@ importers: version: 5.5.4 vitest: specifier: 4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + vitest-browser-react: + specifier: 2.0.4 + version: 2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.0.18) packages: @@ -866,6 +884,12 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dagrejs/dagre@3.0.0': + resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==} + + '@dagrejs/graphlib@4.0.1': + resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} + '@date-fns/tz@1.4.1': resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} @@ -1976,35 +2000,35 @@ packages: cpu: [x64] os: [win32] - '@inquirer/ansi@1.0.2': - resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} - engines: {node: '>=18'} + '@inquirer/ansi@2.0.5': + resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/confirm@5.1.21': - resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} - engines: {node: '>=18'} + '@inquirer/confirm@6.0.12': + resolution: {integrity: sha512-h9FgGun3QwVYNj5TWIZZ+slii73bMoBFjPfVIGtnFuL4t8gBiNDV9PcSfIzkuxvgquJKt9nr1QzszpBzTbH8Og==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/core@10.3.2': - resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} - engines: {node: '>=18'} + '@inquirer/core@11.1.9': + resolution: {integrity: sha512-BDE4fG22uYh1bGSifcj7JSx119TVYNViMhMu85usp4Fswrzh6M0DV3yld64jA98uOAa2GSQ4Bg4bZRm2d2cwSg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: '@types/node': optional: true - '@inquirer/figures@1.0.15': - resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} - engines: {node: '>=18'} + '@inquirer/figures@2.0.5': + resolution: {integrity: sha512-NsSs4kzfm12lNetHwAn3GEuH317IzpwrMCbOuMIVytpjnJ90YYHNwdRgYGuKmVxwuIqSgqk3M5qqQt1cDk0tGQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} - '@inquirer/type@3.0.10': - resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} - engines: {node: '>=18'} + '@inquirer/type@4.0.5': + resolution: {integrity: sha512-aetVUNeKNc/VriqXlw1NRSW0zhMBB0W4bNbWRJgzRl/3d0QNDQFfk0GO5SDdtjMZVg6o8ZKEiadd7SCCzoOn5Q==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} peerDependencies: '@types/node': '>=18' peerDependenciesMeta: @@ -2221,6 +2245,9 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + '@open-draft/logger@0.3.0': resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} @@ -4716,9 +4743,6 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} - '@types/dagre@0.7.53': - resolution: {integrity: sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==} - '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -4778,6 +4802,9 @@ packages: '@types/react@19.2.8': resolution: {integrity: sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -4996,6 +5023,15 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@xyflow/react@12.10.2': + resolution: {integrity: sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + + '@xyflow/system@0.0.76': + resolution: {integrity: sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -5284,6 +5320,9 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -5576,9 +5615,6 @@ packages: dagre-d3-es@7.0.13: resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} - dagre@0.8.5: - resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} - damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -5959,9 +5995,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-wrap-ansi@0.2.0: + resolution: {integrity: sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w==} + fast-xml-parser@5.3.8: resolution: {integrity: sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==} hasBin: true @@ -6135,11 +6180,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphlib@2.1.8: - resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} - - graphql@16.12.0: - resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} + graphql@16.13.2: + resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} hachure-fill@0.5.2: @@ -6215,8 +6257,8 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} - headers-polyfill@4.0.3: - resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} hermes-estree@0.25.1: resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} @@ -6772,6 +6814,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + marked@16.4.2: resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} engines: {node: '>= 20'} @@ -7007,6 +7054,9 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + modern-screenshot@4.7.0: + resolution: {integrity: sha512-9YxN+ddPSMMlhylOv25VHzXrl9u67QRxoh7+SEewGtgUw7t6hHTrjptSDJUSne9oG4Xk/h2cwG15nIt4Hc9ujg==} + module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} @@ -7023,8 +7073,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.12.14: - resolution: {integrity: sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==} + msw@2.13.4: + resolution: {integrity: sha512-fPlKBeFe+8rpcyR3umUmmHuNwu6gc6T3STvkgEa9WDX/HEgal9wDeflpCUAIRtmvaLZM2igfI5y1bZ9G5J26KA==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -7037,9 +7087,9 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true - mute-stream@2.0.0: - resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} - engines: {node: ^18.17.0 || >=20.5.0} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -7692,8 +7742,8 @@ packages: resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} hasBin: true - rettime@0.10.1: - resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + rettime@0.11.8: + resolution: {integrity: sha512-0fERGXktJTyJ+h8fBEiPxHPEFOu0h15JY7JtwrOVqR5K+vb99ho6IyOo7ekLS3h4sJCzIDy4VWKIbZUfe9njmg==} reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} @@ -7776,6 +7826,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -8068,6 +8121,10 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.19: resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} @@ -8095,6 +8152,10 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -8129,8 +8190,8 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} - type-fest@5.4.1: - resolution: {integrity: sha512-xygQcmneDyzsEuKZrFbRMne5HDqMs++aFzefrJTgEIKjQ3rekM+RPfFCVq2Gp1VIDqddoYeppCj4Pcb+RZW0GQ==} + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} engines: {node: '>=20'} type-is@2.0.1: @@ -8353,6 +8414,20 @@ packages: yaml: optional: true + vitest-browser-react@2.0.4: + resolution: {integrity: sha512-FQq2z519Bwp/rANaQXU+ox7M4d0q/bTQkF2pgwRAehE+pqJ6myYOLp+P2Dy2kuk+K4IQJHMyijMCSQ1da/xW8w==} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + vitest: ^4.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -8497,10 +8572,6 @@ packages: world-atlas@2.0.2: resolution: {integrity: sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -8559,10 +8630,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - yoctocolors-cjs@2.1.3: - resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} - engines: {node: '>=18'} - zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -8577,6 +8644,21 @@ packages: zod@4.1.11: resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.8: resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} engines: {node: '>=12.20.0'} @@ -9695,6 +9777,12 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dagrejs/dagre@3.0.0': + dependencies: + '@dagrejs/graphlib': 4.0.1 + + '@dagrejs/graphlib@4.0.1': {} + '@date-fns/tz@1.4.1': {} '@emnapi/core@1.8.1': @@ -11118,38 +11206,32 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inquirer/ansi@1.0.2': - optional: true + '@inquirer/ansi@2.0.5': {} - '@inquirer/confirm@5.1.21(@types/node@24.10.8)': + '@inquirer/confirm@6.0.12(@types/node@24.10.8)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.10.8) - '@inquirer/type': 3.0.10(@types/node@24.10.8) + '@inquirer/core': 11.1.9(@types/node@24.10.8) + '@inquirer/type': 4.0.5(@types/node@24.10.8) optionalDependencies: '@types/node': 24.10.8 - optional: true - '@inquirer/core@10.3.2(@types/node@24.10.8)': + '@inquirer/core@11.1.9(@types/node@24.10.8)': dependencies: - '@inquirer/ansi': 1.0.2 - '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.8) + '@inquirer/ansi': 2.0.5 + '@inquirer/figures': 2.0.5 + '@inquirer/type': 4.0.5(@types/node@24.10.8) cli-width: 4.1.0 - mute-stream: 2.0.0 + fast-wrap-ansi: 0.2.0 + mute-stream: 3.0.0 signal-exit: 4.1.0 - wrap-ansi: 6.2.0 - yoctocolors-cjs: 2.1.3 optionalDependencies: '@types/node': 24.10.8 - optional: true - '@inquirer/figures@1.0.15': - optional: true + '@inquirer/figures@2.0.5': {} - '@inquirer/type@3.0.10(@types/node@24.10.8)': + '@inquirer/type@4.0.5(@types/node@24.10.8)': optionalDependencies: '@types/node': 24.10.8 - optional: true '@internationalized/date@3.10.0': dependencies: @@ -11327,7 +11409,6 @@ snapshots: is-node-process: 1.2.0 outvariant: 1.4.3 strict-event-emitter: 0.5.1 - optional: true '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.8.1)(@emnapi/runtime@1.8.1)': dependencies: @@ -11389,17 +11470,16 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@open-draft/deferred-promise@2.2.0': - optional: true + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 outvariant: 1.4.3 - optional: true - '@open-draft/until@2.1.0': - optional: true + '@open-draft/until@2.1.0': {} '@opentelemetry/api-logs@0.208.0': dependencies: @@ -11780,8 +11860,7 @@ snapshots: dependencies: playwright: 1.56.1 - '@polka/url@1.0.0-next.29': - optional: true + '@polka/url@1.0.0-next.29': {} '@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)': dependencies: @@ -14480,8 +14559,6 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 - '@types/dagre@0.7.53': {} - '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -14548,8 +14625,11 @@ snapshots: dependencies: csstype: 3.2.3 - '@types/statuses@2.0.6': - optional: true + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 24.10.8 + + '@types/statuses@2.0.6': {} '@types/tedious@4.0.14': dependencies: @@ -14707,39 +14787,37 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/browser-playwright@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser-playwright@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: - '@vitest/browser': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) playwright: 1.56.1 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + tinyrainbow: 3.1.0 + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - optional: true - '@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: - '@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) '@vitest/utils': 4.0.18 magic-string: 0.30.21 pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + tinyrainbow: 3.1.0 + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) ws: 8.19.0 transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - optional: true - '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': + '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -14751,9 +14829,9 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) optionalDependencies: - '@vitest/browser': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) '@vitest/expect@4.0.18': dependencies: @@ -14762,20 +14840,20 @@ snapshots: '@vitest/spy': 4.0.18 '@vitest/utils': 4.0.18 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 - '@vitest/mocker@4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - msw: 2.12.14(@types/node@24.10.8)(typescript@5.5.4) + msw: 2.13.4(@types/node@24.10.8)(typescript@5.5.4) vite: 7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/runner@4.0.18': dependencies: @@ -14793,7 +14871,7 @@ snapshots: '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@webassemblyjs/ast@1.14.1': dependencies: @@ -14875,6 +14953,29 @@ snapshots: '@xtuc/long@4.2.2': {} + '@xyflow/react@12.10.2(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@xyflow/system': 0.0.76 + classcat: 5.0.5 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + zustand: 4.5.7(@types/react@19.2.8)(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + - immer + + '@xyflow/system@0.0.76': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -15180,8 +15281,9 @@ snapshots: dependencies: clsx: 2.1.1 - cli-width@4.1.0: - optional: true + classcat@5.0.5: {} + + cli-width@4.1.0: {} client-only@0.0.1: {} @@ -15190,7 +15292,6 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 - optional: true clsx@1.2.1: {} @@ -15266,8 +15367,7 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: - optional: true + cookie@1.1.1: {} cors@2.8.5: dependencies: @@ -15498,11 +15598,6 @@ snapshots: d3: 7.9.0 lodash-es: 4.17.23 - dagre@0.8.5: - dependencies: - graphlib: 2.1.8 - lodash: 4.17.23 - damerau-levenshtein@1.0.8: {} data-urls@6.0.1: @@ -16018,8 +16113,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.0: {} + fast-wrap-ansi@0.2.0: + dependencies: + fast-string-width: 3.0.2 + fast-xml-parser@5.3.8: dependencies: strnum: 2.1.2 @@ -16120,8 +16225,7 @@ snapshots: gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: - optional: true + get-caller-file@2.0.5: {} get-east-asian-width@1.4.0: {} @@ -16187,12 +16291,7 @@ snapshots: graceful-fs@4.2.11: {} - graphlib@2.1.8: - dependencies: - lodash: 4.17.23 - - graphql@16.12.0: - optional: true + graphql@16.13.2: {} hachure-fill@0.5.2: {} @@ -16340,8 +16439,10 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 - headers-polyfill@4.0.3: - optional: true + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.0 hermes-estree@0.25.1: {} @@ -16539,8 +16640,7 @@ snapshots: is-network-error@1.3.0: {} - is-node-process@1.2.0: - optional: true + is-node-process@1.2.0: {} is-number-object@1.1.1: dependencies: @@ -16900,6 +17000,8 @@ snapshots: markdown-table@3.0.4: {} + marked@15.0.12: {} + marked@16.4.2: {} math-intrinsics@1.1.0: {} @@ -17373,6 +17475,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + modern-screenshot@4.7.0: {} + module-details-from-path@1.0.4: {} motion-dom@11.18.1: @@ -17381,41 +17485,38 @@ snapshots: motion-utils@11.18.1: {} - mrmime@2.0.1: - optional: true + mrmime@2.0.1: {} ms@2.1.3: {} - msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4): + msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4): dependencies: - '@inquirer/confirm': 5.1.21(@types/node@24.10.8) + '@inquirer/confirm': 6.0.12(@types/node@24.10.8) '@mswjs/interceptors': 0.41.3 - '@open-draft/deferred-promise': 2.2.0 + '@open-draft/deferred-promise': 3.0.0 '@types/statuses': 2.0.6 cookie: 1.1.1 - graphql: 16.12.0 - headers-polyfill: 4.0.3 + graphql: 16.13.2 + headers-polyfill: 5.0.1 is-node-process: 1.2.0 outvariant: 1.4.3 path-to-regexp: 6.3.0 picocolors: 1.1.1 - rettime: 0.10.1 + rettime: 0.11.8 statuses: 2.0.2 strict-event-emitter: 0.5.1 - tough-cookie: 6.0.0 - type-fest: 5.4.1 + tough-cookie: 6.0.1 + type-fest: 5.6.0 until-async: 3.0.2 yargs: 17.7.2 optionalDependencies: typescript: 5.5.4 transitivePeerDependencies: - '@types/node' - optional: true mustache@4.2.0: {} - mute-stream@2.0.0: - optional: true + mute-stream@3.0.0: {} nanoid@3.3.11: {} @@ -17544,8 +17645,7 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - outvariant@1.4.3: - optional: true + outvariant@1.4.3: {} own-keys@1.0.1: dependencies: @@ -17678,8 +17778,7 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 - path-to-regexp@6.3.0: - optional: true + path-to-regexp@6.3.0: {} path-to-regexp@8.3.0: {} @@ -17706,7 +17805,6 @@ snapshots: pixelmatch@7.1.0: dependencies: pngjs: 7.0.0 - optional: true pkce-challenge@5.0.1: {} @@ -17724,8 +17822,7 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - pngjs@7.0.0: - optional: true + pngjs@7.0.0: {} points-on-curve@0.2.0: {} @@ -18067,8 +18164,7 @@ snapshots: remend@1.0.1: {} - require-directory@2.1.1: - optional: true + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -18095,8 +18191,7 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rettime@0.10.1: - optional: true + rettime@0.11.8: {} reusify@1.1.0: {} @@ -18231,6 +18326,8 @@ snapshots: server-only@0.0.1: {} + set-cookie-parser@3.1.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -18373,7 +18470,6 @@ snapshots: '@polka/url': 1.0.0-next.29 mrmime: 2.0.1 totalist: 3.0.1 - optional: true smol-toml@1.6.1: {} @@ -18434,8 +18530,7 @@ snapshots: - micromark-util-types - supports-color - strict-event-emitter@0.5.1: - optional: true + strict-event-emitter@0.5.1: {} string-width@4.2.3: dependencies: @@ -18563,8 +18658,7 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 - tagged-tag@1.0.0: - optional: true + tagged-tag@1.0.0: {} tailwind-merge@3.3.1: {} @@ -18615,6 +18709,8 @@ snapshots: tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} + tldts-core@7.0.19: {} tldts@7.0.19: @@ -18631,13 +18727,16 @@ snapshots: dependencies: commander: 2.20.3 - totalist@3.0.1: - optional: true + totalist@3.0.1: {} tough-cookie@6.0.0: dependencies: tldts: 7.0.19 + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.19 + tr46@0.0.3: {} tr46@6.0.0: @@ -18662,10 +18761,9 @@ snapshots: type-fest@0.7.1: {} - type-fest@5.4.1: + type-fest@5.6.0: dependencies: tagged-tag: 1.0.0 - optional: true type-is@2.0.1: dependencies: @@ -18773,8 +18871,7 @@ snapshots: webpack-sources: 3.3.3 webpack-virtual-modules: 0.5.0 - until-async@3.0.2: - optional: true + until-async@3.0.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: @@ -18897,10 +18994,19 @@ snapshots: terser: 5.46.0 yaml: 2.8.2 - vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2): + vitest-browser-react@2.0.4(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(vitest@4.0.18): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2) + optionalDependencies: + '@types/react': 19.2.8 + '@types/react-dom': 19.2.3(@types/react@19.2.8) + + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.8)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(terser@5.46.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -18922,7 +19028,7 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/node': 24.10.8 - '@vitest/browser-playwright': 4.0.18(msw@2.12.14(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser-playwright': 4.0.18(msw@2.13.4(@types/node@24.10.8)(typescript@5.5.4))(playwright@1.56.1)(vite@7.3.1(@types/node@24.10.8)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))(vitest@4.0.18) jsdom: 27.4.0(@noble/hashes@1.8.0) transitivePeerDependencies: - jiti @@ -19077,13 +19183,6 @@ snapshots: world-atlas@2.0.2: {} - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - optional: true - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -19106,15 +19205,13 @@ snapshots: xtend@4.0.2: {} - y18n@5.0.8: - optional: true + y18n@5.0.8: {} yallist@3.1.1: {} yaml@2.8.2: {} - yargs-parser@21.1.1: - optional: true + yargs-parser@21.1.1: {} yargs@17.7.2: dependencies: @@ -19125,13 +19222,9 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 - optional: true yocto-queue@0.1.0: {} - yoctocolors-cjs@2.1.3: - optional: true - zod-to-json-schema@3.25.1(zod@4.1.11): dependencies: zod: 4.1.11 @@ -19142,6 +19235,13 @@ snapshots: zod@4.1.11: {} + zustand@4.5.7(@types/react@19.2.8)(react@19.2.5): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.8 + react: 19.2.5 + zustand@5.0.8(@types/react@19.2.8)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): optionalDependencies: '@types/react': 19.2.8 diff --git a/ui/pnpm-workspace.yaml b/ui/pnpm-workspace.yaml index 308cc2c47a..829294f92b 100644 --- a/ui/pnpm-workspace.yaml +++ b/ui/pnpm-workspace.yaml @@ -26,6 +26,8 @@ onlyBuiltDependencies: - "@heroui/shared-utils" # unrs-resolver: Rust module resolver (NAPI-RS). Verifies the correct native binding is available for the platform. - unrs-resolver + # msw: Copies mockServiceWorker.js into the directories listed in package.json's `msw.workerDirectory` (here: `public/`) so the runtime worker stays in sync with the installed msw version. Pure file copy — no native binary, no network access. Required for vitest browser tests to intercept fetches via the service worker. + - msw # --- Level 3: Trust Policy + Exotic Subdeps --- # Fail when a package's trust evidence is downgraded (e.g., new publisher). diff --git a/ui/public/mockServiceWorker.js b/ui/public/mockServiceWorker.js new file mode 100644 index 0000000000..3f01e6f929 --- /dev/null +++ b/ui/public/mockServiceWorker.js @@ -0,0 +1,354 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + */ + +const PACKAGE_VERSION = '2.13.4' +const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82' +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse') +const activeClientIds = new Set() + +addEventListener('install', function () { + self.skipWaiting() +}) + +addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()) +}) + +addEventListener('message', async function (event) { + // Only accept messages from pages served from the same origin as this worker. + if (event.origin !== self.location.origin) { + return + } + + const clientId = Reflect.get(event.source || {}, 'id') + + if (!clientId || !self.clients) { + return + } + + const client = await self.clients.get(clientId) + + if (!client) { + return + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }) + break + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }) + break + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId) + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: { + client: { + id: client.id, + frameType: client.frameType, + }, + }, + }) + break + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId) + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId + }) + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister() + } + + break + } + } +}) + +addEventListener('fetch', function (event) { + const requestInterceptedAt = Date.now() + + // Bypass navigation requests. + if (event.request.mode === 'navigate') { + return + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if ( + event.request.cache === 'only-if-cached' && + event.request.mode !== 'same-origin' + ) { + return + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been terminated (still remains active until the next reload). + if (activeClientIds.size === 0) { + return + } + + const requestId = crypto.randomUUID() + event.respondWith(handleRequest(event, requestId, requestInterceptedAt)) +}) + +/** + * @param {FetchEvent} event + * @param {string} requestId + * @param {number} requestInterceptedAt + */ +async function handleRequest(event, requestId, requestInterceptedAt) { + const client = await resolveMainClient(event) + const requestCloneForEvents = event.request.clone() + const response = await getResponse( + event, + client, + requestId, + requestInterceptedAt, + ) + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + const serializedRequest = await serializeRequest(requestCloneForEvents) + + // Clone the response so both the client and the library could consume it. + const responseClone = response.clone() + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + isMockedResponse: IS_MOCKED_RESPONSE in response, + request: { + id: requestId, + ...serializedRequest, + }, + response: { + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + headers: Object.fromEntries(responseClone.headers.entries()), + body: responseClone.body, + }, + }, + }, + responseClone.body ? [serializedRequest.body, responseClone.body] : [], + ) + } + + return response +} + +/** + * Resolve the main client for the given event. + * Client that issues a request doesn't necessarily equal the client + * that registered the worker. It's with the latter the worker should + * communicate with during the response resolving phase. + * @param {FetchEvent} event + * @returns {Promise} + */ +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId) + + if (activeClientIds.has(event.clientId)) { + return client + } + + if (client?.frameType === 'top-level') { + return client + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }) + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible' + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id) + }) +} + +/** + * @param {FetchEvent} event + * @param {Client | undefined} client + * @param {string} requestId + * @param {number} requestInterceptedAt + * @returns {Promise} + */ +async function getResponse(event, client, requestId, requestInterceptedAt) { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = event.request.clone() + + function passthrough() { + // Cast the request headers to a new Headers instance + // so the headers can be manipulated with. + const headers = new Headers(requestClone.headers) + + // Remove the "accept" header value that marked this request as passthrough. + // This prevents request alteration and also keeps it compliant with the + // user-defined CORS policies. + const acceptHeader = headers.get('accept') + if (acceptHeader) { + const values = acceptHeader.split(',').map((value) => value.trim()) + const filteredValues = values.filter( + (value) => value !== 'msw/passthrough', + ) + + if (filteredValues.length > 0) { + headers.set('accept', filteredValues.join(', ')) + } else { + headers.delete('accept') + } + } + + return fetch(requestClone, { headers }) + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough() + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough() + } + + // Notify the client that a request has been intercepted. + const serializedRequest = await serializeRequest(event.request) + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + interceptedAt: requestInterceptedAt, + ...serializedRequest, + }, + }, + [serializedRequest.body], + ) + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data) + } + + case 'PASSTHROUGH': { + return passthrough() + } + } + + return passthrough() +} + +/** + * @param {Client} client + * @param {any} message + * @param {Array} transferrables + * @returns {Promise} + */ +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel() + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error) + } + + resolve(event.data) + } + + client.postMessage(message, [ + channel.port2, + ...transferrables.filter(Boolean), + ]) + }) +} + +/** + * @param {Response} response + * @returns {Response} + */ +function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error() + } + + const mockedResponse = new Response(response.body, response) + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }) + + return mockedResponse +} + +/** + * @param {Request} request + */ +async function serializeRequest(request) { + return { + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.arrayBuffer(), + keepalive: request.keepalive, + } +} diff --git a/ui/scripts/postinstall.js b/ui/scripts/postinstall.js index d545707178..ea46e57098 100644 --- a/ui/scripts/postinstall.js +++ b/ui/scripts/postinstall.js @@ -25,8 +25,69 @@ function runScriptIfExists(scriptPath, scriptName) { } } +function hardenMswServiceWorker() { + const workerPath = path.join( + __dirname, + "..", + "public", + "mockServiceWorker.js", + ); + + let workerFile; + try { + workerFile = fs.openSync(workerPath, "r+"); + } catch (error) { + if (error.code === "ENOENT") { + console.log("Skip MSW service worker hardening (worker missing)"); + return; + } + throw error; + } + + try { + const workerSource = fs.readFileSync(workerFile, "utf8"); + const originGuard = "event.origin !== self.location.origin"; + + if (workerSource.includes(originGuard)) { + return; + } + + const messageHandlerStart = + "addEventListener('message', async function (event) {\n const clientId = Reflect.get(event.source || {}, 'id')\n"; + const hardenedMessageHandlerStart = + "addEventListener('message', async function (event) {\n" + + " // Only accept messages from pages served from the same origin as this worker.\n" + + " if (event.origin !== self.location.origin) {\n" + + " return\n" + + " }\n\n" + + " const clientId = Reflect.get(event.source || {}, 'id')\n"; + + if (!workerSource.includes(messageHandlerStart)) { + console.warn( + "⚠️ Unable to harden MSW service worker: message handler changed", + ); + return; + } + + const hardenedWorkerSource = workerSource.replace( + messageHandlerStart, + hardenedMessageHandlerStart, + ); + + fs.ftruncateSync(workerFile, 0); + fs.writeSync(workerFile, hardenedWorkerSource, 0, "utf8"); + console.log("Hardened MSW service worker message origin handling"); + } finally { + fs.closeSync(workerFile); + } +} + // Run dependency log update runScriptIfExists("./update-dependency-log.js", "deps:log"); +// Re-apply local hardening after MSW regenerates the worker during install. +// Keep this before setup-git-hooks because that script can exit the process. +hardenMswServiceWorker(); + // Run git hooks setup runScriptIfExists("./setup-git-hooks.js", "setup-git-hooks"); diff --git a/ui/types/attack-paths.ts b/ui/types/attack-paths.ts index 5aecdec671..e1378a1b1c 100644 --- a/ui/types/attack-paths.ts +++ b/ui/types/attack-paths.ts @@ -192,8 +192,8 @@ export interface GraphNode { export interface GraphEdge { id: string; - source: string | object; - target: string | object; + source: string; + target: string; type: string; properties?: GraphNodeProperties; } @@ -232,40 +232,6 @@ export interface AttackPathQueryError { status: number; } -// Finding severity and status constants -export const FINDING_SEVERITIES = { - CRITICAL: "critical", - HIGH: "high", - MEDIUM: "medium", - LOW: "low", - INFO: "info", -} as const; - -type FindingSeverity = - (typeof FINDING_SEVERITIES)[keyof typeof FINDING_SEVERITIES]; - -export const FINDING_STATUSES = { - PASS: "PASS", - FAIL: "FAIL", - MANUAL: "MANUAL", -} as const; - -type FindingStatus = (typeof FINDING_STATUSES)[keyof typeof FINDING_STATUSES]; - -export interface RelatedFinding { - id: string; - title: string; - severity: FindingSeverity; - status: FindingStatus; -} - -// Node Detail Types -export interface NodeDetailData extends GraphNode { - relatedFindings?: RelatedFinding[]; - incomingEdges?: GraphEdge[]; - outgoingEdges?: GraphEdge[]; -} - // Wizard State Types export interface WizardState { currentStep: 1 | 2; @@ -280,9 +246,6 @@ export interface GraphState { selectedNodeId: string | null; loading: boolean; error: string | null; - zoomLevel: number; - panX: number; - panY: number; } // Provider Integration diff --git a/ui/vitest.browser.setup.ts b/ui/vitest.browser.setup.ts new file mode 100644 index 0000000000..199b0d474f --- /dev/null +++ b/ui/vitest.browser.setup.ts @@ -0,0 +1,94 @@ +// Global stylesheet (Tailwind + design tokens) is imported by the Next.js +// layouts in the real app. Tests render the page in isolation, bypassing the +// layout, so without this import Tailwind classes resolve to nothing — the +// page collapses to unstyled HTML and stacked elements end up overlapping +// the graph nodes, blocking Playwright clicks. Pull the stylesheet directly +// so the test bundle gets the same CSS the production page receives. +import "@/styles/globals.css"; + +import { afterAll, afterEach, beforeAll, vi } from "vitest"; + +import { worker } from "./__tests__/msw/worker"; + +// Server Actions ("use server") are bundled by Vite as plain async functions +// — the directive is a Next.js compiler concept, not part of Vite. When the +// page invokes one, it runs in the browser and reaches `auth()` from +// next-auth, which calls `next/headers` (request-scoped AsyncLocalStorage +// only set up by Next's request handler) and throws "headers was called +// outside a request scope". That kills every action before it can hit +// MSW. Stub `auth.config` with a fake session so the action proceeds to +// `fetch()` and MSW takes over. +vi.mock("@/auth.config", () => ({ + auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })), + signIn: vi.fn(), + signOut: vi.fn(), + handlers: {}, +})); + +// Next.js's App Router context (`useRouter`, `useSearchParams`, `usePathname`) +// is not available in vitest browser — there's no Next runtime mounting the +// providers. We back the hooks with the real `window.location` so navigating +// via `history.replaceState` in tests is enough to drive the page. +vi.mock("next/navigation", () => { + const router = { + push: vi.fn(), + replace: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + refresh: vi.fn(), + prefetch: vi.fn(() => Promise.resolve()), + }; + return { + useSearchParams: () => new URLSearchParams(window.location.search), + useRouter: () => router, + usePathname: () => window.location.pathname, + useParams: () => ({}), + redirect: vi.fn(), + notFound: vi.fn(), + }; +}); + +beforeAll(async () => { + await worker.start({ + serviceWorker: { url: "/mockServiceWorker.js" }, + onUnhandledRequest: "error", + }); +}); + +afterEach(() => { + worker.resetHandlers(); +}); + +afterAll(() => { + worker.stop(); +}); + +// React Flow's pan/drag handlers dispatch pointer events that access +// `event.view.document` on the node. When user-event synthesises these +// events the `view` property can be null, producing harmless +// "Cannot read properties of null (reading 'document')" errors. +// Swallow only that specific unhandled error; everything else propagates. +const isReactFlowNullViewError = (reason: unknown): boolean => { + const message = + reason instanceof Error + ? reason.message + : typeof reason === "string" + ? reason + : ""; + return message.includes( + "Cannot read properties of null (reading 'document')", + ); +}; + +window.addEventListener("error", (event) => { + if (isReactFlowNullViewError(event.error)) { + event.preventDefault(); + event.stopImmediatePropagation(); + } +}); + +window.addEventListener("unhandledrejection", (event) => { + if (isReactFlowNullViewError(event.reason)) { + event.preventDefault(); + } +}); diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index b22d7f72cd..bb976e11b1 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -1,39 +1,180 @@ import react from "@vitejs/plugin-react"; +import { playwright } from "@vitest/browser-playwright"; import path from "path"; +import type { TestProjectConfiguration } from "vitest/config"; import { defineConfig } from "vitest/config"; -export default defineConfig({ - plugins: [react()], - test: { - environment: "jsdom", - globals: true, - restoreMocks: true, - mockReset: true, - unstubEnvs: true, - unstubGlobals: true, - setupFiles: ["./vitest.setup.ts"], - include: ["**/*.test.{ts,tsx}"], - exclude: [ - "node_modules", - ".next", - "tests/**/*", // Playwright E2E tests - ], - coverage: { - provider: "v8", - reporter: ["text", "json", "html"], - exclude: [ - "node_modules", - ".next", - "tests/**/*", - "**/*.test.{ts,tsx}", - "vitest.config.ts", - "vitest.setup.ts", +export default defineConfig(() => { + const apiBaseUrl = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost/api/v1"; + + return { + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./"), + }, + }, + test: { + globals: true, + restoreMocks: true, + mockReset: true, + unstubEnvs: true, + unstubGlobals: true, + coverage: { + provider: "v8" as const, + reporter: ["text", "json", "html"], + exclude: [ + "node_modules", + ".next", + "tests/**/*", + "**/*.test.{ts,tsx}", + "**/*.browser.test.{ts,tsx}", + "vitest.config.ts", + "vitest.setup.ts", + "vitest.browser.setup.ts", + "__tests__/**/*", + ], + }, + projects: [ + { + extends: true, + test: { + name: "unit", + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + include: ["**/*.test.{ts,tsx}"], + exclude: [ + "node_modules", + ".next", + "tests/**/*", + "**/*.browser.test.{ts,tsx}", + ], + }, + }, + { + extends: true, + test: { + name: "browser", + setupFiles: ["./vitest.browser.setup.ts"], + include: ["**/*.browser.test.{ts,tsx}"], + exclude: ["node_modules", ".next", "tests/**/*"], + browser: { + enabled: true, + // Vitest's browser default viewport is 414×896 (phone-sized), + // which collapses the responsive layout: the legend stacks + // vertically and ends up overlapping the graph, so Playwright + // can't click nodes. Use a standard desktop viewport. + viewport: { width: 1280, height: 800 }, + provider: playwright(), + headless: true, + instances: [{ browser: "chromium" }], + }, + }, + }, + ] as TestProjectConfiguration[], + }, + define: { + "process.env.NEXT_PUBLIC_API_BASE_URL": JSON.stringify(apiBaseUrl), + // `next/dist/server/web/spec-extension/user-agent.js` references + // `__dirname` directly and is pulled in transitively via `next-auth`. + // Vite serves it to the browser where that global doesn't exist, so we + // replace it at bundle time. `optimizeDeps` alone doesn't help — + // pre-bundling doesn't patch the identifier. + __dirname: JSON.stringify("/"), + __filename: JSON.stringify("/__browser_test__.js"), + }, + optimizeDeps: { + // Pre-bundle every dep that the attack-paths page transitively imports. + // Without this, Vite optimizes them on demand at the first request and + // reloads the page, killing the test run. Keep this list aligned with + // imports through the page's render tree. + include: [ + // Test stack + "vitest-browser-react", + "msw/browser", + + // Next runtime + "next/navigation", + "next/link", + "next/image", + "next/cache", + "next/server", + "next-auth", + "next-auth/react", + "next-auth/providers/credentials", + "next-themes", + + // App component lib + "@heroui/react", + "@heroui/accordion", + "@heroui/breadcrumbs", + "@heroui/card", + "@heroui/chip", + "@heroui/divider", + "@heroui/input", + "@heroui/switch", + "@heroui/theme", + "@heroui/tooltip", + "@heroui/use-clipboard", + "@iconify/react", + + // Radix + "@radix-ui/react-alert-dialog", + "@radix-ui/react-avatar", + "@radix-ui/react-checkbox", + "@radix-ui/react-collapsible", + "@radix-ui/react-dialog", + "@radix-ui/react-dropdown-menu", + "@radix-ui/react-icons", + "@radix-ui/react-label", + "@radix-ui/react-popover", + "@radix-ui/react-radio-group", + "@radix-ui/react-scroll-area", + "@radix-ui/react-select", + "@radix-ui/react-separator", + "@radix-ui/react-tabs", + "@radix-ui/react-toast", + "@radix-ui/react-tooltip", + "@radix-ui/react-slot", + + // Graph + "@xyflow/react", + "@dagrejs/dagre", + + // Forms / state + "react-hook-form", + "@hookform/resolvers/zod", + "zod", + "zustand", + "zustand/middleware", + + // Styling helpers + "lucide-react", + "clsx", + "tailwind-merge", + "class-variance-authority", + "tailwind-variants", + + // App-level deps the page (or its children) pull in + "@tanstack/react-table", + "@react-aria/ssr", + "@react-aria/visually-hidden", + "modern-screenshot", + "framer-motion", + "vaul", + "cmdk", + "react-markdown", + "jwt-decode", + "date-fns", + "js-yaml", + "@codemirror/language", + "@codemirror/state", + "@lezer/highlight", + "@uiw/react-codemirror", + "@sentry/nextjs", + "@extractus/feed-extractor", ], }, - }, - resolve: { - alias: { - "@": path.resolve(__dirname, "./"), - }, - }, + }; });