Compare commits

...

22 Commits

Author SHA1 Message Date
Alan Buscaglia cb0ca6c83d fix(ui): unblock attack paths CI validation
- Remove the duplicated resource-click helper from the harness
- Fix graph formatting required by UI lint hooks
- Restore typecheck and build validation for attack paths
2026-05-08 18:32:09 +02:00
Alan Buscaglia c175da681e [CHAIN] fix(ui): simplify attack paths graph interactions (#11020)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:07:06 +02:00
Alan Buscaglia 0595b5fe10 [CHAIN] fix(ui): improve attack paths graph layout highlights (#11017) 2026-05-08 15:07:06 +02:00
Alan Buscaglia 4046742d94 [CHAIN] feat(ui): expand attack paths node legend coverage (#11016) 2026-05-08 15:07:05 +02:00
Alan Buscaglia c7ba8987ac [CHAIN] feat(ui): improve attack paths graph exploration (#11015) 2026-05-08 15:07:05 +02:00
Alan Buscaglia 7dc7ec5f10 [CHAIN] feat(ui): render attack paths node icons (#11014) 2026-05-08 15:07:05 +02:00
Alan Buscaglia 17be0ba654 [CHAIN] feat(ui): add attack paths node visual mapper (#11013)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:07:05 +02:00
Pablo Fernandez Guerra (PFE) 668ea98e4a [CHAIN] fix(ui): re-fit attack-path viewport on filter and expand, harden minimap (#11010)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Alan Buscaglia <gentlemanprogramming@gmail.com>
2026-05-08 15:07:05 +02:00
Pablo F.G 326dba2da4 chore(openspec): stop tracking openspec as submodule
Detach the openspec submodule so the directory is managed as a
local clone instead. /openspec/ remains in .gitignore so the cloned
working tree is never tracked by this repo.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:07:05 +02:00
Pablo F.G 1cf314b60b fix: format 2026-05-08 15:07:05 +02:00
Pablo Fernandez Guerra (PFE) 647d1a2246 [CHAIN] test(ui): add Vitest Browser test coverage for Attack Paths (#10970)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:07:05 +02:00
Pablo Fernandez Guerra (PFE) 8e9ac9ece0 [CHAIN] feat(ui): add graph export, minimap and fullscreen polish (#10800)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:06:16 +02:00
Pablo Fernandez Guerra (PFE) ba5ef8f901 [CHAIN] feat(ui): add graph interactions and filtered view (#10756)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:06:16 +02:00
Pablo Fernandez Guerra (PFE) 338901b81a [CHAIN] refactor(ui): replace D3 graph rendering with React Flow (#10705)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:06:16 +02:00
Pablo Fernandez Guerra (PFE) a872efac13 [CHAIN] refactor(ui): normalize graph edge types and remove dead code (#10701)
Co-authored-by: Pablo F.G <pablo.fernandez@prowler.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:05:47 +02:00
Pablo F.G 7ec762f34a chore(openspec): bump submodule to include PR4 test coverage proposal
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:04:46 +02:00
Pablo F.G 2ad2ac4936 chore(openspec): consolidate submodule to include all chain task and spec updates
Bumps the openspec submodule to incorporate the linearized task completion
status and spec updates from PR0 (1373), PR1 (1374), and PR2 (1375).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:04:46 +02:00
Pablo F.G 874ce04ebd chore: update openspec with restructured expect-cli tests
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:04:46 +02:00
Pablo F.G be1de9a443 chore: update openspec with expect-cli validation tasks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:04:46 +02:00
Pablo F.G 85ca778882 chore: update openspec submodule with react-flow-migration proposal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:04:46 +02:00
Pablo F.G 1a234161b3 chore: add prowler-openspec-opensource as git submodule
Adds the openspec repository as a submodule at openspec/ for shared
spec definitions used by SDD tooling across AI coding assistants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 15:04:46 +02:00
Davidm4r 832516be2a fix(mcp_server): bump transitive requests to 2.33.1 (advisory 90553) (#11084) 2026-05-08 12:52:46 +02:00
52 changed files with 6687 additions and 2295 deletions
+29 -9
View File
@@ -1,14 +1,14 @@
name: 'UI: Tests'
name: "UI: Tests"
on:
push:
branches:
- 'master'
- 'v5.*'
- "master"
- "v5.*"
pull_request:
branches:
- 'master'
- 'v5.*'
- "master"
- "v5.*"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -16,7 +16,7 @@ concurrency:
env:
UI_WORKING_DIR: ./ui
NODE_VERSION: '24.13.0'
NODE_VERSION: "24.13.0"
permissions: {}
@@ -42,6 +42,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
@@ -133,7 +135,7 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed == 'true'
run: |
echo "Critical paths changed - running ALL unit tests"
pnpm run test:run
pnpm run test:unit
- name: Run unit tests (related to changes only)
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files != ''
@@ -142,7 +144,7 @@ jobs:
echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}"
# Convert space-separated to vitest related format (remove ui/ prefix for relative paths)
CHANGED_FILES=$(echo "${STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES}" | tr ' ' '\n' | sed 's|^ui/||' | tr '\n' ' ')
pnpm exec vitest related $CHANGED_FILES --run
pnpm exec vitest related $CHANGED_FILES --run --project unit
env:
STEPS_CHANGED_SOURCE_OUTPUTS_ALL_CHANGED_FILES: ${{ steps.changed-source.outputs.all_changed_files }}
@@ -150,7 +152,25 @@ jobs:
if: steps.check-changes.outputs.any_changed == 'true' && steps.critical-changes.outputs.any_changed != 'true' && steps.changed-source.outputs.all_changed_files == ''
run: |
echo "Only test files changed - running ALL unit tests"
pnpm run test:run
pnpm run test:unit
- name: Cache Playwright browsers
if: steps.check-changes.outputs.any_changed == 'true'
id: playwright-cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('ui/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-chromium-
- name: Install Playwright Chromium browser
if: steps.check-changes.outputs.any_changed == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install chromium
- name: Run browser tests
if: steps.check-changes.outputs.any_changed == 'true'
run: pnpm run test:browser
- name: Build application
if: steps.check-changes.outputs.any_changed == 'true'
+3 -3
View File
@@ -1009,7 +1009,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.5"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -1017,9 +1017,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
+4
View File
@@ -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
+4
View File
@@ -4,6 +4,10 @@ All notable changes to the **Prowler UI** are documented in this file.
## [1.26.0] (Prowler 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
- Standardized "Providers" wording across UI and documentation, replacing legacy "Cloud Providers" / "Accounts" / "Account Groups" copy [(#10971)](https://github.com/prowler-cloud/prowler/pull/10971)
+231
View File
@@ -0,0 +1,231 @@
import { http, HttpResponse } from "msw";
import type { PageFixture } from "@/app/(prowler)/attack-paths/(workflow)/query-builder/attack-paths-page.fixtures";
import type {
AttackPathQueriesResponse,
AttackPathQuery,
AttackPathQueryResult,
AttackPathScan,
AttackPathScansResponse,
QueryResultAttributes,
} from "@/types/attack-paths";
const API = process.env.NEXT_PUBLIC_API_BASE_URL!;
type JsonApiErrorBody = {
errors: Array<{ detail: string; status: string }>;
};
const toScansApiResponse = (
scans: AttackPathScan[],
): AttackPathScansResponse => ({
data: scans,
links: {
first: `${API}/attack-paths-scans?page=1`,
last: `${API}/attack-paths-scans?page=1`,
next: null,
prev: null,
},
});
const toQueriesApiResponse = (
queries: AttackPathQuery[],
): AttackPathQueriesResponse => ({
data: queries,
});
const toQueryResultApiResponse = (
attrs: QueryResultAttributes,
queryId: string,
): AttackPathQueryResult => ({
data: {
type: "attack-paths-query-run-requests",
id: queryId,
attributes: attrs,
},
});
const toErrorBody = (detail: string, status: number): JsonApiErrorBody => ({
errors: [{ detail, status: String(status) }],
});
const toFindingApiResponse = (fx: PageFixture, findingId: string) => {
const findingNode = fx.queryResult?.nodes.find(
(node) => node.id === findingId,
);
const resourceNode = fx.queryResult?.nodes.find((node) =>
fx.queryResult?.relationships?.some(
(rel) =>
(rel.source === node.id && rel.target === findingId) ||
(rel.target === node.id && rel.source === findingId),
),
);
const scan = fx.scans[0];
const providerId = scan?.relationships?.provider?.data?.id ?? "provider-1";
const resourceId = resourceNode?.id ?? "resource-1";
return {
data: {
type: "findings",
id: findingId,
attributes: {
uid: String(findingNode?.properties.id ?? findingId),
delta: null,
status: String(findingNode?.properties.status ?? "FAIL"),
status_extended: "Status extended",
severity: String(findingNode?.properties.severity ?? "critical"),
check_id: "attack_path_check",
muted: false,
muted_reason: null,
check_metadata: {
risk: "High",
notes: "",
checkid: "attack_path_check",
provider: "aws",
severity: String(findingNode?.properties.severity ?? "critical"),
checktype: [],
dependson: [],
relatedto: [],
categories: ["security"],
checktitle: String(
findingNode?.properties.check_title ?? "Attack path finding",
),
compliance: null,
relatedurl: "",
description: "Attack path finding description",
remediation: {
code: { cli: "", other: "", nativeiac: "", terraform: "" },
recommendation: { url: "", text: "Fix the finding" },
},
additionalurls: [],
servicename: String(resourceNode?.properties.service ?? "s3"),
checkaliases: [],
resourcetype: String(resourceNode?.labels[0] ?? "Resource"),
subservicename: "",
resourceidtemplate: "",
},
raw_result: null,
inserted_at: "2026-04-21T10:00:00Z",
updated_at: "2026-04-21T10:05:00Z",
first_seen_at: null,
},
relationships: {
resources: { data: [{ type: "resources", id: resourceId }] },
scan: { data: { type: "scans", id: scan?.id ?? "scan-1" } },
},
},
included: [
{
type: "resources",
id: resourceId,
attributes: {
uid: String(resourceNode?.properties.arn ?? resourceId),
name: String(resourceNode?.properties.name ?? resourceId),
region: "us-east-1",
service: String(resourceNode?.properties.service ?? "s3"),
tags: {},
type: String(resourceNode?.labels[0] ?? "Resource"),
inserted_at: "2026-04-21T10:00:00Z",
updated_at: "2026-04-21T10:05:00Z",
details: null,
partition: null,
},
},
{
type: "scans",
id: scan?.id ?? "scan-1",
attributes: {
name: "Attack path scan",
trigger: "manual",
state: scan?.attributes.state ?? "completed",
unique_resource_count: 1,
progress: scan?.attributes.progress ?? 100,
duration: scan?.attributes.duration ?? 0,
started_at: scan?.attributes.started_at ?? "2026-04-21T10:00:00Z",
inserted_at: scan?.attributes.inserted_at ?? "2026-04-21T10:00:00Z",
completed_at: scan?.attributes.completed_at ?? "2026-04-21T10:05:00Z",
scheduled_at: null,
next_scan_at: "",
},
relationships: {
provider: { data: { type: "providers", id: providerId } },
},
},
{
type: "providers",
id: providerId,
attributes: {
provider: scan?.attributes.provider_type ?? "aws",
uid: scan?.attributes.provider_uid ?? "123456789",
alias: scan?.attributes.provider_alias ?? "Provider",
connection: {
connected: true,
last_checked_at: "2026-04-21T10:00:00Z",
},
inserted_at: "2026-04-21T10:00:00Z",
updated_at: "2026-04-21T10:05:00Z",
},
},
],
};
};
export const handlersForFixture = (fx: PageFixture) => [
http.get(`${API}/attack-paths-scans`, () =>
HttpResponse.json<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),
);
},
),
http.get<{ findingId: string }>(`${API}/findings/:findingId`, ({ params }) =>
HttpResponse.json(toFindingApiResponse(fx, params.findingId)),
),
];
+13
View File
@@ -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[] = [];
+5
View File
@@ -0,0 +1,5 @@
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);
+25
View File
@@ -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);
}
}
});
@@ -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);
});
});
@@ -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>
@@ -0,0 +1,186 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { AttackPathGraphData } from "@/types/attack-paths";
import { GraphLegend } from "./graph-legend";
vi.mock("next-themes", () => ({
useTheme: () => ({ resolvedTheme: "dark" }),
}));
const graphData: AttackPathGraphData = {
nodes: [
{
id: "aws-account",
labels: ["AWSAccount"],
properties: { name: "Production" },
},
{ id: "bucket", labels: ["S3Bucket"], properties: { name: "logs" } },
{ id: "vpc", labels: ["VPC"], properties: { name: "main" } },
{
id: "finding",
labels: ["ProwlerFinding"],
properties: { check_title: "Public bucket", severity: "critical" },
},
],
relationships: [
{ id: "r1", source: "aws-account", target: "bucket", label: "HAS" },
{ id: "r2", source: "bucket", target: "vpc", label: "CAN_ACCESS" },
{ id: "r3", source: "bucket", target: "finding", label: "HAS_FINDING" },
],
};
describe("GraphLegend", () => {
it("should explain concrete visible node types without generic categories", () => {
// Given - A graph with provider, resource, and finding nodes
// When
render(
<GraphLegend data={graphData} expandedResources={new Set(["bucket"])} />,
);
// Then
expect(
screen.getByRole("heading", { name: /provider roots/i }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /node types/i }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /findings by risk/i }),
).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: /states/i }),
).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /edges/i })).toBeInTheDocument();
expect(screen.getByText("Provider")).toBeInTheDocument();
expect(screen.getByText("S3 Bucket")).toBeInTheDocument();
expect(screen.getByText("VPC")).toBeInTheDocument();
expect(screen.queryByText("Storage")).not.toBeInTheDocument();
expect(screen.queryByText("Network")).not.toBeInTheDocument();
expect(screen.queryByText("Compute")).not.toBeInTheDocument();
expect(screen.queryByText("Identity")).not.toBeInTheDocument();
expect(screen.queryByText("Secret / misc")).not.toBeInTheDocument();
expect(screen.getByText("Critical")).toBeInTheDocument();
expect(screen.queryByText("High")).not.toBeInTheDocument();
expect(screen.queryByText("Medium")).not.toBeInTheDocument();
expect(screen.queryByText("Low / Info")).not.toBeInTheDocument();
expect(screen.getByText("Selected node")).toBeInTheDocument();
expect(screen.getByText("Node with findings")).toBeInTheDocument();
expect(screen.getByText("Normal edge")).toBeInTheDocument();
expect(screen.getByText("Finding edge")).toBeInTheDocument();
expect(screen.getByText("Highlighted path")).toBeInTheDocument();
expect(
screen.getByRole("img", {
name: /highlighted path: prowler green path/i,
}),
).toBeInTheDocument();
expect(screen.queryByText(/orange path/i)).not.toBeInTheDocument();
expect(screen.queryByText(/ctrl/i)).not.toBeInTheDocument();
expect(screen.queryByText(/scroll to zoom/i)).not.toBeInTheDocument();
});
it("should hide finding legend items when finding nodes are hidden", () => {
// Given - A resource has related findings, but it is not expanded yet
// When
render(<GraphLegend data={graphData} />);
// Then
expect(
screen.queryByRole("heading", { name: /findings by risk/i }),
).not.toBeInTheDocument();
expect(screen.queryByText("Finding edge")).not.toBeInTheDocument();
expect(screen.getByText("Node with findings")).toBeInTheDocument();
});
it("should keep unattached findings visible in the legend", () => {
// Given - Findings have no connected resource and stay visible in the full graph
const findingsOnlyGraphData: AttackPathGraphData = {
nodes: [
{
id: "finding-critical",
labels: ["ProwlerFinding"],
properties: { check_title: "Critical finding", severity: "critical" },
},
{
id: "finding-high",
labels: ["ProwlerFinding"],
properties: { check_title: "High finding", severity: "high" },
},
],
relationships: [],
};
// When
render(<GraphLegend data={findingsOnlyGraphData} />);
// Then
expect(
screen.getByRole("heading", { name: /findings by risk/i }),
).toBeInTheDocument();
expect(screen.getByText("Critical")).toBeInTheDocument();
expect(screen.getByText("High")).toBeInTheDocument();
});
it("should list policy and role node types separately", () => {
// Given - A graph whose visible nodes are all identity-related, but distinct
const identityGraphData: AttackPathGraphData = {
nodes: [
{
id: "aws-account",
labels: ["AWSAccount"],
properties: { name: "Production" },
},
{
id: "role",
labels: ["PermissionRole"],
properties: { name: "prowler-pro-dev-gha-role" },
},
{
id: "policy",
labels: ["AWSPolicy"],
properties: { name: "IAMPermissions" },
},
{
id: "statement",
labels: ["AWSPolicyStatement"],
properties: { name: "policy statement" },
},
],
relationships: [
{ id: "r1", source: "aws-account", target: "role", label: "HAS" },
{ id: "r2", source: "role", target: "policy", label: "HAS" },
{ id: "r3", source: "policy", target: "statement", label: "HAS" },
],
};
// When
render(<GraphLegend data={identityGraphData} />);
// Then
expect(screen.getByText("Permission Role")).toBeInTheDocument();
expect(screen.getByText("AWS Policy")).toBeInTheDocument();
expect(screen.getByText("AWS Policy Statement")).toBeInTheDocument();
expect(screen.queryByText("Identity")).not.toBeInTheDocument();
});
it("should stay hidden until graph nodes are available", () => {
// Given - No graph nodes have been loaded yet
const emptyGraphData: AttackPathGraphData = { nodes: [] };
// When
const { container } = render(<GraphLegend data={emptyGraphData} />);
// Then
expect(container).toBeEmptyDOMElement();
expect(
screen.queryByRole("heading", { name: /provider roots/i }),
).not.toBeInTheDocument();
});
});
@@ -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";
@@ -0,0 +1,95 @@
import { render, screen } from "@testing-library/react";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
import type { GraphNode } from "@/types/attack-paths";
import { FindingNode } from "./finding-node";
const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null));
vi.mock("./hidden-handles", () => ({
HiddenHandles: hiddenHandlesMock,
}));
const buildFindingNode = (severity: string, title: string): GraphNode => ({
id: `${severity}-finding`,
labels: ["ProwlerFinding"],
properties: { check_title: title, id: `${severity}-finding`, severity },
});
const buildNodeProps = (graphNode: GraphNode): NodeProps =>
({
id: graphNode.id,
type: "finding",
data: { graphNode },
selected: false,
dragging: false,
zIndex: 0,
isConnectable: false,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}) as unknown as NodeProps;
describe("FindingNode", () => {
it("positions graph handles for horizontal left-to-right edges", () => {
// Given
const props = buildNodeProps(
buildFindingNode("critical", "Root key exposed"),
);
// When
render(<FindingNode {...props} />);
// Then
expect(hiddenHandlesMock).toHaveBeenCalledWith(
expect.objectContaining({
sourcePosition: Position.Right,
sourceStyle: { left: 97, top: 26 },
targetPosition: Position.Left,
targetStyle: { left: 53, top: 26 },
}),
undefined,
);
});
describe("severity visuals", () => {
it("should render the critical finding risk icon with readable text", () => {
// Given
const props = buildNodeProps(
buildFindingNode("critical", "Root key exposed"),
);
// When
render(<FindingNode {...props} />);
// Then
expect(
screen.getByTestId("attack-path-finding-icon-critical"),
).toHaveAccessibleName("Critical finding risk icon");
expect(screen.getByText("Root key exposed")).toBeInTheDocument();
expect(screen.getByText("critical")).toBeInTheDocument();
});
it("should render a distinct medium finding risk icon with readable text", () => {
// Given
const props = buildNodeProps(
buildFindingNode("medium", "Bucket lacks logging"),
);
// When
render(<FindingNode {...props} />);
// Then
expect(
screen.getByTestId("attack-path-finding-icon-medium"),
).toHaveAccessibleName("Medium finding risk icon");
expect(
screen.queryByTestId("attack-path-finding-icon-critical"),
).not.toBeInTheDocument();
expect(screen.getByText("Bucket lacks")).toBeInTheDocument();
expect(screen.getByText("logging")).toBeInTheDocument();
expect(screen.getByText("medium")).toBeInTheDocument();
});
});
});
@@ -0,0 +1,157 @@
"use client";
import { type NodeProps, Position } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface FindingNodeData {
graphNode: GraphNode;
[key: string]: unknown;
}
const NODE_WIDTH = 150;
const NODE_HEIGHT = 112;
const TITLE_MAX_CHARS = 18;
const TITLE_MAX_LINES = 2;
const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
const BADGE_CENTER_Y = 26;
const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS;
const BADGE_RIGHT_X = BADGE_CENTER_X + BADGE_RADIUS;
const ICON_SIZE = 28;
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
const TEXT_X = BADGE_CENTER_X;
const TITLE_Y = 66;
const TITLE_LINE_HEIGHT = 13;
const SEVERITY_Y = 94;
const severityLabel = (severity: unknown): string | undefined => {
if (!severity) return undefined;
const rawSeverity = Array.isArray(severity) ? severity[0] : severity;
return String(rawSeverity).toLowerCase();
};
const toFindingIconTestId = (severity: string | undefined): string =>
`attack-path-finding-icon-${severity ?? "unknown"}`;
const toAccessibleSeverity = (severity: string | undefined): string =>
severity
? `${severity.charAt(0).toUpperCase()}${severity.slice(1)}`
: "Unknown";
export const FindingNode = ({ data, selected }: NodeProps) => {
const { graphNode } = data as FindingNodeData;
const { fillColor, borderColor } = resolveNodeColors({
labels: graphNode.labels,
properties: graphNode.properties,
selected,
});
const title = String(
graphNode.properties?.check_title ||
graphNode.properties?.name ||
graphNode.properties?.id ||
"Finding",
);
const displayTitleLines = getNodeLabelLines(
title,
TITLE_MAX_CHARS,
TITLE_MAX_LINES,
);
const visual = resolveNodeVisual(graphNode);
const Icon = visual.Icon;
const severity = severityLabel(graphNode.properties?.severity);
const iconLabel = `${toAccessibleSeverity(severity)} finding risk icon`;
const badgeStrokeWidth = selected ? 4 : 2.5;
const glowRadius = selected ? 32 : 30;
const glowOpacity = selected ? 0.34 : 0.28;
return (
<>
<HiddenHandles
sourcePosition={Position.Right}
sourceStyle={{ left: BADGE_RIGHT_X, top: BADGE_CENTER_Y }}
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
stroke={borderColor}
strokeOpacity={glowOpacity}
strokeWidth={8}
fill={borderColor}
fillOpacity={glowOpacity / 2}
pointerEvents="none"
/>
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.95}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toFindingIconTestId(severity)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
color="#ffffff"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
strokeWidth={2.4}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={TITLE_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayTitleLines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={TITLE_Y + index * TITLE_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{severity && (
<tspan
x={TEXT_X}
y={SEVERITY_Y}
fontSize="9px"
fill="rgba(255,255,255,0.82)"
>
{severity}
</tspan>
)}
</text>
</svg>
</>
);
};
@@ -0,0 +1,35 @@
"use client";
import { Handle, Position } from "@xyflow/react";
import type { CSSProperties } from "react";
interface HiddenHandlesProps {
sourcePosition?: Position;
style?: CSSProperties;
targetPosition?: Position;
sourceStyle?: CSSProperties;
targetStyle?: CSSProperties;
}
export const HiddenHandles = ({
sourcePosition = Position.Right,
sourceStyle,
style,
targetPosition = Position.Left,
targetStyle,
}: HiddenHandlesProps) => (
<>
<Handle
type="target"
position={targetPosition}
className="invisible"
style={{ ...style, ...targetStyle }}
/>
<Handle
type="source"
position={sourcePosition}
className="invisible"
style={{ ...style, ...sourceStyle }}
/>
</>
);
@@ -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>
</>
);
};
@@ -0,0 +1,48 @@
const splitByMaxChars = (text: string, maxChars: number): string[] => {
const words = text.trim().split(/\s+/).filter(Boolean);
const lines: string[] = [];
let currentLine = "";
for (const word of words) {
if (!currentLine) {
currentLine = word;
continue;
}
const nextLine = `${currentLine} ${word}`;
if (nextLine.length <= maxChars) {
currentLine = nextLine;
continue;
}
lines.push(currentLine);
currentLine = word;
}
if (currentLine) lines.push(currentLine);
return lines;
};
const splitLongToken = (text: string, maxChars: number): string[] => {
const lines: string[] = [];
for (let index = 0; index < text.length; index += maxChars) {
lines.push(text.slice(index, index + maxChars));
}
return lines;
};
export const getNodeLabelLines = (
text: string,
maxChars: number,
maxLines: number,
): string[] => {
if (!text.trim()) return [];
const rawLines = text.includes(" ")
? splitByMaxChars(text, maxChars)
: splitLongToken(text, maxChars);
return rawLines.slice(0, maxLines);
};
@@ -0,0 +1,88 @@
import { render, screen } from "@testing-library/react";
import { type NodeProps, Position } from "@xyflow/react";
import { describe, expect, it, vi } from "vitest";
import type { GraphNode } from "@/types/attack-paths";
import { ResourceNode } from "./resource-node";
const hiddenHandlesMock = vi.hoisted(() => vi.fn(() => null));
vi.mock("./hidden-handles", () => ({
HiddenHandles: hiddenHandlesMock,
}));
const buildGraphNode = (label: string, name: string): GraphNode => ({
id: `${label}-${name}`,
labels: [label],
properties: { id: `${label}-${name}`, name },
});
const buildNodeProps = (graphNode: GraphNode): NodeProps =>
({
id: graphNode.id,
type: "resource",
data: { graphNode },
selected: false,
dragging: false,
zIndex: 0,
isConnectable: false,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
}) as unknown as NodeProps;
describe("ResourceNode", () => {
it("positions graph handles for horizontal left-to-right edges", () => {
// Given
const props = buildNodeProps(buildGraphNode("S3Bucket", "logs"));
// When
render(<ResourceNode {...props} />);
// Then
expect(hiddenHandlesMock).toHaveBeenCalledWith(
expect.objectContaining({
sourcePosition: Position.Right,
sourceStyle: { left: 90, top: 26 },
targetPosition: Position.Left,
targetStyle: { left: 46, top: 26 },
}),
undefined,
);
});
describe("node visual icons", () => {
it("should render the S3 bucket icon with the resource label", () => {
// Given
const props = buildNodeProps(buildGraphNode("S3Bucket", "logs"));
// When
render(<ResourceNode {...props} />);
// Then
expect(
screen.getByTestId("attack-path-node-icon-s3-bucket"),
).toHaveAccessibleName("S3 Bucket icon");
expect(screen.getByText("logs")).toBeInTheDocument();
expect(screen.getByText("S3 Bucket")).toBeInTheDocument();
});
it("should render a distinct VPC icon with the resource label", () => {
// Given
const props = buildNodeProps(buildGraphNode("VPC", "main-vpc"));
// When
render(<ResourceNode {...props} />);
// Then
expect(
screen.getByTestId("attack-path-node-icon-vpc"),
).toHaveAccessibleName("VPC icon");
expect(
screen.queryByTestId("attack-path-node-icon-s3-bucket"),
).not.toBeInTheDocument();
expect(screen.getByText("main-vpc")).toBeInTheDocument();
expect(screen.getByText("VPC")).toBeInTheDocument();
});
});
});
@@ -0,0 +1,142 @@
"use client";
import { type NodeProps, Position } from "@xyflow/react";
import type { GraphNode } from "@/types/attack-paths";
import { resolveNodeColors, resolveNodeVisual } from "../../../_lib";
import { HiddenHandles } from "./hidden-handles";
import { getNodeLabelLines } from "./node-label-lines";
interface ResourceNodeData {
graphNode: GraphNode;
hasFindings?: boolean;
[key: string]: unknown;
}
const NODE_WIDTH = 136;
const NODE_HEIGHT = 112;
const NAME_MAX_CHARS = 16;
const NAME_MAX_LINES = 2;
const BADGE_SIZE = 44;
const BADGE_RADIUS = BADGE_SIZE / 2;
const BADGE_CENTER_X = NODE_WIDTH / 2;
const BADGE_CENTER_Y = 26;
const BADGE_LEFT_X = BADGE_CENTER_X - BADGE_RADIUS;
const BADGE_RIGHT_X = BADGE_CENTER_X + BADGE_RADIUS;
const ICON_SIZE = 28;
const ICON_X = BADGE_CENTER_X - ICON_SIZE / 2;
const ICON_Y = BADGE_CENTER_Y - ICON_SIZE / 2;
const TEXT_X = BADGE_CENTER_X;
const NAME_Y = 66;
const NAME_LINE_HEIGHT = 13;
const TYPE_Y = 94;
const toIconTestId = (description: string): string =>
`attack-path-node-icon-${description
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")}`;
export const ResourceNode = ({ data, selected }: NodeProps) => {
const { graphNode, hasFindings } = data as ResourceNodeData;
const { fillColor, borderColor } = resolveNodeColors({
labels: graphNode.labels,
properties: graphNode.properties,
selected,
hasFindings,
});
const badgeStrokeWidth = selected ? 4 : hasFindings ? 3 : 1.5;
const glowRadius = selected ? 31 : hasFindings ? 29 : 0;
const glowOpacity = selected ? 0.32 : hasFindings ? 0.26 : 0;
const visual = resolveNodeVisual(graphNode);
const Icon = visual.Icon;
const displayNameLines = getNodeLabelLines(
visual.displayName,
NAME_MAX_CHARS,
NAME_MAX_LINES,
);
const typeLabel = visual.description;
const iconLabel = `${visual.description} icon`;
return (
<>
<HiddenHandles
sourcePosition={Position.Right}
sourceStyle={{ left: BADGE_RIGHT_X, top: BADGE_CENTER_Y }}
targetPosition={Position.Left}
targetStyle={{ left: BADGE_LEFT_X, top: BADGE_CENTER_Y }}
/>
<svg width={NODE_WIDTH} height={NODE_HEIGHT} className="overflow-visible">
{glowRadius > 0 && (
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={glowRadius}
fill={borderColor}
fillOpacity={glowOpacity}
pointerEvents="none"
/>
)}
<circle
cx={BADGE_CENTER_X}
cy={BADGE_CENTER_Y}
r={BADGE_RADIUS}
fill={fillColor}
fillOpacity={0.92}
stroke={borderColor}
strokeWidth={badgeStrokeWidth}
className={selected ? "selected-node" : undefined}
/>
<g
aria-label={iconLabel}
data-testid={toIconTestId(visual.description)}
role="img"
transform={`translate(${ICON_X}, ${ICON_Y})`}
>
<Icon
aria-hidden="true"
className="rounded-md"
focusable="false"
height={ICON_SIZE}
role="presentation"
size={ICON_SIZE}
width={ICON_SIZE}
/>
</g>
<text
x={TEXT_X}
y={NAME_Y}
textAnchor="middle"
dominantBaseline="middle"
fill="#ffffff"
style={{ textShadow: "0 1px 2px rgba(0,0,0,0.5)" }}
pointerEvents="none"
>
{displayNameLines.map((line, index) => (
<tspan
key={`${line}-${index}`}
x={TEXT_X}
y={NAME_Y + index * NAME_LINE_HEIGHT}
fontSize="11px"
fontWeight="600"
>
{line}
</tspan>
))}
{typeLabel && (
<tspan
x={TEXT_X}
y={TYPE_Y}
fontSize="9px"
fill="rgba(255,255,255,0.8)"
>
{typeLabel}
</tspan>
)}
</text>
</svg>
</>
);
};
@@ -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";
@@ -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>
);
};
@@ -0,0 +1,35 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useGraphStore } from "./use-graph-state";
describe("useGraphStore", () => {
beforeEach(() => {
useGraphStore.getState().reset();
});
it("keeps only one expanded findings resource open at a time", () => {
// Given
const store = useGraphStore.getState();
// When
store.toggleExpandedResource("resource-a");
useGraphStore.getState().toggleExpandedResource("resource-b");
// Then
expect(Array.from(useGraphStore.getState().expandedResources)).toEqual([
"resource-b",
]);
});
it("closes the expanded findings resource when toggled again", () => {
// Given
const store = useGraphStore.getState();
// When
store.toggleExpandedResource("resource-a");
useGraphStore.getState().toggleExpandedResource("resource-a");
// Then
expect(useGraphStore.getState().expandedResources.size).toBe(0);
});
});
@@ -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,13 @@ const useGraphStore = create<GraphStore>((set) => ({
fullData,
selectedNodeId: nodeId,
}),
toggleExpandedResource: (resourceId) =>
set((state) => {
const next = state.expandedResources.has(resourceId)
? new Set<string>()
: new Set([resourceId]);
return { expandedResources: next };
}),
reset: () => set(initialState),
}));
@@ -106,11 +115,6 @@ export const useGraphState = () => {
store.setError(error);
};
const updateZoomAndPan = (zoomLevel: number, panX: number, panY: number) => {
store.setZoom(zoomLevel);
store.setPan(panX, panY);
};
const resetGraph = () => {
store.reset();
};
@@ -162,18 +166,16 @@ export const useGraphState = () => {
selectedNode: getSelectedNode(),
loading: store.loading,
error: store.error,
zoomLevel: store.zoomLevel,
panX: store.panX,
panY: store.panY,
isFilteredView: store.isFilteredView,
filteredNodeId: store.filteredNodeId,
filteredNode: getFilteredNode(),
expandedResources: store.expandedResources,
toggleExpandedResource: store.toggleExpandedResource,
updateGraphData,
selectNode,
startLoading,
stopLoading,
setError,
updateZoomAndPan,
resetGraph,
clearGraph,
enterFilteredView,
@@ -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;
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
GRAPH_ALERT_BORDER_COLOR,
GRAPH_EDGE_HIGHLIGHT_COLOR,
resolveNodeColors,
} from "./graph-colors";
describe("resolveNodeColors", () => {
it("prioritizes selected state over hasFindings for the border color", () => {
const selectedColors = resolveNodeColors({
labels: ["EC2Instance"],
selected: true,
hasFindings: true,
});
const alertOnlyColors = resolveNodeColors({
labels: ["EC2Instance"],
selected: false,
hasFindings: true,
});
expect(selectedColors.borderColor).toBe(GRAPH_EDGE_HIGHLIGHT_COLOR);
expect(alertOnlyColors.borderColor).toBe(GRAPH_ALERT_BORDER_COLOR);
});
});
@@ -54,8 +54,8 @@ export const GRAPH_NODE_BORDER_COLORS = {
// Edge colors per theme
export const GRAPH_EDGE_COLOR_DARK = "#ffffff"; // White for dark theme
export const GRAPH_EDGE_COLOR_LIGHT = "#1e293b"; // Slate 800 for light theme
export const GRAPH_EDGE_HIGHLIGHT_COLOR = "#f97316"; // Orange 500 (on hover)
export const GRAPH_EDGE_GLOW_COLOR = "#fb923c";
export const GRAPH_EDGE_HIGHLIGHT_COLOR = "#34d399"; // Prowler green (hover/selection)
export const GRAPH_EDGE_GLOW_COLOR = "#6ee7b7";
export const GRAPH_SELECTION_COLOR = "#ffffff";
export const GRAPH_BORDER_COLOR = "#374151";
export const GRAPH_ALERT_BORDER_COLOR = "#ef4444"; // Red 500 - for resources with findings
@@ -128,6 +128,37 @@ export const getNodeBorderColor = (
return GRAPH_NODE_BORDER_COLORS.default;
};
interface ResolveNodeColorsParams {
labels: string[];
properties?: Record<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 = selected
? GRAPH_EDGE_HIGHLIGHT_COLOR
: hasFindings
? GRAPH_ALERT_BORDER_COLOR
: getNodeBorderColor(labels, properties);
return { fillColor, borderColor };
};
/**
* Check if a background color is light (for determining text color)
*/
@@ -4,21 +4,40 @@
import type { AttackPathGraphData } from "@/types/attack-paths";
/**
* Type for edge node reference - can be a string ID or an object with id property
* Note: We use `object` to match GraphEdge type from attack-paths.ts
*/
export type EdgeNodeRef = string | object;
export const resolveHiddenFindingIds = ({
expandedResources,
findingNodeIds,
findingToResources,
isFilteredView,
}: {
expandedResources: ReadonlySet<string>;
findingNodeIds: ReadonlySet<string>;
findingToResources: ReadonlyMap<string, ReadonlySet<string>>;
isFilteredView: boolean;
}): Set<string> => {
const hiddenFindingIds = new Set<string>();
/**
* Helper to get edge source/target ID from string or object
*/
export const getEdgeNodeId = (nodeRef: EdgeNodeRef): string => {
if (typeof nodeRef === "string") {
return nodeRef;
if (isFilteredView) {
return hiddenFindingIds;
}
// Edge node references are objects with an id property
return (nodeRef as { id: string }).id;
findingNodeIds.forEach((findingId) => {
const connectedResources = findingToResources.get(findingId);
if (!connectedResources) {
return;
}
const anyExpanded = Array.from(connectedResources).some((resourceId) =>
expandedResources.has(resourceId),
);
if (!anyExpanded) {
hiddenFindingIds.add(findingId);
}
});
return hiddenFindingIds;
};
/**
@@ -44,10 +63,8 @@ export const computeFilteredSubgraph = (
});
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
forwardEdges.get(sourceId)?.add(targetId);
backwardEdges.get(targetId)?.add(sourceId);
forwardEdges.get(edge.source)?.add(edge.target);
backwardEdges.get(edge.target)?.add(edge.source);
});
const visibleNodeIds = new Set<string>();
@@ -84,35 +101,30 @@ export const computeFilteredSubgraph = (
traverseDownstream(targetNodeId);
// Also include findings directly connected to the selected node
const nodeLabelMap = new Map(nodes.map((n) => [n.id, n.labels]));
edges.forEach((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
const sourceNode = nodes.find((n) => n.id === sourceId);
const targetNode = nodes.find((n) => n.id === targetId);
const sourceIsFinding = sourceNode?.labels.some((l) =>
const sourceIsFinding = (nodeLabelMap.get(edge.source) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
);
const targetIsFinding = targetNode?.labels.some((l) =>
const targetIsFinding = (nodeLabelMap.get(edge.target) ?? []).some((l) =>
l.toLowerCase().includes("finding"),
);
// Include findings connected to the selected node
if (sourceId === targetNodeId && targetIsFinding) {
visibleNodeIds.add(targetId);
if (edge.source === targetNodeId && targetIsFinding) {
visibleNodeIds.add(edge.target);
}
if (targetId === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(sourceId);
if (edge.target === targetNodeId && sourceIsFinding) {
visibleNodeIds.add(edge.source);
}
});
// Filter nodes and edges to only include visible ones
const filteredNodes = nodes.filter((node) => visibleNodeIds.has(node.id));
const filteredEdges = edges.filter((edge) => {
const sourceId = getEdgeNodeId(edge.source);
const targetId = getEdgeNodeId(edge.target);
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
const filteredEdges = edges.filter(
(edge) =>
visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
);
return {
nodes: filteredNodes,
@@ -1,9 +1,5 @@
export {
exportGraphAsJSON,
exportGraphAsPNG,
exportGraphAsSVG,
} from "./export";
export { formatNodeLabel, formatNodeLabels } from "./format";
export { exportGraphAsJSON, exportGraphAsPNG } from "./export";
export { formatNodeLabel, formatNodeLabels, truncateLabel } from "./format";
export {
getNodeBorderColor,
getNodeColor,
@@ -14,10 +10,17 @@ export {
GRAPH_NODE_BORDER_COLORS,
GRAPH_NODE_COLORS,
GRAPH_SELECTION_COLOR,
resolveNodeColors,
} from "./graph-colors";
export {
computeFilteredSubgraph,
type EdgeNodeRef,
getEdgeNodeId,
getPathEdges,
resolveHiddenFindingIds,
} from "./graph-utils";
export { layoutWithDagre } from "./layout";
export {
NODE_CATEGORY,
type NodeCategory,
type NodeVisual,
resolveNodeVisual,
} from "./node-visuals";
@@ -0,0 +1,275 @@
import { Position } from "@xyflow/react";
import { describe, expect, it } from "vitest";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
import { layoutWithDagre } from "./layout";
const findingNode: GraphNode = {
id: "finding-1",
labels: ["ProwlerFinding"],
properties: { check_title: "Open S3 bucket", severity: "high" },
};
const resourceNode: GraphNode = {
id: "resource-1",
labels: ["S3Bucket"],
properties: { name: "bucket-1" },
};
const internetNode: GraphNode = {
id: "internet-1",
labels: ["Internet"],
properties: {},
};
describe("layoutWithDagre", () => {
it("returns empty arrays for empty input", () => {
const result = layoutWithDagre([], []);
expect(result.rfNodes).toEqual([]);
expect(result.rfEdges).toEqual([]);
});
it("assigns node types and dimensions from labels", () => {
const { rfNodes } = layoutWithDagre(
[findingNode, resourceNode, internetNode],
[],
);
const byId = new Map(rfNodes.map((n) => [n.id, n]));
expect(byId.get("finding-1")).toMatchObject({
type: "finding",
width: 150,
height: 112,
});
expect(byId.get("resource-1")).toMatchObject({
type: "resource",
width: 136,
height: 112,
});
expect(byId.get("internet-1")).toMatchObject({
type: "internet",
width: 80,
height: 80,
});
});
it("is deterministic: same input produces equal output across runs", () => {
const nodes = [findingNode, resourceNode];
const edges: GraphEdge[] = [
{
id: "e1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
];
const a = layoutWithDagre(nodes, edges);
const b = layoutWithDagre(nodes, edges);
expect(a).toEqual(b);
});
it("places connected children to the right and stacks siblings within the horizontal rank", () => {
const rootNode: GraphNode = {
id: "root",
labels: ["AWSAccount"],
properties: { name: "account" },
};
const siblingNodes: GraphNode[] = [
{
id: "bucket",
labels: ["S3Bucket"],
properties: { name: "bucket" },
},
{
id: "lambda",
labels: ["AWSLambda"],
properties: { name: "function" },
},
{
id: "database",
labels: ["RDSInstance"],
properties: { name: "database" },
},
];
const { rfNodes } = layoutWithDagre(
[rootNode, ...siblingNodes],
siblingNodes.map((node) => ({
id: `root-${node.id}`,
source: "root",
target: node.id,
type: "CONNECTS_TO",
})),
);
const rootPosition = rfNodes.find(
(candidate) => candidate.id === "root",
)?.position;
const siblingPositions = siblingNodes.map((node) => {
const rfNode = rfNodes.find((candidate) => candidate.id === node.id);
expect(rfNode).toBeDefined();
return rfNode?.position ?? { x: 0, y: 0 };
});
const xSpread =
Math.max(...siblingPositions.map((position) => position.x)) -
Math.min(...siblingPositions.map((position) => position.x));
const ySpread =
Math.max(...siblingPositions.map((position) => position.y)) -
Math.min(...siblingPositions.map((position) => position.y));
expect(rootPosition).toBeDefined();
siblingPositions.forEach((position) => {
expect(position.x).toBeGreaterThan(rootPosition?.x ?? 0);
});
expect(ySpread).toBeGreaterThan(xSpread);
});
it("connects edges through right and left node sides for horizontal layout", () => {
const { rfNodes } = layoutWithDagre(
[findingNode, resourceNode],
[
{
id: "e1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
],
);
rfNodes.forEach((node) => {
expect(node.sourcePosition).toBe(Position.Right);
expect(node.targetPosition).toBe(Position.Left);
});
});
it("offsets dagre center positions by half of the node dimensions (top-left)", () => {
const { rfNodes } = layoutWithDagre([findingNode, resourceNode], []);
rfNodes.forEach((node) => {
expect(Number.isFinite(node.position.x)).toBe(true);
expect(Number.isFinite(node.position.y)).toBe(true);
});
// Different node types must end up with different sizes — confirms the
// dimension-aware offset is wired up.
const findingDims = rfNodes.find((n) => n.id === "finding-1");
const resourceDims = rfNodes.find((n) => n.id === "resource-1");
expect(findingDims?.width).not.toEqual(resourceDims?.width);
});
it("reverses container relationships while preserving original endpoints in edge data", () => {
const containerNode: GraphNode = {
id: "container",
labels: ["AWSAccount"],
properties: { name: "acct" },
};
const childNode: GraphNode = {
id: "child",
labels: ["S3Bucket"],
properties: { name: "bucket" },
};
const { rfEdges } = layoutWithDagre(
[containerNode, childNode],
[
{
id: "e1",
source: "container",
target: "child",
type: "RUNS_IN",
},
],
);
expect(rfEdges).toHaveLength(1);
expect(rfEdges[0]).toMatchObject({
source: "child",
target: "container",
data: { originalSource: "container", originalTarget: "child" },
});
});
it("animates edges that touch a finding node and tags them with finding-edge", () => {
const { rfEdges } = layoutWithDagre(
[findingNode, resourceNode, internetNode],
[
{
id: "e1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
{
id: "e2",
source: "internet-1",
target: "resource-1",
type: "CONNECTS_TO",
},
],
);
const findingEdge = rfEdges.find(
(e) => e.source === "resource-1" && e.target === "finding-1",
);
const plainEdge = rfEdges.find(
(e) => e.source === "internet-1" && e.target === "resource-1",
);
expect(findingEdge).toMatchObject({
animated: true,
className: "finding-edge",
});
expect(plainEdge).toMatchObject({
animated: false,
className: "resource-edge",
});
});
it("preserves the original edge id when the graph has a single edge", () => {
const { rfEdges } = layoutWithDagre(
[findingNode, resourceNode],
[
{
id: "ignored-by-rf",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
],
);
expect(rfEdges[0]?.id).toBe("ignored-by-rf");
});
it("preserves parallel edges between the same nodes with unique ids", () => {
const { rfEdges } = layoutWithDagre(
[resourceNode, findingNode],
[
{
id: "edge-1",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
{
id: "edge-2",
source: "resource-1",
target: "finding-1",
type: "HAS_FINDING",
},
],
);
expect(rfEdges).toHaveLength(2);
expect(rfEdges.map((edge) => edge.id)).toEqual(["edge-1", "edge-2"]);
expect(rfEdges.every((edge) => edge.source === "resource-1")).toBe(true);
expect(rfEdges.every((edge) => edge.target === "finding-1")).toBe(true);
});
});
@@ -0,0 +1,161 @@
/**
* Pure Dagre layout adapter for React Flow
* Converts normalized GraphNode[] + GraphEdge[] to positioned RF nodes
*/
import { Graph, layout as dagreLayout } from "@dagrejs/dagre";
import { type Edge, type Node, Position } from "@xyflow/react";
import type { GraphEdge, GraphNode } from "@/types/attack-paths";
// Node dimensions matching the rendered React Flow custom nodes.
const RESOURCE_NODE_WIDTH = 136;
const RESOURCE_NODE_HEIGHT = 112;
const FINDING_NODE_WIDTH = 150;
const FINDING_NODE_HEIGHT = 112;
const INTERNET_DIAMETER = 80; // NODE_HEIGHT * 0.8 * 2
// Container relationships that get reversed for proper hierarchy
const CONTAINER_RELATIONS = new Set([
"RUNS_IN",
"BELONGS_TO",
"LOCATED_IN",
"PART_OF",
]);
interface NodeData extends Record<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: FINDING_NODE_WIDTH, height: FINDING_NODE_HEIGHT };
if (type === NODE_TYPE.INTERNET)
return { width: INTERNET_DIAMETER, height: INTERNET_DIAMETER };
return { width: RESOURCE_NODE_WIDTH, height: RESOURCE_NODE_HEIGHT };
};
/**
* Pure layout function: computes positioned React Flow nodes from graph data.
* Deterministic — same inputs always produce same outputs.
*/
export const layoutWithDagre = (
nodes: GraphNode[],
edges: GraphEdge[],
): { rfNodes: Node<NodeData>[]; rfEdges: Edge[] } => {
const g = new Graph({ multigraph: true });
g.setGraph({
rankdir: "LR",
nodesep: 80,
ranksep: 150,
marginx: 50,
marginy: 50,
});
g.setDefaultEdgeLabel(() => ({}));
// Add nodes with type-based dimensions
nodes.forEach((node) => {
const type = getNodeType(node.labels);
const { width, height } = getNodeDimensions(type);
g.setNode(node.id, { label: node.id, width, height });
});
// Add edges, reversing container relationships for proper hierarchy
edges.forEach((edge) => {
let sourceId = edge.source;
let targetId = edge.target;
if (CONTAINER_RELATIONS.has(edge.type)) {
[sourceId, targetId] = [targetId, sourceId];
}
if (sourceId && targetId) {
g.setEdge(
sourceId,
targetId,
{
id: edge.id,
originalSource: edge.source,
originalTarget: edge.target,
},
edge.id,
);
}
});
dagreLayout(g);
// Build RF nodes from layout
const rfNodes: Node<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,
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
data: { graphNode: node },
width,
height,
};
});
// Build RF edges from dagre edges (using layout order, not original)
const rfEdges: Edge[] = g
.edges()
.map((e: { v: string; w: string; name?: string }) => {
const edgeData = g.edge(e) as {
id?: string;
originalSource: string;
originalTarget: string;
};
// Check if either end is a finding node
const sourceNode = nodes.find((n) => n.id === e.v);
const targetNode = nodes.find((n) => n.id === e.w);
const hasFinding =
isFindingNode(sourceNode?.labels ?? []) ||
isFindingNode(targetNode?.labels ?? []);
return {
id: edgeData.id ?? e.name ?? `${e.v}-${e.w}`,
source: e.v,
target: e.w,
animated: hasFinding,
className: hasFinding ? "finding-edge" : "resource-edge",
data: {
pathKey: `${e.v}-${e.w}`,
originalSource: edgeData.originalSource,
originalTarget: edgeData.originalTarget,
},
};
});
return { rfNodes, rfEdges };
};
@@ -0,0 +1,474 @@
import {
AlertTriangle,
Bot,
Braces,
CircleAlert,
FileKey2,
Globe2,
Info,
KeyRound,
Route,
Server,
Shield,
ShieldCheck,
Siren,
Tags,
UserCog,
Users,
Waypoints,
} from "lucide-react";
import { describe, expect, it } from "vitest";
import {
AWSProviderBadge,
AzureProviderBadge,
GCPProviderBadge,
KS8ProviderBadge,
} from "@/components/icons/providers-badge";
import {
AmazonEC2Icon,
AmazonRDSIcon,
AmazonS3Icon,
AmazonVPCIcon,
AWSIAMIcon,
AWSLambdaIcon,
} from "@/components/icons/services/IconServices";
import type { GraphNode } from "@/types/attack-paths";
import { NODE_CATEGORY, resolveNodeVisual } from "./node-visuals";
const buildNode = (labels: string[], properties = {}): GraphNode => ({
id: labels[0] ?? "unknown-node",
labels,
properties,
});
describe("resolveNodeVisual", () => {
describe("exact label mappings", () => {
it("should resolve AWSAccount nodes to account metadata", () => {
// Given
const node = buildNode(["AWSAccount"], { name: "Production" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.ACCOUNT,
displayName: "Production",
description: "AWS Account",
fallbackUsed: false,
});
expect(visual.Icon).toBe(AWSProviderBadge);
});
it("should resolve cloud provider root nodes to provider badges", () => {
// Given
const providerNodes = [
{
label: "AzureTenant",
description: "Azure Tenant",
Icon: AzureProviderBadge,
},
{
label: "GCPProject",
description: "Google Cloud Project",
Icon: GCPProviderBadge,
},
{
label: "KubernetesCluster",
description: "Kubernetes Cluster",
Icon: KS8ProviderBadge,
},
];
for (const providerNode of providerNodes) {
// When
const visual = resolveNodeVisual(
buildNode([providerNode.label], { name: providerNode.description }),
);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.ACCOUNT,
displayName: providerNode.description,
description: providerNode.description,
fallbackUsed: false,
});
expect(visual.Icon).toBe(providerNode.Icon);
}
});
it("should resolve S3Bucket nodes to storage metadata", () => {
// Given
const node = buildNode(["S3Bucket"], { name: "public-assets" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.STORAGE,
displayName: "public-assets",
description: "S3 Bucket",
fallbackUsed: false,
});
expect(visual.Icon).toBe(AmazonS3Icon);
});
it.each([
["AWSAccount", NODE_CATEGORY.ACCOUNT, "AWS Account", AWSProviderBadge],
["S3Bucket", NODE_CATEGORY.STORAGE, "S3 Bucket", AmazonS3Icon],
["S3", NODE_CATEGORY.STORAGE, "S3", AmazonS3Icon],
["VPC", NODE_CATEGORY.NETWORK, "VPC", AmazonVPCIcon],
["Subnet", NODE_CATEGORY.NETWORK, "Subnet", Waypoints],
["SecurityGroup", NODE_CATEGORY.NETWORK, "Security Group", Shield],
["InternetGateway", NODE_CATEGORY.NETWORK, "Internet Gateway", Route],
["DefaultGateway", NODE_CATEGORY.NETWORK, "Default Gateway", Route],
["EC2Instance", NODE_CATEGORY.COMPUTE, "EC2 Instance", AmazonEC2Icon],
[
"VirtualMachine",
NODE_CATEGORY.COMPUTE,
"Virtual Machine",
AmazonEC2Icon,
],
["Compute", NODE_CATEGORY.COMPUTE, "Compute", Server],
["NIC", NODE_CATEGORY.COMPUTE, "NIC", Server],
["IAMUser", NODE_CATEGORY.IDENTITY, "IAM User", AWSIAMIcon],
["IAMRole", NODE_CATEGORY.IDENTITY, "IAM Role", AWSIAMIcon],
["ServiceAccount", NODE_CATEGORY.IDENTITY, "Service Account", Bot],
["AccessKey", NODE_CATEGORY.SECRET, "Access Key", KeyRound],
["Secret", NODE_CATEGORY.SECRET, "Secret", KeyRound],
] as const)(
"should map %s to %s with the expected icon",
(label, category, description, Icon) => {
// Given
const node = buildNode([label]);
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category,
description,
fallbackUsed: false,
});
expect(visual.Icon).toBe(Icon);
},
);
it("should resolve VPC nodes to network metadata", () => {
// Given
const node = buildNode(["VPC"], { name: "main-vpc" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.NETWORK,
displayName: "main-vpc",
description: "VPC",
fallbackUsed: false,
});
expect(visual.Icon).toBe(AmazonVPCIcon);
});
it("should resolve ProwlerFinding nodes to finding metadata", () => {
// Given
const node = buildNode(["ProwlerFinding"], {
check_title: "S3 bucket is public",
});
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.FINDING,
displayName: "S3 bucket is public",
description: "Prowler Finding",
fallbackUsed: false,
});
});
it("should resolve finding icons from severity", () => {
// Given
const findingNodes = [
{ severity: "critical", Icon: Siren },
{ severity: "high", Icon: AlertTriangle },
{ severity: "medium", Icon: CircleAlert },
{ severity: "low", Icon: Info },
{ severity: "informational", Icon: Info },
];
for (const findingNode of findingNodes) {
// When
const visual = resolveNodeVisual(
buildNode(["ProwlerFinding"], {
check_title: `${findingNode.severity} finding`,
severity: findingNode.severity,
}),
);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.FINDING,
description: "Prowler Finding",
fallbackUsed: false,
});
expect(visual.Icon).toBe(findingNode.Icon);
}
});
it("should use the generic alert icon when finding severity is unknown", () => {
// Given
const node = buildNode(["ProwlerFinding"], {
check_title: "Unknown risk",
severity: "unknown",
});
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual.Icon).toBe(AlertTriangle);
});
it("should resolve Internet nodes to internet metadata", () => {
// Given
const node = buildNode(["Internet"]);
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.INTERNET,
displayName: "Internet",
description: "Internet",
fallbackUsed: false,
});
});
});
describe("alias and normalized mappings", () => {
it("should resolve IAMUser nodes to identity metadata with the AWS IAM icon", () => {
// Given
const node = buildNode(["IAMUser"], { name: "alice" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.IDENTITY,
displayName: "alice",
description: "IAM User",
fallbackUsed: false,
});
expect(visual.Icon).toBe(AWSIAMIcon);
});
it("should resolve case-insensitive AccessKey labels to secret metadata", () => {
// Given
const node = buildNode(["access_key"], { id: "AKIA123" });
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.SECRET,
displayName: "AKIA123",
description: "Access Key",
fallbackUsed: false,
});
});
it("should resolve AWS identity and policy labels to distinct icons", () => {
// Given
const identityNodes = [
{
label: "AWSUser",
description: "AWS User",
Icon: UserCog,
},
{
label: "AWSManagedPolicy",
description: "AWS Managed Policy",
Icon: FileKey2,
},
{
label: "AWSPolicyStatement",
description: "AWS Policy Statement",
Icon: Braces,
},
{
label: "PermissionRole",
description: "Permission Role",
Icon: ShieldCheck,
},
];
for (const identityNode of identityNodes) {
// When
const visual = resolveNodeVisual(
buildNode([identityNode.label], { name: identityNode.description }),
);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.IDENTITY,
displayName: identityNode.description,
description: identityNode.description,
fallbackUsed: false,
});
expect(visual.Icon).toBe(identityNode.Icon);
}
});
it("should resolve all AWS labels used by predefined Attack Paths queries", () => {
// Given
const awsQueryNodes = [
{
label: "AWSTag",
category: NODE_CATEGORY.MISC,
description: "AWS Tag",
Icon: Tags,
},
{
label: "EC2SecurityGroup",
category: NODE_CATEGORY.NETWORK,
description: "EC2 Security Group",
Icon: Shield,
},
{
label: "IpPermissionInbound",
category: NODE_CATEGORY.NETWORK,
description: "Inbound IP Permission",
Icon: Shield,
},
{
label: "IpRange",
category: NODE_CATEGORY.NETWORK,
description: "IP Range",
Icon: Globe2,
},
{
label: "AWSPrincipal",
category: NODE_CATEGORY.IDENTITY,
description: "AWS Principal",
Icon: ShieldCheck,
},
{
label: "AWSGroup",
category: NODE_CATEGORY.IDENTITY,
description: "AWS Group",
Icon: Users,
},
{
label: "RDSInstance",
category: NODE_CATEGORY.STORAGE,
description: "RDS Instance",
Icon: AmazonRDSIcon,
},
{
label: "LoadBalancer",
category: NODE_CATEGORY.NETWORK,
description: "Load Balancer",
Icon: Route,
},
{
label: "ELBListener",
category: NODE_CATEGORY.NETWORK,
description: "ELB Listener",
Icon: Route,
},
{
label: "LoadBalancerV2",
category: NODE_CATEGORY.NETWORK,
description: "Load Balancer V2",
Icon: Route,
},
{
label: "ELBV2Listener",
category: NODE_CATEGORY.NETWORK,
description: "ELB V2 Listener",
Icon: Route,
},
{
label: "ElasticIPAddress",
category: NODE_CATEGORY.NETWORK,
description: "Elastic IP Address",
Icon: Globe2,
},
{
label: "EC2PrivateIp",
category: NODE_CATEGORY.NETWORK,
description: "EC2 Private IP",
Icon: Waypoints,
},
{
label: "NetworkInterface",
category: NODE_CATEGORY.NETWORK,
description: "Network Interface",
Icon: Waypoints,
},
{
label: "LaunchTemplate",
category: NODE_CATEGORY.COMPUTE,
description: "Launch Template",
Icon: Server,
},
{
label: "AWSLambda",
category: NODE_CATEGORY.COMPUTE,
description: "AWS Lambda",
Icon: AWSLambdaIcon,
},
{
label: "AWSSageMakerNotebookInstance",
category: NODE_CATEGORY.COMPUTE,
description: "SageMaker Notebook Instance",
Icon: Bot,
},
];
for (const awsQueryNode of awsQueryNodes) {
// When
const visual = resolveNodeVisual(
buildNode([awsQueryNode.label], { name: awsQueryNode.description }),
);
// Then
expect(visual).toMatchObject({
category: awsQueryNode.category,
displayName: awsQueryNode.description,
description: awsQueryNode.description,
fallbackUsed: false,
});
expect(visual.Icon).toBe(awsQueryNode.Icon);
}
});
});
describe("fallback behavior", () => {
it("should use formatted labels for unknown nodes and mark the fallback", () => {
// Given
const node = buildNode(["CustomGraphNode"]);
// When
const visual = resolveNodeVisual(node);
// Then
expect(visual).toMatchObject({
category: NODE_CATEGORY.MISC,
displayName: "Custom Graph Node",
description: "Custom Graph Node",
fallbackUsed: true,
});
});
});
});
@@ -0,0 +1,522 @@
import {
AlertTriangle,
Bot,
Box,
Braces,
CircleAlert,
FileKey2,
Globe2,
Info,
KeyRound,
Route,
Server,
Shield,
ShieldCheck,
Siren,
Tags,
UserCog,
Users,
Waypoints,
} from "lucide-react";
import type { ElementType } from "react";
import {
AlibabaCloudProviderBadge,
AWSProviderBadge,
AzureProviderBadge,
CloudflareProviderBadge,
GCPProviderBadge,
GitHubProviderBadge,
GoogleWorkspaceProviderBadge,
IacProviderBadge,
ImageProviderBadge,
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
VercelProviderBadge,
} from "@/components/icons/providers-badge";
import {
AmazonEC2Icon,
AmazonRDSIcon,
AmazonS3Icon,
AmazonVPCIcon,
AWSIAMIcon,
AWSLambdaIcon,
} from "@/components/icons/services/IconServices";
import type { GraphNode, GraphNodePropertyValue } from "@/types/attack-paths";
import { formatNodeLabel } from "./format";
export const NODE_CATEGORY = {
FINDING: "finding",
INTERNET: "internet",
ACCOUNT: "account",
STORAGE: "storage",
NETWORK: "network",
COMPUTE: "compute",
IDENTITY: "identity",
SECRET: "secret",
MISC: "misc",
} as const;
export type NodeCategory = (typeof NODE_CATEGORY)[keyof typeof NODE_CATEGORY];
interface KnownNodeVisualMapping {
category: NodeCategory;
description: string;
Icon: ElementType;
}
export interface NodeVisual extends KnownNodeVisualMapping {
displayName: string;
fallbackUsed: boolean;
}
const KNOWN_NODE_VISUALS = {
awsaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "AWS Account",
Icon: AWSProviderBadge,
},
azureaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Azure Account",
Icon: AzureProviderBadge,
},
azuretenant: {
category: NODE_CATEGORY.ACCOUNT,
description: "Azure Tenant",
Icon: AzureProviderBadge,
},
gcpaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Google Cloud Account",
Icon: GCPProviderBadge,
},
gcpproject: {
category: NODE_CATEGORY.ACCOUNT,
description: "Google Cloud Project",
Icon: GCPProviderBadge,
},
googlecloudaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Google Cloud Account",
Icon: GCPProviderBadge,
},
kubernetescluster: {
category: NODE_CATEGORY.ACCOUNT,
description: "Kubernetes Cluster",
Icon: KS8ProviderBadge,
},
k8scluster: {
category: NODE_CATEGORY.ACCOUNT,
description: "Kubernetes Cluster",
Icon: KS8ProviderBadge,
},
githubaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "GitHub Account",
Icon: GitHubProviderBadge,
},
githuborganization: {
category: NODE_CATEGORY.ACCOUNT,
description: "GitHub Organization",
Icon: GitHubProviderBadge,
},
m365tenant: {
category: NODE_CATEGORY.ACCOUNT,
description: "Microsoft 365 Tenant",
Icon: M365ProviderBadge,
},
googleworkspace: {
category: NODE_CATEGORY.ACCOUNT,
description: "Google Workspace",
Icon: GoogleWorkspaceProviderBadge,
},
iac: {
category: NODE_CATEGORY.ACCOUNT,
description: "Infrastructure as Code",
Icon: IacProviderBadge,
},
containerregistry: {
category: NODE_CATEGORY.ACCOUNT,
description: "Container Registry",
Icon: ImageProviderBadge,
},
oraclecloudaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Oracle Cloud Account",
Icon: OracleCloudProviderBadge,
},
mongodbatlas: {
category: NODE_CATEGORY.ACCOUNT,
description: "MongoDB Atlas",
Icon: MongoDBAtlasProviderBadge,
},
alibabacloudaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Alibaba Cloud Account",
Icon: AlibabaCloudProviderBadge,
},
cloudflareaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Cloudflare Account",
Icon: CloudflareProviderBadge,
},
openstackaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "OpenStack Account",
Icon: OpenStackProviderBadge,
},
vercelaccount: {
category: NODE_CATEGORY.ACCOUNT,
description: "Vercel Account",
Icon: VercelProviderBadge,
},
s3bucket: {
category: NODE_CATEGORY.STORAGE,
description: "S3 Bucket",
Icon: AmazonS3Icon,
},
s3: {
category: NODE_CATEGORY.STORAGE,
description: "S3",
Icon: AmazonS3Icon,
},
vpc: {
category: NODE_CATEGORY.NETWORK,
description: "VPC",
Icon: AmazonVPCIcon,
},
subnet: {
category: NODE_CATEGORY.NETWORK,
description: "Subnet",
Icon: Waypoints,
},
securitygroup: {
category: NODE_CATEGORY.NETWORK,
description: "Security Group",
Icon: Shield,
},
ec2securitygroup: {
category: NODE_CATEGORY.NETWORK,
description: "EC2 Security Group",
Icon: Shield,
},
ippermissioninbound: {
category: NODE_CATEGORY.NETWORK,
description: "Inbound IP Permission",
Icon: Shield,
},
iprange: {
category: NODE_CATEGORY.NETWORK,
description: "IP Range",
Icon: Globe2,
},
elasticipaddress: {
category: NODE_CATEGORY.NETWORK,
description: "Elastic IP Address",
Icon: Globe2,
},
ec2privateip: {
category: NODE_CATEGORY.NETWORK,
description: "EC2 Private IP",
Icon: Waypoints,
},
networkinterface: {
category: NODE_CATEGORY.NETWORK,
description: "Network Interface",
Icon: Waypoints,
},
internetgateway: {
category: NODE_CATEGORY.NETWORK,
description: "Internet Gateway",
Icon: Route,
},
defaultgateway: {
category: NODE_CATEGORY.NETWORK,
description: "Default Gateway",
Icon: Route,
},
loadbalancer: {
category: NODE_CATEGORY.NETWORK,
description: "Load Balancer",
Icon: Route,
},
loadbalancerv2: {
category: NODE_CATEGORY.NETWORK,
description: "Load Balancer V2",
Icon: Route,
},
elblistener: {
category: NODE_CATEGORY.NETWORK,
description: "ELB Listener",
Icon: Route,
},
elbv2listener: {
category: NODE_CATEGORY.NETWORK,
description: "ELB V2 Listener",
Icon: Route,
},
ec2instance: {
category: NODE_CATEGORY.COMPUTE,
description: "EC2 Instance",
Icon: AmazonEC2Icon,
},
launchtemplate: {
category: NODE_CATEGORY.COMPUTE,
description: "Launch Template",
Icon: Server,
},
awslambda: {
category: NODE_CATEGORY.COMPUTE,
description: "AWS Lambda",
Icon: AWSLambdaIcon,
},
awssagemakernotebookinstance: {
category: NODE_CATEGORY.COMPUTE,
description: "SageMaker Notebook Instance",
Icon: Bot,
},
virtualmachine: {
category: NODE_CATEGORY.COMPUTE,
description: "Virtual Machine",
Icon: AmazonEC2Icon,
},
rdsinstance: {
category: NODE_CATEGORY.STORAGE,
description: "RDS Instance",
Icon: AmazonRDSIcon,
},
compute: {
category: NODE_CATEGORY.COMPUTE,
description: "Compute",
Icon: Server,
},
nic: {
category: NODE_CATEGORY.COMPUTE,
description: "NIC",
Icon: Server,
},
iamuser: {
category: NODE_CATEGORY.IDENTITY,
description: "IAM User",
Icon: AWSIAMIcon,
},
awsuser: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS User",
Icon: UserCog,
},
awsgroup: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Group",
Icon: Users,
},
awsprincipal: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Principal",
Icon: ShieldCheck,
},
iamrole: {
category: NODE_CATEGORY.IDENTITY,
description: "IAM Role",
Icon: AWSIAMIcon,
},
awsrole: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Role",
Icon: ShieldCheck,
},
permissionrole: {
category: NODE_CATEGORY.IDENTITY,
description: "Permission Role",
Icon: ShieldCheck,
},
awsmanagedpolicy: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Managed Policy",
Icon: FileKey2,
},
awspolicy: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Policy",
Icon: FileKey2,
},
policy: {
category: NODE_CATEGORY.IDENTITY,
description: "Policy",
Icon: FileKey2,
},
awspolicystatement: {
category: NODE_CATEGORY.IDENTITY,
description: "AWS Policy Statement",
Icon: Braces,
},
policystatement: {
category: NODE_CATEGORY.IDENTITY,
description: "Policy Statement",
Icon: Braces,
},
accesskey: {
category: NODE_CATEGORY.SECRET,
description: "Access Key",
Icon: KeyRound,
},
secret: {
category: NODE_CATEGORY.SECRET,
description: "Secret",
Icon: KeyRound,
},
serviceaccount: {
category: NODE_CATEGORY.IDENTITY,
description: "Service Account",
Icon: Bot,
},
awstag: {
category: NODE_CATEGORY.MISC,
description: "AWS Tag",
Icon: Tags,
},
} as const satisfies Record<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 FINDING_SEVERITY = {
CRITICAL: "critical",
HIGH: "high",
MEDIUM: "medium",
LOW: "low",
INFO: "info",
INFORMATIONAL: "informational",
} as const;
type FindingSeverity = (typeof FINDING_SEVERITY)[keyof typeof FINDING_SEVERITY];
const FINDING_SEVERITY_ICONS = {
[FINDING_SEVERITY.CRITICAL]: Siren,
[FINDING_SEVERITY.HIGH]: AlertTriangle,
[FINDING_SEVERITY.MEDIUM]: CircleAlert,
[FINDING_SEVERITY.LOW]: Info,
[FINDING_SEVERITY.INFO]: Info,
[FINDING_SEVERITY.INFORMATIONAL]: Info,
} as const satisfies Record<FindingSeverity, ElementType>;
const stringifyProperty = (
value: GraphNodePropertyValue,
): string | undefined => {
if (value === null || value === undefined) return undefined;
if (Array.isArray(value)) return value.join(", ");
return String(value);
};
const firstDefinedProperty = (
node: GraphNode,
keys: string[],
): string | undefined => {
for (const key of keys) {
const value = stringifyProperty(node.properties[key]);
if (value) return value;
}
return undefined;
};
const getPrimaryFormattedLabel = (node: GraphNode): string => {
const primaryLabel = node.labels[0];
if (!primaryLabel) return "Unknown";
return formatNodeLabel(primaryLabel.replace(/[_-]/g, " "));
};
const resolveDisplayName = (node: GraphNode): string =>
firstDefinedProperty(node, ["name", "display_name", "title", "id"]) ??
getPrimaryFormattedLabel(node);
const resolveFindingDisplayName = (node: GraphNode): string =>
firstDefinedProperty(node, ["check_title", "title", "name", "id"]) ??
getPrimaryFormattedLabel(node);
const resolveFindingSeverity = (
node: GraphNode,
): FindingSeverity | undefined => {
const severity = firstDefinedProperty(node, ["severity"]);
if (!severity) return undefined;
const normalizedSeverity = severity.toLowerCase();
return normalizedSeverity in FINDING_SEVERITY_ICONS
? (normalizedSeverity as FindingSeverity)
: undefined;
};
const resolveFindingIcon = (node: GraphNode): ElementType => {
const severity = resolveFindingSeverity(node);
return severity ? FINDING_SEVERITY_ICONS[severity] : AlertTriangle;
};
const resolveKnownMapping = (
labels: string[],
): KnownNodeVisualMapping | undefined => {
for (const label of labels) {
const normalizedLabel = normalizeLabel(label);
if (isKnownNodeLabel(normalizedLabel)) {
return KNOWN_NODE_VISUALS[normalizedLabel];
}
}
return undefined;
};
export const resolveNodeVisual = (node: GraphNode): NodeVisual => {
if (node.labels.some(isFindingLabel)) {
return {
category: NODE_CATEGORY.FINDING,
displayName: resolveFindingDisplayName(node),
description: "Prowler Finding",
Icon: resolveFindingIcon(node),
fallbackUsed: false,
};
}
if (node.labels.some(isInternetLabel)) {
return {
category: NODE_CATEGORY.INTERNET,
displayName: "Internet",
description: "Internet",
Icon: Globe2,
fallbackUsed: false,
};
}
const knownMapping = resolveKnownMapping(node.labels);
if (knownMapping) {
return {
...knownMapping,
displayName: resolveDisplayName(node),
fallbackUsed: false,
};
}
const fallbackLabel = getPrimaryFormattedLabel(node);
return {
category: NODE_CATEGORY.MISC,
displayName: resolveDisplayName(node),
description: fallbackLabel,
Icon: Box,
fallbackUsed: true,
};
};
@@ -0,0 +1,623 @@
/**
* 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, vi } from "vitest";
import { handlersForFixture } from "@/__tests__/msw/handlers/attack-paths";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
const { getFindingByIdMock } = vi.hoisted(() => ({
getFindingByIdMock: vi.fn(),
}));
vi.mock("@/actions/findings", async () => {
const actual =
await vi.importActual<typeof import("@/actions/findings")>(
"@/actions/findings",
);
getFindingByIdMock.mockImplementation(actual.getFindingById);
return {
...actual,
getFindingById: getFindingByIdMock,
};
});
import { useGraphStore } from "./_hooks/use-graph-state";
import { getPathEdges } from "./_lib";
import { isFindingNode, layoutWithDagre } from "./_lib/layout";
import AttackPathsPage from "./attack-paths-page";
import { fixtures, type PageFixture } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
interface Fixtures {
mountWith: (fx?: PageFixture) => Promise<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();
getFindingByIdMock.mockClear();
});
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);
expect(new Set(edgeIds).size).toBe(edgeIds.length);
for (const id of edgeIds) {
expect(id.length).toBeGreaterThan(0);
}
});
test("a query that returns one node renders just that node", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.singleNode());
await graph.executeQuery();
await graph.waitForLayoutStable(1);
expect(graph.nodes).toHaveLength(1);
});
test("a query that returns no graph data surfaces an error without crashing", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.emptyGraph());
try {
await graph.executeQuery();
} catch {
/* expected: layout never stabilizes */
}
expect(graph.nodes).toHaveLength(0);
});
test("a 200-node graph finishes laying out within 5s", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.large(200));
const start = performance.now();
await graph.executeQuery();
await graph.waitForLayoutStable(1);
const elapsed = performance.now() - start;
expect(elapsed).toBeLessThan(5000);
});
test("disconnected components are both visible", async ({ mountWith }) => {
const graph = await mountWith(fixtures.disconnected());
await graph.executeQuery();
await graph.waitForLayoutStable(4);
expect(graph.nodes.length).toBe(4);
});
test("a query that returns only resources renders no findings", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.resourcesOnly());
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.findingNodes.length).toBe(0);
expect(graph.resourceNodes.length).toBe(3);
});
test("findings without a connected resource stay visible in the full graph", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.findingsOnly());
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.findingNodes.length).toBe(3);
expect(graph.resourceNodes.length).toBe(0);
});
test("hidden findings do not reserve layout space until expanded", async ({
mountWith,
}) => {
// Given - a graph whose findings are hidden in the initial tier-1 view.
// The initial rendered positions should match a layout computed from only
// visible resources/edges, not from hidden finding nodes.
const fixture = fixtures.typical();
if (!fixture.queryResult) throw new Error("Expected graph fixture data");
const visibleNodes = fixture.queryResult.nodes.filter(
(node) => !isFindingNode(node.labels),
);
const visibleNodeIds = new Set(visibleNodes.map((node) => node.id));
const visibleEdges = (fixture.queryResult.relationships ?? [])
.filter(
(edge) =>
visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target),
)
.map((edge) => ({
...edge,
type: edge.label,
}));
const expectedPositions = Object.fromEntries(
layoutWithDagre(visibleNodes, visibleEdges).rfNodes.map((node) => [
node.id,
node.position,
]),
);
const graph = await mountWith(fixture);
await graph.executeQuery();
await graph.waitForLayoutStable(3);
// Then - hidden findings do not influence initial resource coordinates.
for (const node of visibleNodes) {
expect(graph.nodePositionsById[node.id]).toEqual(
expectedPositions[node.id],
);
}
});
test("self-loops, cycles, long labels, unicode, and duplicate edges all render", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.edgeCases());
await graph.executeQuery();
await graph.waitForLayoutStable(5);
expect(graph.nodes.length).toBe(7);
expect(graph.containsText(/🔒-secure-bucket-日本語/)).toBe(true);
});
});
describe("exploring the graph", () => {
test("clicking a finding opens the filtered view and finding details", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
expect(graph.isInFilteredView).toBe(false);
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
expect(getFindingByIdMock).toHaveBeenCalledTimes(1);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
});
test("clicking a resource with findings directly reveals related finding nodes", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.findingNodes.length).toBe(0);
expect(graph.hasNodeDetailsModal).toBe(false);
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
});
test("clicking an expanded resource with findings hides its related finding nodes", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBe(0);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
});
test("clicking a resource with findings re-fits around the resource and its findings", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
const initialViewport = graph.viewportTransform;
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitFor(
() => graph.viewportTransform !== initialViewport,
2000,
);
const contextualViewport = graph.viewportTransform;
await graph.fit();
await graph.waitFor(
() => graph.viewportTransform !== contextualViewport,
2000,
);
});
test("clicking an expanded resource re-fits the remaining visible graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitForTransition();
const expandedViewport = graph.viewportTransform;
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBe(0);
await graph.waitFor(
() => graph.viewportTransform !== expandedViewport,
2000,
);
});
test("returning from a finding keeps the expanded findings context fitted", async ({
mountWith,
}) => {
const graph = await mountWith(fixtures.large(20));
await graph.executeQuery();
await graph.waitForLayoutStable(16);
await graph.clickFirstResourceNode();
expect(graph.findingNodes.length).toBeGreaterThan(0);
await graph.waitForTransition();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.exitFilteredView();
expect(graph.isInFilteredView).toBe(false);
await graph.waitForTransition();
expect(graph.findingNodes.length).toBeGreaterThan(0);
expect(graph.viewportTransform).toBeTruthy();
});
test("clicking a resource without findings does nothing", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
expect(graph.findingNodes.length).toBe(0);
await graph.clickFirstResourceNodeWithoutFindings();
expect(graph.findingNodes.length).toBe(0);
expect(graph.hasNodeDetailsModal).toBe(false);
expect(graph.hasNodeActionDialog).toBe(false);
});
test("exiting the filtered view restores the full graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
const fullNodes = graph.nodes.length;
await graph.clickFirstFindingNode();
await graph.exitFilteredView();
await graph.waitForLayoutStable(fullNodes);
expect(graph.isInFilteredView).toBe(false);
});
test("hovering a node highlights its path edges", async ({ mountWith }) => {
const fixture = fixtures.typical();
const graph = await mountWith(fixture);
await graph.executeQuery();
await graph.waitForLayoutStable(3);
const hoveredNodeId = graph.resourceNodes[0]?.getAttribute("data-id");
expect(hoveredNodeId).toBeTruthy();
const findingIds = new Set(
(fixture.queryResult?.nodes ?? [])
.filter((node) => isFindingNode(node.labels))
.map((node) => node.id),
);
const visibleEdges = (fixture.queryResult?.relationships ?? [])
.filter(
(edge) => !findingIds.has(edge.source) && !findingIds.has(edge.target),
)
.map((edge) => ({ sourceId: edge.source, targetId: edge.target }));
const expectedPathKeys = getPathEdges(hoveredNodeId ?? "", visibleEdges);
const expectedHighlightedIds = (fixture.queryResult?.relationships ?? [])
.filter((edge) => expectedPathKeys.has(`${edge.source}-${edge.target}`))
.map((edge) => edge.id)
.sort();
await graph.hoverFirstResourceNode();
await graph.waitForTransition(120);
expect(
graph.highlightedEdges.map((edge) => edge.dataset.id ?? "").sort(),
).toEqual(expectedHighlightedIds);
await graph.unhoverNodes();
await graph.waitForTransition(120);
expect(graph.highlightedEdges.length).toBe(0);
});
test("selecting a node keeps its path edges highlighted", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickFirstResourceNodeWithoutFindings();
expect(graph.highlightedEdges.length).toBeGreaterThan(0);
});
test("clicking the empty canvas keeps the full graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.clickEmptyCanvas();
expect(graph.isInFilteredView).toBe(false);
});
test("rapid clicks on a finding don't duplicate the filtered view", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
await graph.rapidlyClickFirstFindingNode(2);
expect(graph.isInFilteredView).toBe(true);
expect(getFindingByIdMock).toHaveBeenCalledTimes(1);
});
test("double-clicking a node doesn't break state", async ({ mountWith }) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.dblClickFirstResourceNode();
expect(graph.nodes.length).toBeGreaterThan(0);
});
});
describe("auto-fitting the viewport", () => {
test("the minimap viewport indicator has a visible border", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.minimapMaskStrokeWidth).toBeGreaterThan(0);
});
test("expanding resources re-fits the viewport when revealed findings fall off-screen", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
// Given - zoom into the current overview so newly revealed findings can
// sit entirely outside the current frame. The expand auto-fit should then
// recover the user instead of leaving them hunting off-screen.
for (let i = 0; i < 5; i++) {
await graph.zoomIn();
await graph.waitForTransition(80);
}
// Hidden findings are not measured by the initial declarative fit, so
// their positions can sit outside the framed viewport. Expanding the
// resources should re-fit so the user does not have to hunt for the
// newly visible findings off-screen.
const before = graph.viewportTransform;
expect(before).toBeTruthy();
await graph.expandAllFindings();
await graph.waitForTransition();
expect(graph.viewportTransform).not.toBe(before);
});
test("clicking a finding re-fits the viewport for the filtered subgraph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
const beforeFilter = graph.viewportTransform;
expect(beforeFilter).toBeTruthy();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.waitForTransition();
expect(graph.viewportTransform).not.toBe(beforeFilter);
});
test("Back to Full View re-fits the viewport for the full graph", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
await graph.clickFirstFindingNode();
expect(graph.isInFilteredView).toBe(true);
await graph.waitForTransition();
const filterT = graph.viewportTransform;
await graph.exitFilteredView();
await graph.waitForLayoutStable(3);
await graph.waitForTransition();
expect(graph.viewportTransform).not.toBe(filterT);
});
});
describe("exporting the graph", () => {
test("the export button is enabled when a graph is rendered", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
expect(graph.toolbar.isExportButtonEnabled).toBe(true);
});
test("clicking export downloads a PNG sized to the configured export canvas", async ({
mountWith,
}) => {
const graph = await mountWith();
await graph.executeQuery();
await graph.waitForLayoutStable(3);
await graph.expandAllFindings();
const png = await graph.captureExportPNG();
expect(png.filename).toBe("attack-path-graph.png");
expect(png.mimeType).toBe("image/png");
// 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,48 @@
import { describe, expect, it, vi } from "vitest";
import { fixtures } from "./attack-paths-page.fixtures";
import { AttackPathPageHarness } from "./attack-paths-page.harness";
describe("AttackPathPageHarness", () => {
it("should fail graph node clicks when browser pointer interaction fails", async () => {
// Given - A rendered graph node whose pointer interaction fails
const harness = new AttackPathPageHarness(fixtures.typical());
const node = document.createElement("div");
node.className = "react-flow__node react-flow__node-resource";
node.setAttribute("data-id", "ec2-1");
document.body.append(node);
const pointerError = new Error("pointer intercepted");
const clickSpy = vi
.spyOn(harness.user, "click")
.mockRejectedValue(pointerError);
const domClickSpy = vi.spyOn(node, "click");
// When / Then
await expect(harness.clickNode("ec2-1")).rejects.toThrow(
"pointer intercepted",
);
expect(clickSpy).toHaveBeenCalledWith(node);
expect(domClickSpy).not.toHaveBeenCalled();
});
it("should dispatch rapid finding clicks synchronously for race tests", async () => {
// Given - A rendered finding node and a harness rapid-click helper
const harness = new AttackPathPageHarness(fixtures.typical());
const finding = document.createElement("button");
finding.className = "react-flow__node react-flow__node-finding";
finding.setAttribute("data-id", "f-1");
document.body.append(finding);
const userClickSpy = vi.spyOn(harness.user, "click");
const domClickSpy = vi.spyOn(finding, "click");
vi.spyOn(harness, "waitForTransition").mockResolvedValue();
// When
await harness.rapidlyClickFirstFindingNode(2);
// Then
expect(userClickSpy).not.toHaveBeenCalled();
expect(domClickSpy).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,654 @@
/**
* 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 };
});
}
get nodePositionsById(): Record<string, { x: number; y: number }> {
return Object.fromEntries(
this.nodes.map((el) => {
const id = el.getAttribute("data-id") ?? "";
const match = /translate\(\s*([-\d.]+)px?\s*,\s*([-\d.]+)px?\s*\)/.exec(
el.style.transform,
);
return [
id,
match ? { x: Number(match[1]), y: Number(match[2]) } : { x: 0, y: 0 },
];
}),
);
}
// --- Predicates ---
get isInFilteredView(): boolean {
return !!this.container.querySelector(
'[aria-label="Return to full graph view"]',
);
}
isNodeSelected(nodeId: string): boolean {
const el = this.getNodeById(nodeId);
return !!el && el.classList.contains("selected");
}
isEdgeHighlighted(edgeId: string): boolean {
const el = this.getEdgeById(edgeId);
return !!el && el.classList.contains("highlighted");
}
isNodeHidden(nodeId: string): boolean {
return !this.getNodeById(nodeId);
}
// --- Lookups ---
getNodeById(id: string): HTMLElement | null {
return this.container.querySelector<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 ---
private async clickElement(
element: HTMLElement,
options?: { fallbackToDomClick?: boolean },
): Promise<void> {
try {
await this.user.click(element);
} catch (error) {
if (!options?.fallbackToDomClick) throw error;
element.click();
}
}
private async clickGraphElement(element: HTMLElement): Promise<void> {
await this.closeFindingDrawerIfOpen();
await this.user.click(element);
}
async closeFindingDrawerIfOpen(): Promise<void> {
const drawer = Array.from(
document.querySelectorAll<HTMLElement>('[role="dialog"]'),
).find((dialog) =>
/resource finding details|finding overview/i.test(
dialog.textContent ?? "",
),
);
if (!drawer) return;
const closeButton = Array.from(
drawer.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => /^close$/i.test(button.textContent?.trim() ?? ""));
if (closeButton) {
await this.clickElement(closeButton);
await this.waitForTransition();
}
}
async selectQuery(queryId?: string): Promise<void> {
await this.closeFindingDrawerIfOpen();
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.clickGraphElement(el);
await this.waitForTransition();
}
async clickFirstFindingNode(): Promise<HTMLElement> {
const [finding] = this.findingNodes;
if (!finding) throw new Error("clickFirstFindingNode: no finding rendered");
await this.clickGraphElement(finding);
await this.waitForTransition();
return finding;
}
async clickFirstResourceNode(): Promise<HTMLElement> {
const [resource] = this.resourceNodes;
if (!resource)
throw new Error("clickFirstResourceNode: no resource rendered");
await this.clickGraphElement(resource);
await this.waitForTransition();
return resource;
}
async clickFirstResourceNodeWithoutFindings(): Promise<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.clickGraphElement(resource);
await this.waitForTransition();
return resource;
}
/**
* Dispatch same-tick clicks against the first finding node. This models the
* duplicate-click race before the drawer overlay can intercept later pointer
* actions, so it intentionally uses native DOM clicks instead of awaited
* user-event pointer interactions.
*/
async rapidlyClickFirstFindingNode(times = 2): Promise<HTMLElement> {
const [finding] = this.findingNodes;
if (!finding)
throw new Error("rapidlyClickFirstFindingNode: no finding rendered");
await this.closeFindingDrawerIfOpen();
for (let i = 0; i < times; i++) {
finding.click();
}
await this.waitForTransition();
return finding;
}
async dblClickFirstResourceNode(): Promise<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);
}
}
}
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 exitFilteredView(): Promise<void> {
await this.closeFindingDrawerIfOpen();
const btn = this.toolbar.backToFullViewButton;
if (!btn) throw new Error("exitFilteredView: not in filtered view");
await this.clickElement(btn);
await this.waitForTransition();
}
async openFullscreen(): Promise<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),
};
}
}
@@ -0,0 +1,19 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
describe("AttackPathsPage", () => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const filePath = path.join(currentDir, "attack-paths-page.tsx");
const source = readFileSync(filePath, "utf8");
it("keeps the page description without rendering a duplicate Attack Paths heading", () => {
// Then
expect(source).not.toContain(">\n Attack Paths\n </h2>");
expect(source).toContain(
"Select a scan, build a query, and visualize Attack Paths in your",
);
});
});
@@ -1,6 +1,6 @@
"use client";
import { ArrowLeft, Info, Maximize2, X } from "lucide-react";
import { ArrowLeft, Info, Maximize2 } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
@@ -22,17 +22,15 @@ import {
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
} from "@/components/shadcn";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/shadcn/dialog";
import { Spinner } from "@/components/shadcn/spinner/spinner";
import { useToast } from "@/components/ui";
import type {
AttackPathQuery,
@@ -48,17 +46,16 @@ import {
GraphControls,
GraphLegend,
GraphLoading,
NodeDetailContent,
QueryDescription,
QueryExecutionError,
QueryParametersForm,
QuerySelector,
ScanListTable,
} from "./_components";
import type { AttackPathGraphRef } from "./_components/graph/attack-path-graph";
import type { GraphHandle } from "./_components/graph/attack-path-graph";
import { useGraphState } from "./_hooks/use-graph-state";
import { useQueryBuilder } from "./_hooks/use-query-builder";
import { exportGraphAsSVG, formatNodeLabel } from "./_lib";
import { exportGraphAsPNG } from "./_lib";
/**
* Attack Paths
@@ -76,10 +73,10 @@ export default function AttackPathsPage() {
const [queriesLoading, setQueriesLoading] = useState(true);
const [queriesError, setQueriesError] = useState<string | null>(null);
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const graphRef = useRef<AttackPathGraphRef>(null);
const fullscreenGraphRef = useRef<AttackPathGraphRef>(null);
const graphRef = useRef<GraphHandle>(null);
const fullscreenGraphRef = useRef<GraphHandle>(null);
const findingNavigationInFlightRef = useRef(false);
const hasResetRef = useRef(false);
const nodeDetailsRef = useRef<HTMLDivElement>(null);
const graphContainerRef = useRef<HTMLDivElement>(null);
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
@@ -95,6 +92,11 @@ export default function AttackPathsPage() {
}
}, [graphState]);
// Reset graph state when scan changes
useEffect(() => {
graphState.resetGraph();
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
// Load available scans on mount
useEffect(() => {
const loadScans = async () => {
@@ -189,10 +191,6 @@ export default function AttackPathsPage() {
loadQueries();
}, [scanId, toast]);
const handleQueryChange = (queryId: string) => {
queryBuilder.handleQueryChange(queryId);
};
const showErrorToast = (title: string, description: string) => {
toast({
title,
@@ -289,21 +287,39 @@ export default function AttackPathsPage() {
};
const handleNodeClick = (node: GraphNode) => {
// Enter filtered view showing only paths containing this node
graphState.enterFilteredView(node.id);
// For findings, also scroll to the details section
const isFinding = node.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
if (isFinding) {
setTimeout(() => {
nodeDetailsRef.current?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}, 100);
if (findingNavigationInFlightRef.current) {
return;
}
findingNavigationInFlightRef.current = true;
// Findings skip the intermediate node-details modal. The finding drawer
// is the useful destination, so open it directly from the graph click.
graphState.enterFilteredView(node.id);
// enterFilteredView stores the filtered node as selected so the graph can
// highlight it. Clear the selection right after for findings so the node
// details modal does not open before the finding drawer.
graphState.selectNode(null);
void handleViewFinding(String(node.properties?.id || node.id));
return;
}
const sourceData = graphState.fullData || graphState.data;
const hasFindings = sourceData?.edges?.some((edge) => {
if (edge.source !== node.id && edge.target !== node.id) return false;
const otherId = edge.source === node.id ? edge.target : edge.source;
const otherNode = sourceData.nodes?.find(({ id }) => id === otherId);
return otherNode?.labels.some((label) =>
label.toLowerCase().includes("finding"),
);
});
if (hasFindings) {
graphState.toggleExpandedResource(node.id);
}
};
@@ -311,37 +327,35 @@ export default function AttackPathsPage() {
graphState.exitFilteredView();
};
const handleCloseDetails = () => {
graphState.selectNode(null);
};
const getFindingId = (node: GraphNode | null) =>
node ? String(node.properties?.id || node.id) : "";
const handleViewFinding = (findingId: string) => {
const handleViewFinding = async (findingId: string) => {
if (!findingId) return;
void finding.navigateToFinding(findingId);
try {
await finding.navigateToFinding(findingId);
} finally {
findingNavigationInFlightRef.current = false;
}
};
const handleGraphExport = (svgElement: SVGSVGElement | null) => {
const handleGraphExport = async (target: "main" | "fullscreen") => {
const ref = target === "fullscreen" ? fullscreenGraphRef : graphRef;
const handle = ref.current;
if (!handle) return;
try {
if (svgElement) {
exportGraphAsSVG(svgElement, "attack-path-graph.svg");
toast({
title: "Success",
description: "Graph exported as SVG",
variant: "default",
});
} else {
throw new Error("Could not find graph element");
}
} catch (error) {
await exportGraphAsPNG(
handle.getContainerElement(),
handle.getNodesBounds(),
);
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Failed to export graph",
variant: "destructive",
title: "Success",
description: "Graph exported",
variant: "default",
});
} catch (error) {
const description =
error instanceof Error ? error.message : "Failed to export graph";
showErrorToast("Export failed", description);
}
};
@@ -353,12 +367,9 @@ export default function AttackPathsPage() {
onRefresh={refreshScans}
/>
{/* Header */}
{/* Page introduction */}
<div>
<h2 className="dark:text-prowler-theme-pale/90 text-xl font-semibold">
Attack Paths
</h2>
<p className="text-text-neutral-secondary mt-2 text-sm">
<p className="text-text-neutral-secondary text-sm">
Select a scan, build a query, and visualize Attack Paths in your
infrastructure.
</p>
@@ -423,7 +434,7 @@ export default function AttackPathsPage() {
<QuerySelector
queries={queries}
selectedQueryId={queryBuilder.selectedQuery}
onQueryChange={handleQueryChange}
onQueryChange={queryBuilder.handleQueryChange}
/>
{queryBuilder.selectedQueryData && (
@@ -512,8 +523,9 @@ export default function AttackPathsPage() {
💡
</span>
<span className="flex-1">
Click on any node to filter and view its connected
paths
Click a finding to focus its connected path, or click
a resource with findings to show or hide its related
findings
</span>
</div>
)}
@@ -524,11 +536,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 +558,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 +574,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 +585,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,82 +608,23 @@ 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">
<GraphLegend data={graphState.data} />
<div className="flex justify-center overflow-x-auto">
<GraphLegend
data={graphState.data}
expandedResources={graphState.expandedResources}
isFilteredView={graphState.isFilteredView}
/>
</div>
</>
) : null}
</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>
<NodeDetailContent
node={graphState.selectedNode}
allNodes={graphState.data.nodes}
onViewFinding={handleViewFinding}
viewFindingLoading={finding.findingDetailLoading}
/>
</div>
)}
{finding.findingDetails && (
<FindingDetailDrawer
key={finding.findingDetails.id}
+1 -1
View File
@@ -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>
}
+56 -16
View File
@@ -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",
@@ -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-04-21T12:43:22.528Z"
},
{
"section": "dependencies",
"name": "nanoid",
@@ -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",
@@ -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
View File
@@ -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,16 +78,15 @@
"@tailwindcss/postcss": "4.1.18",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@types/dagre": "0.7.53",
"@types/js-yaml": "4.0.9",
"@uiw/react-codemirror": "4.25.8",
"@xyflow/react": "12.10.2",
"ai": "5.0.109",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"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",
@@ -94,6 +97,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",
@@ -140,6 +144,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",
@@ -159,6 +165,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",
@@ -166,7 +173,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": {
@@ -195,5 +203,10 @@
}
},
"version": "0.0.1",
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"msw": {
"workerDirectory": [
"public"
]
}
}
+253 -131
View File
@@ -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)
@@ -197,9 +200,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
@@ -230,6 +230,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
@@ -363,9 +366,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)
@@ -420,6 +429,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
@@ -443,7 +455,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:
@@ -990,6 +1005,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==}
@@ -2110,35 +2131,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:
@@ -2370,6 +2391,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==}
@@ -5177,9 +5201,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==}
@@ -5242,6 +5263,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==}
@@ -5566,6 +5590,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'}
@@ -5881,6 +5914,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'}
@@ -6201,9 +6237,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==}
@@ -6727,9 +6760,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
@@ -6947,11 +6989,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:
@@ -7027,8 +7066,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==}
@@ -7925,6 +7964,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==}
@@ -7941,8 +7983,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:
@@ -7955,9 +7997,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==}
@@ -8724,8 +8766,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==}
@@ -8812,6 +8854,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'}
@@ -9150,6 +9195,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==}
@@ -9177,6 +9226,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==}
@@ -9224,8 +9277,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:
@@ -9470,6 +9523,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}
@@ -9623,10 +9690,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'}
@@ -9689,10 +9752,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'}
@@ -9714,6 +9773,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'}
@@ -10923,6 +10997,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':
@@ -12362,31 +12442,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
@@ -12667,6 +12746,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
@@ -13053,8 +13134,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:
@@ -16135,8 +16215,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
@@ -16205,6 +16283,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':
@@ -16424,39 +16506,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
@@ -16468,9 +16548,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:
@@ -16479,20 +16559,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:
@@ -16510,7 +16590,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:
@@ -16592,6 +16672,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
@@ -16929,6 +17032,8 @@ snapshots:
dependencies:
clsx: 2.1.1
classcat@5.0.5: {}
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@@ -17265,11 +17370,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: {}
@@ -17955,8 +18055,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
@@ -18163,11 +18273,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: {}
@@ -18315,7 +18421,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: {}
@@ -19417,6 +19526,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:
@@ -19425,29 +19536,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:
@@ -19457,7 +19567,7 @@ snapshots:
mustache@4.2.0: {}
mute-stream@2.0.0: {}
mute-stream@3.0.0: {}
nanoid@3.3.11: {}
@@ -19817,7 +19927,6 @@ snapshots:
pixelmatch@7.1.0:
dependencies:
pngjs: 7.0.0
optional: true
pkce-challenge@5.0.1: {}
@@ -19835,8 +19944,7 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
pngjs@7.0.0:
optional: true
pngjs@7.0.0: {}
points-on-curve@0.2.0: {}
@@ -20307,7 +20415,7 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
rettime@0.10.1: {}
rettime@0.11.8: {}
reusify@1.1.0: {}
@@ -20444,6 +20552,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
@@ -20490,7 +20600,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
@@ -20632,7 +20742,6 @@ snapshots:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
optional: true
sisteransi@1.0.5: {}
@@ -20903,6 +21012,8 @@ snapshots:
tinyrainbow@3.0.3: {}
tinyrainbow@3.1.0: {}
tldts-core@7.0.19: {}
tldts@7.0.19:
@@ -20919,13 +21030,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:
@@ -20970,7 +21084,7 @@ snapshots:
type-fest@0.7.1: {}
type-fest@5.4.1:
type-fest@5.6.0:
dependencies:
tagged-tag: 1.0.0
@@ -21244,10 +21358,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
@@ -21269,7 +21392,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
@@ -21430,12 +21553,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
@@ -21483,8 +21600,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):
@@ -21503,6 +21618,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
+2
View File
@@ -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).
+349
View File
@@ -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,
}
}
+2 -39
View File
@@ -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
+94
View File
@@ -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
View File
@@ -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, "./"),
},
},
};
});