mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-05-06 16:58:19 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c023affa7 | |||
| 58b0fa556d | |||
| aa311623fe | |||
| 142b45a387 | |||
| ec102d1569 | |||
| d26f455784 | |||
| 4d5a77a58a | |||
| c183d5e868 | |||
| 74e5118646 | |||
| 48882b553f | |||
| 8acbddd125 | |||
| 786059bfb2 | |||
| 3d4f5e66ab | |||
| a4fc230cf4 | |||
| 1d54244f2b | |||
| ff2bf5b01d | |||
| ba84b23afb | |||
| a9427c8024 | |||
| 10a62a6850 | |||
| b4601abb4e | |||
| 5c981f5683 | |||
| 9922b15391 | |||
| 6e77abea01 | |||
| 4d57f3bef1 |
@@ -42,6 +42,8 @@ jobs:
|
||||
fonts.gstatic.com:443
|
||||
api.github.com:443
|
||||
release-assets.githubusercontent.com:443
|
||||
cdn.playwright.dev:443
|
||||
objects.githubusercontent.com:443
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -152,6 +154,24 @@ jobs:
|
||||
echo "Only test files changed - running ALL unit tests"
|
||||
pnpm run test:run
|
||||
|
||||
- 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'
|
||||
run: pnpm run build
|
||||
|
||||
@@ -121,8 +121,8 @@ To update the environment file:
|
||||
Edit the `.env` file and change version values:
|
||||
|
||||
```env
|
||||
PROWLER_UI_VERSION="5.25.1"
|
||||
PROWLER_API_VERSION="5.25.1"
|
||||
PROWLER_UI_VERSION="5.25.2"
|
||||
PROWLER_API_VERSION="5.25.2"
|
||||
```
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -2,6 +2,18 @@
|
||||
|
||||
All notable changes to the **Prowler UI** are documented in this file.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### 🚀 Added
|
||||
|
||||
- Browser test mode using Vitest with the Playwright provider, with initial coverage of the Attack Paths page and a new `pnpm test:browser` script wired into CI
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
- Attack Paths graph: extract shared primitives across `FindingNode`, `ResourceNode`, and `InternetNode` (hidden handles, label truncation, fill/border resolution) without forcing a generic node renderer [(#10705)](https://github.com/prowler-cloud/prowler/pull/10705)
|
||||
|
||||
---
|
||||
|
||||
## [1.25.2] (Prowler v5.25.2)
|
||||
|
||||
### 🔄 Changed
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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) }],
|
||||
});
|
||||
|
||||
export const handlersForFixture = (fx: PageFixture) => [
|
||||
http.get(`${API}/attack-paths-scans`, () =>
|
||||
HttpResponse.json<AttackPathScansResponse>(toScansApiResponse(fx.scans)),
|
||||
),
|
||||
|
||||
http.get<{ scanId: string }>(
|
||||
`${API}/attack-paths-scans/:scanId/queries`,
|
||||
() =>
|
||||
HttpResponse.json<AttackPathQueriesResponse>(
|
||||
toQueriesApiResponse(fx.queries),
|
||||
),
|
||||
),
|
||||
|
||||
http.post<{ scanId: string }>(
|
||||
`${API}/attack-paths-scans/:scanId/queries/run`,
|
||||
() => {
|
||||
if (fx.queryError) {
|
||||
return HttpResponse.json<JsonApiErrorBody>(
|
||||
toErrorBody(fx.queryError.error, fx.queryError.status),
|
||||
{ status: fx.queryError.status },
|
||||
);
|
||||
}
|
||||
if (!fx.queryResult) {
|
||||
return HttpResponse.json<JsonApiErrorBody>(
|
||||
toErrorBody("No data found", 404),
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json<AttackPathQueryResult>(
|
||||
toQueryResultApiResponse(fx.queryResult, fx.queryId),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
http.post<{ scanId: string }>(
|
||||
`${API}/attack-paths-scans/:scanId/queries/custom`,
|
||||
() => {
|
||||
if (fx.queryError) {
|
||||
return HttpResponse.json<JsonApiErrorBody>(
|
||||
toErrorBody(fx.queryError.error, fx.queryError.status),
|
||||
{ status: fx.queryError.status },
|
||||
);
|
||||
}
|
||||
if (!fx.queryResult) {
|
||||
return HttpResponse.json<JsonApiErrorBody>(
|
||||
toErrorBody("No data found", 404),
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json<AttackPathQueryResult>(
|
||||
toQueryResultApiResponse(fx.queryResult, fx.queryId),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
@@ -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[] = [];
|
||||
@@ -0,0 +1,5 @@
|
||||
import { setupWorker } from "msw/browser";
|
||||
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
@@ -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<typeof vitestRender>[1];
|
||||
|
||||
export function render(ui: ReactElement, options?: RenderOptions) {
|
||||
const userWrapper = options?.wrapper as
|
||||
| ComponentType<PropsWithChildren>
|
||||
| undefined;
|
||||
|
||||
const Wrapper = userWrapper
|
||||
? ({ children }: PropsWithChildren) => {
|
||||
const Inner = userWrapper;
|
||||
return (
|
||||
<TestProviders>
|
||||
<Inner>{children}</Inner>
|
||||
</TestProviders>
|
||||
);
|
||||
}
|
||||
: TestProviders;
|
||||
|
||||
return vitestRender(ui, { ...options, wrapper: Wrapper });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
+516
-1110
File diff suppressed because it is too large
Load Diff
+43
@@ -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(<GraphControls {...baseProps} />);
|
||||
|
||||
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(<GraphControls {...baseProps} onExport={onExport} />);
|
||||
|
||||
const exportButton = screen.getByRole("button", {
|
||||
name: /^export graph$/i,
|
||||
});
|
||||
|
||||
expect(exportButton).toBeEnabled();
|
||||
|
||||
await user.click(exportButton);
|
||||
|
||||
expect(onExport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+9
-2
@@ -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"
|
||||
>
|
||||
<ZoomIn size={18} />
|
||||
</Button>
|
||||
@@ -52,6 +53,7 @@ export const GraphControls = ({
|
||||
size="sm"
|
||||
onClick={onZoomOut}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
<ZoomOut size={18} />
|
||||
</Button>
|
||||
@@ -66,6 +68,7 @@ export const GraphControls = ({
|
||||
size="sm"
|
||||
onClick={onFitToScreen}
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Fit graph to view"
|
||||
>
|
||||
<Minimize2 size={18} />
|
||||
</Button>
|
||||
@@ -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"}
|
||||
>
|
||||
<Download size={18} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Export graph</TooltipContent>
|
||||
<TooltipContent>
|
||||
{onExport ? "Export graph" : "Export available soon"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { type NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { resolveNodeColors, truncateLabel } from "../../../_lib";
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface FindingNodeData {
|
||||
graphNode: GraphNode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const HEXAGON_WIDTH = 200;
|
||||
const HEXAGON_HEIGHT = 55;
|
||||
const TITLE_MAX_CHARS = 24;
|
||||
|
||||
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 displayTitle = truncateLabel(title, TITLE_MAX_CHARS);
|
||||
|
||||
// Hexagon SVG path
|
||||
const w = HEXAGON_WIDTH;
|
||||
const h = HEXAGON_HEIGHT;
|
||||
const sideInset = w * 0.15;
|
||||
const hexPath = `
|
||||
M ${sideInset} 0
|
||||
L ${w - sideInset} 0
|
||||
L ${w} ${h / 2}
|
||||
L ${w - sideInset} ${h}
|
||||
L ${sideInset} ${h}
|
||||
L 0 ${h / 2}
|
||||
Z
|
||||
`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HiddenHandles />
|
||||
<svg
|
||||
width={w}
|
||||
height={h}
|
||||
className="overflow-visible"
|
||||
style={{ filter: selected ? undefined : "url(#glow)" }}
|
||||
>
|
||||
<path
|
||||
d={hexPath}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.85}
|
||||
stroke={borderColor}
|
||||
strokeWidth={selected ? 4 : 2}
|
||||
className={selected ? "selected-node" : undefined}
|
||||
/>
|
||||
<text
|
||||
x={w / 2}
|
||||
y={h / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#ffffff"
|
||||
fontSize="11px"
|
||||
fontWeight="600"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
{displayTitle}
|
||||
</text>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
|
||||
export const HiddenHandles = () => (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="invisible" />
|
||||
<Handle type="source" position={Position.Right} className="invisible" />
|
||||
</>
|
||||
);
|
||||
+81
@@ -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 (
|
||||
<>
|
||||
<HiddenHandles />
|
||||
<svg width={DIAMETER} height={DIAMETER} className="overflow-visible">
|
||||
{/* Main circle */}
|
||||
<circle
|
||||
cx={RADIUS}
|
||||
cy={RADIUS}
|
||||
r={RADIUS}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.85}
|
||||
stroke={borderColor}
|
||||
strokeWidth={strokeWidth}
|
||||
className={selected ? "selected-node" : undefined}
|
||||
/>
|
||||
{/* Horizontal ellipse (equator) */}
|
||||
<ellipse
|
||||
cx={RADIUS}
|
||||
cy={RADIUS}
|
||||
rx={RADIUS}
|
||||
ry={RADIUS * 0.35}
|
||||
fill="none"
|
||||
stroke={borderColor}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
{/* Vertical ellipse (meridian) */}
|
||||
<ellipse
|
||||
cx={RADIUS}
|
||||
cy={RADIUS}
|
||||
rx={RADIUS * 0.35}
|
||||
ry={RADIUS}
|
||||
fill="none"
|
||||
stroke={borderColor}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
{/* Label */}
|
||||
<text
|
||||
x={RADIUS}
|
||||
y={RADIUS}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#ffffff"
|
||||
fontSize="11px"
|
||||
fontWeight="600"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
Internet
|
||||
</text>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { type NodeProps } from "@xyflow/react";
|
||||
|
||||
import type { GraphNode } from "@/types/attack-paths";
|
||||
|
||||
import { resolveNodeColors, truncateLabel } from "../../../_lib";
|
||||
import { formatNodeLabel } from "../../../_lib/format";
|
||||
import { HiddenHandles } from "./hidden-handles";
|
||||
|
||||
interface ResourceNodeData {
|
||||
graphNode: GraphNode;
|
||||
hasFindings?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 180;
|
||||
const NODE_HEIGHT = 50;
|
||||
const NODE_RADIUS = 25;
|
||||
const NAME_MAX_CHARS = 22;
|
||||
|
||||
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 strokeWidth = selected ? 4 : hasFindings ? 2.5 : 1.5;
|
||||
|
||||
const name = String(
|
||||
graphNode.properties?.name ||
|
||||
graphNode.properties?.id ||
|
||||
(graphNode.labels.length > 0
|
||||
? formatNodeLabel(graphNode.labels[0])
|
||||
: "Unknown"),
|
||||
);
|
||||
const displayName = truncateLabel(name, NAME_MAX_CHARS);
|
||||
|
||||
const typeLabel =
|
||||
graphNode.labels.length > 0 ? formatNodeLabel(graphNode.labels[0]) : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<HiddenHandles />
|
||||
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
|
||||
<rect
|
||||
x={0}
|
||||
y={0}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
rx={NODE_RADIUS}
|
||||
ry={NODE_RADIUS}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.85}
|
||||
stroke={borderColor}
|
||||
strokeWidth={strokeWidth}
|
||||
className={selected ? "selected-node" : undefined}
|
||||
/>
|
||||
<text
|
||||
x={NODE_WIDTH / 2}
|
||||
y={NODE_HEIGHT / 2}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="#ffffff"
|
||||
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<tspan
|
||||
x={NODE_WIDTH / 2}
|
||||
dy="-0.3em"
|
||||
fontSize="11px"
|
||||
fontWeight="600"
|
||||
>
|
||||
{displayName}
|
||||
</tspan>
|
||||
{typeLabel && (
|
||||
<tspan
|
||||
x={NODE_WIDTH / 2}
|
||||
dy="1.3em"
|
||||
fontSize="9px"
|
||||
fill="rgba(255,255,255,0.8)"
|
||||
>
|
||||
{typeLabel}
|
||||
</tspan>
|
||||
)}
|
||||
</text>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-1
@@ -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";
|
||||
|
||||
-105
@@ -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 (
|
||||
<div
|
||||
key={edge.id}
|
||||
className="border-border-neutral-tertiary dark:border-border-neutral-tertiary flex items-center justify-between rounded border p-2"
|
||||
>
|
||||
<code className="text-text-neutral-secondary dark:text-text-neutral-secondary text-xs">
|
||||
{displayId}
|
||||
</code>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-2 py-1 text-xs font-medium",
|
||||
isOutgoing
|
||||
? "bg-bg-data-info text-text-neutral-primary dark:text-text-neutral-primary"
|
||||
: "bg-bg-pass-primary text-text-neutral-primary dark:text-text-neutral-primary",
|
||||
)}
|
||||
>
|
||||
{formatEdgeType(edge.type)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Node relationships section showing incoming and outgoing edges
|
||||
*/
|
||||
export const NodeRelationships = ({
|
||||
incomingEdges,
|
||||
outgoingEdges,
|
||||
}: NodeRelationshipsProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Outgoing Relationships */}
|
||||
<div>
|
||||
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
|
||||
Outgoing Relationships ({outgoingEdges.length})
|
||||
</h4>
|
||||
{outgoingEdges.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{outgoingEdges.map((edge) => (
|
||||
<EdgeItem key={edge.id} edge={edge} isOutgoing />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
|
||||
No outgoing relationships
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Incoming Relationships */}
|
||||
<div className="border-border-neutral-tertiary dark:border-border-neutral-tertiary border-t pt-6">
|
||||
<h4 className="dark:text-prowler-theme-pale/90 mb-3 text-sm font-semibold">
|
||||
Incoming Relationships ({incomingEdges.length})
|
||||
</h4>
|
||||
{incomingEdges.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{incomingEdges.map((edge) => (
|
||||
<EdgeItem key={edge.id} edge={edge} isOutgoing={false} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-text-neutral-tertiary dark:text-text-neutral-tertiary text-xs">
|
||||
No incoming relationships
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
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<GraphStore>((set) => ({
|
||||
export const useGraphStore = create<GraphStore>((set) => ({
|
||||
...initialState,
|
||||
setGraphData: (data) =>
|
||||
set({
|
||||
@@ -54,12 +56,12 @@ const useGraphStore = create<GraphStore>((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,16 @@ const useGraphStore = create<GraphStore>((set) => ({
|
||||
fullData,
|
||||
selectedNodeId: nodeId,
|
||||
}),
|
||||
toggleExpandedResource: (resourceId) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.expandedResources);
|
||||
if (next.has(resourceId)) {
|
||||
next.delete(resourceId);
|
||||
} else {
|
||||
next.add(resourceId);
|
||||
}
|
||||
return { expandedResources: next };
|
||||
}),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
|
||||
@@ -106,11 +118,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 +169,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,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Rect } from "@xyflow/react";
|
||||
import { domToPng } from "modern-screenshot";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { exportGraphAsJSON, exportGraphAsPNG } from "./export";
|
||||
|
||||
vi.mock("modern-screenshot", () => ({
|
||||
domToPng: vi.fn(),
|
||||
}));
|
||||
|
||||
const bounds: Rect = { x: 0, y: 0, width: 100, height: 100 };
|
||||
|
||||
const buildContainerWithViewport = () => {
|
||||
const container = document.createElement("div");
|
||||
const viewport = document.createElement("div");
|
||||
viewport.className = "react-flow__viewport";
|
||||
container.appendChild(viewport);
|
||||
return container;
|
||||
};
|
||||
|
||||
describe("exportGraphAsPNG", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(domToPng).mockReset();
|
||||
});
|
||||
|
||||
it("throws when the container is not mounted", async () => {
|
||||
await expect(exportGraphAsPNG(null, bounds)).rejects.toThrow(
|
||||
"Graph container not mounted",
|
||||
);
|
||||
expect(domToPng).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the React Flow viewport is missing inside the container", async () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
await expect(exportGraphAsPNG(container, bounds)).rejects.toThrow(
|
||||
"React Flow viewport not found in container",
|
||||
);
|
||||
expect(domToPng).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when bounds are null (no nodes to export)", async () => {
|
||||
const container = buildContainerWithViewport();
|
||||
|
||||
await expect(exportGraphAsPNG(container, null)).rejects.toThrow(
|
||||
"No nodes to export",
|
||||
);
|
||||
expect(domToPng).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-throws a generic export error when domToPng rejects", async () => {
|
||||
const container = buildContainerWithViewport();
|
||||
vi.mocked(domToPng).mockRejectedValueOnce(new Error("rasterizer boom"));
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await expect(exportGraphAsPNG(container, bounds)).rejects.toThrow(
|
||||
"Failed to export graph",
|
||||
);
|
||||
expect(domToPng).toHaveBeenCalledOnce();
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("exportGraphAsJSON", () => {
|
||||
it("re-throws a generic export error when serialization fails", () => {
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => exportGraphAsJSON(circular)).toThrow("Failed to export graph");
|
||||
expect(consoleError).toHaveBeenCalled();
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
/**
|
||||
* Export utilities for attack path graphs
|
||||
* Handles exporting graph visualization to various formats
|
||||
* React Flow renders HTML, so PNG export uses modern-screenshot + RF viewport math
|
||||
*/
|
||||
|
||||
/**
|
||||
* Helper function to download a blob as a file
|
||||
* @param blob The blob to download
|
||||
* @param filename The name of the file
|
||||
*/
|
||||
import { getViewportForBounds, type Rect } from "@xyflow/react";
|
||||
import { domToPng } from "modern-screenshot";
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -19,106 +17,73 @@ 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",
|
||||
) => {
|
||||
if (!svgElement) return;
|
||||
|
||||
try {
|
||||
// Clone the SVG element to avoid modifying the original
|
||||
const clonedSvg = svgElement.cloneNode(true) as SVGSVGElement;
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
const bbox = originalContainer.getBBox();
|
||||
|
||||
// Add padding around the content
|
||||
const padding = 50;
|
||||
const contentWidth = bbox.width + padding * 2;
|
||||
const contentHeight = bbox.height + padding * 2;
|
||||
|
||||
// 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}`,
|
||||
);
|
||||
|
||||
// Remove the zoom transform from the container - the viewBox now handles positioning
|
||||
containerGroup.removeAttribute("transform");
|
||||
|
||||
// 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 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");
|
||||
}
|
||||
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);
|
||||
};
|
||||
|
||||
// Export target dimensions — fixed so bounds math is deterministic across zoom levels
|
||||
const EXPORT_IMAGE_WIDTH = 1920;
|
||||
const EXPORT_IMAGE_HEIGHT = 1080;
|
||||
const EXPORT_MIN_ZOOM = 0.2;
|
||||
const EXPORT_MAX_ZOOM = 2;
|
||||
const EXPORT_PADDING = 0.1;
|
||||
const EXPORT_BACKGROUND = "#1c1917";
|
||||
|
||||
/**
|
||||
* 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 via modern-screenshot.
|
||||
*
|
||||
* Receives pre-computed node bounds (use `GraphHandle.getNodesBounds()` so the
|
||||
* React Flow instance's `nodeLookup` is honored for sub-flows). Then uses
|
||||
* `getViewportForBounds()` to produce a viewport transform that fits all nodes
|
||||
* inside the export canvas regardless of the user's current zoom/pan, and
|
||||
* applies it to `.react-flow__viewport` before rasterizing.
|
||||
*/
|
||||
export const exportGraphAsPNG = async (
|
||||
svgElement: SVGSVGElement | null,
|
||||
containerElement: HTMLDivElement | null,
|
||||
bounds: Rect | null,
|
||||
filename: string = "attack-path-graph.png",
|
||||
) => {
|
||||
if (!svgElement) return;
|
||||
if (!containerElement) {
|
||||
throw new Error("Graph container not mounted");
|
||||
}
|
||||
|
||||
const viewportElement = containerElement.querySelector<HTMLElement>(
|
||||
".react-flow__viewport",
|
||||
);
|
||||
if (!viewportElement) {
|
||||
throw new Error("React Flow viewport not found in container");
|
||||
}
|
||||
|
||||
if (!bounds) {
|
||||
throw new Error("No nodes to export");
|
||||
}
|
||||
|
||||
const viewport = getViewportForBounds(
|
||||
bounds,
|
||||
EXPORT_IMAGE_WIDTH,
|
||||
EXPORT_IMAGE_HEIGHT,
|
||||
EXPORT_MIN_ZOOM,
|
||||
EXPORT_MAX_ZOOM,
|
||||
EXPORT_PADDING,
|
||||
);
|
||||
|
||||
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)}`;
|
||||
const dataUrl = await domToPng(viewportElement, {
|
||||
backgroundColor: EXPORT_BACKGROUND,
|
||||
width: EXPORT_IMAGE_WIDTH,
|
||||
height: EXPORT_IMAGE_HEIGHT,
|
||||
style: {
|
||||
width: `${EXPORT_IMAGE_WIDTH}px`,
|
||||
height: `${EXPORT_IMAGE_HEIGHT}px`,
|
||||
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
|
||||
},
|
||||
});
|
||||
downloadDataUrl(dataUrl, filename);
|
||||
} catch (error) {
|
||||
console.error("Failed to export graph as PNG:", error);
|
||||
throw new Error("Failed to export graph");
|
||||
@@ -126,9 +91,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<string, unknown>,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,37 @@ export const getNodeBorderColor = (
|
||||
return GRAPH_NODE_BORDER_COLORS.default;
|
||||
};
|
||||
|
||||
interface ResolveNodeColorsParams {
|
||||
labels: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
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 = hasFindings
|
||||
? GRAPH_ALERT_BORDER_COLOR
|
||||
: selected
|
||||
? GRAPH_EDGE_HIGHLIGHT_COLOR
|
||||
: getNodeBorderColor(labels, properties);
|
||||
return { fillColor, borderColor };
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a background color is light (for determining text color)
|
||||
*/
|
||||
|
||||
@@ -4,23 +4,6 @@
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Helper to get edge source/target ID from string or object
|
||||
*/
|
||||
export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => {
|
||||
if (typeof nodeRef === "string") {
|
||||
return nodeRef;
|
||||
}
|
||||
// Edge node references are objects with an id property
|
||||
return (nodeRef as { id: string }).id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute a filtered subgraph containing only the path through the target node.
|
||||
* This follows the directed graph structure of attack paths:
|
||||
@@ -44,10 +27,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<string>();
|
||||
@@ -84,35 +65,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,
|
||||
|
||||
@@ -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,13 @@ export {
|
||||
GRAPH_NODE_BORDER_COLORS,
|
||||
GRAPH_NODE_COLORS,
|
||||
GRAPH_SELECTION_COLOR,
|
||||
resolveNodeColors,
|
||||
} from "./graph-colors";
|
||||
export { computeFilteredSubgraph, getPathEdges } from "./graph-utils";
|
||||
export { layoutWithDagre } from "./layout";
|
||||
export {
|
||||
computeFilteredSubgraph,
|
||||
type EdgeNodeRef,
|
||||
getEdgeNodeId,
|
||||
getPathEdges,
|
||||
} from "./graph-utils";
|
||||
NODE_CATEGORY,
|
||||
type NodeCategory,
|
||||
type NodeVisual,
|
||||
resolveNodeVisual,
|
||||
} from "./node-visuals";
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
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: 200,
|
||||
height: 55,
|
||||
});
|
||||
expect(byId.get("resource-1")).toMatchObject({
|
||||
type: "resource",
|
||||
width: 180,
|
||||
height: 50,
|
||||
});
|
||||
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("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("builds rf edge IDs as `${source}-${target}` after layout", () => {
|
||||
const { rfEdges } = layoutWithDagre(
|
||||
[findingNode, resourceNode],
|
||||
[
|
||||
{
|
||||
id: "ignored-by-rf",
|
||||
source: "resource-1",
|
||||
target: "finding-1",
|
||||
type: "HAS_FINDING",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(rfEdges[0]?.id).toBe("resource-1-finding-1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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, Node } from "@xyflow/react";
|
||||
|
||||
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
|
||||
|
||||
// Node dimensions matching the original D3 implementation
|
||||
const NODE_WIDTH = 180;
|
||||
const NODE_HEIGHT = 50;
|
||||
const HEXAGON_WIDTH = 200;
|
||||
const HEXAGON_HEIGHT = 55;
|
||||
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<string, unknown> {
|
||||
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: HEXAGON_WIDTH, height: HEXAGON_HEIGHT };
|
||||
if (type === NODE_TYPE.INTERNET)
|
||||
return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER };
|
||||
return { width: NODE_WIDTH, height: 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<NodeData>[]; rfEdges: Edge[] } => {
|
||||
const g = new Graph();
|
||||
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, {
|
||||
originalSource: edge.source,
|
||||
originalTarget: edge.target,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
dagreLayout(g);
|
||||
|
||||
// Build RF nodes from layout
|
||||
const rfNodes: Node<NodeData>[] = 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,
|
||||
},
|
||||
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 }) => {
|
||||
const edgeData = g.edge(e) as {
|
||||
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: `${e.v}-${e.w}`,
|
||||
source: e.v,
|
||||
target: e.w,
|
||||
animated: hasFinding,
|
||||
className: hasFinding ? "finding-edge" : "resource-edge",
|
||||
data: {
|
||||
originalSource: edgeData.originalSource,
|
||||
originalTarget: edgeData.originalTarget,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { rfNodes, rfEdges };
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import { Globe2, KeyRound, Network, Server, UserRound } from "lucide-react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AmazonEC2Icon,
|
||||
AmazonS3Icon,
|
||||
AmazonVPCIcon,
|
||||
AWSAccountIcon,
|
||||
AWSIAMIcon,
|
||||
} 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(AWSAccountIcon);
|
||||
});
|
||||
|
||||
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", AWSAccountIcon],
|
||||
["S3Bucket", NODE_CATEGORY.STORAGE, "S3 Bucket", AmazonS3Icon],
|
||||
["S3", NODE_CATEGORY.STORAGE, "S3", AmazonS3Icon],
|
||||
["VPC", NODE_CATEGORY.NETWORK, "VPC", AmazonVPCIcon],
|
||||
["Subnet", NODE_CATEGORY.NETWORK, "Subnet", Network],
|
||||
["SecurityGroup", NODE_CATEGORY.NETWORK, "Security Group", Network],
|
||||
["InternetGateway", NODE_CATEGORY.NETWORK, "Internet Gateway", Globe2],
|
||||
["DefaultGateway", NODE_CATEGORY.NETWORK, "Default Gateway", Globe2],
|
||||
["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", UserRound],
|
||||
["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 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Box,
|
||||
Globe2,
|
||||
KeyRound,
|
||||
Network,
|
||||
Server,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import type { ElementType } from "react";
|
||||
|
||||
import {
|
||||
AmazonEC2Icon,
|
||||
AmazonS3Icon,
|
||||
AmazonVPCIcon,
|
||||
AWSAccountIcon,
|
||||
AWSIAMIcon,
|
||||
} 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: AWSAccountIcon,
|
||||
},
|
||||
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: Network,
|
||||
},
|
||||
securitygroup: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Security Group",
|
||||
Icon: Network,
|
||||
},
|
||||
internetgateway: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Internet Gateway",
|
||||
Icon: Globe2,
|
||||
},
|
||||
defaultgateway: {
|
||||
category: NODE_CATEGORY.NETWORK,
|
||||
description: "Default Gateway",
|
||||
Icon: Globe2,
|
||||
},
|
||||
ec2instance: {
|
||||
category: NODE_CATEGORY.COMPUTE,
|
||||
description: "EC2 Instance",
|
||||
Icon: AmazonEC2Icon,
|
||||
},
|
||||
virtualmachine: {
|
||||
category: NODE_CATEGORY.COMPUTE,
|
||||
description: "Virtual Machine",
|
||||
Icon: AmazonEC2Icon,
|
||||
},
|
||||
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,
|
||||
},
|
||||
iamrole: {
|
||||
category: NODE_CATEGORY.IDENTITY,
|
||||
description: "IAM Role",
|
||||
Icon: AWSIAMIcon,
|
||||
},
|
||||
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: UserRound,
|
||||
},
|
||||
} as const satisfies Record<string, KnownNodeVisualMapping>;
|
||||
|
||||
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 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 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: AlertTriangle,
|
||||
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,
|
||||
};
|
||||
};
|
||||
+498
@@ -0,0 +1,498 @@
|
||||
/**
|
||||
* Browser-mode tests for <AttackPathsPage />.
|
||||
*
|
||||
* 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 } from "vitest";
|
||||
|
||||
import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
|
||||
import { useGraphStore } from "./_hooks/use-graph-state";
|
||||
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<AttackPathPageHarness>;
|
||||
}
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
const test = base.extend<Fixtures>({
|
||||
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(<AttackPathsPage />);
|
||||
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);
|
||||
for (const id of edgeIds) {
|
||||
expect(id).toMatch(/^[\w-]+-[\w-]+$/);
|
||||
}
|
||||
});
|
||||
|
||||
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 are hidden by default", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
// Tier 1 view: unattached findings stay hidden until the user expands
|
||||
// their adjacent resource — none here, so nothing renders.
|
||||
const graph = await mountWith(fixtures.findingsOnly());
|
||||
try {
|
||||
await graph.executeQuery();
|
||||
} catch {
|
||||
/* expected: nothing visible, layout never stabilizes */
|
||||
}
|
||||
expect(graph.findingNodes.length).toBe(0);
|
||||
expect(graph.resourceNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
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", 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(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("clicking a resource with findings opens the action selector", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
|
||||
expect(graph.hasNodeActionDialog).toBe(true);
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("choosing Show findings reveals related finding nodes", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseShowFindingsAction();
|
||||
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
});
|
||||
|
||||
test("expanded resources offer Hide findings in the action selector", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseShowFindingsAction();
|
||||
expect(graph.findingNodes.length).toBeGreaterThan(0);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
|
||||
expect(graph.hasNodeActionDialog).toBe(true);
|
||||
expect(graph.containsText(/Hide findings/i)).toBe(true);
|
||||
|
||||
await graph.chooseHideFindingsAction();
|
||||
|
||||
expect(graph.findingNodes.length).toBe(0);
|
||||
});
|
||||
|
||||
test("choosing View node details opens node details in a modal", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.clickFirstResourceNode();
|
||||
await graph.chooseViewNodeDetailsAction();
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(true);
|
||||
});
|
||||
|
||||
test("clicking a resource without findings opens node details in a modal", async ({
|
||||
mountWith,
|
||||
}) => {
|
||||
const graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(false);
|
||||
|
||||
await graph.clickFirstResourceNodeWithoutFindings();
|
||||
|
||||
expect(graph.hasNodeDetailsModal).toBe(true);
|
||||
});
|
||||
|
||||
test("clicking a parent node in filtered view asks whether to go back or view details", 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.clickFirstResourceNode();
|
||||
|
||||
expect(graph.hasNodeActionDialog).toBe(true);
|
||||
expect(graph.containsText(/Back to full graph/i)).toBe(true);
|
||||
|
||||
await graph.chooseBackToFullGraphAction();
|
||||
|
||||
expect(graph.isInFilteredView).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 graph = await mountWith();
|
||||
await graph.executeQuery();
|
||||
await graph.waitForLayoutStable(3);
|
||||
|
||||
await graph.hoverFirstResourceNode();
|
||||
await graph.waitForTransition(120);
|
||||
expect(graph.highlightedEdges.length).toBeGreaterThanOrEqual(0);
|
||||
|
||||
await graph.unhoverNodes();
|
||||
await graph.waitForTransition(120);
|
||||
expect(graph.highlightedEdges.length).toBe(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);
|
||||
});
|
||||
|
||||
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");
|
||||
// Regressions in the viewport element passed to `domToPng`, the
|
||||
// configured export size, or the bounds-driven viewport transform
|
||||
// fail loudly here.
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Typed fixture builders for <AttackPathsPage /> 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["attributes"]> = {},
|
||||
): 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["attributes"]> = {},
|
||||
): 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,
|
||||
};
|
||||
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* Test harness for <AttackPathsPage /> 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<HTMLElement>(
|
||||
AttackPathPageHarness.NODE_SEL,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
get edges(): HTMLElement[] {
|
||||
return Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>(
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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<HTMLElement>(
|
||||
`${AttackPathPageHarness.NODE_SEL}[data-id="${id}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
getEdgeById(id: string): HTMLElement | null {
|
||||
return this.container.querySelector<HTMLElement>(
|
||||
`${AttackPathPageHarness.EDGE_SEL}[data-id="${id}"]`,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Handles ---
|
||||
|
||||
private q(selector: string): HTMLElement | null {
|
||||
return this.container.querySelector<HTMLElement>(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<string> {
|
||||
const alert = await this.waitFor(
|
||||
() => this.container.querySelector<HTMLElement>('[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<SVGPathElement>(
|
||||
".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<HTMLElement>('[role="dialog"]');
|
||||
}
|
||||
|
||||
get nodeDetailsHeading(): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[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<HTMLElement>("[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<void> {
|
||||
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<T>(
|
||||
fn: () => T | null | undefined | false,
|
||||
timeoutMs = 3000,
|
||||
): Promise<T> {
|
||||
return vi.waitFor(
|
||||
() => {
|
||||
const v = fn();
|
||||
if (!v) throw new Error("waitFor predicate not yet truthy");
|
||||
return v;
|
||||
},
|
||||
{ timeout: timeoutMs, interval: 16 },
|
||||
) as Promise<T>;
|
||||
}
|
||||
|
||||
async waitForTransition(ms = 350): Promise<void> {
|
||||
await new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
// --- Action methods ---
|
||||
|
||||
async selectQuery(queryId?: string): Promise<void> {
|
||||
const trigger = await this.waitFor<HTMLButtonElement>(
|
||||
() =>
|
||||
this.container.querySelector<HTMLButtonElement>(
|
||||
'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<HTMLElement>(
|
||||
() =>
|
||||
document.querySelector<HTMLElement>(
|
||||
`[role="option"][data-value="${targetId}"]`,
|
||||
) ??
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
).find((el) => targetName && el.textContent?.includes(targetName)),
|
||||
10000,
|
||||
);
|
||||
await this.user.click(option);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async executeQuery(options: { selectFirst?: boolean } = {}): Promise<void> {
|
||||
if (options.selectFirst !== false) {
|
||||
await this.selectQuery();
|
||||
}
|
||||
|
||||
const button = await this.waitFor<HTMLButtonElement>(
|
||||
() =>
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLButtonElement>("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<void> {
|
||||
const el = this.getNodeById(nodeId);
|
||||
if (!el) throw new Error(`clickNode: node "${nodeId}" not found`);
|
||||
await this.user.click(el);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async clickFirstFindingNode(): Promise<HTMLElement> {
|
||||
const [finding] = this.findingNodes;
|
||||
if (!finding) throw new Error("clickFirstFindingNode: no finding rendered");
|
||||
await this.user.click(finding);
|
||||
await this.waitForTransition();
|
||||
return finding;
|
||||
}
|
||||
|
||||
async clickFirstResourceNode(): Promise<HTMLElement> {
|
||||
const [resource] = this.resourceNodes;
|
||||
if (!resource)
|
||||
throw new Error("clickFirstResourceNode: no resource rendered");
|
||||
await this.user.click(resource);
|
||||
await this.waitForTransition();
|
||||
return resource;
|
||||
}
|
||||
|
||||
async clickFirstResourceNodeWithoutFindings(): Promise<HTMLElement> {
|
||||
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<string>();
|
||||
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.user.click(resource);
|
||||
await this.waitForTransition();
|
||||
return resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the first finding `times` times back-to-back, with no transition
|
||||
* waits between clicks. Used for rapid-click race tests.
|
||||
*/
|
||||
async rapidlyClickFirstFindingNode(times = 2): Promise<HTMLElement> {
|
||||
const [finding] = this.findingNodes;
|
||||
if (!finding)
|
||||
throw new Error("rapidlyClickFirstFindingNode: no finding rendered");
|
||||
for (let i = 0; i < times; i++) {
|
||||
await this.user.click(finding);
|
||||
}
|
||||
await this.waitForTransition();
|
||||
return finding;
|
||||
}
|
||||
|
||||
async dblClickFirstResourceNode(): Promise<HTMLElement> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
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);
|
||||
if (this.hasNodeActionDialog) {
|
||||
await this.chooseShowFindingsAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async hoverNode(nodeId: string): Promise<void> {
|
||||
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<HTMLElement> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const btn = this.toolbar.zoomInButton;
|
||||
if (!btn) throw new Error("zoomIn: toolbar not rendered");
|
||||
await this.user.click(btn);
|
||||
}
|
||||
|
||||
async zoomOut(): Promise<void> {
|
||||
const btn = this.toolbar.zoomOutButton;
|
||||
if (!btn) throw new Error("zoomOut: toolbar not rendered");
|
||||
await this.user.click(btn);
|
||||
}
|
||||
|
||||
async fit(): Promise<void> {
|
||||
const btn = this.toolbar.fitButton;
|
||||
if (!btn) throw new Error("fit: toolbar not rendered");
|
||||
await this.user.click(btn);
|
||||
}
|
||||
|
||||
async closeNodeDetailsModal(): Promise<void> {
|
||||
const btn = this.q('button[aria-label="Close node details"]');
|
||||
if (!btn) throw new Error("closeNodeDetailsModal: modal not rendered");
|
||||
await this.user.click(btn);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseShowFindingsAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /show findings/i.test(btn.textContent ?? ""));
|
||||
if (!button) throw new Error("chooseShowFindingsAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseHideFindingsAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /hide findings/i.test(btn.textContent ?? ""));
|
||||
if (!button) throw new Error("chooseHideFindingsAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseViewNodeDetailsAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /view node details/i.test(btn.textContent ?? ""));
|
||||
if (!button)
|
||||
throw new Error("chooseViewNodeDetailsAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async chooseBackToFullGraphAction(): Promise<void> {
|
||||
const button = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((btn) => /back to full graph/i.test(btn.textContent ?? ""));
|
||||
if (!button)
|
||||
throw new Error("chooseBackToFullGraphAction: button not found");
|
||||
await this.user.click(button);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async exitFilteredView(): Promise<void> {
|
||||
const btn = this.toolbar.backToFullViewButton;
|
||||
if (!btn) throw new Error("exitFilteredView: not in filtered view");
|
||||
await this.user.click(btn);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
async openFullscreen(): Promise<void> {
|
||||
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<void> {
|
||||
const dialog = this.fullscreenDialog;
|
||||
if (!dialog) return;
|
||||
const close = dialog.querySelector<HTMLButtonElement>(
|
||||
'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<void> {
|
||||
const scope =
|
||||
target === "fullscreen" ? this.fullscreenDialog : this.container;
|
||||
if (!scope) throw new Error("exportAsPNG: target scope missing");
|
||||
const btn = scope.querySelector<HTMLButtonElement>(
|
||||
'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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,16 @@ 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 { Modal } from "@/components/shadcn/modal/modal";
|
||||
import { useToast } from "@/components/ui";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
@@ -48,17 +47,40 @@ import {
|
||||
GraphControls,
|
||||
GraphLegend,
|
||||
GraphLoading,
|
||||
NodeDetailContent,
|
||||
NodeDetailPanel as NodeDetailDrawer,
|
||||
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";
|
||||
|
||||
const NODE_ACTION_CONTEXT = {
|
||||
RESOURCE_FINDINGS: "resource-findings",
|
||||
FILTERED_PARENT: "filtered-parent",
|
||||
} as const;
|
||||
|
||||
type NodeActionContext =
|
||||
(typeof NODE_ACTION_CONTEXT)[keyof typeof NODE_ACTION_CONTEXT];
|
||||
|
||||
interface NodeActionBase {
|
||||
node: GraphNode;
|
||||
context: NodeActionContext;
|
||||
}
|
||||
|
||||
interface ResourceFindingsNodeAction extends NodeActionBase {
|
||||
context: typeof NODE_ACTION_CONTEXT.RESOURCE_FINDINGS;
|
||||
}
|
||||
|
||||
interface FilteredParentNodeAction extends NodeActionBase {
|
||||
context: typeof NODE_ACTION_CONTEXT.FILTERED_PARENT;
|
||||
}
|
||||
|
||||
type NodeActionState = ResourceFindingsNodeAction | FilteredParentNodeAction;
|
||||
|
||||
/**
|
||||
* Attack Paths
|
||||
@@ -76,10 +98,10 @@ export default function AttackPathsPage() {
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const graphRef = useRef<AttackPathGraphRef>(null);
|
||||
const fullscreenGraphRef = useRef<AttackPathGraphRef>(null);
|
||||
const [nodeAction, setNodeAction] = useState<NodeActionState | null>(null);
|
||||
const graphRef = useRef<GraphHandle>(null);
|
||||
const fullscreenGraphRef = useRef<GraphHandle>(null);
|
||||
const hasResetRef = useRef(false);
|
||||
const nodeDetailsRef = useRef<HTMLDivElement>(null);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
@@ -95,6 +117,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 +216,6 @@ export default function AttackPathsPage() {
|
||||
loadQueries();
|
||||
}, [scanId, toast]);
|
||||
|
||||
const handleQueryChange = (queryId: string) => {
|
||||
queryBuilder.handleQueryChange(queryId);
|
||||
};
|
||||
|
||||
const showErrorToast = (title: string, description: string) => {
|
||||
toast({
|
||||
title,
|
||||
@@ -289,22 +312,44 @@ 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);
|
||||
// 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);
|
||||
handleViewFinding(String(node.properties?.id || node.id));
|
||||
return;
|
||||
}
|
||||
|
||||
if (graphState.isFilteredView) {
|
||||
setNodeAction({ node, context: NODE_ACTION_CONTEXT.FILTERED_PARENT });
|
||||
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) {
|
||||
setNodeAction({ node, context: NODE_ACTION_CONTEXT.RESOURCE_FINDINGS });
|
||||
return;
|
||||
}
|
||||
|
||||
finding.resetFindingDetails();
|
||||
graphState.selectNode(node.id);
|
||||
};
|
||||
|
||||
const handleBackToFullView = () => {
|
||||
@@ -315,33 +360,53 @@ export default function AttackPathsPage() {
|
||||
graphState.selectNode(null);
|
||||
};
|
||||
|
||||
const getFindingId = (node: GraphNode | null) =>
|
||||
node ? String(node.properties?.id || node.id) : "";
|
||||
const handleShowNodeFindings = () => {
|
||||
if (!nodeAction) return;
|
||||
graphState.toggleExpandedResource(nodeAction.node.id);
|
||||
setNodeAction(null);
|
||||
};
|
||||
|
||||
const handleOpenNodeDetails = () => {
|
||||
if (!nodeAction) return;
|
||||
finding.resetFindingDetails();
|
||||
graphState.selectNode(nodeAction.node.id);
|
||||
setNodeAction(null);
|
||||
};
|
||||
|
||||
const handleReturnToFullGraph = () => {
|
||||
graphState.exitFilteredView();
|
||||
graphState.selectNode(null);
|
||||
setNodeAction(null);
|
||||
};
|
||||
|
||||
const handleViewFinding = (findingId: string) => {
|
||||
if (!findingId) return;
|
||||
void finding.navigateToFinding(findingId);
|
||||
};
|
||||
|
||||
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
|
||||
const actionNodeFindingsExpanded = nodeAction
|
||||
? graphState.expandedResources.has(nodeAction.node.id)
|
||||
: false;
|
||||
|
||||
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(),
|
||||
);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -423,7 +488,7 @@ export default function AttackPathsPage() {
|
||||
<QuerySelector
|
||||
queries={queries}
|
||||
selectedQueryId={queryBuilder.selectedQuery}
|
||||
onQueryChange={handleQueryChange}
|
||||
onQueryChange={queryBuilder.handleQueryChange}
|
||||
/>
|
||||
|
||||
{queryBuilder.selectedQueryData && (
|
||||
@@ -524,11 +589,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 +611,10 @@ export default function AttackPathsPage() {
|
||||
<DialogContent className="flex h-full max-h-screen w-full max-w-full flex-col gap-0 rounded-none border-0 p-0 sm:max-w-full">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Fullscreen graph view</DialogTitle>
|
||||
<DialogDescription>
|
||||
Explore the attack path graph at full size. Use
|
||||
the toolbar to zoom, fit, or export the graph.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="px-4 pt-4 pb-4 sm:px-6 sm:pt-6">
|
||||
<GraphControls
|
||||
@@ -562,15 +627,10 @@ export default function AttackPathsPage() {
|
||||
onFitToScreen={() =>
|
||||
fullscreenGraphRef.current?.resetZoom()
|
||||
}
|
||||
onExport={() =>
|
||||
handleGraphExport(
|
||||
fullscreenGraphRef.current?.getSVGElement() ||
|
||||
null,
|
||||
)
|
||||
}
|
||||
onExport={() => handleGraphExport("fullscreen")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6">
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden px-4 pb-4 sm:px-6 sm:pb-6 lg:flex-row">
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<AttackPathGraph
|
||||
ref={fullscreenGraphRef}
|
||||
@@ -578,64 +638,11 @@ export default function AttackPathsPage() {
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
expandedResources={
|
||||
graphState.expandedResources
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* Node Detail Panel - Side by side */}
|
||||
{graphState.selectedNode && (
|
||||
<section aria-labelledby="node-details-heading">
|
||||
<Card className="w-96 overflow-y-auto">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3
|
||||
id="node-details-heading"
|
||||
className="text-sm font-semibold"
|
||||
>
|
||||
Node Details
|
||||
</h3>
|
||||
<Button
|
||||
onClick={handleCloseDetails}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
aria-label="Close node details"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-text-neutral-secondary mb-4 text-xs">
|
||||
{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"}
|
||||
</p>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-semibold">
|
||||
Type
|
||||
</h4>
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
{graphState.selectedNode?.labels
|
||||
.map(formatNodeLabel)
|
||||
.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -654,11 +661,12 @@ export default function AttackPathsPage() {
|
||||
onNodeClick={handleNodeClick}
|
||||
selectedNodeId={graphState.selectedNodeId}
|
||||
isFilteredView={graphState.isFilteredView}
|
||||
expandedResources={graphState.expandedResources}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legend below */}
|
||||
<div className="hidden justify-center lg:flex">
|
||||
<div className="flex justify-center overflow-x-auto">
|
||||
<GraphLegend data={graphState.data} />
|
||||
</div>
|
||||
</>
|
||||
@@ -666,68 +674,48 @@ export default function AttackPathsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node Detail Panel - Below Graph */}
|
||||
{graphState.selectedNode && graphState.data && (
|
||||
<div
|
||||
ref={nodeDetailsRef}
|
||||
className="minimal-scrollbar rounded-large shadow-small border-border-neutral-secondary bg-bg-neutral-secondary relative z-0 flex w-full flex-col gap-4 overflow-auto border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold">Node Details</h3>
|
||||
<p className="text-text-neutral-secondary mt-1 text-sm">
|
||||
{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",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{graphState.selectedNode.labels.some((label) =>
|
||||
label.toLowerCase().includes("finding"),
|
||||
) && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleViewFinding(getFindingId(graphState.selectedNode))
|
||||
}
|
||||
disabled={finding.findingDetailLoading}
|
||||
aria-label={`View finding ${getFindingId(graphState.selectedNode)}`}
|
||||
>
|
||||
{finding.findingDetailLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : (
|
||||
"View Finding"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCloseDetails}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label="Close node details"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Node Detail Drawer */}
|
||||
{graphState.data && (
|
||||
<NodeDetailDrawer
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
onClose={handleCloseDetails}
|
||||
onViewFinding={handleViewFinding}
|
||||
viewFindingLoading={finding.findingDetailLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
<NodeDetailContent
|
||||
node={graphState.selectedNode}
|
||||
allNodes={graphState.data.nodes}
|
||||
onViewFinding={handleViewFinding}
|
||||
viewFindingLoading={finding.findingDetailLoading}
|
||||
/>
|
||||
</div>
|
||||
{nodeAction && (
|
||||
<Modal
|
||||
open={!!nodeAction}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNodeAction(null);
|
||||
}}
|
||||
size="md"
|
||||
title="Choose node action"
|
||||
description={
|
||||
nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT
|
||||
? "You're viewing a filtered path. Choose whether to return to the full graph or inspect this node."
|
||||
: "This node has related findings. Choose whether to reveal them in the graph or inspect the node metadata."
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={handleOpenNodeDetails}>
|
||||
View node details
|
||||
</Button>
|
||||
{nodeAction.context === NODE_ACTION_CONTEXT.FILTERED_PARENT ? (
|
||||
<Button onClick={handleReturnToFullGraph}>
|
||||
Back to full graph
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleShowNodeFindings}>
|
||||
{actionNodeFindingsExpanded
|
||||
? "Hide findings"
|
||||
: "Show findings"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{finding.findingDetails && (
|
||||
|
||||
@@ -15,7 +15,7 @@ export function Navbar({ title, icon }: NavbarProps) {
|
||||
title={title}
|
||||
icon={icon}
|
||||
feedsSlot={
|
||||
<Suspense fallback={<FeedsLoadingFallback />}>
|
||||
<Suspense key="feeds" fallback={<FeedsLoadingFallback />}>
|
||||
<FeedsServer limit={15} />
|
||||
</Suspense>
|
||||
}
|
||||
|
||||
+62
-22
@@ -55,6 +55,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",
|
||||
@@ -133,7 +141,7 @@
|
||||
"from": "16.1.6",
|
||||
"to": "16.2.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
@@ -367,14 +375,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",
|
||||
@@ -391,6 +391,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",
|
||||
@@ -447,14 +455,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",
|
||||
@@ -535,6 +535,14 @@
|
||||
"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-05-05T07:21:33.657Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
"name": "nanoid",
|
||||
@@ -549,7 +557,7 @@
|
||||
"from": "16.1.6",
|
||||
"to": "16.2.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
@@ -581,7 +589,7 @@
|
||||
"from": "19.2.4",
|
||||
"to": "19.2.5",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
@@ -597,7 +605,7 @@
|
||||
"from": "19.2.4",
|
||||
"to": "19.2.5",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "dependencies",
|
||||
@@ -765,7 +773,7 @@
|
||||
"from": "16.1.6",
|
||||
"to": "16.2.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
@@ -887,6 +895,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",
|
||||
@@ -933,7 +957,7 @@
|
||||
"from": "16.1.6",
|
||||
"to": "16.2.3",
|
||||
"strategy": "installed",
|
||||
"generatedAt": "2026-04-17T09:52:03.464Z"
|
||||
"generatedAt": "2026-04-22T13:43:48.734Z"
|
||||
},
|
||||
{
|
||||
"section": "devDependencies",
|
||||
@@ -1039,6 +1063,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",
|
||||
@@ -1102,5 +1134,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"
|
||||
}
|
||||
]
|
||||
|
||||
+19
-6
@@ -16,8 +16,11 @@
|
||||
"lint:knip:fix": "knip --fix --max-issues 504",
|
||||
"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",
|
||||
@@ -35,6 +38,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",
|
||||
@@ -74,9 +78,9 @@
|
||||
"@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",
|
||||
"alert": "6.0.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
@@ -84,7 +88,6 @@
|
||||
"cmdk": "1.1.1",
|
||||
"codemirror": "6.0.2",
|
||||
"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",
|
||||
@@ -95,6 +98,7 @@
|
||||
"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",
|
||||
@@ -141,6 +145,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",
|
||||
"autoprefixer": "10.4.19",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
@@ -160,6 +166,7 @@
|
||||
"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",
|
||||
@@ -167,7 +174,8 @@
|
||||
"tailwind-variants": "0.1.20",
|
||||
"tailwindcss": "4.1.18",
|
||||
"typescript": "5.5.4",
|
||||
"vitest": "4.0.18"
|
||||
"vitest": "4.0.18",
|
||||
"vitest-browser-react": "2.0.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
@@ -196,5 +204,10 @@
|
||||
}
|
||||
},
|
||||
"version": "0.0.1",
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+253
-131
@@ -53,6 +53,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
|
||||
@@ -170,15 +173,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)
|
||||
@@ -200,9 +203,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
|
||||
@@ -233,6 +233,9 @@ importers:
|
||||
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
|
||||
@@ -366,9 +369,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)
|
||||
autoprefixer:
|
||||
specifier: 10.4.19
|
||||
version: 10.4.19(postcss@8.4.38)
|
||||
@@ -423,6 +432,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
|
||||
@@ -446,7 +458,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:
|
||||
|
||||
@@ -993,6 +1008,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==}
|
||||
|
||||
@@ -2113,35 +2134,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:
|
||||
@@ -2373,6 +2394,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==}
|
||||
|
||||
@@ -5180,9 +5204,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==}
|
||||
|
||||
@@ -5245,6 +5266,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==}
|
||||
|
||||
@@ -5569,6 +5593,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'}
|
||||
@@ -5890,6 +5923,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-cursor@5.0.0:
|
||||
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6210,9 +6246,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==}
|
||||
|
||||
@@ -6736,9 +6769,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
|
||||
@@ -6956,11 +6998,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:
|
||||
@@ -7036,8 +7075,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==}
|
||||
@@ -7934,6 +7973,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==}
|
||||
|
||||
@@ -7950,8 +7992,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:
|
||||
@@ -7964,9 +8006,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==}
|
||||
@@ -8733,8 +8775,8 @@ packages:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
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==}
|
||||
@@ -8821,6 +8863,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'}
|
||||
@@ -9159,6 +9204,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==}
|
||||
|
||||
@@ -9186,6 +9235,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==}
|
||||
|
||||
@@ -9233,8 +9286,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:
|
||||
@@ -9479,6 +9532,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}
|
||||
@@ -9632,10 +9699,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'}
|
||||
@@ -9698,10 +9761,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'}
|
||||
|
||||
yoctocolors@2.1.2:
|
||||
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -9723,6 +9782,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'}
|
||||
@@ -10932,6 +11006,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': {}
|
||||
|
||||
'@dotenvx/dotenvx@1.51.4':
|
||||
@@ -12371,31 +12451,30 @@ snapshots:
|
||||
'@img/sharp-win32-x64@0.34.5':
|
||||
optional: true
|
||||
|
||||
'@inquirer/ansi@1.0.2': {}
|
||||
'@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
|
||||
|
||||
'@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
|
||||
|
||||
'@inquirer/figures@1.0.15': {}
|
||||
'@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
|
||||
|
||||
@@ -12676,6 +12755,8 @@ snapshots:
|
||||
|
||||
'@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
|
||||
@@ -13062,8 +13143,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:
|
||||
@@ -16144,8 +16224,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
|
||||
@@ -16214,6 +16292,10 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/set-cookie-parser@2.4.10':
|
||||
dependencies:
|
||||
'@types/node': 24.10.8
|
||||
|
||||
'@types/statuses@2.0.6': {}
|
||||
|
||||
'@types/tedious@4.0.14':
|
||||
@@ -16433,39 +16515,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
|
||||
@@ -16477,9 +16557,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:
|
||||
@@ -16488,20 +16568,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:
|
||||
@@ -16519,7 +16599,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:
|
||||
@@ -16601,6 +16681,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
|
||||
@@ -16943,6 +17046,8 @@ snapshots:
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
classcat@5.0.5: {}
|
||||
|
||||
cli-cursor@5.0.0:
|
||||
dependencies:
|
||||
restore-cursor: 5.1.0
|
||||
@@ -17279,11 +17384,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-uri-to-buffer@4.0.1: {}
|
||||
@@ -17969,8 +18069,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
|
||||
@@ -18177,11 +18287,7 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
graphlib@2.1.8:
|
||||
dependencies:
|
||||
lodash: 4.17.23
|
||||
|
||||
graphql@16.12.0: {}
|
||||
graphql@16.13.2: {}
|
||||
|
||||
hachure-fill@0.5.2: {}
|
||||
|
||||
@@ -18329,7 +18435,10 @@ snapshots:
|
||||
property-information: 7.1.0
|
||||
space-separated-tokens: 2.0.2
|
||||
|
||||
headers-polyfill@4.0.3: {}
|
||||
headers-polyfill@5.0.1:
|
||||
dependencies:
|
||||
'@types/set-cookie-parser': 2.4.10
|
||||
set-cookie-parser: 3.1.0
|
||||
|
||||
hermes-estree@0.25.1: {}
|
||||
|
||||
@@ -19431,6 +19540,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:
|
||||
@@ -19439,29 +19550,28 @@ 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:
|
||||
@@ -19471,7 +19581,7 @@ snapshots:
|
||||
|
||||
mustache@4.2.0: {}
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
mute-stream@3.0.0: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
@@ -19831,7 +19941,6 @@ snapshots:
|
||||
pixelmatch@7.1.0:
|
||||
dependencies:
|
||||
pngjs: 7.0.0
|
||||
optional: true
|
||||
|
||||
pkce-challenge@5.0.1: {}
|
||||
|
||||
@@ -19849,8 +19958,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
pngjs@7.0.0:
|
||||
optional: true
|
||||
pngjs@7.0.0: {}
|
||||
|
||||
points-on-curve@0.2.0: {}
|
||||
|
||||
@@ -20321,7 +20429,7 @@ snapshots:
|
||||
onetime: 7.0.0
|
||||
signal-exit: 4.1.0
|
||||
|
||||
rettime@0.10.1: {}
|
||||
rettime@0.11.8: {}
|
||||
|
||||
reusify@1.1.0: {}
|
||||
|
||||
@@ -20458,6 +20566,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
|
||||
@@ -20504,7 +20614,7 @@ snapshots:
|
||||
fuzzysort: 3.1.0
|
||||
https-proxy-agent: 7.0.6
|
||||
kleur: 4.1.5
|
||||
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)
|
||||
node-fetch: 3.3.2
|
||||
open: 11.0.0
|
||||
ora: 8.2.0
|
||||
@@ -20646,7 +20756,6 @@ snapshots:
|
||||
'@polka/url': 1.0.0-next.29
|
||||
mrmime: 2.0.1
|
||||
totalist: 3.0.1
|
||||
optional: true
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
@@ -20917,6 +21026,8 @@ snapshots:
|
||||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tldts-core@7.0.19: {}
|
||||
|
||||
tldts@7.0.19:
|
||||
@@ -20933,13 +21044,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:
|
||||
@@ -20984,7 +21098,7 @@ snapshots:
|
||||
|
||||
type-fest@0.7.1: {}
|
||||
|
||||
type-fest@5.4.1:
|
||||
type-fest@5.6.0:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
|
||||
@@ -21258,10 +21372,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
|
||||
@@ -21283,7 +21406,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
|
||||
@@ -21444,12 +21567,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
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -21497,8 +21614,6 @@ snapshots:
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yoctocolors-cjs@2.1.3: {}
|
||||
|
||||
yoctocolors@2.1.2: {}
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@3.25.76):
|
||||
@@ -21517,6 +21632,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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
/* 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) {
|
||||
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<Client | undefined>}
|
||||
*/
|
||||
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<Response>}
|
||||
*/
|
||||
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<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
+173
-32
@@ -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, "./"),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user