mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-03 18:05:50 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
902c12edd1 | ||
|
|
035607518b | ||
|
|
130557eaef | ||
|
|
06efb2f442 | ||
|
|
7f84f18b5b | ||
|
|
d0cd3bad76 | ||
|
|
a6df802fb5 | ||
|
|
82980d4119 | ||
|
|
9bcbbf5404 |
No files matched your search
@@ -151,14 +151,6 @@ When performing these actions, ALWAYS invoke the corresponding skill FIRST:
|
||||
|
||||
Prowler is an open-source cloud security assessment tool supporting AWS, Azure, GCP, Kubernetes, GitHub, M365, and more.
|
||||
|
||||
| Component | Location | Tech Stack |
|
||||
|-----------|----------|------------|
|
||||
| SDK | `prowler/` | Python 3.10+, uv |
|
||||
| API | `api/` | Django 5.1, DRF, Celery |
|
||||
| UI | `ui/` | Next.js 16, React 19, Tailwind 4 |
|
||||
| MCP Server | `mcp_server/` | FastMCP, Python 3.12+ |
|
||||
| Dashboard | `dashboard/` | Dash, Plotly |
|
||||
|
||||
---
|
||||
|
||||
## Python Development
|
||||
|
||||
@@ -97,29 +97,6 @@ Needs retry → Celery task
|
||||
|
||||
---
|
||||
|
||||
## TECH STACK
|
||||
|
||||
Django 5.1.x | DRF 3.15.x | djangorestframework-jsonapi 7.x | Celery 5.4.x | PostgreSQL 16 | pytest 8.x
|
||||
|
||||
---
|
||||
|
||||
## PROJECT STRUCTURE
|
||||
|
||||
```text
|
||||
api/src/backend/
|
||||
├── api/ # Main Django app
|
||||
│ ├── v1/ # API version 1 (views, serializers, urls)
|
||||
│ ├── models.py # Django models
|
||||
│ ├── filters.py # FilterSet classes
|
||||
│ ├── base_views.py # Base ViewSet classes
|
||||
│ ├── rls.py # Row-Level Security
|
||||
│ └── tests/ # Unit tests
|
||||
├── config/ # Django configuration
|
||||
└── tasks/ # Celery tasks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## COMMANDS
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pyasn1 to 0.6.5, patching the ASN.1 decoding denial of service advisories
|
||||
+1
-1
@@ -367,7 +367,7 @@ constraint-dependencies = [
|
||||
"py-deviceid==0.1.1",
|
||||
"py-iam-expand==0.3.0",
|
||||
"py-ocsf-models==0.10.0",
|
||||
"pyasn1==0.6.4",
|
||||
"pyasn1==0.6.5",
|
||||
"pyasn1-modules==0.4.2",
|
||||
"pycodestyle==2.14.0",
|
||||
"pycparser==3.0",
|
||||
|
||||
Generated
+6
-6
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
revision = 5
|
||||
requires-python = ">=3.11, <3.13"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12' and sys_platform == 'win32'",
|
||||
@@ -283,7 +283,7 @@ constraints = [
|
||||
{ name = "py-deviceid", specifier = "==0.1.1" },
|
||||
{ name = "py-iam-expand", specifier = "==0.3.0" },
|
||||
{ name = "py-ocsf-models", specifier = "==0.10.0" },
|
||||
{ name = "pyasn1", specifier = "==0.6.4" },
|
||||
{ name = "pyasn1", specifier = "==0.6.5" },
|
||||
{ name = "pyasn1-modules", specifier = "==0.4.2" },
|
||||
{ name = "pycodestyle", specifier = "==2.14.0" },
|
||||
{ name = "pycparser", specifier = "==3.0" },
|
||||
@@ -4204,7 +4204,7 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
broker = [
|
||||
{ name = "pymsalruntime", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pymsalruntime" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5174,11 +5174,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyasn1"
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/7e/3c0da41061a2021cac7cd524fd57092adbe5b515e8bf9005702e366802e2/pyasn1-0.6.5.tar.gz", hash = "sha256:6b11616a6bcf3a40e635f082bb232fa57b5e0dd7f517bd7ddd69ec3794508a47", size = 155152, upload-time = "2026-10-27T06:32:49.125Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1f/3eefd5c84c35cae44549ce1c7f7e58f7945b34f5a6bce07a135438522073/pyasn1-0.6.5-py3-none-any.whl", hash = "sha256:4017b0fce125096b8573b39c989fdb1a4b19cf761e683398184550fea0e66587", size = 84807, upload-time = "2026-10-27T06:32:48.045Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -25,7 +25,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
# =============================================================================
|
||||
# Final stage - Minimal runtime environment
|
||||
# =============================================================================
|
||||
FROM python:3.13.16-alpine3.23@sha256:07b20c774a247152e74f622034c1482495d299e070eec620ddff13e01264290b
|
||||
FROM python:3.13.18-alpine3.23@sha256:e4aeaca9081549f19bd5bc8605ed5ab257ba34f97e0471f8e09161f45b4b08b8
|
||||
|
||||
LABEL maintainer="https://github.com/prowler-cloud"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
joserfc to 1.7.5, patching the JWE decryption denial of service advisory
|
||||
@@ -0,0 +1 @@
|
||||
Python base image from 3.13.16 to 3.13.18 in the MCP Server container image, patching CVE-2026-115557
|
||||
@@ -79,7 +79,7 @@ constraint-dependencies = [
|
||||
"authlib==1.9.1",
|
||||
"cryptography==50.0.0",
|
||||
"dnspython==2.9.0",
|
||||
"joserfc==1.6.8",
|
||||
"joserfc==1.7.5",
|
||||
"mcp==1.28.1",
|
||||
"pyjwt==2.14.0",
|
||||
"python-multipart==0.0.30"
|
||||
|
||||
Generated
+4
-4
@@ -13,7 +13,7 @@ constraints = [
|
||||
{ name = "authlib", specifier = "==1.9.1" },
|
||||
{ name = "cryptography", specifier = "==50.0.0" },
|
||||
{ name = "dnspython", specifier = "==2.9.0" },
|
||||
{ name = "joserfc", specifier = "==1.6.8" },
|
||||
{ name = "joserfc", specifier = "==1.7.5" },
|
||||
{ name = "mcp", specifier = "==1.28.1" },
|
||||
{ name = "pyjwt", specifier = "==2.14.0" },
|
||||
{ name = "python-multipart", specifier = "==0.0.30" },
|
||||
@@ -594,14 +594,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "joserfc"
|
||||
version = "1.6.8"
|
||||
version = "1.7.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
pyasn1 to 0.6.5 and joserfc to 1.7.5 in the SDK, patching the ASN.1 decoding and JWE decryption denial of service advisories
|
||||
+2
-2
@@ -288,7 +288,7 @@ constraint-dependencies = [
|
||||
"itsdangerous==2.2.0",
|
||||
"jinja2==3.1.6",
|
||||
"jmespath==1.1.0",
|
||||
"joserfc==1.6.8",
|
||||
"joserfc==1.7.5",
|
||||
"jsonpatch==1.33",
|
||||
"jsonpath-ng==1.8.0",
|
||||
"jsonpointer==3.1.1",
|
||||
@@ -342,7 +342,7 @@ constraint-dependencies = [
|
||||
"protobuf==7.34.1",
|
||||
"psutil==7.2.2",
|
||||
"py-partiql-parser==0.6.1",
|
||||
"pyasn1==0.6.4",
|
||||
"pyasn1==0.6.5",
|
||||
"pyasn1-modules==0.4.2",
|
||||
"pycodestyle==2.12.1",
|
||||
"pycparser==3.0",
|
||||
|
||||
+1
-25
@@ -183,31 +183,7 @@ test("action works", { tag: ["@critical", "@feature"] }, async ({ page }) => {
|
||||
|
||||
---
|
||||
|
||||
## TECH STACK
|
||||
|
||||
Next.js 16.2.3 | React 19.2.5 | Tailwind 4.1.18 | shadcn/ui
|
||||
Zod 4.1.11 | React Hook Form 7.62.0 | Zustand 5.0.8 | NextAuth 5.0.0-beta.30 | Recharts 2.15.4
|
||||
|
||||
> **Note**: `components/ui/` only holds temporary re-export shims for the prowler-cloud overlay. Do NOT add new components there.
|
||||
|
||||
---
|
||||
|
||||
## PROJECT STRUCTURE
|
||||
|
||||
```text
|
||||
ui/
|
||||
├── app/(auth)/ # Auth pages
|
||||
├── app/(prowler)/ # Main app: compliance, findings, providers, scans
|
||||
├── components/shadcn/ # shadcn/ui components (USE THIS)
|
||||
├── components/ui/ # Cloud-overlay re-export shims (do not add here)
|
||||
├── actions/ # Server actions
|
||||
├── types/ # Shared types
|
||||
├── hooks/ # Shared hooks
|
||||
├── lib/ # Utilities
|
||||
├── store/ # Zustand state
|
||||
├── tests/ # Playwright E2E
|
||||
└── styles/ # Global CSS
|
||||
```
|
||||
> **Note**: `components/ui/` only holds temporary re-export shims for the prowler-cloud overlay. Do NOT add new components there; use `components/shadcn/`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
} from "@/lib";
|
||||
import { getFindingGroupFilterOptions } from "@/lib/finding-group-filter-options";
|
||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
isSampleScanFilter,
|
||||
pickSampleFindingFilters,
|
||||
sampleFindingGroupResources,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
/**
|
||||
@@ -187,6 +192,19 @@ async function fetchFindingGroupResourcesEndpoint(
|
||||
filters = {},
|
||||
}: FetchFindingGroupResourcesParams,
|
||||
) {
|
||||
if (isSampleScanFilter(filters)) {
|
||||
const status = filters["filter[status]"] ?? filters["filter[status__in]"];
|
||||
const search = filters["filter[name__icontains]"];
|
||||
return sampleFindingGroupResources({
|
||||
checkId,
|
||||
page,
|
||||
pageSize,
|
||||
status: Array.isArray(status) ? status.join(",") : status,
|
||||
search: Array.isArray(search) ? search[0] : search,
|
||||
filters: pickSampleFindingFilters(filters),
|
||||
});
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const normalizedFilters = normalizeFindingGroupResourceFilters(filters);
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@ import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { IN_WATCHLIST_FILTER_KEY } from "@/lib/compliance/watchlist";
|
||||
import {
|
||||
isSampleId,
|
||||
sampleComplianceFrameworks,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { FindingComplianceFrameworksResult } from "@/types/compliance-watchlist";
|
||||
|
||||
@@ -32,6 +36,9 @@ export const getFindingComplianceFrameworks = async (
|
||||
}
|
||||
|
||||
const { inWatchlist = false } = parsedOptions.data;
|
||||
if (isSampleId(parsedFindingId.data)) {
|
||||
return sampleComplianceFrameworks(parsedFindingId.data);
|
||||
}
|
||||
if (!isCloud()) return { frameworks: [], unavailable: true };
|
||||
|
||||
try {
|
||||
|
||||
@@ -278,6 +278,14 @@ describe("getLatestFindingsByResourceUid", () => {
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
});
|
||||
|
||||
it("does not substitute sample records for a real resource with the same ARN", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
await getLatestFindingsByResourceUid({
|
||||
resourceUid: "arn:aws:s3:::acme-customer-exports",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should restrict to FAIL, exclude muted findings, and apply severity/time sorting by default", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
} from "@/lib";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
sampleFindingsDocument,
|
||||
sampleFindingsQuery,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const FINDING_IDS_RESOLUTION_PAGE_SIZE = 500;
|
||||
@@ -255,12 +259,24 @@ export const getLatestFindingsByResourceUid = async ({
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
includeMuted = false,
|
||||
sample = false,
|
||||
}: {
|
||||
resourceUid: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
includeMuted?: boolean;
|
||||
/** Explicitly scoped by the sample finding; real resource names can match the demo. */
|
||||
sample?: boolean;
|
||||
}) => {
|
||||
if (sample) {
|
||||
return sampleFindingsDocument(
|
||||
sampleFindingsQuery({
|
||||
"filter[resource_uid]": resourceUid,
|
||||
"filter[status]": "FAIL",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-guards";
|
||||
import { FINDING_STATUS, type FindingStatus } from "@/types/components";
|
||||
import {
|
||||
FINDING_TRIAGE_BILLING_HREF,
|
||||
FINDING_TRIAGE_DISABLED_REASON,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
FINDING_TRIAGE_STATUS,
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
@@ -143,6 +145,12 @@ const createSummary = (
|
||||
summary.disabledReason = options.disabledReason;
|
||||
}
|
||||
|
||||
// Sample findings have no triage record to write to.
|
||||
if (isSampleId(summary.findingId)) {
|
||||
summary.canEdit = false;
|
||||
summary.disabledReason = FINDING_TRIAGE_DISABLED_REASON.SAMPLE;
|
||||
}
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { ACTION_ERROR_STATUS, UsageLimitError } from "@/lib/action-errors";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
@@ -281,6 +285,7 @@ export async function loadFindingTriageDetail(
|
||||
}
|
||||
|
||||
export async function updateFindingTriage(input: UpdateFindingTriageInput) {
|
||||
if (isSampleId(input.findingId)) throw new Error(SAMPLE_WRITE_MESSAGE);
|
||||
try {
|
||||
return await applyFindingTriageUpdate(input);
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,14 @@ import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findi
|
||||
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
getSampleFinding,
|
||||
isSampleId,
|
||||
isSampleScanFilter,
|
||||
sampleFindingDocument,
|
||||
sampleFindingsDocument,
|
||||
sampleFindingsQuery,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const withFindingTriageSummaries = <T extends { data?: unknown }>(
|
||||
@@ -24,6 +32,26 @@ export const getFindings = async ({
|
||||
sort = "",
|
||||
filters = {},
|
||||
}) => {
|
||||
if (isSampleScanFilter(filters)) {
|
||||
const findings = sampleFindingsQuery(filters);
|
||||
const size = Number(pageSize) || 10;
|
||||
const current = Math.max(1, Number(page) || 1);
|
||||
const document = sampleFindingsDocument(
|
||||
findings.slice((current - 1) * size, current * size),
|
||||
);
|
||||
return withFindingTriageSummaries({
|
||||
...document,
|
||||
meta: {
|
||||
...document.meta,
|
||||
pagination: {
|
||||
page: current,
|
||||
pages: Math.max(1, Math.ceil(findings.length / size)),
|
||||
count: findings.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
if (isNaN(Number(page)) || page < 1)
|
||||
@@ -164,6 +192,11 @@ export const getFindingById = async (
|
||||
include = "",
|
||||
_options?: GetFindingByIdOptions,
|
||||
) => {
|
||||
if (isSampleId(findingId)) {
|
||||
const finding = getSampleFinding(findingId);
|
||||
return finding ? sampleFindingDocument(finding) : undefined;
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const url = new URL(`${apiBaseUrl}/findings/${findingId}`);
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { handleApiError } from "@/lib/server-actions-helper";
|
||||
import type {
|
||||
IntegrationProps,
|
||||
@@ -122,6 +126,8 @@ export const sendJiraDispatch = async ({
|
||||
| { success: true; taskId: string; message: string }
|
||||
| { success: false; error: string }
|
||||
> => {
|
||||
if (targetIds.some(isSampleId))
|
||||
return { success: false, error: SAMPLE_WRITE_MESSAGE };
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/integrations/${integrationId}/jira/dispatches`,
|
||||
|
||||
+41
-16
@@ -1,6 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { getSampleIssueDetail } from "@/lib/sample-preview/sample-issues";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
sampleFindingsQuery,
|
||||
sampleIssueFindingsDocument,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
ISSUE_FINDINGS_LIMIT,
|
||||
@@ -86,6 +93,11 @@ export const getIssues = async ({
|
||||
export const getIssue = async (
|
||||
id: string,
|
||||
): Promise<IssueDetailResponse | undefined> => {
|
||||
if (isSampleId(id)) {
|
||||
const issue = getSampleIssueDetail(id);
|
||||
return issue ? { data: issue } : undefined;
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/issues/${encodeURIComponent(id)}`);
|
||||
|
||||
@@ -106,6 +118,13 @@ export const getIssueFindings = async (
|
||||
if (!scanId) return undefined;
|
||||
const uids = Array.from(new Set(findingUids)).slice(0, ISSUE_FINDINGS_LIMIT);
|
||||
if (uids.length === 0) return [];
|
||||
if (isSampleId(scanId)) {
|
||||
return toIssueFindings(
|
||||
sampleIssueFindingsDocument(
|
||||
sampleFindingsQuery({ "filter[uid__in]": uids.join(",") }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/findings`);
|
||||
@@ -156,32 +175,38 @@ export const updateIssueTriage = async (
|
||||
id: string,
|
||||
{ status, note }: UpdateIssueTriageInput,
|
||||
): Promise<IssueWriteResult> =>
|
||||
writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
|
||||
{
|
||||
type: "issue-triages",
|
||||
attributes: { status, ...(note ? { note } : {}) },
|
||||
},
|
||||
);
|
||||
isSampleId(id)
|
||||
? { error: SAMPLE_WRITE_MESSAGE }
|
||||
: writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.UPDATE,
|
||||
{
|
||||
type: "issue-triages",
|
||||
attributes: { status, ...(note ? { note } : {}) },
|
||||
},
|
||||
);
|
||||
|
||||
/** Add a note to an issue's triage without changing its status. */
|
||||
export const addIssueNote = async (
|
||||
id: string,
|
||||
body: string,
|
||||
): Promise<IssueWriteResult> =>
|
||||
writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
attributes: { body },
|
||||
},
|
||||
);
|
||||
isSampleId(id)
|
||||
? { error: SAMPLE_WRITE_MESSAGE }
|
||||
: writeIssueTriage(
|
||||
`/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
ISSUE_TRIAGE_WRITE_METHOD.ADD_NOTE,
|
||||
{
|
||||
type: "issue-triage-notes",
|
||||
attributes: { body },
|
||||
},
|
||||
);
|
||||
|
||||
export const getIssueNotes = async (
|
||||
id: string,
|
||||
): Promise<IssueTriageNote[] | undefined> => {
|
||||
if (isSampleId(id)) return [];
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/issues/${encodeURIComponent(id)}/triage/notes`,
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
|
||||
import {
|
||||
DeleteMuteRuleActionState,
|
||||
@@ -131,6 +135,10 @@ export const createMuteRule = async (
|
||||
};
|
||||
}
|
||||
|
||||
if (findingIds.some(isSampleId)) {
|
||||
return { errors: { general: SAMPLE_WRITE_MESSAGE } };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(`${apiBaseUrl}/mute-rules`);
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
sanitizeErrorMessage,
|
||||
} from "@/lib";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import {
|
||||
isSampleId,
|
||||
sampleResourceDrawerData,
|
||||
sampleResourceEvents,
|
||||
} from "@/lib/sample-preview/sample-records";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { OrganizationResource } from "@/types/organizations";
|
||||
@@ -184,6 +189,9 @@ export const getResourceEvents = async (
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
) => {
|
||||
if (isSampleId(resourceId)) {
|
||||
return { data: sampleResourceEvents(resourceId) };
|
||||
}
|
||||
if (!SAFE_RESOURCE_ID_PATTERN.test(resourceId)) {
|
||||
return { error: "Invalid resource ID format." };
|
||||
}
|
||||
@@ -288,6 +296,10 @@ export const getResourceDrawerData = async ({
|
||||
pageSize?: number;
|
||||
query?: string;
|
||||
}) => {
|
||||
if (isSampleId(resourceId)) {
|
||||
return sampleResourceDrawerData(resourceId, query);
|
||||
}
|
||||
|
||||
const isCloudEnv = isCloud();
|
||||
|
||||
const [resourceData, findingsResponse, organizationsResponse] =
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
isReportDownloadLocked,
|
||||
REPORT_DOWNLOAD_LOCKED_ERROR,
|
||||
} from "@/lib/report-download-access";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_WRITE_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { addScanOperation } from "@/lib/sentry-breadcrumbs";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SCAN_STATES } from "@/types/attack-paths";
|
||||
@@ -195,6 +199,7 @@ export const createPartialScan = async ({
|
||||
providerId: string;
|
||||
resourceUids: string[];
|
||||
}) => {
|
||||
if (isSampleId(providerId)) return { error: SAMPLE_WRITE_MESSAGE };
|
||||
if (!providerId) {
|
||||
return { error: "Provider ID is required" };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
interface OverviewGridProps {
|
||||
threatScore: ReactNode;
|
||||
// Cloud-only widgets: the page leaves them out where they do not apply.
|
||||
exploitability?: ReactNode;
|
||||
topIssues?: ReactNode;
|
||||
checkFindings: ReactNode;
|
||||
riskSeverity: ReactNode;
|
||||
resourcesInventory: ReactNode;
|
||||
complianceWatchlist: ReactNode;
|
||||
serviceWatchlist: ReactNode;
|
||||
attackSurface: ReactNode;
|
||||
severityOverTime: ReactNode;
|
||||
graphsTabs: ReactNode;
|
||||
}
|
||||
|
||||
/** The Overview widget layout, shared by the real page and the sample preview. */
|
||||
export function OverviewGrid({
|
||||
threatScore,
|
||||
exploitability,
|
||||
topIssues,
|
||||
checkFindings,
|
||||
riskSeverity,
|
||||
resourcesInventory,
|
||||
complianceWatchlist,
|
||||
serviceWatchlist,
|
||||
attackSurface,
|
||||
severityOverTime,
|
||||
graphsTabs,
|
||||
}: OverviewGridProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
|
||||
{threatScore}
|
||||
{exploitability}
|
||||
{checkFindings}
|
||||
{riskSeverity}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">{resourcesInventory}</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
|
||||
the line's max-content rather than to its own 312px, and a card wider
|
||||
than the column loses its right border and padding to
|
||||
`overflow-hidden`. It never wrapped anyway. */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
{/* Sized to its own list, unlike the service card below: this one is
|
||||
as long as the organization made its watchlist, so growing it to
|
||||
fill the column puts a hole under two pinned frameworks. */}
|
||||
{/* `h-full` only where the cards sit side by side and share a row
|
||||
height. In the column it would hand the card the stretched
|
||||
wrapper's height and undo the fit-to-content sizing. */}
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
|
||||
{complianceWatchlist}
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
{serviceWatchlist}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
{attackSurface}
|
||||
{topIssues}
|
||||
{severityOverTime}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">{graphsTabs}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,10 +25,14 @@ function getScoreColor(score: number): string {
|
||||
}
|
||||
|
||||
interface RiskPlotClientProps {
|
||||
disableNavigation?: boolean;
|
||||
data: RiskPlotPoint[];
|
||||
}
|
||||
|
||||
export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
export function RiskPlotClient({
|
||||
data,
|
||||
disableNavigation = false,
|
||||
}: RiskPlotClientProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [selectedPoint, setSelectedPoint] = useState<RiskPlotPoint | null>(
|
||||
@@ -131,7 +135,7 @@ export function RiskPlotClient({ data }: RiskPlotClientProps) {
|
||||
</div>
|
||||
<HorizontalBarChart
|
||||
data={selectedPoint.severityData}
|
||||
onBarClick={handleBarClick}
|
||||
onBarClick={disableNavigation ? undefined : handleBarClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -13,10 +13,14 @@ import { SEVERITY_FILTER_MAP } from "@/types/severities";
|
||||
import { CategorySelector } from "./category-selector";
|
||||
|
||||
interface RiskRadarViewClientProps {
|
||||
disableNavigation?: boolean;
|
||||
data: RadarDataPoint[];
|
||||
}
|
||||
|
||||
export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
|
||||
export function RiskRadarViewClient({
|
||||
data,
|
||||
disableNavigation = false,
|
||||
}: RiskRadarViewClientProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [selectedPoint, setSelectedPoint] = useState<RadarDataPoint | null>(
|
||||
@@ -99,7 +103,7 @@ export function RiskRadarViewClient({ data }: RiskRadarViewClientProps) {
|
||||
</div>
|
||||
<HorizontalBarChart
|
||||
data={selectedPoint.severityData}
|
||||
onBarClick={handleBarClick}
|
||||
onBarClick={disableNavigation ? undefined : handleBarClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SAMPLE_SERVICES_RESPONSE,
|
||||
SAMPLE_STATUS,
|
||||
} from "@/lib/sample-preview/sample-overview";
|
||||
|
||||
import { OverviewSamplePreview } from "./overview-sample-preview";
|
||||
|
||||
const { pushMock } = vi.hoisted(() => ({ pushMock: vi.fn() }));
|
||||
|
||||
// Card links import server actions; their auth chain has no place in jsdom.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: pushMock, refresh: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
|
||||
// The graph tabs have their own test; here they would only add chart noise.
|
||||
vi.mock("./sample-graphs-tabs", () => ({
|
||||
SampleGraphsTabs: () => null,
|
||||
}));
|
||||
|
||||
describe("OverviewSamplePreview", () => {
|
||||
it("renders the demo account's passing and failing totals", () => {
|
||||
render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
const card = screen
|
||||
.getByText("Check Findings")
|
||||
.closest('[data-slot="card"]')!;
|
||||
expect(
|
||||
within(card as HTMLElement).getByText(
|
||||
SAMPLE_STATUS.fail.toLocaleString(),
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
within(card as HTMLElement).getByText(
|
||||
SAMPLE_STATUS.pass.toLocaleString(),
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("only leaves links live where the target page shows the sample", () => {
|
||||
const { container } = render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
const links = Array.from(container.querySelectorAll("a"));
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
for (const link of links) {
|
||||
expect(link.getAttribute("href")).toMatch(
|
||||
/^\/(findings|issues|compliance|resources)(\?|$)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("opens sample findings filtered by the selected service", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<OverviewSamplePreview showIssues={false} />);
|
||||
|
||||
// The riskiest service of the demo account leads the watchlist.
|
||||
const riskiest = [...SAMPLE_SERVICES_RESPONSE.data].sort(
|
||||
(left, right) => right.attributes.fail - left.attributes.fail,
|
||||
)[0];
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: new RegExp(riskiest.id, "i") }),
|
||||
);
|
||||
const url = new URL(pushMock.mock.calls[0][0], "https://prowler.test");
|
||||
expect(url.pathname).toBe("/findings");
|
||||
expect(url.searchParams.get("filter[service__in]")).toBe(riskiest.id);
|
||||
expect(url.searchParams.get("filter[status__in]")).toBe("FAIL");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { adaptAttackSurfaceOverview } from "@/actions/overview/attack-surface/attack-surface.adapter";
|
||||
import { adaptComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.adapter";
|
||||
import type { ComplianceWatchlistResponse } from "@/actions/overview/compliance-watchlist/compliance-watchlist.types";
|
||||
import { adaptResourceGroupOverview } from "@/actions/overview/resources-inventory/resources-inventory.adapter";
|
||||
import { FindingSeverityOverTimeCard } from "@/components/overview/finding-severity-over-time-card";
|
||||
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { verdictCounts } from "@/lib/issues/verdicts";
|
||||
import {
|
||||
SAMPLE_ISSUES,
|
||||
SAMPLE_ISSUES_OVERVIEW,
|
||||
} from "@/lib/sample-preview/sample-issues";
|
||||
import {
|
||||
SAMPLE_ATTACK_SURFACE_RESPONSE,
|
||||
SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
|
||||
SAMPLE_FAILED_BY_SEVERITY,
|
||||
SAMPLE_RESOURCE_GROUPS_RESPONSE,
|
||||
SAMPLE_SERVICES_RESPONSE,
|
||||
SAMPLE_SEVERITY_TRENDS,
|
||||
SAMPLE_STATUS,
|
||||
SAMPLE_THREAT_SCORE,
|
||||
} from "@/lib/sample-preview/sample-overview";
|
||||
|
||||
import { OverviewGrid } from "../_components/overview-grid";
|
||||
import { AttackSurface } from "../attack-surface/_components/attack-surface";
|
||||
import { ExploitabilityChart } from "../exploitability";
|
||||
import { TopIssues } from "../exploitability/_components/top-issues";
|
||||
import { ResourcesInventory } from "../resources-inventory/_components/resources-inventory";
|
||||
import { RiskSeverityChart } from "../risk-severity";
|
||||
import { FindingSeverityOverTime } from "../severity-over-time";
|
||||
import { StatusChart } from "../status-chart";
|
||||
import { ThreatScore } from "../threat-score";
|
||||
import { ComplianceWatchlist, ServiceWatchlist } from "../watchlist";
|
||||
|
||||
import { SampleGraphsTabs } from "./sample-graphs-tabs";
|
||||
|
||||
const NO_SCOPE: Record<string, string> = {};
|
||||
|
||||
interface OverviewSamplePreviewProps {
|
||||
// Exploitability and Top issues only exist in Prowler Cloud, behind their flag.
|
||||
showIssues: boolean;
|
||||
/** Compliance totals derived from the sample requirements, when available. */
|
||||
complianceWatchlist?: ComplianceWatchlistResponse["data"];
|
||||
}
|
||||
|
||||
/** The Overview filled with the demo account, for tenants without a scan. */
|
||||
export function OverviewSamplePreview({
|
||||
showIssues,
|
||||
complianceWatchlist = SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE.data,
|
||||
}: OverviewSamplePreviewProps) {
|
||||
const complianceItems = adaptComplianceWatchlistResponse({
|
||||
...SAMPLE_COMPLIANCE_WATCHLIST_RESPONSE,
|
||||
data: complianceWatchlist,
|
||||
}).map((item) => ({
|
||||
id: item.id,
|
||||
framework: item.complianceId,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
score: item.score,
|
||||
}));
|
||||
|
||||
return (
|
||||
<OverviewGrid
|
||||
threatScore={
|
||||
<ThreatScore
|
||||
score={SAMPLE_THREAT_SCORE.score}
|
||||
scoreDelta={SAMPLE_THREAT_SCORE.scoreDelta}
|
||||
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
|
||||
criticalRequirements={SAMPLE_THREAT_SCORE.criticalRequirements}
|
||||
/>
|
||||
}
|
||||
exploitability={
|
||||
showIssues ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<ExploitabilityChart
|
||||
counts={verdictCounts(SAMPLE_ISSUES_OVERVIEW)}
|
||||
issues={SAMPLE_ISSUES_OVERVIEW.issues}
|
||||
findings={SAMPLE_ISSUES_OVERVIEW.findings}
|
||||
actionable={SAMPLE_ISSUES_OVERVIEW.actionable}
|
||||
coveredFindings={SAMPLE_ISSUES_OVERVIEW.not_exploitable_findings}
|
||||
scope={NO_SCOPE}
|
||||
/>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
checkFindings={
|
||||
<StatusChart
|
||||
failFindingsData={{
|
||||
total: SAMPLE_STATUS.fail,
|
||||
new: SAMPLE_STATUS.failNew,
|
||||
}}
|
||||
passFindingsData={{
|
||||
total: SAMPLE_STATUS.pass,
|
||||
new: SAMPLE_STATUS.passNew,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
riskSeverity={<RiskSeverityChart {...SAMPLE_FAILED_BY_SEVERITY} />}
|
||||
resourcesInventory={
|
||||
<ResourcesInventory
|
||||
items={adaptResourceGroupOverview(SAMPLE_RESOURCE_GROUPS_RESPONSE)}
|
||||
/>
|
||||
}
|
||||
complianceWatchlist={
|
||||
// The sample Compliance page is per scan: no multi-scan view to link.
|
||||
<ComplianceWatchlist items={complianceItems} hasWatchlist={false} />
|
||||
}
|
||||
serviceWatchlist={
|
||||
<ServiceWatchlist items={SAMPLE_SERVICES_RESPONSE.data} />
|
||||
}
|
||||
attackSurface={
|
||||
<AttackSurface
|
||||
items={adaptAttackSurfaceOverview(SAMPLE_ATTACK_SURFACE_RESPONSE)}
|
||||
/>
|
||||
}
|
||||
topIssues={
|
||||
showIssues ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<TopIssues issues={SAMPLE_ISSUES} scope={NO_SCOPE} />
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
severityOverTime={
|
||||
<FindingSeverityOverTimeCard>
|
||||
<FindingSeverityOverTime data={SAMPLE_SEVERITY_TRENDS} readOnly />
|
||||
</FindingSeverityOverTimeCard>
|
||||
}
|
||||
graphsTabs={<SampleGraphsTabs />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import {
|
||||
SAMPLE_NEW_FINDINGS,
|
||||
SAMPLE_RADAR_DATA,
|
||||
} from "@/lib/sample-preview/sample-graphs";
|
||||
|
||||
import { SampleGraphsTabs } from "./sample-graphs-tabs";
|
||||
|
||||
it("offers each latest new failed finding as a drawer trigger", async () => {
|
||||
const screen = await render(<SampleGraphsTabs />);
|
||||
const [finding] = SAMPLE_NEW_FINDINGS;
|
||||
|
||||
// The trigger opens the finding drawer, which resolves sample ids locally.
|
||||
await expect
|
||||
.element(
|
||||
screen
|
||||
.getByRole("button", {
|
||||
name: finding.attributes.check_metadata.checktitle,
|
||||
})
|
||||
.first(),
|
||||
)
|
||||
.toBeEnabled();
|
||||
});
|
||||
|
||||
it("lets users explore sample categories without opening live findings", async () => {
|
||||
const screen = await render(<SampleGraphsTabs />);
|
||||
const category = SAMPLE_RADAR_DATA[0];
|
||||
|
||||
// A click that lands before the tabs hydrate is lost: retry until selected.
|
||||
const radarTab = screen.getByRole("tab", { name: "Risk Radar" });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
await radarTab.click();
|
||||
return radarTab.element().getAttribute("aria-selected");
|
||||
})
|
||||
.toBe("true");
|
||||
await expect.element(screen.getByRole("combobox")).toBeVisible();
|
||||
await screen.getByRole("combobox").click();
|
||||
await screen
|
||||
.getByRole("option", { name: category.category, exact: true })
|
||||
.click();
|
||||
|
||||
await expect
|
||||
.element(screen.getByText(`${category.value} Total Findings`))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: /Critical/ }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { SankeyChart } from "@/components/graphs/sankey-chart";
|
||||
import { ThreatMap } from "@/components/graphs/threat-map";
|
||||
import { LinkToFindings } from "@/components/overview";
|
||||
import { LatestFindingsTable } from "@/components/overview/new-findings-table/table";
|
||||
import { CardTitle } from "@/components/shadcn";
|
||||
import {
|
||||
SAMPLE_NEW_FINDINGS,
|
||||
SAMPLE_RADAR_DATA,
|
||||
SAMPLE_RISK_PLOT_POINTS,
|
||||
SAMPLE_SANKEY_DATA,
|
||||
SAMPLE_THREAT_MAP_DATA,
|
||||
} from "@/lib/sample-preview/sample-graphs";
|
||||
|
||||
import { GraphsTabsClient } from "../graphs-tabs/_components/graphs-tabs-client";
|
||||
import type { TabId } from "../graphs-tabs/_config/graphs-tabs-config";
|
||||
import { RiskPlotClient } from "../graphs-tabs/risk-plot/risk-plot-client";
|
||||
import { RiskRadarViewClient } from "../graphs-tabs/risk-radar-view/risk-radar-view-client";
|
||||
|
||||
const SAMPLE_TABS_CONTENT: Record<TabId, ReactNode> = {
|
||||
findings: (
|
||||
<div className="flex w-full flex-1">
|
||||
<div className="flex w-full flex-col">
|
||||
<LatestFindingsTable
|
||||
data={SAMPLE_NEW_FINDINGS}
|
||||
header={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<CardTitle>Latest New Failed Findings</CardTitle>
|
||||
<p className="text-text-neutral-tertiary text-xs">
|
||||
Showing the latest 10 sorted by severity
|
||||
</p>
|
||||
</div>
|
||||
<LinkToFindings />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"threat-map": (
|
||||
<div className="flex w-full flex-1">
|
||||
<div className="w-full flex-1 overflow-hidden">
|
||||
<ThreatMap
|
||||
disableNavigation
|
||||
data={SAMPLE_THREAT_MAP_DATA}
|
||||
height={460}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"risk-radar": (
|
||||
<div className="flex w-full flex-1">
|
||||
<RiskRadarViewClient disableNavigation data={SAMPLE_RADAR_DATA} />
|
||||
</div>
|
||||
),
|
||||
"risk-pipeline": (
|
||||
<div className="flex w-full flex-1">
|
||||
<div className="w-full flex-1 overflow-visible">
|
||||
<SankeyChart
|
||||
disableNavigation
|
||||
data={SAMPLE_SANKEY_DATA}
|
||||
zeroDataProviders={SAMPLE_SANKEY_DATA.zeroDataProviders}
|
||||
height={460}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"risk-plot": (
|
||||
<div className="flex w-full flex-1">
|
||||
<div className="w-full flex-1 overflow-visible">
|
||||
<RiskPlotClient disableNavigation data={SAMPLE_RISK_PLOT_POINTS} />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const SampleGraphsTabs = () => (
|
||||
<GraphsTabsClient tabsContent={SAMPLE_TABS_CONTENT} />
|
||||
);
|
||||
+12
-8
@@ -20,10 +20,12 @@ import { type TimeRange, TimeRangeSelector } from "./time-range-selector";
|
||||
|
||||
interface FindingSeverityOverTimeProps {
|
||||
data: LineDataPoint[];
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const FindingSeverityOverTime = ({
|
||||
data: initialData,
|
||||
readOnly = false,
|
||||
}: FindingSeverityOverTimeProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -131,13 +133,15 @@ export const FindingSeverityOverTime = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8 w-fit">
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="mb-8 w-fit">
|
||||
<TimeRangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
@@ -152,7 +156,7 @@ export const FindingSeverityOverTime = ({
|
||||
lines={lines}
|
||||
height={400}
|
||||
xAxisInterval={getXAxisInterval()}
|
||||
onPointClick={handlePointClick}
|
||||
onPointClick={readOnly ? undefined : handlePointClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSeverityTrendsByTimeRange } from "@/actions/overview/severity-trends";
|
||||
import { FindingSeverityOverTimeCard } from "@/components/overview/finding-severity-over-time-card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
@@ -42,16 +43,8 @@ export const FindingSeverityOverTimeSSR = async ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card variant="base" className="flex h-full flex-1 flex-col">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Findings Severity Over Time</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-1 flex-col px-6">
|
||||
<FindingSeverityOverTime data={result.data.data} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingSeverityOverTimeCard>
|
||||
<FindingSeverityOverTime data={result.data.data} />
|
||||
</FindingSeverityOverTimeCard>
|
||||
);
|
||||
};
|
||||
+30
-11
@@ -27,6 +27,9 @@ import { ScanStatusBadge } from "./scan-status-badge";
|
||||
|
||||
interface ScanListTableProps {
|
||||
scans: AttackPathScan[];
|
||||
/** Overrides the `scanId` search param, e.g. for the sample scan. */
|
||||
selectedScanId?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
@@ -100,8 +103,10 @@ const buildMetadata = (
|
||||
|
||||
const getColumns = ({
|
||||
selectedScanId,
|
||||
readOnly,
|
||||
}: {
|
||||
selectedScanId: string | null;
|
||||
readOnly: boolean;
|
||||
}): ColumnDef<AttackPathScan>[] => [
|
||||
{
|
||||
id: "select",
|
||||
@@ -115,10 +120,11 @@ const getColumns = ({
|
||||
<RadioGroupItem
|
||||
value={row.original.id}
|
||||
checked={isSelected}
|
||||
disabled={!canSelect}
|
||||
disabled={readOnly || !canSelect}
|
||||
className={cn(
|
||||
"size-5",
|
||||
canSelect &&
|
||||
!readOnly &&
|
||||
canSelect &&
|
||||
!isSelected &&
|
||||
"border-text-neutral-secondary cursor-pointer",
|
||||
!canSelect && "disabled:opacity-70",
|
||||
@@ -227,12 +233,16 @@ const getColumns = ({
|
||||
* Table displaying AWS account Attack Paths scans
|
||||
* Shows scan metadata and allows selection of completed scans
|
||||
*/
|
||||
export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
export const ScanListTable = ({
|
||||
scans,
|
||||
selectedScanId: selectedScanIdOverride,
|
||||
readOnly = false,
|
||||
}: ScanListTableProps) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedScanId = searchParams.get("scanId");
|
||||
const selectedScanId = selectedScanIdOverride ?? searchParams.get("scanId");
|
||||
const pageSize = parsePageParam(
|
||||
searchParams.get("scanPageSize"),
|
||||
DEFAULT_PAGE_SIZE,
|
||||
@@ -278,23 +288,32 @@ export const ScanListTable = ({ scans }: ScanListTableProps) => {
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
disabled={readOnly}
|
||||
value={selectedScanId ?? ""}
|
||||
onValueChange={handleSelectScan}
|
||||
className="gap-0"
|
||||
>
|
||||
<DataTable
|
||||
columns={getColumns({ selectedScanId })}
|
||||
columns={getColumns({ selectedScanId, readOnly })}
|
||||
data={paginatedScans}
|
||||
metadata={buildMetadata(scans.length, currentPage, totalPages)}
|
||||
metadata={
|
||||
readOnly
|
||||
? undefined
|
||||
: buildMetadata(scans.length, currentPage, totalPages)
|
||||
}
|
||||
controlledPage={currentPage}
|
||||
controlledPageSize={pageSize}
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
onRowClick={(row) => {
|
||||
if (row.original.attributes.graph_data_ready) {
|
||||
handleSelectScan(row.original.id);
|
||||
}
|
||||
}}
|
||||
onRowClick={
|
||||
readOnly
|
||||
? undefined
|
||||
: (row) => {
|
||||
if (row.original.attributes.graph_data_ready) {
|
||||
handleSelectScan(row.original.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
enableRowSelection
|
||||
rowSelection={getSelectedRowSelection(paginatedScans, selectedScanId)}
|
||||
/>
|
||||
|
||||
+8
-3
@@ -20,15 +20,19 @@ export interface UseAttackPathScansResult {
|
||||
* project forbids `useEffect` in components, but a reusable data hook is the
|
||||
* sanctioned place for a mount-time fetch when no fetching library is wired up.
|
||||
*/
|
||||
export function useAttackPathScans(): UseAttackPathScansResult {
|
||||
const [scans, setScans] = useState<AttackPathScan[]>([]);
|
||||
const [scansLoading, setScansLoading] = useState(true);
|
||||
export function useAttackPathScans(
|
||||
/** Sample scans: shown as loaded, and the API is never called. */
|
||||
sampleScans?: AttackPathScan[],
|
||||
): UseAttackPathScansResult {
|
||||
const [scans, setScans] = useState<AttackPathScan[]>(sampleScans ?? []);
|
||||
const [scansLoading, setScansLoading] = useState(!sampleScans);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Silent background refresh for auto-refresh: never flips loading/error, so it
|
||||
// can't disrupt the visible view if it fails.
|
||||
const refreshScans = async () => {
|
||||
if (sampleScans) return;
|
||||
try {
|
||||
const scansData = await getAttackPathScans();
|
||||
if (scansData?.data) {
|
||||
@@ -43,6 +47,7 @@ export function useAttackPathScans(): UseAttackPathScansResult {
|
||||
// the error view's Retry action. A successful empty result (`{ data: [] }`) is
|
||||
// not an error; only a missing payload or a thrown request is.
|
||||
const loadScans = async () => {
|
||||
if (sampleScans) return;
|
||||
setScansLoading(true);
|
||||
setLoadError(false);
|
||||
try {
|
||||
|
||||
@@ -56,8 +56,13 @@ const getDefaultValues = (query?: AttackPathQuery) => {
|
||||
* Custom hook for managing query builder form state
|
||||
* Handles query selection, parameter validation, and form submission
|
||||
*/
|
||||
export const useQueryBuilder = (availableQueries: AttackPathQuery[]) => {
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(null);
|
||||
export const useQueryBuilder = (
|
||||
availableQueries: AttackPathQuery[],
|
||||
initialQueryId: string | null = null,
|
||||
) => {
|
||||
const [selectedQuery, setSelectedQuery] = useState<string | null>(
|
||||
initialQueryId,
|
||||
);
|
||||
|
||||
const getQueryById = (queryId: string | null) =>
|
||||
availableQueries.find((query) => query.id === queryId);
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { failedSampleFindings } from "@/lib/sample-preview/sample-records";
|
||||
import { useLighthouseContextStore } from "@/store/lighthouse-context/store";
|
||||
import { resetLighthouseContextStore } from "@/store/lighthouse-context/store.test-utils";
|
||||
|
||||
const actions = vi.hoisted(() => ({
|
||||
getAttackPathScans: vi.fn(),
|
||||
getAvailableQueries: vi.fn(),
|
||||
executeQuery: vi.fn(),
|
||||
executeCustomQuery: vi.fn(),
|
||||
getFindingById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/attack-paths", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/actions/attack-paths")>()),
|
||||
getAttackPathScans: actions.getAttackPathScans,
|
||||
getAvailableQueries: actions.getAvailableQueries,
|
||||
executeQuery: actions.executeQuery,
|
||||
executeCustomQuery: actions.executeCustomQuery,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/actions/findings")>()),
|
||||
getFindingById: actions.getFindingById,
|
||||
}));
|
||||
|
||||
import { useGraphStore } from "./_hooks/use-graph-state";
|
||||
import AttackPathsPage from "./attack-paths-page";
|
||||
import { AttackPathPageHarness } from "./attack-paths-page.harness";
|
||||
|
||||
const SAMPLE_FIXTURE = {
|
||||
scans: [SAMPLE_ATTACK_PATHS.scan],
|
||||
scanId: SAMPLE_ATTACK_PATHS.scan.id,
|
||||
queries: [SAMPLE_ATTACK_PATHS.query],
|
||||
queryId: SAMPLE_ATTACK_PATHS.query.id,
|
||||
queryResult: SAMPLE_ATTACK_PATHS.result,
|
||||
};
|
||||
|
||||
const [BUCKET_FINDING] = failedSampleFindings("s3_bucket_public_access");
|
||||
const [INSTANCE_FINDING] = failedSampleFindings("ec2_instance_imdsv2_enabled");
|
||||
|
||||
beforeEach(() => {
|
||||
actions.getFindingById.mockReset();
|
||||
useGraphStore.getState().reset();
|
||||
resetLighthouseContextStore();
|
||||
window.history.replaceState({}, "", "/attack-paths");
|
||||
});
|
||||
|
||||
describe("Attack Paths sample preview", () => {
|
||||
it("shows the sample graph, opens its findings and keeps it off the API and Lighthouse", async () => {
|
||||
// Given
|
||||
const screen = await render(
|
||||
<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />,
|
||||
);
|
||||
const graph = new AttackPathPageHarness(SAMPLE_FIXTURE);
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole("radio", { name: "Selected scan" }))
|
||||
.toBeDisabled();
|
||||
|
||||
// When
|
||||
await vi.waitFor(
|
||||
() => expect(graph.getNodeById("sample-s3-exports")).not.toBeNull(),
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
await graph.clickNode("sample-s3-exports");
|
||||
await vi.waitFor(() =>
|
||||
expect(graph.getNodeById(BUCKET_FINDING.id)).not.toBeNull(),
|
||||
);
|
||||
await graph.clickNode(BUCKET_FINDING.id);
|
||||
// The finding drawer resolves the sample record through the read action.
|
||||
await vi.waitFor(() =>
|
||||
expect(actions.getFindingById).toHaveBeenCalledWith(
|
||||
BUCKET_FINDING.id,
|
||||
expect.anything(),
|
||||
),
|
||||
);
|
||||
await graph.executeQuery({ selectFirst: false });
|
||||
|
||||
// Then
|
||||
expect(graph.showsNodeLabel(INSTANCE_FINDING.resource.name)).toBe(true);
|
||||
expect(
|
||||
graph.showsNodeLabel(SAMPLE_ATTACK_PATHS.query.attributes.name),
|
||||
).toBe(true);
|
||||
expect(actions.getAttackPathScans).not.toHaveBeenCalled();
|
||||
expect(actions.getAvailableQueries).not.toHaveBeenCalled();
|
||||
expect(actions.executeQuery).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useLighthouseContextStore.getState().contributions["attack-path-current"],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
OUTCOME_NODE_LABEL,
|
||||
} from "@/lib/attack-paths/group-graph";
|
||||
import { buildAttackPathContext } from "@/lib/lighthouse/context/contributions";
|
||||
import type { SampleAttackPaths } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type {
|
||||
AttackPathQuery,
|
||||
@@ -74,18 +75,24 @@ import {
|
||||
const SCROLL_CONTAINER_CLASS =
|
||||
"minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm";
|
||||
|
||||
export default function AttackPathsPage() {
|
||||
interface AttackPathsPageProps {
|
||||
/** Sample mode: renders this data, never calls the attack-paths API. */
|
||||
sample?: SampleAttackPaths;
|
||||
}
|
||||
|
||||
export default function AttackPathsPage({ sample }: AttackPathsPageProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const scanId = searchParams.get("scanId");
|
||||
// The sample scan id stays out of the URL, so the real page never sees it.
|
||||
const scanId = sample ? sample.scan.id : searchParams.get("scanId");
|
||||
const graphState = useGraphState();
|
||||
const finding = useFindingDetails();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { scans, scansLoading, loadError, refreshScans, retryLoadScans } =
|
||||
useAttackPathScans();
|
||||
useAttackPathScans(sample ? [sample.scan] : undefined);
|
||||
|
||||
const [queriesLoading, setQueriesLoading] = useState(true);
|
||||
const [queriesLoading, setQueriesLoading] = useState(!sample);
|
||||
const [queriesError, setQueriesError] = useState<string | null>(null);
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const graphRef = useRef<GraphHandle>(null);
|
||||
@@ -94,13 +101,26 @@ export default function AttackPathsPage() {
|
||||
const hasResetRef = useRef(false);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>([]);
|
||||
const [queries, setQueries] = useState<AttackPathQuery[]>(
|
||||
sample ? [sample.query] : [],
|
||||
);
|
||||
// Snapshot of the executed query's outcome, so the graph's terminal node
|
||||
// reflects the query that produced the current graph (not a later selection).
|
||||
const [executedOutcome, setExecutedOutcome] =
|
||||
useState<AttackPathOutcome | null>(null);
|
||||
|
||||
const queryBuilder = useQueryBuilder(queries);
|
||||
const queryBuilder = useQueryBuilder(queries, sample?.query.id ?? null);
|
||||
|
||||
// The sample has one precomputed result: "running" its query shows it.
|
||||
const showSampleGraph = (sampleData: SampleAttackPaths) => {
|
||||
graphState.updateGraphData(adaptQueryResultToGraphData(sampleData.result), {
|
||||
queryId: sampleData.query.id,
|
||||
queryLabel: sampleData.query.attributes.name,
|
||||
queryKind: ATTACK_PATH_QUERY_KIND.PREDEFINED,
|
||||
parameters: {},
|
||||
});
|
||||
setExecutedOutcome(sampleData.query.attributes.outcome ?? null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasResetRef.current) {
|
||||
@@ -110,7 +130,8 @@ export default function AttackPathsPage() {
|
||||
}, [graphState]);
|
||||
|
||||
useEffect(() => {
|
||||
graphState.resetGraph();
|
||||
if (sample) showSampleGraph(sample);
|
||||
else graphState.resetGraph();
|
||||
}, [scanId]); // eslint-disable-line react-hooks/exhaustive-deps -- reset on scanId change only
|
||||
|
||||
// Poll while a scan is in flight so the page auto-advances when the graph is ready.
|
||||
@@ -126,6 +147,8 @@ export default function AttackPathsPage() {
|
||||
selectedScan.attributes.state !== SCAN_STATES.COMPLETED;
|
||||
|
||||
useEffect(() => {
|
||||
if (sample) return;
|
||||
|
||||
const loadQueries = async () => {
|
||||
if (!scanId) {
|
||||
setQueriesError("No scan selected");
|
||||
@@ -168,7 +191,7 @@ export default function AttackPathsPage() {
|
||||
};
|
||||
|
||||
loadQueries();
|
||||
}, [scanId, toast]);
|
||||
}, [sample, scanId, toast]);
|
||||
|
||||
const showErrorToast = (title: string, description: string) => {
|
||||
toast({
|
||||
@@ -179,6 +202,11 @@ export default function AttackPathsPage() {
|
||||
};
|
||||
|
||||
const handleExecuteQuery = async () => {
|
||||
if (sample) {
|
||||
showSampleGraph(sample);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scanId || !queryBuilder.selectedQuery) {
|
||||
showErrorToast("Error", "Please select both a scan and a query");
|
||||
return;
|
||||
@@ -320,10 +348,10 @@ export default function AttackPathsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
findingNavigationInFlightRef.current = true;
|
||||
// Open finding drawer directly, bypassing the node-details modal.
|
||||
graphState.enterFilteredView(node.id);
|
||||
graphState.selectNode(null); // clear so node-details modal doesn't open first
|
||||
findingNavigationInFlightRef.current = true;
|
||||
void handleViewFinding(String(node.properties?.id || node.id));
|
||||
return;
|
||||
}
|
||||
@@ -406,24 +434,26 @@ export default function AttackPathsPage() {
|
||||
graphState.selectedNode ?? graphState.filteredNode;
|
||||
const lighthouseGraphData = graphState.fullData ?? graphState.data;
|
||||
const lighthouseExecution = graphState.loading ? null : graphState.execution;
|
||||
const lighthouseContext = scanId
|
||||
? buildAttackPathContext({
|
||||
pathname,
|
||||
scanId,
|
||||
queryId: lighthouseExecution?.queryId,
|
||||
queryLabel: lighthouseExecution?.queryLabel,
|
||||
queryKind: lighthouseExecution?.queryKind,
|
||||
parameters: lighthouseExecution?.parameters,
|
||||
graphData: lighthouseExecution ? lighthouseGraphData : null,
|
||||
selectedNode:
|
||||
lighthouseExecution && lighthouseSelectedNode
|
||||
? {
|
||||
id: lighthouseSelectedNode.id,
|
||||
type: lighthouseSelectedNode.labels[0],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: null;
|
||||
// Sample data never reaches Lighthouse.
|
||||
const lighthouseContext =
|
||||
scanId && !sample
|
||||
? buildAttackPathContext({
|
||||
pathname,
|
||||
scanId,
|
||||
queryId: lighthouseExecution?.queryId,
|
||||
queryLabel: lighthouseExecution?.queryLabel,
|
||||
queryKind: lighthouseExecution?.queryKind,
|
||||
parameters: lighthouseExecution?.parameters,
|
||||
graphData: lighthouseExecution ? lighthouseGraphData : null,
|
||||
selectedNode:
|
||||
lighthouseExecution && lighthouseSelectedNode
|
||||
? {
|
||||
id: lighthouseSelectedNode.id,
|
||||
type: lighthouseSelectedNode.labels[0],
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -465,8 +495,13 @@ export default function AttackPathsPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* The sample scan is the only one: nothing to pick or page. */}
|
||||
<Suspense fallback={<div>Loading scans...</div>}>
|
||||
<ScanListTable scans={scans} />
|
||||
<ScanListTable
|
||||
scans={scans}
|
||||
selectedScanId={sample?.scan.id}
|
||||
readOnly={Boolean(sample)}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{isViewingPreviousCycleData && (
|
||||
|
||||
@@ -1 +1,22 @@
|
||||
export { default } from "./(workflow)/query-builder/attack-paths-page";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_ATTACK_PATHS } from "@/lib/sample-preview/sample-attack-paths";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
|
||||
import AttackPathsPage from "./(workflow)/query-builder/attack-paths-page";
|
||||
|
||||
export default async function AttackPathsRoute() {
|
||||
// Decided on the server, so the sample never flashes in after the real page.
|
||||
const samplePreview = await getSamplePreviewState();
|
||||
if (!samplePreview.active) return <AttackPathsPage />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.ATTACK_PATHS}
|
||||
/>
|
||||
<AttackPathsPage sample={SAMPLE_ATTACK_PATHS} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,9 +14,11 @@ const {
|
||||
getComplianceAttributesMock,
|
||||
getComplianceOverviewMetadataInfoMock,
|
||||
getComplianceRequirementsMock,
|
||||
getSamplePreviewStateMock,
|
||||
getScanMock,
|
||||
mapComplianceDataMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getSamplePreviewStateMock: vi.fn(),
|
||||
getComplianceAttributesMock: vi.fn(),
|
||||
getComplianceOverviewMetadataInfoMock: vi.fn(),
|
||||
getComplianceRequirementsMock: vi.fn(),
|
||||
@@ -112,6 +114,14 @@ vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
|
||||
getSamplePreviewState: getSamplePreviewStateMock,
|
||||
}));
|
||||
|
||||
vi.mock("../_components/compliance-detail-sample-preview", () => ({
|
||||
ComplianceDetailSamplePreview: () => <div>Sample compliance detail</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../_components/cross-account-detail", () => ({
|
||||
CrossAccountDetail: () => null,
|
||||
}));
|
||||
@@ -231,3 +241,46 @@ describe("Compliance detail task response", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compliance detail for the sample scan", () => {
|
||||
const renderSampleDetail = async () =>
|
||||
ComplianceDetail({
|
||||
params: Promise.resolve({ compliancetitle: "CIS" }),
|
||||
searchParams: Promise.resolve({
|
||||
complianceId: "cis_5.0_aws",
|
||||
version: "5.0",
|
||||
scanId: "sample-scan",
|
||||
}),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the sample detail without asking the API about the scan", async () => {
|
||||
// Given
|
||||
getSamplePreviewStateMock.mockResolvedValue({
|
||||
active: true,
|
||||
stage: "no_scan",
|
||||
runningScan: null,
|
||||
});
|
||||
|
||||
// When
|
||||
render((await renderSampleDetail()) as ReactElement);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Sample compliance detail")).toBeInTheDocument();
|
||||
expect(getScanMock).not.toHaveBeenCalled();
|
||||
expect(getComplianceRequirementsMock).not.toHaveBeenCalled();
|
||||
expect(getComplianceOverviewMetadataInfoMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the sample scan back to Compliance once the preview is over", async () => {
|
||||
// Given
|
||||
getSamplePreviewStateMock.mockResolvedValue({ active: false });
|
||||
|
||||
// When / Then
|
||||
await expect(renderSampleDetail()).rejects.toThrow("redirect");
|
||||
expect(getScanMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,8 @@ import {
|
||||
import { LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE } from "@/lib/lighthouse/context/constants";
|
||||
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { isReportDownloadLocked } from "@/lib/report-download-access";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-guards";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SearchParamsProps } from "@/types";
|
||||
@@ -50,6 +52,7 @@ import {
|
||||
import { isKnownProviderType } from "@/types/providers";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { ComplianceDetailSamplePreview } from "../_components/compliance-detail-sample-preview";
|
||||
import { CrossAccountDetail } from "../_components/cross-account-detail";
|
||||
import { CrossProviderDetail } from "../_components/cross-provider-detail";
|
||||
import { buildSearchParamsKey } from "../_lib/search-params-key";
|
||||
@@ -78,6 +81,25 @@ export default async function ComplianceDetail({
|
||||
notFound();
|
||||
}
|
||||
|
||||
// The sample scan never reaches the API: its detail is built from the catalog.
|
||||
if (scanId && isSampleId(scanId)) {
|
||||
const preview = await getSamplePreviewState();
|
||||
if (!preview.active) redirect("/compliance");
|
||||
|
||||
return (
|
||||
<ComplianceDetailSamplePreview
|
||||
state={preview}
|
||||
compliancetitle={compliancetitle}
|
||||
complianceId={complianceId}
|
||||
version={version}
|
||||
filter={getSingleSearchParam(
|
||||
resolvedSearchParams["filter[cis_profile_level]"],
|
||||
)}
|
||||
targetSection={section}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionOnlyPromise = isReportDownloadLocked();
|
||||
|
||||
// Cross-provider mode replaces the per-scan pipeline with the universal
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
ClientAccordionWrapper,
|
||||
ComplianceHeader,
|
||||
ComplianceWarming,
|
||||
RequirementsStatusCard,
|
||||
TopFailedSectionsCard,
|
||||
} from "@/components/compliance";
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { Card } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { getComplianceMapper } from "@/lib/compliance/compliance-mapper";
|
||||
import { SAMPLE_SCAN } from "@/lib/sample-preview/sample-compliance";
|
||||
import { getSampleComplianceDetail } from "@/lib/sample-preview/sample-compliance-requirements";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
import type { Framework, RequirementsTotals } from "@/types/compliance";
|
||||
|
||||
interface ComplianceDetailSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
compliancetitle: string;
|
||||
complianceId: string;
|
||||
version?: string;
|
||||
/** CIS profile level, applied by the mapper like on the real page. */
|
||||
filter?: string;
|
||||
/** `?section=`: the accordion section to open, like on the real page. */
|
||||
targetSection?: string;
|
||||
}
|
||||
|
||||
/** A sample framework's detail: real catalog, sample statuses, no reports or Lighthouse. */
|
||||
export async function ComplianceDetailSamplePreview({
|
||||
state,
|
||||
compliancetitle,
|
||||
complianceId,
|
||||
version,
|
||||
filter,
|
||||
targetSection,
|
||||
}: ComplianceDetailSamplePreviewProps) {
|
||||
const detail = await getSampleComplianceDetail(complianceId);
|
||||
const formattedTitle = compliancetitle.split("-").join(" ");
|
||||
const title = version ? `${formattedTitle} - ${version}` : formattedTitle;
|
||||
|
||||
// The catalog is still warming (or unavailable): same state as the real page.
|
||||
if (!detail) {
|
||||
return (
|
||||
<ContentLayout title={title}>
|
||||
<SamplePreviewBanner
|
||||
state={state}
|
||||
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
|
||||
/>
|
||||
<ComplianceWarming />
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const mapper = getComplianceMapper(
|
||||
detail.attributesData.data[0]?.attributes.framework,
|
||||
);
|
||||
const data: Framework[] = mapper.mapComplianceData(
|
||||
detail.attributesData,
|
||||
{ data: detail.requirements },
|
||||
filter,
|
||||
);
|
||||
const totals = data.reduce<RequirementsTotals>(
|
||||
(acc, framework) => ({
|
||||
pass: acc.pass + framework.pass,
|
||||
fail: acc.fail + framework.fail,
|
||||
manual: acc.manual + framework.manual,
|
||||
}),
|
||||
{ pass: 0, fail: 0, manual: 0 },
|
||||
);
|
||||
const topFailed = mapper.getTopFailedSections(data);
|
||||
const accordionItems = mapper.toAccordionItems(data, SAMPLE_SCAN.id);
|
||||
// Mappers key sections as `${framework.name}-${section}`.
|
||||
const sectionKeys = new Set(
|
||||
data.map((framework) => `${framework.name}-${targetSection}`),
|
||||
);
|
||||
const initialExpandedKeys = targetSection
|
||||
? accordionItems
|
||||
.filter((item) => sectionKeys.has(item.key))
|
||||
.map((item) => item.key)
|
||||
.slice(0, 1)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ContentLayout title={title}>
|
||||
<SamplePreviewBanner
|
||||
state={state}
|
||||
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
|
||||
/>
|
||||
<Card variant="base" className="mb-6 w-full gap-4 p-4 md:p-5">
|
||||
<ComplianceHeader
|
||||
scans={[]}
|
||||
uniqueRegions={[]}
|
||||
showSearch={false}
|
||||
framework={compliancetitle}
|
||||
showProviders={false}
|
||||
logoPath={getComplianceIcon(compliancetitle)}
|
||||
complianceTitle={compliancetitle}
|
||||
selectedScan={SAMPLE_SCAN}
|
||||
/>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-[minmax(280px,400px)_1fr]">
|
||||
<RequirementsStatusCard
|
||||
pass={totals.pass}
|
||||
fail={totals.fail}
|
||||
manual={totals.manual}
|
||||
/>
|
||||
<TopFailedSectionsCard
|
||||
sections={topFailed.items}
|
||||
dataType={topFailed.type}
|
||||
prepopulated={topFailed.prepopulated}
|
||||
/>
|
||||
</div>
|
||||
{/* Requirement findings come from the sample records via the sample scan id. */}
|
||||
<ClientAccordionWrapper
|
||||
items={accordionItems}
|
||||
defaultExpandedKeys={initialExpandedKeys}
|
||||
scrollToKey={initialExpandedKeys[0]}
|
||||
/>
|
||||
</div>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
|
||||
export const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
|
||||
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import { ComplianceSamplePreview } from "./compliance-sample-preview";
|
||||
|
||||
it("opens sample framework details without offering report exports", async () => {
|
||||
// Given / When
|
||||
const screen = await render(
|
||||
<ComplianceSamplePreview
|
||||
state={{
|
||||
active: true,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_PROVIDER,
|
||||
runningScan: null,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then: frameworks open their sample detail; reports stay unavailable.
|
||||
await expect
|
||||
.element(screen.getByRole("heading", { name: "CIS - 5.0", exact: true }))
|
||||
.toBeVisible();
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole("button", { name: "Open CIS - 5.0 compliance details" }),
|
||||
)
|
||||
.toBeEnabled();
|
||||
await expect
|
||||
.element(screen.getByRole("progressbar", { name: "ThreatScore progress" }))
|
||||
.toHaveAttribute("aria-valuenow", String(SAMPLE_THREAT_SCORE.score));
|
||||
await expect
|
||||
.element(screen.getByRole("button", { name: /export/i }))
|
||||
.not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ThreatScoreBadge } from "@/components/compliance";
|
||||
import { ComplianceCard } from "@/components/compliance/compliance-card";
|
||||
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import {
|
||||
SAMPLE_COMPLIANCE_OVERVIEWS,
|
||||
SAMPLE_SCAN,
|
||||
} from "@/lib/sample-preview/sample-compliance";
|
||||
import { SAMPLE_THREAT_SCORE } from "@/lib/sample-preview/sample-overview";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
|
||||
import { ComplianceOverviewPanel } from "./compliance-overview-panel";
|
||||
|
||||
interface ComplianceSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
/** Totals derived from the same requirements the detail page shows. */
|
||||
overviews?: ComplianceOverviewData[];
|
||||
}
|
||||
|
||||
/** Sample per-scan compliance: cards open the sample detail; no reports. */
|
||||
export function ComplianceSamplePreview({
|
||||
state,
|
||||
overviews = SAMPLE_COMPLIANCE_OVERVIEWS,
|
||||
}: ComplianceSamplePreviewProps) {
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner
|
||||
state={state}
|
||||
page={SAMPLE_PREVIEW_PAGE.COMPLIANCE}
|
||||
/>
|
||||
<div className="mb-6">
|
||||
<ThreatScoreBadge
|
||||
readOnly
|
||||
score={SAMPLE_THREAT_SCORE.score}
|
||||
scanId={SAMPLE_SCAN.id}
|
||||
provider={SAMPLE_SCAN.providerInfo.provider}
|
||||
selectedScan={SAMPLE_SCAN}
|
||||
sectionScores={SAMPLE_THREAT_SCORE.sectionScores}
|
||||
/>
|
||||
</div>
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceFrameworkGrid>
|
||||
{overviews.map(({ id, attributes }) => (
|
||||
<div key={id} className="h-full [&>*]:h-full">
|
||||
<ComplianceCard
|
||||
hideActions
|
||||
title={attributes.framework}
|
||||
version={attributes.version}
|
||||
passingRequirements={attributes.requirements_passed}
|
||||
totalRequirements={attributes.total_requirements}
|
||||
prevPassingRequirements={attributes.requirements_passed}
|
||||
prevTotalRequirements={attributes.total_requirements}
|
||||
scanId={SAMPLE_SCAN.id}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={SAMPLE_SCAN}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</ComplianceFrameworkGrid>
|
||||
</ComplianceOverviewPanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
} from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SAMPLE_PREVIEW_STAGE,
|
||||
type SamplePreviewState,
|
||||
} from "@/lib/sample-preview/sample-preview-state";
|
||||
|
||||
import Compliance from "./page";
|
||||
|
||||
const {
|
||||
@@ -19,6 +24,7 @@ const {
|
||||
complianceOverviewGridSpy,
|
||||
getComplianceOverviewMetadataInfoMock,
|
||||
getCompliancesOverviewMock,
|
||||
getSamplePreviewStateMock,
|
||||
getScanMock,
|
||||
getScansMock,
|
||||
getThreatScoreMock,
|
||||
@@ -29,6 +35,9 @@ const {
|
||||
complianceOverviewGridSpy: vi.fn(),
|
||||
getComplianceOverviewMetadataInfoMock: vi.fn(),
|
||||
getCompliancesOverviewMock: vi.fn(),
|
||||
getSamplePreviewStateMock: vi.fn(
|
||||
async (): Promise<SamplePreviewState> => ({ active: false }),
|
||||
),
|
||||
getScanMock: vi.fn(),
|
||||
getScansMock: vi.fn(),
|
||||
getThreatScoreMock: vi.fn(),
|
||||
@@ -52,6 +61,18 @@ vi.mock("@/actions/scans", () => ({
|
||||
getScansByState: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
|
||||
getSamplePreviewState: getSamplePreviewStateMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sample-preview/sample-compliance-requirements", () => ({
|
||||
getSampleComplianceTotals: async () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./_components/compliance-sample-preview", () => ({
|
||||
ComplianceSamplePreview: () => <div>Sample compliance preview</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
@@ -155,6 +176,30 @@ describe("Compliance overview page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compliance sample preview", () => {
|
||||
it("renders the sample and skips the real fetches until the first scan completes", async () => {
|
||||
// Given - a Cloud tenant whose first scan has not completed yet
|
||||
getSamplePreviewStateMock.mockResolvedValue({
|
||||
active: true,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
|
||||
runningScan: null,
|
||||
});
|
||||
|
||||
// When
|
||||
render(
|
||||
(await Compliance({
|
||||
searchParams: Promise.resolve({}),
|
||||
})) as ReactElement,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(screen.getByText("Sample compliance preview")).toBeInTheDocument();
|
||||
expect(getScansMock).not.toHaveBeenCalled();
|
||||
expect(getCompliancesOverviewMock).not.toHaveBeenCalled();
|
||||
expect(loadComplianceWatchlistContextMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Compliance overview task response", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -17,10 +17,12 @@ import { ComplianceFilters } from "@/components/compliance/compliance-header/com
|
||||
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
|
||||
import { WatchlistControls } from "@/components/compliance/watchlist/watchlist-controls";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { pickLatestCisPerProvider } from "@/lib/compliance/compliance-report-types";
|
||||
import { isReportDownloadLocked } from "@/lib/report-download-access";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { toSampleComplianceOverviews } from "@/lib/sample-preview/sample-compliance";
|
||||
import { getSampleComplianceTotals } from "@/lib/sample-preview/sample-compliance-requirements";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import {
|
||||
ExpandedScanData,
|
||||
@@ -30,8 +32,10 @@ import {
|
||||
} from "@/types";
|
||||
import { COMPLIANCE_TAB, ComplianceOverviewData } from "@/types/compliance";
|
||||
|
||||
import { ComplianceOverviewPanel } from "./_components/compliance-overview-panel";
|
||||
import { CompliancePageTabs } from "./_components/compliance-page-tabs";
|
||||
import { getComplianceTab } from "./_components/compliance-page-tabs.shared";
|
||||
import { ComplianceSamplePreview } from "./_components/compliance-sample-preview";
|
||||
import { CrossAccountOverviewSection } from "./_components/cross-account-overview-section";
|
||||
import { CrossProviderOverview } from "./_components/cross-provider-overview";
|
||||
import {
|
||||
@@ -71,6 +75,21 @@ export default async function Compliance({
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
// Sample data replaces both tabs until the first scan completes.
|
||||
const preview = await getSamplePreviewState();
|
||||
if (preview.active) {
|
||||
return (
|
||||
<ContentLayout title="Compliance" icon={<ShieldCheck />}>
|
||||
<ComplianceSamplePreview
|
||||
state={preview}
|
||||
overviews={toSampleComplianceOverviews(
|
||||
await getSampleComplianceTotals(),
|
||||
)}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const searchParamsKey = JSON.stringify(resolvedSearchParams || {});
|
||||
|
||||
@@ -409,19 +428,3 @@ const ComplianceWatchlistControls = async ({
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="none"
|
||||
className="minimal-scrollbar relative z-0 w-full gap-4 overflow-auto shadow-sm"
|
||||
>
|
||||
<CardContent className="flex flex-col gap-4 p-4">{children}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_FAILED_CHECKS } from "@/lib/sample-preview/sample-dataset";
|
||||
import { SAMPLE_PREVIEW_STAGE } from "@/lib/sample-preview/sample-preview-state";
|
||||
import { SAMPLE_FINDINGS } from "@/lib/sample-preview/sample-records";
|
||||
|
||||
import { FindingsSamplePreview } from "./findings-sample-preview";
|
||||
|
||||
const { fetchMock, getScanMock, resolveFindingIdsMock, searchParams } =
|
||||
vi.hoisted(() => ({
|
||||
// One instance, like Next's hook: a new one per render loops the filter bar.
|
||||
searchParams: new URLSearchParams(),
|
||||
fetchMock: vi.fn(),
|
||||
getScanMock: vi.fn(),
|
||||
resolveFindingIdsMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => searchParams,
|
||||
usePathname: () => "/findings",
|
||||
}));
|
||||
vi.mock("@/auth.config", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next-auth/react", () => ({
|
||||
useSession: () => ({ data: null, status: "unauthenticated" }),
|
||||
}));
|
||||
vi.mock("@/actions/scans", () => ({ getScan: getScanMock }));
|
||||
vi.mock("@/actions/findings/findings-by-resource", () => ({
|
||||
resolveFindingIdsByVisibleGroupResources: resolveFindingIdsMock,
|
||||
}));
|
||||
|
||||
const STATE = {
|
||||
active: true as const,
|
||||
stage: SAMPLE_PREVIEW_STAGE.NO_SCAN,
|
||||
runningScan: null,
|
||||
};
|
||||
|
||||
describe("FindingsSamplePreview", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
// jsdom has no scrollIntoView; the filter bar's command lists call it.
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
// jsdom has no IntersectionObserver; the drill-down's infinite scroll needs one.
|
||||
vi.stubGlobal(
|
||||
"IntersectionObserver",
|
||||
class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("renders every failed sample check without fetching", () => {
|
||||
const { container } = render(<FindingsSamplePreview state={STATE} />);
|
||||
|
||||
expect(
|
||||
screen.getByText("You are exploring sample data"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const publicBucket = SAMPLE_FAILED_CHECKS.find(
|
||||
(check) => check.checkId === "s3_bucket_public_access",
|
||||
)!;
|
||||
const row = screen
|
||||
.getByText(publicBucket.title)
|
||||
.closest("tr") as HTMLElement;
|
||||
// Impacted resources: failed of failed + passed.
|
||||
expect(
|
||||
within(row).getByText(String(publicBucket.failed)),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(row).getByText(String(publicBucket.failed + publicBucket.passed)),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const table = container.querySelector("table") as HTMLElement;
|
||||
expect(within(table).getAllByRole("row", { hidden: true })).toHaveLength(
|
||||
SAMPLE_FAILED_CHECKS.length + 1,
|
||||
);
|
||||
expect(table.closest("[inert]")).toBeNull();
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getScanMock).not.toHaveBeenCalled();
|
||||
expect(resolveFindingIdsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands a sample group into its resources from the sample records", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<FindingsSamplePreview state={STATE} />);
|
||||
|
||||
await user.click(
|
||||
screen.getByText("Ensure S3 buckets are not publicly accessible"),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("acme-customer-exports"),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands a PASS group into passing resources only", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FindingsSamplePreview
|
||||
state={STATE}
|
||||
searchParams={{ "filter[status__in]": "PASS" }}
|
||||
/>,
|
||||
);
|
||||
await user.click(
|
||||
screen.getByText("Ensure S3 buckets are not publicly accessible"),
|
||||
);
|
||||
const passing = SAMPLE_FINDINGS.find(
|
||||
(finding) =>
|
||||
finding.check.checkId === "s3_bucket_public_access" &&
|
||||
finding.status === "PASS",
|
||||
)!;
|
||||
expect(await screen.findByText(passing.resource.name)).toBeInTheDocument();
|
||||
expect(screen.queryByText("acme-customer-exports")).not.toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the severity an Overview link carried", () => {
|
||||
render(
|
||||
<FindingsSamplePreview
|
||||
state={STATE}
|
||||
searchParams={{
|
||||
"filter[severity__in]": "critical",
|
||||
"filter[status__in]": "FAIL",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const critical = SAMPLE_FAILED_CHECKS.filter(
|
||||
(check) => check.severity === "critical",
|
||||
);
|
||||
const high = SAMPLE_FAILED_CHECKS.find(
|
||||
(check) => check.severity === "high",
|
||||
)!;
|
||||
for (const check of critical) {
|
||||
expect(screen.getByText(check.title)).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText(high.title)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the real filter bar without fetching its options", () => {
|
||||
render(<FindingsSamplePreview state={STATE} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: /^Filter by Provider\./ }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "More Filters" }),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FindingsFilters } from "@/components/findings/findings-filters";
|
||||
import { FindingsGroupTable } from "@/components/findings/table";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import {
|
||||
filterSampleFindingGroups,
|
||||
SAMPLE_FILTER_PROVIDER,
|
||||
SAMPLE_FINDING_FILTER_OPTIONS,
|
||||
} from "@/lib/sample-preview/sample-findings";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
import type { SearchParamsProps } from "@/types/components";
|
||||
|
||||
// Scoping to the sample scan is what makes every drill-down action answer
|
||||
// from the sample records instead of the API.
|
||||
const SAMPLE_SCAN_FILTERS: Record<string, string> = {
|
||||
"filter[scan]": SAMPLE_ACCOUNT.scanId,
|
||||
};
|
||||
|
||||
interface FindingsSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
/** The filter bar, Overview drill-downs and the table's search and sorting. */
|
||||
searchParams?: SearchParamsProps;
|
||||
}
|
||||
|
||||
/** Sample finding groups with the real filter bar; options come from the records. */
|
||||
export function FindingsSamplePreview({
|
||||
state,
|
||||
searchParams = {},
|
||||
}: FindingsSamplePreviewProps) {
|
||||
const { groups, resourceFilters } = filterSampleFindingGroups(searchParams);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.FINDINGS} />
|
||||
<FilterTransitionWrapper>
|
||||
<div className="mb-6">
|
||||
{/* No scans or groups: only the sample account and its own values. */}
|
||||
<FindingsFilters
|
||||
providers={[SAMPLE_FILTER_PROVIDER]}
|
||||
providerGroups={[]}
|
||||
completedScanIds={[]}
|
||||
scanDetails={[]}
|
||||
uniqueRegions={SAMPLE_FINDING_FILTER_OPTIONS.regions}
|
||||
uniqueServices={SAMPLE_FINDING_FILTER_OPTIONS.services}
|
||||
uniqueResourceTypes={SAMPLE_FINDING_FILTER_OPTIONS.resourceTypes}
|
||||
uniqueCategories={SAMPLE_FINDING_FILTER_OPTIONS.categories}
|
||||
uniqueGroups={SAMPLE_FINDING_FILTER_OPTIONS.groups}
|
||||
/>
|
||||
</div>
|
||||
<FindingsGroupTable
|
||||
// Key resets client state (selection, drill-down) when data or filters change.
|
||||
key={`${groups.map((group) => group.id).join(",")}|${JSON.stringify(resourceFilters)}`}
|
||||
data={groups}
|
||||
resolvedFilters={{ ...SAMPLE_SCAN_FILTERS, ...resourceFilters }}
|
||||
hasHistoricalData
|
||||
/>
|
||||
</FilterTransitionWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,17 +24,32 @@ import {
|
||||
isFindingsPreviewLocked,
|
||||
} from "@/lib/findings-access";
|
||||
import { resolveFindingScanDateFilters } from "@/lib/findings-scan-filters";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { ScanProps } from "@/types";
|
||||
import { SearchParamsProps } from "@/types/components";
|
||||
|
||||
import { FindingsFiltersSection } from "./_components/findings-filters-section";
|
||||
import { FindingsFiltersSkeleton } from "./_components/findings-filters-skeleton";
|
||||
import { FindingsSamplePreview } from "./_components/findings-sample-preview";
|
||||
|
||||
export default async function Findings({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
// Sample data replaces the page (and its paywall) until the first scan completes.
|
||||
const preview = await getSamplePreviewState();
|
||||
if (preview.active) {
|
||||
return (
|
||||
<ContentLayout title="Findings" icon={<Tag />}>
|
||||
<FindingsSamplePreview
|
||||
state={preview}
|
||||
searchParams={await searchParams}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { GlobalSidePanel } from "@/components/side-panel/global-side-panel";
|
||||
import { SAMPLE_ISSUES } from "@/lib/sample-preview/sample-issues";
|
||||
|
||||
import { IssuesList } from "./issues-list";
|
||||
|
||||
it("opens a sample issue and its findings without fetching tenant data", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
const issue = SAMPLE_ISSUES[0];
|
||||
const content = await IssuesList({ searchParams: {}, sample: true });
|
||||
const screen = await render(
|
||||
<>
|
||||
{content}
|
||||
<GlobalSidePanel />
|
||||
</>,
|
||||
);
|
||||
|
||||
await screen
|
||||
.getByRole("button", { name: issue.attributes.title, exact: true })
|
||||
.click();
|
||||
await screen.getByRole("tab", { name: "Findings", exact: true }).click();
|
||||
|
||||
await expect
|
||||
.element(
|
||||
screen.getByRole("button", {
|
||||
name: "Ensure S3 buckets are not publicly accessible",
|
||||
exact: true,
|
||||
}),
|
||||
)
|
||||
.toBeVisible();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getIssues, getIssuesOverview } from "@/actions/issues";
|
||||
import { SAMPLE_ISSUES } from "@/lib/sample-preview/sample-issues";
|
||||
import { VERDICT } from "@/types/issues";
|
||||
|
||||
import { IssuesList } from "./issues-list";
|
||||
|
||||
vi.mock("@/actions/issues", () => ({
|
||||
getIssues: vi.fn(),
|
||||
getIssuesOverview: vi.fn(),
|
||||
}));
|
||||
// Modules that reach next-auth under jsdom; the panel has its own tests.
|
||||
vi.mock("@/components/issues/issue-panel", () => ({ IssuePanel: () => null }));
|
||||
vi.mock("@/lib", () => ({ getPaginationInfo: vi.fn() }));
|
||||
vi.mock("@/contexts", () => ({ useFilterTransitionOptional: () => null }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/issues",
|
||||
useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
const [exploitable] = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.verdict === VERDICT.EXPLOITABLE,
|
||||
);
|
||||
const [notExploitable] = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.verdict === VERDICT.NOT_EXPLOITABLE,
|
||||
);
|
||||
|
||||
describe("IssuesList sample preview", () => {
|
||||
it("sorts sample issues when the findings column is selected", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { sort: "-findings_count" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
const titles = new Set(
|
||||
SAMPLE_ISSUES.map((issue) => issue.attributes.title),
|
||||
);
|
||||
const displayed = screen
|
||||
.getAllByRole("button")
|
||||
.map((button) => button.textContent)
|
||||
.filter((title) => title && titles.has(title));
|
||||
expect(displayed).toEqual(
|
||||
[...SAMPLE_ISSUES]
|
||||
.sort(
|
||||
(a, b) => b.attributes.findings_count - a.attributes.findings_count,
|
||||
)
|
||||
.map((issue) => issue.attributes.title),
|
||||
);
|
||||
});
|
||||
|
||||
it("filters sample issues by verdict without calling the API", async () => {
|
||||
// When
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[verdict]": VERDICT.EXPLOITABLE },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: exploitable.attributes.title,
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText(notExploitable.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
expect(getIssues).not.toHaveBeenCalled();
|
||||
expect(getIssuesOverview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("matches nothing for a scope the demo account is not in", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[cloud]": "gcp" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("No issues match this filter.")).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText(exploitable.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
// The summary is scoped too, not just the rows.
|
||||
expect(screen.getByLabelText("All, 0 issues")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("matches every verdict of a verdict__in list", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: {
|
||||
"filter[verdict__in]": `${VERDICT.EXPLOITABLE},${VERDICT.NOT_EXPLOITABLE}`,
|
||||
},
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: exploitable.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: notExploitable.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
const outsideList = SAMPLE_ISSUES.filter(
|
||||
(issue) =>
|
||||
issue.attributes.verdict !== VERDICT.EXPLOITABLE &&
|
||||
issue.attributes.verdict !== VERDICT.NOT_EXPLOITABLE,
|
||||
);
|
||||
expect(outsideList.length).toBeGreaterThan(0);
|
||||
for (const issue of outsideList) {
|
||||
expect(
|
||||
screen.queryByText(issue.attributes.title),
|
||||
).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("reads a scalar verdict filter as one literal value", async () => {
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: {
|
||||
"filter[verdict]": `${VERDICT.EXPLOITABLE},${VERDICT.NOT_EXPLOITABLE}`,
|
||||
},
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByText("No issues match this filter.")).toBeVisible();
|
||||
});
|
||||
|
||||
it("scopes the rows by severity like the API", async () => {
|
||||
const critical = SAMPLE_ISSUES.filter(
|
||||
(issue) => issue.attributes.severity === "critical",
|
||||
);
|
||||
const other = SAMPLE_ISSUES.find(
|
||||
(issue) => issue.attributes.severity !== "critical",
|
||||
);
|
||||
|
||||
render(
|
||||
await IssuesList({
|
||||
searchParams: { "filter[severity]": "critical" },
|
||||
sample: true,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const issue of critical) {
|
||||
expect(
|
||||
screen.getByRole("button", { name: issue.attributes.title }),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
expect(screen.queryByText(other!.attributes.title)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,14 @@ import { IssuesSummary } from "@/components/issues/issues-summary";
|
||||
import { IssuesTable } from "@/components/issues/issues-table";
|
||||
import { VerdictFilter } from "@/components/issues/verdict-filter";
|
||||
import { ISSUE_SCOPE_KEYS } from "@/lib/issues/scope";
|
||||
import { verdictCounts } from "@/lib/issues/verdicts";
|
||||
import { verdictCounts, VERDICTS } from "@/lib/issues/verdicts";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import {
|
||||
SAMPLE_ISSUES,
|
||||
summarizeSampleIssues,
|
||||
} from "@/lib/sample-preview/sample-issues";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import type { IssueResponse, IssuesOverviewResponse } from "@/types/issues";
|
||||
|
||||
/** Only the filters the issues endpoint understands are forwarded. */
|
||||
const ISSUE_FILTER_KEYS = [
|
||||
@@ -21,14 +27,17 @@ const ISSUE_FILTER_KEYS = [
|
||||
"filter[triage_status]",
|
||||
"filter[triage_status__in]",
|
||||
...ISSUE_SCOPE_KEYS,
|
||||
];
|
||||
] as const;
|
||||
|
||||
export const IssuesList = async ({
|
||||
searchParams,
|
||||
filters: filterControls,
|
||||
providerUids,
|
||||
sample = false,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
/** Renders the sample issues instead of calling the issues API. */
|
||||
sample?: boolean;
|
||||
/** The scope selectors, rendered above the verdict tabs: they scope every tab. */
|
||||
filters?: ReactNode;
|
||||
/** Provider uid by provider id, for the issue panel. */
|
||||
@@ -48,16 +57,20 @@ export const IssuesList = async ({
|
||||
([key]) => key !== "filter[verdict]" && key !== "filter[verdict__in]",
|
||||
),
|
||||
);
|
||||
const [summary, issues] = await Promise.all([
|
||||
getIssuesOverview({ filters: scope }),
|
||||
getIssues({
|
||||
filters,
|
||||
page: Number(searchParams.page) || 1,
|
||||
pageSize: Number(searchParams.pageSize) || 10,
|
||||
sort:
|
||||
typeof searchParams.sort === "string" ? searchParams.sort : undefined,
|
||||
}),
|
||||
]);
|
||||
const [summary, issues] = sample
|
||||
? sampleIssues(filters, scope, searchParams.sort)
|
||||
: await Promise.all([
|
||||
getIssuesOverview({ filters: scope }),
|
||||
getIssues({
|
||||
filters,
|
||||
page: Number(searchParams.page) || 1,
|
||||
pageSize: Number(searchParams.pageSize) || 10,
|
||||
sort:
|
||||
typeof searchParams.sort === "string"
|
||||
? searchParams.sort
|
||||
: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
const attributes = summary?.data?.attributes;
|
||||
// handleApiResponse resolves {error, status} on a 4xx and undefined on a
|
||||
@@ -103,3 +116,74 @@ export const IssuesList = async ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type SampleIssue = (typeof SAMPLE_ISSUES)[number];
|
||||
|
||||
/** The value each forwarded filter matches on a sample issue. */
|
||||
const SAMPLE_FILTER_FIELDS: Record<
|
||||
(typeof ISSUE_FILTER_KEYS)[number],
|
||||
(issue: SampleIssue) => string | undefined
|
||||
> = {
|
||||
"filter[verdict]": (issue) => issue.attributes.verdict,
|
||||
"filter[verdict__in]": (issue) => issue.attributes.verdict,
|
||||
"filter[cloud]": (issue) => issue.attributes.cloud,
|
||||
"filter[pattern]": (issue) => issue.attributes.pattern,
|
||||
"filter[severity]": (issue) => issue.attributes.severity,
|
||||
"filter[provider_type]": (issue) => issue.attributes.cloud,
|
||||
"filter[provider_type__in]": (issue) => issue.attributes.cloud,
|
||||
"filter[provider_alias]": () => SAMPLE_ACCOUNT.alias,
|
||||
"filter[provider_id__in]": (issue) => issue.relationships?.provider?.data?.id,
|
||||
// The sample account belongs to no provider group.
|
||||
"filter[provider_groups__in]": () => undefined,
|
||||
"filter[triage_status]": (issue) => issue.attributes.triage_status,
|
||||
"filter[triage_status__in]": (issue) => issue.attributes.triage_status,
|
||||
};
|
||||
|
||||
const matchesSampleFilters = (
|
||||
issue: SampleIssue,
|
||||
filters: Record<string, string>,
|
||||
) =>
|
||||
Object.entries(filters).every(([key, value]) => {
|
||||
const field =
|
||||
SAMPLE_FILTER_FIELDS[key as keyof typeof SAMPLE_FILTER_FIELDS];
|
||||
const actual = field?.(issue);
|
||||
// Only `__in` filters take a list; the rest match one literal value.
|
||||
const accepted = key.endsWith("__in]") ? value.split(",") : [value];
|
||||
return actual !== undefined && accepted.includes(actual);
|
||||
});
|
||||
|
||||
/** The sample summary and rows, filtered like the API would. */
|
||||
function sampleIssues(
|
||||
filters: Record<string, string>,
|
||||
scope: Record<string, string>,
|
||||
sort: SearchParamsProps["sort"],
|
||||
): [IssuesOverviewResponse, IssueResponse] {
|
||||
const scoped = SAMPLE_ISSUES.filter((issue) =>
|
||||
matchesSampleFilters(issue, scope),
|
||||
);
|
||||
const rows = SAMPLE_ISSUES.filter((issue) =>
|
||||
matchesSampleFilters(issue, filters),
|
||||
);
|
||||
if (typeof sort === "string") {
|
||||
const descending = sort.startsWith("-");
|
||||
const field = descending ? sort.slice(1) : sort;
|
||||
if (field === "verdict_rank" || field === "findings_count") {
|
||||
const value = (issue: SampleIssue) =>
|
||||
field === "findings_count"
|
||||
? issue.attributes.findings_count
|
||||
: VERDICTS.findIndex(({ id }) => id === issue.attributes.verdict);
|
||||
rows.sort((a, b) => (value(a) - value(b)) * (descending ? -1 : 1));
|
||||
}
|
||||
}
|
||||
return [
|
||||
{
|
||||
data: {
|
||||
type: "issues-overview",
|
||||
attributes: summarizeSampleIssues(scoped),
|
||||
},
|
||||
},
|
||||
{
|
||||
data: rows,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { getAllProviders, getAllProviderGroups, notFound } = vi.hoisted(() => ({
|
||||
const {
|
||||
getAllProviders,
|
||||
getAllProviderGroups,
|
||||
getSamplePreviewState,
|
||||
notFound,
|
||||
} = vi.hoisted(() => ({
|
||||
getAllProviders: vi.fn(),
|
||||
getAllProviderGroups: vi.fn(),
|
||||
getSamplePreviewState: vi.fn(),
|
||||
notFound: vi.fn(() => {
|
||||
throw new Error("NOT_FOUND");
|
||||
}),
|
||||
@@ -22,6 +28,12 @@ vi.mock("@/components/shadcn/content-layout", () => ({
|
||||
ContentLayout: () => null,
|
||||
}));
|
||||
vi.mock("./_components/issues-list", () => ({ IssuesList: () => null }));
|
||||
vi.mock("@/components/shared/sample-preview/sample-preview-banner", () => ({
|
||||
SamplePreviewBanner: () => null,
|
||||
}));
|
||||
vi.mock("@/lib/sample-preview/get-sample-preview-state", () => ({
|
||||
getSamplePreviewState,
|
||||
}));
|
||||
|
||||
import Issues from "./page";
|
||||
|
||||
@@ -29,6 +41,7 @@ describe("Issues deployment availability", () => {
|
||||
beforeEach(() => {
|
||||
getAllProviders.mockResolvedValue({ data: [] });
|
||||
getAllProviderGroups.mockResolvedValue({ data: [] });
|
||||
getSamplePreviewState.mockResolvedValue({ active: false });
|
||||
});
|
||||
it("returns not found before loading data in Local Server", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "false");
|
||||
@@ -45,4 +58,15 @@ describe("Issues deployment availability", () => {
|
||||
expect(getAllProviders).toHaveBeenCalledOnce();
|
||||
expect(getAllProviderGroups).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("skips the scope controls' data while the sample is shown", async () => {
|
||||
vi.stubEnv("UI_CLOUD_ENABLED", "true");
|
||||
getSamplePreviewState.mockResolvedValue({
|
||||
active: true,
|
||||
stage: "no_provider",
|
||||
runningScan: null,
|
||||
});
|
||||
await Issues({ searchParams: Promise.resolve({}) });
|
||||
expect(getAllProviders).not.toHaveBeenCalled();
|
||||
expect(getAllProviderGroups).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,20 @@ import { ProviderAccountSelectors } from "@/components/filters/provider-account-
|
||||
import { ProviderGroupSelector } from "@/components/filters/provider-group-selector";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { FeatureFlagRoute } from "@/components/shared/feature-flag-route";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { SAMPLE_ACCOUNT } from "@/lib/sample-preview/sample-dataset";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { IssuesList } from "./_components/issues-list";
|
||||
|
||||
const SAMPLE_PROVIDER_UIDS = {
|
||||
[SAMPLE_ACCOUNT.providerId]: SAMPLE_ACCOUNT.uid,
|
||||
};
|
||||
|
||||
export default async function Issues({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -22,6 +30,26 @@ export default async function Issues({
|
||||
if (!isCloud()) notFound();
|
||||
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const samplePreview = await getSamplePreviewState();
|
||||
if (samplePreview.active) {
|
||||
return (
|
||||
<ContentLayout title="Issues" icon={<ShieldAlert />}>
|
||||
<FeatureFlagRoute flag={FEATURE_FLAG.ISSUES}>
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.ISSUES}
|
||||
/>
|
||||
{/* No scope selectors: the sample account is not one of theirs. */}
|
||||
<IssuesList
|
||||
searchParams={resolvedSearchParams}
|
||||
sample
|
||||
providerUids={SAMPLE_PROVIDER_UIDS}
|
||||
/>
|
||||
</FeatureFlagRoute>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const [providersData, providerGroupsData] = await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRound, Loader2, PlugZap, Save, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import {
|
||||
createLighthouseV2Configuration,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
updateLighthouseV2Configuration,
|
||||
} from "@/app/(prowler)/lighthouse/_actions";
|
||||
import {
|
||||
BEDROCK_AUTH_METHOD,
|
||||
buildCredentialPayload,
|
||||
buildLighthouseV2ConfigFormSchema,
|
||||
buildLighthouseV2ConfigurationInput,
|
||||
@@ -74,6 +75,14 @@ export function LighthouseV2ConfigurationForm({
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const status = getConnectionStatus(configuration);
|
||||
const bedrockAuthMethod = useWatch({
|
||||
control: form.control,
|
||||
name: "bedrockAuthMethod",
|
||||
});
|
||||
// An API key has no instance-role fallback, so the hint only fits access keys.
|
||||
const usesInstanceRoleHint =
|
||||
provider.credentialsOptional &&
|
||||
bedrockAuthMethod !== BEDROCK_AUTH_METHOD.API_KEY;
|
||||
|
||||
const runConnectionTest = async (configurationId: string) => {
|
||||
setTesting(true);
|
||||
@@ -143,7 +152,12 @@ export function LighthouseV2ConfigurationForm({
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset(getFormDefaults(result.data));
|
||||
// Keep the chosen Bedrock auth method only when its credentials were
|
||||
// saved; a blank save keeps the stored ones, whatever method they use.
|
||||
form.reset({
|
||||
...getFormDefaults(result.data),
|
||||
...(credentials ? { bedrockAuthMethod: values.bedrockAuthMethod } : {}),
|
||||
});
|
||||
onConfigurationSaved(result.data);
|
||||
notifyLighthouseV2ConfigurationsChanged();
|
||||
if (shouldTestAfterSave) {
|
||||
@@ -260,14 +274,17 @@ export function LighthouseV2ConfigurationForm({
|
||||
icon={<KeyRound className="size-4" />}
|
||||
title="Credentials"
|
||||
description={
|
||||
provider.credentialsOptional
|
||||
? "Leave the keys blank to use this installation's own AWS role; only the region is needed."
|
||||
usesInstanceRoleHint
|
||||
? configuration
|
||||
? "Leave blank to keep existing credentials, or enter only the region to use this installation's own AWS role."
|
||||
: "Leave the keys blank to use this installation's own AWS role; only the region is needed."
|
||||
: configuration
|
||||
? "Leave blank to keep existing credentials."
|
||||
: "Credentials are required for new configurations."
|
||||
}
|
||||
>
|
||||
<CredentialFields
|
||||
control={form.control}
|
||||
credentialsOptional={provider.credentialsOptional}
|
||||
errors={form.formState.errors}
|
||||
hasStoredCredentials={hasConfiguration}
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import { type useForm } from "react-hook-form";
|
||||
import { Controller, type useForm, useWatch } from "react-hook-form";
|
||||
|
||||
import { type LighthouseV2ConfigFormValues } from "@/app/(prowler)/lighthouse/_lib/config";
|
||||
import {
|
||||
BEDROCK_AUTH_METHOD,
|
||||
type LighthouseV2ConfigFormValues,
|
||||
} from "@/app/(prowler)/lighthouse/_lib/config";
|
||||
import {
|
||||
LIGHTHOUSE_V2_PROVIDER_TYPE,
|
||||
type LighthouseV2ProviderType,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { Field, FieldError, FieldLabel } from "@/components/shadcn/field/field";
|
||||
import { Input } from "@/components/shadcn/input/input";
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
} from "@/components/shadcn/radio-group/radio-group";
|
||||
|
||||
// Stored secrets are never sent back by the API, so the fields would look
|
||||
// empty even when a key exists; the masked placeholder stands in for it.
|
||||
const STORED_SECRET_PLACEHOLDER = "•".repeat(36);
|
||||
|
||||
export function CredentialFields({
|
||||
control,
|
||||
credentialsOptional = false,
|
||||
errors,
|
||||
hasStoredCredentials = false,
|
||||
provider,
|
||||
register,
|
||||
}: {
|
||||
control: ReturnType<typeof useForm<LighthouseV2ConfigFormValues>>["control"];
|
||||
// Keys may be left blank (Bedrock with the installation's own role).
|
||||
credentialsOptional?: boolean;
|
||||
errors: ReturnType<
|
||||
@@ -30,17 +39,50 @@ export function CredentialFields({
|
||||
typeof useForm<LighthouseV2ConfigFormValues>
|
||||
>["register"];
|
||||
}) {
|
||||
// With optional keys a saved configuration may have none, so the masked
|
||||
// placeholder would suggest a key that is not there.
|
||||
const isBedrock = provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK;
|
||||
// The masked placeholder would suggest a key that may not be there: with
|
||||
// optional keys a saved configuration may have none, and for Bedrock the
|
||||
// API does not say whether access keys or an API key are stored.
|
||||
const secretPlaceholder =
|
||||
hasStoredCredentials && !credentialsOptional
|
||||
hasStoredCredentials && !credentialsOptional && !isBedrock
|
||||
? STORED_SECRET_PLACEHOLDER
|
||||
: undefined;
|
||||
const keyLabelSuffix = credentialsOptional ? " (optional)" : "";
|
||||
const bedrockAuthMethod = useWatch({ control, name: "bedrockAuthMethod" });
|
||||
const usesBedrockApiKey =
|
||||
isBedrock && bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY;
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{isBedrock && (
|
||||
<Field>
|
||||
<FieldLabel>Authentication method</FieldLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name="bedrockAuthMethod"
|
||||
render={({ field }) => (
|
||||
<RadioGroup
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
orientation="horizontal"
|
||||
aria-label="Authentication method"
|
||||
>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value={BEDROCK_AUTH_METHOD.ACCESS_KEYS} />
|
||||
AWS access keys
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<RadioGroupItem value={BEDROCK_AUTH_METHOD.API_KEY} />
|
||||
Bedrock API key
|
||||
</label>
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{(provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI ||
|
||||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE) && (
|
||||
provider === LIGHTHOUSE_V2_PROVIDER_TYPE.OPENAI_COMPATIBLE ||
|
||||
usesBedrockApiKey) && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-api-key">API key</FieldLabel>
|
||||
<Input
|
||||
@@ -72,41 +114,45 @@ export function CredentialFields({
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK && (
|
||||
{isBedrock && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-access-key">
|
||||
AWS access key ID{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-access-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={secretPlaceholder}
|
||||
aria-invalid={Boolean(errors.awsAccessKeyId)}
|
||||
{...register("awsAccessKeyId")}
|
||||
/>
|
||||
{errors.awsAccessKeyId?.message && (
|
||||
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
{!usesBedrockApiKey && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-access-key">
|
||||
AWS access key ID{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-access-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={secretPlaceholder}
|
||||
aria-invalid={Boolean(errors.awsAccessKeyId)}
|
||||
{...register("awsAccessKeyId")}
|
||||
/>
|
||||
{errors.awsAccessKeyId?.message && (
|
||||
<FieldError>{errors.awsAccessKeyId.message}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-secret-key">
|
||||
AWS secret access key{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-secret-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={secretPlaceholder}
|
||||
aria-invalid={Boolean(errors.awsSecretAccessKey)}
|
||||
{...register("awsSecretAccessKey")}
|
||||
/>
|
||||
{errors.awsSecretAccessKey?.message && (
|
||||
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="lighthouse-v2-secret-key">
|
||||
AWS secret access key{keyLabelSuffix}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id="lighthouse-v2-secret-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={secretPlaceholder}
|
||||
aria-invalid={Boolean(errors.awsSecretAccessKey)}
|
||||
{...register("awsSecretAccessKey")}
|
||||
/>
|
||||
{errors.awsSecretAccessKey?.message && (
|
||||
<FieldError>{errors.awsSecretAccessKey.message}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field className="md:col-span-2">
|
||||
<FieldLabel htmlFor="lighthouse-v2-region">AWS region</FieldLabel>
|
||||
|
||||
@@ -284,14 +284,14 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
maskedKey,
|
||||
);
|
||||
|
||||
// But not for Bedrock: the API does not say whether access keys or an
|
||||
// API key are stored, so masking either set could claim the wrong one
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
expect(screen.getByLabelText("AWS access key ID")).toHaveAttribute(
|
||||
expect(screen.getByLabelText("AWS access key ID")).not.toHaveAttribute(
|
||||
"placeholder",
|
||||
maskedKey,
|
||||
);
|
||||
expect(screen.getByLabelText("AWS secret access key")).toHaveAttribute(
|
||||
expect(screen.getByLabelText("AWS secret access key")).not.toHaveAttribute(
|
||||
"placeholder",
|
||||
maskedKey,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -330,6 +330,44 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
expect(createConfigurationMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not hint at a stored Bedrock API key it cannot know about", async () => {
|
||||
// Given: Bedrock has a stored configuration of unknown credential type
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByLabelText("API key")).not.toHaveAttribute("placeholder");
|
||||
});
|
||||
|
||||
it("falls back to access keys after saving Bedrock without new credentials", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
|
||||
|
||||
// When: saved with blank fields, so the stored credentials stay
|
||||
await user.click(
|
||||
within(
|
||||
screen.getByRole("region", { name: "Lighthouse AI settings" }),
|
||||
).getByRole("button", { name: /^Save$/i }),
|
||||
);
|
||||
|
||||
// Then: the form no longer claims an API key that was never saved
|
||||
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
|
||||
expect(updateConfigurationMock.mock.calls[0]?.[1]).not.toHaveProperty(
|
||||
"credentials",
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole("radio", { name: "AWS access keys" }),
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it("shows Bedrock access key, secret key, and region fields", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
@@ -344,6 +382,42 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
expect(screen.getByLabelText("AWS region")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves Bedrock with an API key instead of access keys", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
updateConfigurationMock.mockResolvedValue({ data: configurations[1] });
|
||||
testConnectionMock.mockResolvedValue({ data: configurations[1] });
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
|
||||
// When: the API key method is chosen
|
||||
await user.click(screen.getByRole("radio", { name: "Bedrock API key" }));
|
||||
|
||||
// Then: the access key fields give way to the API key field
|
||||
expect(
|
||||
screen.queryByLabelText("AWS access key ID"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("AWS secret access key"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// When
|
||||
await user.type(screen.getByLabelText("API key"), "ABSKlongterm");
|
||||
await user.type(screen.getByLabelText("AWS region"), "us-east-1");
|
||||
await user.click(
|
||||
within(
|
||||
screen.getByRole("region", { name: "Lighthouse AI settings" }),
|
||||
).getByRole("button", { name: /^Save$/i }),
|
||||
);
|
||||
|
||||
// Then
|
||||
await waitFor(() => expect(updateConfigurationMock).toHaveBeenCalled());
|
||||
expect(updateConfigurationMock.mock.calls[0]?.[1].credentials).toEqual({
|
||||
api_key: "ABSKlongterm",
|
||||
aws_region_name: "us-east-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("changes a Bedrock region without keys when the installation role is allowed", async () => {
|
||||
// Given: the API reports Bedrock needs no credentials (Private Cloud)
|
||||
const user = userEvent.setup();
|
||||
@@ -358,6 +432,13 @@ describe("LighthouseV2ConfigPage", () => {
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: /Amazon Bedrock/i }));
|
||||
|
||||
// Then: blank fields keep the stored credentials, so the hint says so
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Leave blank to keep existing credentials, or enter only the region to use this installation's own AWS role.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// When: only the region is entered
|
||||
expect(
|
||||
screen.getByLabelText("AWS access key ID (optional)"),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { LIGHTHOUSE_V2_PROVIDER_TYPE } from "@/app/(prowler)/lighthouse/_types";
|
||||
|
||||
import {
|
||||
BEDROCK_AUTH_METHOD,
|
||||
buildCredentialPayload,
|
||||
buildLighthouseV2ConfigFormSchema,
|
||||
EMPTY_FORM_VALUES,
|
||||
@@ -136,4 +137,101 @@ describe("Bedrock configuration form", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when authenticating with a Bedrock API key", () => {
|
||||
const apiKeyValues = (overrides: Partial<LighthouseV2ConfigFormValues>) =>
|
||||
values({ bedrockAuthMethod: BEDROCK_AUTH_METHOD.API_KEY, ...overrides });
|
||||
|
||||
it("should require the API key and the region for a new configuration", () => {
|
||||
// Given / When / Then
|
||||
expect(errorPaths(false, apiKeyValues({}))).toEqual([
|
||||
"apiKey",
|
||||
"awsRegionName",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should still require the API key where the installation role is allowed", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(
|
||||
false,
|
||||
apiKeyValues({ awsRegionName: "us-east-1" }),
|
||||
OPTIONAL,
|
||||
),
|
||||
).toEqual(["apiKey"]);
|
||||
});
|
||||
|
||||
it("should require the region to rotate the key on an existing configuration", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(true, apiKeyValues({ apiKey: "ABSKlongterm" })),
|
||||
).toEqual(["awsRegionName"]);
|
||||
});
|
||||
|
||||
it.each(["ABSKlongterm", "bedrock-api-key-shortterm"])(
|
||||
"should accept the %s key format",
|
||||
(apiKey) => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(
|
||||
false,
|
||||
apiKeyValues({ apiKey, awsRegionName: "us-east-1" }),
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("should reject a key that is not a Bedrock API key", () => {
|
||||
// Given / When / Then
|
||||
expect(
|
||||
errorPaths(
|
||||
false,
|
||||
apiKeyValues({ apiKey: "sk-proj-123", awsRegionName: "us-east-1" }),
|
||||
),
|
||||
).toEqual(["apiKey"]);
|
||||
});
|
||||
|
||||
it("should send the key and region, without leftover access keys", () => {
|
||||
// Given: access keys typed before switching to the API key method
|
||||
const formValues = apiKeyValues({
|
||||
apiKey: " ABSKlongterm ",
|
||||
awsAccessKeyId: "AKIA",
|
||||
awsSecretAccessKey: "secret",
|
||||
awsRegionName: "us-east-1",
|
||||
});
|
||||
|
||||
// When / Then
|
||||
expect(buildCredentialPayload(BEDROCK, formValues, false)).toEqual({
|
||||
api_key: "ABSKlongterm",
|
||||
aws_region_name: "us-east-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep the stored configuration when nothing is entered", () => {
|
||||
// Given / When / Then
|
||||
expect(errorPaths(true, apiKeyValues({}))).toEqual([]);
|
||||
expect(
|
||||
buildCredentialPayload(BEDROCK, apiKeyValues({}), true),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when authenticating with access keys", () => {
|
||||
it("should ignore an API key left over from the other method", () => {
|
||||
// Given
|
||||
const formValues = values({
|
||||
apiKey: "ABSKlongterm",
|
||||
awsAccessKeyId: "AKIA",
|
||||
awsSecretAccessKey: "secret",
|
||||
awsRegionName: "us-east-1",
|
||||
});
|
||||
|
||||
// When / Then
|
||||
expect(buildCredentialPayload(BEDROCK, formValues, false)).toEqual({
|
||||
aws_access_key_id: "AKIA",
|
||||
aws_secret_access_key: "secret",
|
||||
aws_region_name: "us-east-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,8 +37,20 @@ export interface FeedbackState {
|
||||
variant: FeedbackVariant;
|
||||
}
|
||||
|
||||
export const BEDROCK_AUTH_METHOD = {
|
||||
ACCESS_KEYS: "access_keys",
|
||||
API_KEY: "api_key",
|
||||
} as const;
|
||||
|
||||
export type BedrockAuthMethod =
|
||||
(typeof BEDROCK_AUTH_METHOD)[keyof typeof BEDROCK_AUTH_METHOD];
|
||||
|
||||
// Long-term keys start with "ABSK", short-term ones with "bedrock-api-key-".
|
||||
const BEDROCK_API_KEY_PATTERN = /^(ABSK|bedrock-api-key-)\S+$/;
|
||||
|
||||
const lighthouseV2ConfigFormSchemaBase = z.object({
|
||||
apiKey: z.string(),
|
||||
bedrockAuthMethod: z.enum(BEDROCK_AUTH_METHOD),
|
||||
awsAccessKeyId: z.string(),
|
||||
awsSecretAccessKey: z.string(),
|
||||
awsRegionName: z.string(),
|
||||
@@ -51,6 +63,7 @@ export type LighthouseV2ConfigFormValues = z.infer<
|
||||
|
||||
export const EMPTY_FORM_VALUES: LighthouseV2ConfigFormValues = {
|
||||
apiKey: "",
|
||||
bedrockAuthMethod: BEDROCK_AUTH_METHOD.ACCESS_KEYS,
|
||||
awsAccessKeyId: "",
|
||||
awsSecretAccessKey: "",
|
||||
awsRegionName: "",
|
||||
@@ -117,6 +130,34 @@ export function buildLighthouseV2ConfigFormSchema(
|
||||
|
||||
if (provider !== LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) return;
|
||||
|
||||
if (data.bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY) {
|
||||
const regionName = data.awsRegionName.trim();
|
||||
if (hasConfiguration && !apiKey && !regionName) return;
|
||||
|
||||
if (!apiKey) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Bedrock API key is required.",
|
||||
path: ["apiKey"],
|
||||
});
|
||||
} else if (!BEDROCK_API_KEY_PATTERN.test(apiKey)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"Bedrock API keys start with 'ABSK' (long-term) or 'bedrock-api-key-' (short-term).",
|
||||
path: ["apiKey"],
|
||||
});
|
||||
}
|
||||
if (!regionName) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "AWS region is required.",
|
||||
path: ["awsRegionName"],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (credentialsOptional) {
|
||||
// The installation's own role: a region alone is enough, and keys,
|
||||
// when given, still have to come as a pair.
|
||||
@@ -190,6 +231,14 @@ export function buildCredentialPayload(
|
||||
{ credentialsOptional = false }: LighthouseV2ProviderCapabilities = {},
|
||||
): LighthouseV2Credentials | undefined {
|
||||
if (provider === LIGHTHOUSE_V2_PROVIDER_TYPE.BEDROCK) {
|
||||
if (values.bedrockAuthMethod === BEDROCK_AUTH_METHOD.API_KEY) {
|
||||
const apiKey = values.apiKey.trim();
|
||||
const regionName = values.awsRegionName.trim();
|
||||
if (hasConfiguration && !apiKey && !regionName) return undefined;
|
||||
|
||||
return { api_key: apiKey, aws_region_name: regionName };
|
||||
}
|
||||
|
||||
const hasBedrockCredentials =
|
||||
Boolean(values.awsAccessKeyId.trim()) ||
|
||||
Boolean(values.awsSecretAccessKey.trim()) ||
|
||||
|
||||
@@ -38,8 +38,12 @@ describe("Overview page", () => {
|
||||
|
||||
it("uses the responsive container breakpoint for the primary metric row", () => {
|
||||
// Given
|
||||
const metricRowClassName = source.match(
|
||||
/<div className="([^"]+)">\s*<Suspense fallback={<ThreatScoreSkeleton \/>}>/,
|
||||
const gridSource = readFileSync(
|
||||
path.join(currentDir, "_overview/_components/overview-grid.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const metricRowClassName = gridSource.match(
|
||||
/<div className="([^"]+)">\s*\{threatScore\}/,
|
||||
)?.[1];
|
||||
|
||||
// When
|
||||
|
||||
+89
-71
@@ -12,12 +12,17 @@ import {
|
||||
} from "@/components/layout/app-sidebar";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
import { FeatureFlagGate } from "@/components/shared/feature-flag-gate";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { FEATURE_FLAG } from "@/lib/feature-flags";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { getSampleComplianceTotals } from "@/lib/sample-preview/sample-compliance-requirements";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
|
||||
import { OverviewBanner } from "./_overview/_components/overview-banner";
|
||||
import { OverviewGrid } from "./_overview/_components/overview-grid";
|
||||
import { OverviewProviderContext } from "./_overview/_components/overview-provider-context";
|
||||
import { getLighthouseOverviewBannerHref } from "./_overview/_lib/lighthouse-banner";
|
||||
import { OVERVIEW_BANNER_VARIANT } from "./_overview/_lib/overview-banner";
|
||||
@@ -42,6 +47,7 @@ import {
|
||||
RiskSeverityChartSkeleton,
|
||||
RiskSeverityChartSSR,
|
||||
} from "./_overview/risk-severity";
|
||||
import { OverviewSamplePreview } from "./_overview/sample-preview/overview-sample-preview";
|
||||
import {
|
||||
FindingSeverityOverTimeSkeleton,
|
||||
FindingSeverityOverTimeSSR,
|
||||
@@ -60,12 +66,35 @@ export default async function Home({
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const [providersData, providerGroupsData, lighthouseBannerHref] =
|
||||
await Promise.all([
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
|
||||
]);
|
||||
// Resolved alongside the page data, so tenants with scans wait no longer.
|
||||
const [
|
||||
samplePreview,
|
||||
providersData,
|
||||
providerGroupsData,
|
||||
lighthouseBannerHref,
|
||||
] = await Promise.all([
|
||||
getSamplePreviewState(),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
getLighthouseOverviewBannerHref(isCloud(), getLighthouseV2Configurations),
|
||||
]);
|
||||
|
||||
// No completed scan yet: the demo account stands in for the tenant's data.
|
||||
if (samplePreview.active) {
|
||||
return (
|
||||
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
|
||||
<AppSidebarModeSync mode={APP_SIDEBAR_MODE.BROWSE} />
|
||||
<SamplePreviewBanner
|
||||
state={samplePreview}
|
||||
page={SAMPLE_PREVIEW_PAGE.OVERVIEW}
|
||||
/>
|
||||
<OverviewSamplePreview
|
||||
showIssues={isCloud()}
|
||||
complianceWatchlist={await getSampleComplianceTotals()}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentLayout title="Overview" icon={<SquareChartGantt />}>
|
||||
@@ -99,82 +128,71 @@ export default async function Home({
|
||||
<ProviderGroupSelector groups={providerGroupsData?.data ?? []} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:flex-wrap lg:items-stretch">
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
{isCloud() && (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<ExploitabilityChartSkeleton />}>
|
||||
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
)}
|
||||
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
|
||||
the line's max-content rather than to its own 312px, and a card wider
|
||||
than the column loses its right border and padding to
|
||||
`overflow-hidden`. It never wrapped anyway. */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
{/* Sized to its own list, unlike the service card below: this one is
|
||||
as long as the organization made its watchlist, so growing it to
|
||||
fill the column puts a hole under two pinned frameworks. */}
|
||||
{/* `h-full` only where the cards sit side by side and share a row
|
||||
height. In the column it would hand the card the stretched
|
||||
wrapper's height and undo the fit-to-content sizing. */}
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts column: Attack Surface, Top issues, Findings Over Time */}
|
||||
<div className="flex flex-1 flex-col gap-6">
|
||||
<OverviewGrid
|
||||
threatScore={
|
||||
<Suspense fallback={<ThreatScoreSkeleton />}>
|
||||
<ThreatScoreSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
exploitability={
|
||||
isCloud() ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<ExploitabilityChartSkeleton />}>
|
||||
<ExploitabilityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
) : undefined
|
||||
}
|
||||
checkFindings={
|
||||
<Suspense fallback={<StatusChartSkeleton />}>
|
||||
<CheckFindingsSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
riskSeverity={
|
||||
<Suspense fallback={<RiskSeverityChartSkeleton />}>
|
||||
<RiskSeverityChartSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
resourcesInventory={
|
||||
<Suspense fallback={<ResourcesInventorySkeleton />}>
|
||||
<ResourcesInventorySSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
complianceWatchlist={
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
serviceWatchlist={
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ServiceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
attackSurface={
|
||||
<Suspense fallback={<AttackSurfaceSkeleton />}>
|
||||
<AttackSurfaceSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
{isCloud() && (
|
||||
}
|
||||
topIssues={
|
||||
isCloud() ? (
|
||||
<FeatureFlagGate flag={FEATURE_FLAG.ISSUES}>
|
||||
<Suspense fallback={<TopIssuesSkeleton />}>
|
||||
<TopIssuesSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</FeatureFlagGate>
|
||||
)}
|
||||
) : undefined
|
||||
}
|
||||
severityOverTime={
|
||||
<Suspense fallback={<FindingSeverityOverTimeSkeleton />}>
|
||||
<FindingSeverityOverTimeSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
</div>
|
||||
}
|
||||
graphsTabs={
|
||||
<Suspense fallback={<RiskPipelineViewSkeleton />}>
|
||||
<GraphsTabsWrapper searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { listScanConfigurations } from "@/actions/scan-configurations";
|
||||
import { ProvidersAccountsView } from "@/components/providers/providers-accounts-view";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { SearchParamsProps } from "@/types";
|
||||
import {
|
||||
@@ -33,17 +34,19 @@ export const ProvidersTabContent = async ({
|
||||
searchParams: SearchParamsProps;
|
||||
}) => {
|
||||
const isCloudEnvironment = isCloud();
|
||||
const [providersView, scanConfigsState] = await Promise.all([
|
||||
const [providersView, scanConfigsState, samplePreview] = await Promise.all([
|
||||
loadProvidersAccountsViewData({
|
||||
searchParams,
|
||||
isCloud: isCloudEnvironment,
|
||||
}),
|
||||
loadScanConfigs(isCloudEnvironment),
|
||||
getSamplePreviewState(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ProvidersAccountsView
|
||||
isCloud={isCloudEnvironment}
|
||||
samplePreviewActive={isCloudEnvironment && samplePreview.active}
|
||||
filters={providersView.filters}
|
||||
providers={providersView.providers}
|
||||
providerGroups={providersView.providerGroups}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ResourcesFilters } from "@/components/resources/resources-filters";
|
||||
import { ResourcesTableWithSelection } from "@/components/resources/table";
|
||||
import { SamplePreviewBanner } from "@/components/shared/sample-preview/sample-preview-banner";
|
||||
import { FilterTransitionWrapper } from "@/contexts";
|
||||
import { SAMPLE_FILTER_PROVIDER } from "@/lib/sample-preview/sample-findings";
|
||||
import { SAMPLE_PREVIEW_PAGE } from "@/lib/sample-preview/sample-preview-events";
|
||||
import type { SamplePreviewActive } from "@/lib/sample-preview/sample-preview-state";
|
||||
import {
|
||||
filterSampleResources,
|
||||
getSampleResourceRow,
|
||||
SAMPLE_RESOURCE_FILTER_OPTIONS,
|
||||
} from "@/lib/sample-preview/sample-resources";
|
||||
import type { SearchParamsProps } from "@/types";
|
||||
|
||||
interface ResourcesSamplePreviewProps {
|
||||
state: SamplePreviewActive;
|
||||
searchParams: SearchParamsProps;
|
||||
}
|
||||
|
||||
/** Sample resources with the real filter bar, table and resource drawer. */
|
||||
export function ResourcesSamplePreview({
|
||||
state,
|
||||
searchParams,
|
||||
}: ResourcesSamplePreviewProps) {
|
||||
const { data, metadata } = filterSampleResources(searchParams);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SamplePreviewBanner state={state} page={SAMPLE_PREVIEW_PAGE.RESOURCES} />
|
||||
<FilterTransitionWrapper>
|
||||
<div className="mb-6">
|
||||
<ResourcesFilters
|
||||
providers={[SAMPLE_FILTER_PROVIDER]}
|
||||
providerGroups={[]}
|
||||
uniqueRegions={SAMPLE_RESOURCE_FILTER_OPTIONS.regions}
|
||||
uniqueServices={SAMPLE_RESOURCE_FILTER_OPTIONS.services}
|
||||
uniqueResourceTypes={SAMPLE_RESOURCE_FILTER_OPTIONS.types}
|
||||
uniqueGroups={SAMPLE_RESOURCE_FILTER_OPTIONS.groups}
|
||||
/>
|
||||
</div>
|
||||
<ResourcesTableWithSelection
|
||||
data={data}
|
||||
metadata={metadata}
|
||||
// A copied resource link reopens its drawer, as on the real page.
|
||||
initialResource={getSampleResourceRow(
|
||||
searchParams.resourceId?.toString(),
|
||||
)}
|
||||
contributeLighthouseContext={false}
|
||||
/>
|
||||
</FilterTransitionWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,14 +22,31 @@ import {
|
||||
hasDateOrScanFilter,
|
||||
replaceFieldKey,
|
||||
} from "@/lib";
|
||||
import { getSamplePreviewState } from "@/lib/sample-preview/get-sample-preview-state";
|
||||
import { ResourceProps, SearchParamsProps } from "@/types";
|
||||
|
||||
import { ResourcesSamplePreview } from "./_components/resources-sample-preview";
|
||||
|
||||
export default async function Resources({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParamsProps>;
|
||||
}) {
|
||||
const resolvedSearchParams = await searchParams;
|
||||
|
||||
// No completed scan yet: the demo account stands in for the tenant's resources.
|
||||
const preview = await getSamplePreviewState();
|
||||
if (preview.active) {
|
||||
return (
|
||||
<ContentLayout title="Resources" icon={<Warehouse />}>
|
||||
<ResourcesSamplePreview
|
||||
state={preview}
|
||||
searchParams={resolvedSearchParams}
|
||||
/>
|
||||
</ContentLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const { encodedSort } = extractSortAndKey(resolvedSearchParams);
|
||||
const { filters, query } = extractFiltersAndQuery(resolvedSearchParams);
|
||||
const outputFilters = replaceFieldKey(filters, "inserted_at", "updated_at");
|
||||
|
||||
@@ -52,6 +52,8 @@ interface ComplianceCardProps {
|
||||
watchlistAction?: ReactNode;
|
||||
/** Prowler Cloud tenants without a paid plan cannot download reports. */
|
||||
subscriptionOnly?: boolean;
|
||||
/** Hides report downloads and watchlist changes, e.g. for sample scans. */
|
||||
hideActions?: boolean;
|
||||
}
|
||||
|
||||
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
@@ -65,6 +67,7 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
isLatestCisForProvider = false,
|
||||
watchlistAction,
|
||||
subscriptionOnly = false,
|
||||
hideActions = false,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -159,28 +162,30 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
</div>
|
||||
</CardContent>
|
||||
</button>
|
||||
<CardAction
|
||||
className="absolute top-2 right-2 z-10 flex items-center gap-1"
|
||||
role="group"
|
||||
aria-label="Compliance actions"
|
||||
>
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
orientation="column"
|
||||
buttonWidth="icon"
|
||||
presentation="dropdown"
|
||||
scanId={scanId}
|
||||
complianceId={complianceId}
|
||||
reportType={getReportTypeForCompliance(
|
||||
title,
|
||||
complianceId,
|
||||
isLatestCisForProvider,
|
||||
)}
|
||||
disabled={hasRegionFilter}
|
||||
subscriptionOnly={subscriptionOnly}
|
||||
/>
|
||||
{watchlistAction}
|
||||
</CardAction>
|
||||
{!hideActions && (
|
||||
<CardAction
|
||||
className="absolute top-2 right-2 z-10 flex items-center gap-1"
|
||||
role="group"
|
||||
aria-label="Compliance actions"
|
||||
>
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
orientation="column"
|
||||
buttonWidth="icon"
|
||||
presentation="dropdown"
|
||||
scanId={scanId}
|
||||
complianceId={complianceId}
|
||||
reportType={getReportTypeForCompliance(
|
||||
title,
|
||||
complianceId,
|
||||
isLatestCisForProvider,
|
||||
)}
|
||||
disabled={hasRegionFilter}
|
||||
subscriptionOnly={subscriptionOnly}
|
||||
/>
|
||||
{watchlistAction}
|
||||
</CardAction>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
|
||||
/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the
|
||||
@@ -33,6 +37,7 @@ export function ComplianceFrameworkChip({
|
||||
}: ComplianceFrameworkChipProps) {
|
||||
const icon = getComplianceIcon(framework.complianceId);
|
||||
const label = complianceFrameworkLabel(framework);
|
||||
const sample = isSampleId(framework.id);
|
||||
const content = icon ? (
|
||||
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
@@ -50,7 +55,7 @@ export function ComplianceFrameworkChip({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable ? (
|
||||
{isNavigable && !sample ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={icon ? "bare" : "outline"}
|
||||
@@ -68,7 +73,9 @@ export function ComplianceFrameworkChip({
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{sample ? `${label}. ${SAMPLE_DATA_READ_ONLY_MESSAGE}` : label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ interface ThreatScoreBadgeProps {
|
||||
sectionScores?: SectionScores;
|
||||
/** Prowler Cloud tenants without a paid plan cannot download reports. */
|
||||
subscriptionOnly?: boolean;
|
||||
/** Display scores without detail navigation or report downloads. */
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export const ThreatScoreBadge = ({
|
||||
@@ -46,7 +48,9 @@ export const ThreatScoreBadge = ({
|
||||
provider,
|
||||
sectionScores,
|
||||
subscriptionOnly = false,
|
||||
readOnly = false,
|
||||
}: ThreatScoreBadgeProps) => {
|
||||
const DetailContainer = readOnly ? "div" : "button";
|
||||
const router = useRouter();
|
||||
const runReportDownload = useReportDownload(subscriptionOnly);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -117,11 +121,11 @@ export const ThreatScoreBadge = ({
|
||||
return (
|
||||
<Card variant="base" padding="md" className="relative gap-4">
|
||||
<CardContent className="flex flex-col gap-4 p-0 pr-14 lg:flex-row lg:items-start lg:gap-6">
|
||||
{/* Clickable ThreatScore button */}
|
||||
<button
|
||||
className="border-border-neutral-secondary bg-bg-neutral-tertiary hover:border-border-neutral-primary hover:bg-bg-neutral-secondary flex shrink-0 cursor-pointer flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors lg:pr-3"
|
||||
onClick={handleCardClick}
|
||||
type="button"
|
||||
{/* ThreatScore summary, with detail navigation when available. */}
|
||||
<DetailContainer
|
||||
className="border-border-neutral-secondary bg-bg-neutral-tertiary enabled:hover:border-border-neutral-primary enabled:hover:bg-bg-neutral-secondary flex shrink-0 flex-row items-center justify-between gap-4 rounded-xl border p-3 pr-12 text-left transition-colors enabled:cursor-pointer lg:pr-3"
|
||||
onClick={readOnly ? undefined : handleCardClick}
|
||||
type={readOnly ? undefined : "button"}
|
||||
>
|
||||
<ThreatScoreLogo />
|
||||
|
||||
@@ -136,24 +140,29 @@ export const ThreatScoreBadge = ({
|
||||
indicatorClassName={getScoreIndicatorClass(getScoreColor(score))}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</DetailContainer>
|
||||
|
||||
{/* Pillar breakdown — always visible, in canonical order */}
|
||||
{pillars.length > 0 && (
|
||||
<div className="border-border-neutral-secondary flex-1 space-y-2 border-t pt-3 lg:border-t-0 lg:border-l lg:pt-0 lg:pl-6">
|
||||
{pillars.map(({ name, score: sectionScore, hasData }) => (
|
||||
<button
|
||||
<DetailContainer
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => hasData && handlePillarClick(name)}
|
||||
disabled={!hasData}
|
||||
aria-disabled={!hasData}
|
||||
aria-label={
|
||||
hasData
|
||||
? `Open ${name} details`
|
||||
: `${name} (no data for this scan)`
|
||||
type={readOnly ? undefined : "button"}
|
||||
onClick={
|
||||
readOnly
|
||||
? undefined
|
||||
: () => hasData && handlePillarClick(name)
|
||||
}
|
||||
className="hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
|
||||
disabled={readOnly ? undefined : !hasData}
|
||||
aria-label={
|
||||
readOnly
|
||||
? undefined
|
||||
: hasData
|
||||
? `Open ${name} details`
|
||||
: `${name} (no data for this scan)`
|
||||
}
|
||||
className="enabled:hover:bg-bg-neutral-secondary focus-visible:ring-border-neutral-primary -mx-1 flex w-full items-center gap-2 rounded-md px-1 py-0.5 text-left text-xs transition-colors focus:outline-none focus-visible:ring-2 enabled:cursor-pointer disabled:cursor-default disabled:opacity-60"
|
||||
>
|
||||
<span className="text-text-neutral-secondary w-1/3 min-w-0 shrink-0 truncate lg:w-1/4">
|
||||
{name}
|
||||
@@ -175,38 +184,40 @@ export const ThreatScoreBadge = ({
|
||||
>
|
||||
{hasData ? `${sectionScore.toFixed(1)}%` : "—"}
|
||||
</span>
|
||||
</button>
|
||||
</DetailContainer>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{/* ActionDropdown for downloads — top-right */}
|
||||
<div className="absolute top-3 right-4">
|
||||
<ActionDropdown
|
||||
variant="bordered"
|
||||
ariaLabel="Open compliance export actions"
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
<FileTextIcon
|
||||
className={isDownloadingCsv ? "animate-download-icon" : ""}
|
||||
/>
|
||||
}
|
||||
label="Download CSV report"
|
||||
onSelect={handleDownloadCsv}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
<DownloadIcon
|
||||
className={isDownloadingPdf ? "animate-download-icon" : ""}
|
||||
/>
|
||||
}
|
||||
label="Download PDF report"
|
||||
onSelect={handleDownloadPdf}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className="absolute top-3 right-4">
|
||||
<ActionDropdown
|
||||
variant="bordered"
|
||||
ariaLabel="Open compliance export actions"
|
||||
>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
<FileTextIcon
|
||||
className={isDownloadingCsv ? "animate-download-icon" : ""}
|
||||
/>
|
||||
}
|
||||
label="Download CSV report"
|
||||
onSelect={handleDownloadCsv}
|
||||
/>
|
||||
<ActionDropdownItem
|
||||
icon={
|
||||
<DownloadIcon
|
||||
className={isDownloadingPdf ? "animate-download-icon" : ""}
|
||||
/>
|
||||
}
|
||||
label="Download PDF report"
|
||||
onSelect={handleDownloadPdf}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SAMPLE_DATA_READ_ONLY_MESSAGE } from "@/lib/sample-preview/sample-guards";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks to avoid deep dependency chains
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -44,6 +46,32 @@ describe("FloatingSelectionActions — onBeforeOpen error handling", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ selectedFindingIds: ["sample-finding-1"] },
|
||||
{
|
||||
selectedFindingIds: ["s3_bucket_public_access"],
|
||||
disabledReason: SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
},
|
||||
])(
|
||||
"blocks sample selection actions before resolving IDs: %j",
|
||||
async (props) => {
|
||||
const onBeforeOpen = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FloatingSelectionActions
|
||||
selectedCount={1}
|
||||
{...props}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>,
|
||||
);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("title", SAMPLE_DATA_READ_ONLY_MESSAGE);
|
||||
await user.click(button);
|
||||
expect(onBeforeOpen).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
|
||||
// Given — onBeforeOpen always throws
|
||||
const onBeforeOpen = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown/action-dropdown";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
|
||||
|
||||
import { JiraDispatchActionItem } from "./jira-dispatch-action-item";
|
||||
@@ -27,6 +31,7 @@ interface FloatingSelectionActionsBaseProps {
|
||||
label?: string;
|
||||
/** Custom mute action label. Defaults to "Mute". */
|
||||
muteLabel?: string;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
type FloatingSelectionActionsProps = FloatingSelectionActionsBaseProps &
|
||||
@@ -51,7 +56,13 @@ export function FloatingSelectionActions({
|
||||
muteLabel = "Mute",
|
||||
jiraPayload,
|
||||
jiraLabel,
|
||||
disabledReason,
|
||||
}: FloatingSelectionActionsProps) {
|
||||
const reason =
|
||||
disabledReason ??
|
||||
(selectedFindingIds.some(isSampleId)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
@@ -75,6 +86,7 @@ export function FloatingSelectionActions({
|
||||
};
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
if (reason) return;
|
||||
if (onBeforeOpen) {
|
||||
setResolvedIds([]);
|
||||
setMutePreparationError(null);
|
||||
@@ -146,17 +158,21 @@ export function FloatingSelectionActions({
|
||||
icon={<VolumeX />}
|
||||
label={muteLabel}
|
||||
aria-label={muteLabel}
|
||||
disabled={Boolean(reason)}
|
||||
disabledTooltip={reason}
|
||||
onSelect={() => void handleMuteClick()}
|
||||
/>
|
||||
<JiraDispatchActionItem
|
||||
label={jiraLabel}
|
||||
payload={jiraPayload}
|
||||
disabledReason={reason}
|
||||
/>
|
||||
</ActionDropdown>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => void handleMuteClick()}
|
||||
disabled={isResolving}
|
||||
disabled={isResolving || Boolean(reason)}
|
||||
title={reason}
|
||||
size="lg"
|
||||
>
|
||||
{isResolving ? (
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
PROWLER_CLOUD_ONLY_TOOLTIP,
|
||||
} from "@/lib/deployment";
|
||||
import { getJiraDispatchActionState } from "@/lib/jira-dispatch-action";
|
||||
import { getJiraSelectionBatches } from "@/lib/jira-dispatch-selection";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { useCloudUpgradeStore, useJiraDispatchStore } from "@/store";
|
||||
import { CLOUD_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
|
||||
@@ -14,11 +19,13 @@ import type { JiraDispatchModalPayload } from "@/types/jira-dispatch";
|
||||
interface JiraDispatchActionItemProps {
|
||||
label: string;
|
||||
payload: JiraDispatchModalPayload | null | undefined;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
export const JiraDispatchActionItem = ({
|
||||
label,
|
||||
payload,
|
||||
disabledReason,
|
||||
}: JiraDispatchActionItemProps) => {
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
(state) => state.openCloudUpgrade,
|
||||
@@ -29,12 +36,21 @@ export const JiraDispatchActionItem = ({
|
||||
|
||||
if (!payload) return null;
|
||||
|
||||
const reason =
|
||||
disabledReason ??
|
||||
(getJiraSelectionBatches(payload.selection).some((batch) =>
|
||||
batch.targetIds.some(isSampleId),
|
||||
)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined);
|
||||
|
||||
const { requiresUpgrade } = getJiraDispatchActionState(
|
||||
payload,
|
||||
isGroupedJiraDispatchEnabled(),
|
||||
);
|
||||
|
||||
const handleSelect = () => {
|
||||
if (reason) return;
|
||||
if (requiresUpgrade) {
|
||||
openCloudUpgrade(CLOUD_UPGRADE_FEATURE.JIRA_DISPATCH);
|
||||
return;
|
||||
@@ -48,7 +64,11 @@ export const JiraDispatchActionItem = ({
|
||||
icon={<JiraIcon size={20} />}
|
||||
label={label}
|
||||
aria-label={label}
|
||||
tooltip={requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined}
|
||||
disabled={Boolean(reason)}
|
||||
disabledTooltip={reason}
|
||||
tooltip={
|
||||
!reason && requiresUpgrade ? PROWLER_CLOUD_ONLY_TOOLTIP : undefined
|
||||
}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -11,11 +11,13 @@ export const RECHECK_RESOURCE_LABEL = "Re-check resource";
|
||||
|
||||
interface RecheckResourceActionItemProps {
|
||||
target: Partial<PartialScanTarget> | null | undefined;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
/** Prowler Cloud only: opens the partial-scan confirmation for one resource. */
|
||||
export const RecheckResourceActionItem = ({
|
||||
target,
|
||||
disabledReason,
|
||||
}: RecheckResourceActionItemProps) => {
|
||||
const resolvedTarget = usePartialScanTarget(target);
|
||||
const openPartialScan = usePartialScanStore((state) => state.openPartialScan);
|
||||
@@ -27,7 +29,11 @@ export const RecheckResourceActionItem = ({
|
||||
icon={<RefreshCw className="size-5" />}
|
||||
label={RECHECK_RESOURCE_LABEL}
|
||||
aria-label={RECHECK_RESOURCE_LABEL}
|
||||
onSelect={() => openPartialScan(resolvedTarget)}
|
||||
disabled={Boolean(disabledReason)}
|
||||
disabledTooltip={disabledReason}
|
||||
onSelect={() => {
|
||||
if (!disabledReason) openPartialScan(resolvedTarget);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -15,6 +15,7 @@ vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ hasPermission: hasPermissionMock }),
|
||||
}));
|
||||
|
||||
import { SAMPLE_DATA_READ_ONLY_MESSAGE } from "@/lib/sample-preview/sample-guards";
|
||||
import { usePartialScanStore } from "@/store/partial-scan/store";
|
||||
|
||||
import { RECHECK_RESOURCE_LABEL } from "./recheck-resource-action-item";
|
||||
@@ -54,12 +55,22 @@ describe("RecheckResourceIconButton", () => {
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is always green and pulsing", () => {
|
||||
render(<RecheckResourceIconButton target={target} />);
|
||||
|
||||
it("disables sample re-checks without opening the confirmation or triggering the row", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = vi.fn();
|
||||
render(
|
||||
<div onClick={onRowClick}>
|
||||
<RecheckResourceIconButton
|
||||
target={target}
|
||||
disabledReason={SAMPLE_DATA_READ_ONLY_MESSAGE}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
const button = screen.getByRole("button", { name: RECHECK_RESOURCE_LABEL });
|
||||
expect(button.className).toContain("text-button-primary");
|
||||
expect(button.className).toContain("animate-pulse");
|
||||
expect(button).toBeDisabled();
|
||||
await user.click(button);
|
||||
expect(usePartialScanStore.getState().activeTarget).toBeNull();
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is hidden outside Prowler Cloud", () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { usePartialScanTarget } from "@/hooks/use-partial-scan-target";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePartialScanStore } from "@/store";
|
||||
import type { PartialScanTarget } from "@/types/partial-scans";
|
||||
|
||||
@@ -19,13 +18,14 @@ import { RECHECK_RESOURCE_LABEL } from "./recheck-resource-action-item";
|
||||
interface RecheckResourceIconButtonProps {
|
||||
target: Partial<PartialScanTarget> | null | undefined;
|
||||
className?: string;
|
||||
disabledReason?: string;
|
||||
}
|
||||
|
||||
// Beside "last seen": always green and pulsing, so the re-check is noticed.
|
||||
// It is an action, not a notification, so it never settles into a seen state.
|
||||
// Beside "last seen", using the shared icon-button and disabled states.
|
||||
export function RecheckResourceIconButton({
|
||||
target,
|
||||
className,
|
||||
disabledReason,
|
||||
}: RecheckResourceIconButtonProps) {
|
||||
const resolvedTarget = usePartialScanTarget(target);
|
||||
const openPartialScan = usePartialScanStore((state) => state.openPartialScan);
|
||||
@@ -35,28 +35,33 @@ export function RecheckResourceIconButton({
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
// The row itself opens the detail drawer on click.
|
||||
event.stopPropagation();
|
||||
if (disabledReason) return;
|
||||
openPartialScan(resolvedTarget);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="bare"
|
||||
size="icon"
|
||||
aria-label={RECHECK_RESOURCE_LABEL}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"text-button-primary hover:text-button-primary active:text-button-primary size-5 shrink-0 rounded-md motion-safe:animate-pulse",
|
||||
"hover:[animation-play-state:paused] focus-visible:[animation-play-state:paused]",
|
||||
className,
|
||||
)}
|
||||
<span
|
||||
className={className}
|
||||
tabIndex={disabledReason ? 0 : undefined}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<RefreshCw className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="bare"
|
||||
size="icon-xs"
|
||||
aria-label={RECHECK_RESOURCE_LABEL}
|
||||
disabled={Boolean(disabledReason)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<RefreshCw aria-hidden />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{RECHECK_RESOURCE_LABEL}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{disabledReason ?? RECHECK_RESOURCE_LABEL}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -283,6 +283,74 @@ function getColumnIds(columns: ReturnType<typeof getColumnFindingResources>) {
|
||||
);
|
||||
}
|
||||
|
||||
describe("sample resource actions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isCloudMock.mockReturnValue(true);
|
||||
isGroupedJiraDispatchEnabledMock.mockReturnValue(true);
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
usePartialScanStore.getState().closePartialScan();
|
||||
});
|
||||
|
||||
const sampleResource = () =>
|
||||
makeResource({
|
||||
id: "sample-resource-s3_bucket-1",
|
||||
findingId: "sample-finding-s3_bucket_public_access-1",
|
||||
checkId: "s3_bucket_public_access",
|
||||
providerUid: "123456789012",
|
||||
providerAlias: "Sample AWS account",
|
||||
resourceName: "acme-customer-exports",
|
||||
resourceUid: "arn:aws:s3:::acme-customer-exports",
|
||||
triage: makeTriageSummary({
|
||||
findingId: "sample-finding-s3_bucket_public_access-1",
|
||||
canEdit: false,
|
||||
disabledReason: FINDING_TRIAGE_DISABLED_REASON.SAMPLE,
|
||||
}),
|
||||
});
|
||||
|
||||
it("does not send sample IDs to Lighthouse from a resource row", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderResourceActionsCell({ resource: sampleResource() });
|
||||
const action = screen.getByRole("button", { name: "Lighthouse Skills" });
|
||||
|
||||
// When
|
||||
expect(action).toBeDisabled();
|
||||
await user.click(action);
|
||||
|
||||
// Then
|
||||
expect(launchSkillMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not dispatch sample findings to the real Jira workflow", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderResourceActionsCell({ resource: sampleResource() });
|
||||
const action = screen.getByRole("button", { name: /Send.*Jira/ });
|
||||
|
||||
// When
|
||||
expect(action).toBeDisabled();
|
||||
await user.click(action);
|
||||
|
||||
// Then
|
||||
expect(useJiraDispatchStore.getState().activePayload).toBeNull();
|
||||
});
|
||||
|
||||
it("does not start the real re-check workflow for a sample resource", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
renderResourceActionsCell({ resource: sampleResource() });
|
||||
const action = screen.getByRole("button", { name: "Re-check resource" });
|
||||
|
||||
// When
|
||||
expect(action).toBeDisabled();
|
||||
await user.click(action);
|
||||
|
||||
// Then
|
||||
expect(usePartialScanStore.getState().activeTarget).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function renderResourceActionsCell({
|
||||
resource = makeResource(),
|
||||
onSkillLaunchOpenDrawer,
|
||||
|
||||
@@ -23,6 +23,10 @@ import { getFailingForLabel } from "@/lib/date-utils";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import type {
|
||||
@@ -99,6 +103,9 @@ const ResourceRowActions = ({
|
||||
onTriageDetailLoadAction?: FindingTriageDetailLoadHandler;
|
||||
}) => {
|
||||
const resource = row.original;
|
||||
const sampleDisabledReason = isSampleId(resource.findingId)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined;
|
||||
const canMute = canMuteFindingResource(resource);
|
||||
const launchSkill = useLighthouseSkillLaunch();
|
||||
const launchPrompt = useLighthousePromptLaunch();
|
||||
@@ -138,6 +145,7 @@ const ResourceRowActions = ({
|
||||
});
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
if (sampleDisabledReason) return;
|
||||
const displayIds = getDisplayIds();
|
||||
|
||||
// Single resource: findingId is already a real finding UUID
|
||||
@@ -202,7 +210,8 @@ const ResourceRowActions = ({
|
||||
)
|
||||
}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={!canMute || isResolving}
|
||||
disabled={Boolean(sampleDisabledReason) || !canMute || isResolving}
|
||||
disabledTooltip={sampleDisabledReason}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<JiraDispatchActionItem
|
||||
@@ -213,9 +222,11 @@ const ResourceRowActions = ({
|
||||
/>
|
||||
<RecheckResourceActionItem
|
||||
target={buildResourceRecheckTarget(resource)}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
{isCloud() && (
|
||||
<LighthouseSkillsSubmenu
|
||||
disabledReason={sampleDisabledReason}
|
||||
onLaunch={(skill) => {
|
||||
onSkillLaunchOpenDrawer?.(row.index);
|
||||
launchSkill(skill, buildResourceFindingItem(resource));
|
||||
@@ -403,6 +414,11 @@ export function getColumnFindingResources({
|
||||
<DateWithTime dateTime={row.original.lastSeenAt} />
|
||||
<RecheckResourceIconButton
|
||||
target={buildResourceRecheckTarget(row.original)}
|
||||
disabledReason={
|
||||
isSampleId(row.original.findingId)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</InfoField>
|
||||
|
||||
@@ -179,6 +179,25 @@ describe("DataTableRowActions", () => {
|
||||
useJiraDispatchStore.getState().closeJiraDispatch();
|
||||
});
|
||||
|
||||
it("blocks sample group actions even though their target check IDs are real", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<DataTableRowActions
|
||||
row={makeFindingRow({
|
||||
id: "sample-group-s3_bucket_public_access",
|
||||
rowType: "group",
|
||||
checkId: "s3_bucket_public_access",
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const jira = screen.getByRole("button", { name: /Jira/ });
|
||||
const mute = screen.getByRole("button", { name: /Mute Finding Group/ });
|
||||
expect(jira).toBeDisabled();
|
||||
expect(mute).toBeDisabled();
|
||||
await user.click(jira);
|
||||
expect(useJiraDispatchStore.getState().activePayload).toBeNull();
|
||||
});
|
||||
|
||||
it("offers a Cloud re-check of the row's resource with its provider id", async () => {
|
||||
// Given — a flat finding row expanded with its resource and provider
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -17,6 +17,10 @@ import { isFindingGroupMuted } from "@/lib/findings-groups";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { buildFindingResourceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { getOptionalText } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -128,6 +132,9 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
}: DataTableRowActionsProps<T>) {
|
||||
const router = useRouter();
|
||||
const finding = row.original;
|
||||
const sampleDisabledReason = isSampleId(finding.id)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined;
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
|
||||
const [mutePreparationError, setMutePreparationError] = useState<
|
||||
@@ -211,6 +218,7 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
};
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
if (sampleDisabledReason) return;
|
||||
if (resolveMuteIds) {
|
||||
setResolvedIds([]);
|
||||
setMutePreparationError(null);
|
||||
@@ -316,13 +324,22 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
)
|
||||
}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={!canMute || isResolving}
|
||||
disabled={Boolean(sampleDisabledReason) || !canMute || isResolving}
|
||||
disabledTooltip={sampleDisabledReason}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
<JiraDispatchActionItem label={jiraLabel} payload={jiraPayload} />
|
||||
<RecheckResourceActionItem target={recheckTarget} />
|
||||
<JiraDispatchActionItem
|
||||
label={jiraLabel}
|
||||
payload={jiraPayload}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
<RecheckResourceActionItem
|
||||
target={recheckTarget}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
{isCloud() && !isGroup && (
|
||||
<LighthouseSkillsSubmenu
|
||||
disabledReason={sampleDisabledReason}
|
||||
onLaunch={handleLaunchSkill}
|
||||
onSubmitPrompt={handleSubmitPrompt}
|
||||
/>
|
||||
|
||||
@@ -42,6 +42,8 @@ import { FindingTriageStatusControl } from "./finding-triage-status-control";
|
||||
|
||||
export const CLOUD_ONLY_TOOLTIP_COPY = "Available in Prowler Cloud";
|
||||
export const EDITING_UNAVAILABLE_COPY = "Editing is currently unavailable.";
|
||||
export const SAMPLE_TRIAGE_COPY =
|
||||
"Sample data. Connect your cloud account to triage your own findings.";
|
||||
|
||||
const getDisabledCopy = ({
|
||||
triage,
|
||||
@@ -56,6 +58,10 @@ const getDisabledCopy = ({
|
||||
return CLOUD_ONLY_TOOLTIP_COPY;
|
||||
}
|
||||
|
||||
if (triage.disabledReason === FINDING_TRIAGE_DISABLED_REASON.SAMPLE) {
|
||||
return SAMPLE_TRIAGE_COPY;
|
||||
}
|
||||
|
||||
// Status-picker only: notes stay available on resolved findings.
|
||||
if (lockResolved && isTriageStatusLocked(triage.status)) {
|
||||
return FINDING_TRIAGE_RESOLVED_LOCKED_COPY;
|
||||
|
||||
@@ -18,6 +18,10 @@ import {
|
||||
buildFindingGroupContext,
|
||||
buildFindingSummaryContext,
|
||||
} from "@/lib/lighthouse/context/contributions";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { useCloudUpgradeStore } from "@/store";
|
||||
import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
import { PAID_PLAN_UPGRADE_FEATURE } from "@/types/cloud-upgrade";
|
||||
@@ -444,6 +448,12 @@ const FindingsGroupTableContent = ({
|
||||
|
||||
{(selectedCheckIds.length > 0 || hasResourceSelection) && jiraPayload && (
|
||||
<FloatingSelectionActions
|
||||
disabledReason={
|
||||
selectedFindings.some((finding) => isSampleId(finding.id)) ||
|
||||
activeResourceSelection.some(isSampleId)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined
|
||||
}
|
||||
selectedCount={
|
||||
selectedCheckIds.length + activeResourceSelection.length
|
||||
}
|
||||
|
||||
@@ -58,6 +58,24 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
}));
|
||||
|
||||
describe("Lighthouse skills launch controls", () => {
|
||||
it("does not offer skills or prompts for a sample resource", () => {
|
||||
render(
|
||||
<LighthouseSkillsRowButton
|
||||
findingItem={{
|
||||
kind: "finding",
|
||||
id: "sample-finding-1",
|
||||
source: "focused",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Sample finding",
|
||||
findingId: "sample-finding-1",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
||||
expect(requestPanelSkillLaunchMock).not.toHaveBeenCalled();
|
||||
expect(requestPanelChatMessageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSidePanelStore.setState({
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { getSkillsForSurface } from "@/lib/lighthouse/skills/registry";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import {
|
||||
@@ -45,7 +49,10 @@ export function useLighthouseSkillLaunch() {
|
||||
return (
|
||||
skill: LighthouseSkillDefinition,
|
||||
findingItem: LighthouseFindingContextItem,
|
||||
) => launchPanelSkill(skill, buildRowEnvelope(findingItem));
|
||||
) => {
|
||||
if (isSampleId(findingItem.id)) return;
|
||||
launchPanelSkill(skill, buildRowEnvelope(findingItem));
|
||||
};
|
||||
}
|
||||
|
||||
// Free-text sibling of useLighthouseSkillLaunch: starts a fresh Lighthouse
|
||||
@@ -54,6 +61,7 @@ export function useLighthousePromptLaunch() {
|
||||
const openSidePanel = useSidePanelStore((state) => state.openPanel);
|
||||
|
||||
return (text: string, findingItem: LighthouseFindingContextItem) => {
|
||||
if (isSampleId(findingItem.id)) return;
|
||||
openSidePanel(SIDE_PANEL_TAB.AI_CHAT);
|
||||
void import("@/app/(prowler)/lighthouse/_lib/panel-chat-store").then(
|
||||
({ requestPanelChatMessage }) =>
|
||||
@@ -169,10 +177,22 @@ function AskLighthouseAnythingRow({
|
||||
export function LighthouseSkillsSubmenu({
|
||||
onLaunch,
|
||||
onSubmitPrompt,
|
||||
disabledReason,
|
||||
}: {
|
||||
onLaunch: (skill: LighthouseSkillDefinition) => void;
|
||||
onSubmitPrompt?: (text: string) => void;
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
if (disabledReason) {
|
||||
return (
|
||||
<ActionDropdownItem
|
||||
icon={<LighthouseIcon size={16} />}
|
||||
label="Lighthouse Skills"
|
||||
disabled
|
||||
disabledTooltip={disabledReason}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -212,6 +232,17 @@ export function LighthouseSkillsRowButton({
|
||||
const launchSkill = useLighthouseSkillLaunch();
|
||||
const launchPrompt = useLighthousePromptLaunch();
|
||||
|
||||
if (isSampleId(findingItem.id)) {
|
||||
return (
|
||||
<span title={SAMPLE_DATA_READ_ONLY_MESSAGE}>
|
||||
<CornerDownRight
|
||||
className="text-text-neutral-tertiary size-4 shrink-0"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative flex size-4 shrink-0 items-center justify-center">
|
||||
<ActionDropdown
|
||||
|
||||
+83
@@ -707,6 +707,89 @@ describe("ResourceDetailDrawerContent — resource navigation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — sample data", () => {
|
||||
it("does not navigate to compliance details for a nonexistent sample scan", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={{
|
||||
...mockFinding,
|
||||
id: "sample-finding-s3_bucket_public_access-1",
|
||||
resourceId: "sample-resource-s3_bucket-1",
|
||||
scan: {
|
||||
id: "sample-scan",
|
||||
name: "Sample scan",
|
||||
trigger: "manual",
|
||||
state: "completed",
|
||||
uniqueResourceCount: 24,
|
||||
progress: 100,
|
||||
duration: 742,
|
||||
startedAt: "2026-10-28T09:12:00Z",
|
||||
completedAt: "2026-10-28T09:12:00Z",
|
||||
insertedAt: "2026-10-28T09:12:00Z",
|
||||
scheduledAt: null,
|
||||
},
|
||||
}}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const link = screen.queryByRole("button", {
|
||||
name: "Open PCI-DSS 4.0 compliance details",
|
||||
});
|
||||
if (link) await user.click(link);
|
||||
|
||||
// Then
|
||||
expect(mockWindowOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not link sample resources to pages or consoles that lack them", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={{
|
||||
...mockFinding,
|
||||
id: "sample-finding-s3_bucket_public_access-1",
|
||||
resourceId: "sample-resource-s3_bucket-1",
|
||||
resourceUid: "arn:aws:s3:::acme-customer-exports",
|
||||
providerType: "aws",
|
||||
}}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "View Resource" }),
|
||||
).not.toBeInTheDocument();
|
||||
const hrefs = Array.from(container.querySelectorAll("a")).map((link) =>
|
||||
link.getAttribute("href"),
|
||||
);
|
||||
expect(hrefs.filter((href) => href?.includes("aws.amazon.com"))).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ResourceDetailDrawerContent — triage drawer actions", () => {
|
||||
it("should render Triage and Add Triage Note for other findings rows", () => {
|
||||
// Given
|
||||
|
||||
+60
-32
@@ -84,6 +84,10 @@ import { updateFindingTriageOrThrow } from "@/lib/finding-triage-update";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { getRegionFlag } from "@/lib/region-flags";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
@@ -289,6 +293,9 @@ export function ResourceDetailDrawerContent({
|
||||
// During carousel navigation we only trust row-backed data until the next
|
||||
// finding payload is fully ready, otherwise stale details flash briefly.
|
||||
const f = isNavigating ? null : currentFinding;
|
||||
const sampleDisabledReason = isSampleId(f?.id)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined;
|
||||
const jiraPayload = createJiraDispatchPayload({
|
||||
targetIds: f ? [f.id] : [],
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
@@ -328,25 +335,32 @@ export function ResourceDetailDrawerContent({
|
||||
searchParams.get("filter[scan__in]"),
|
||||
);
|
||||
const complianceScanId =
|
||||
selectedScanIds.length === 1
|
||||
? selectedScanIds[0]
|
||||
: selectedScanIds.length === 0
|
||||
? (f?.scan?.id ?? null)
|
||||
: null;
|
||||
isSampleId(f?.id) || isSampleId(f?.scan?.id)
|
||||
? null
|
||||
: selectedScanIds.length === 1
|
||||
? selectedScanIds[0]
|
||||
: selectedScanIds.length === 0
|
||||
? (f?.scan?.id ?? null)
|
||||
: null;
|
||||
const regionFilter = searchParams.get("filter[region__in]");
|
||||
const nativeIacConfig = resolveNativeIacConfig(providerType);
|
||||
const showOverviewCheckMetaContent = showCheckMetaContent;
|
||||
const resourceDetailHref = f?.resourceId
|
||||
? buildResourceDetailHref(f.resourceId)
|
||||
: null;
|
||||
const externalResourceTarget = resolveExternalTarget({
|
||||
providerType,
|
||||
resourceUid,
|
||||
providerUid,
|
||||
resourceName,
|
||||
findingUid: f?.uid,
|
||||
region: resourceRegion,
|
||||
});
|
||||
// Resources and Scans have no sample data, so sample records link nowhere.
|
||||
const resourceDetailHref =
|
||||
f?.resourceId && !isSampleId(f.resourceId)
|
||||
? buildResourceDetailHref(f.resourceId)
|
||||
: null;
|
||||
// The sample account is not theirs: its console links would open nothing.
|
||||
const externalResourceTarget = isSampleId(f?.resourceId)
|
||||
? null
|
||||
: resolveExternalTarget({
|
||||
providerType,
|
||||
resourceUid,
|
||||
providerUid,
|
||||
resourceName,
|
||||
findingUid: f?.uid,
|
||||
region: resourceRegion,
|
||||
});
|
||||
const findingRecommendationUrl = f?.remediation.recommendation.url;
|
||||
const checkRecommendationUrl = checkMeta.remediation.recommendation.url;
|
||||
const recommendationUrl = isNonEmptyString(findingRecommendationUrl)
|
||||
@@ -492,8 +506,9 @@ export function ResourceDetailDrawerContent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lighthouse skill launcher: chip rail under the title. */}
|
||||
{isCloud() && !isNavigating && (
|
||||
{/* Lighthouse skill launcher: chip rail under the title. Sample
|
||||
findings exist only in the UI, so Lighthouse has nothing to read. */}
|
||||
{isCloud() && !isNavigating && !isSampleId(currentFinding?.id) && (
|
||||
<LighthouseSkillsRail
|
||||
onLaunchSkill={handleLaunchSkill}
|
||||
onSubmitPrompt={handleSubmitPrompt}
|
||||
@@ -634,7 +649,10 @@ export function ResourceDetailDrawerContent({
|
||||
<span className="group flex items-center gap-1.5">
|
||||
<DateWithTime inline dateTime={lastSeenAt || "-"} />
|
||||
{f && (
|
||||
<RecheckResourceIconButton target={recheckTarget} />
|
||||
<RecheckResourceIconButton
|
||||
target={recheckTarget}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</InfoField>
|
||||
@@ -687,14 +705,18 @@ export function ResourceDetailDrawerContent({
|
||||
)
|
||||
}
|
||||
label={f.isMuted ? "Muted" : "Mute"}
|
||||
disabled={f.isMuted}
|
||||
disabled={f.isMuted || Boolean(sampleDisabledReason)}
|
||||
disabledTooltip={sampleDisabledReason}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
/>
|
||||
<JiraDispatchActionItem
|
||||
label={buildJiraActionLabel({ findingCount: 1 })}
|
||||
payload={jiraPayload}
|
||||
/>
|
||||
<RecheckResourceActionItem target={recheckTarget} />
|
||||
<RecheckResourceActionItem
|
||||
target={recheckTarget}
|
||||
disabledReason={sampleDisabledReason}
|
||||
/>
|
||||
{externalResourceTarget && (
|
||||
<ActionDropdownItem
|
||||
icon={<ExternalLink className="size-5" />}
|
||||
@@ -1130,16 +1152,18 @@ export function ResourceDetailDrawerContent({
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
Showing the latest scan that evaluated this finding
|
||||
</p>
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={`/scans?filter[id__in]=${f?.scan?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View scan
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
{!isSampleId(f?.scan?.id) && (
|
||||
<Button variant="link" size="link-sm" asChild>
|
||||
<Link
|
||||
href={`/scans?filter[id__in]=${f?.scan?.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
View scan
|
||||
<ExternalLink className="size-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<InfoField label="Scan Name" variant="compact">
|
||||
@@ -1429,6 +1453,9 @@ function OtherFindingRow({
|
||||
}) {
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const isMuted = finding.isMuted || isOptimisticallyMuted;
|
||||
const sampleDisabledReason = isSampleId(finding.id)
|
||||
? SAMPLE_DATA_READ_ONLY_MESSAGE
|
||||
: undefined;
|
||||
const jiraPayload = createJiraDispatchPayload({
|
||||
targetIds: [finding.id],
|
||||
targetType: JIRA_DISPATCH_TARGET.FINDING_ID,
|
||||
@@ -1523,7 +1550,8 @@ function OtherFindingRow({
|
||||
)
|
||||
}
|
||||
label={isMuted ? "Muted" : "Mute"}
|
||||
disabled={isMuted}
|
||||
disabled={isMuted || Boolean(sampleDisabledReason)}
|
||||
disabledTooltip={sampleDisabledReason}
|
||||
onSelect={() => setIsMuteModalOpen(true)}
|
||||
/>
|
||||
<JiraDispatchActionItem
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
isManualPassTriageUpdate,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-guards";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
@@ -256,6 +257,7 @@ export function useResourceDetailDrawer({
|
||||
resourceUid,
|
||||
pageSize: 50,
|
||||
includeMuted: includeMutedInOtherFindings,
|
||||
...(isSampleId(findingId) ? { sample: true } : {}),
|
||||
});
|
||||
const adapted = adaptFindingsByResourceResponse(response);
|
||||
|
||||
|
||||
@@ -289,9 +289,11 @@ export function LineChart({
|
||||
</ChartContainer>
|
||||
|
||||
<div className="mt-4 flex flex-col items-start gap-2">
|
||||
<p className="text-text-neutral-tertiary pl-2 text-xs">
|
||||
Click to filter by severity
|
||||
</p>
|
||||
{onPointClick && (
|
||||
<p className="text-text-neutral-tertiary pl-2 text-xs">
|
||||
Click to filter by severity
|
||||
</p>
|
||||
)}
|
||||
<ChartLegend
|
||||
items={legendItems}
|
||||
selectedItem={selectedLine}
|
||||
|
||||
@@ -43,6 +43,7 @@ interface ZeroDataProvider {
|
||||
}
|
||||
|
||||
interface SankeyChartProps {
|
||||
disableNavigation?: boolean;
|
||||
data: {
|
||||
nodes: SankeyNode[];
|
||||
links: SankeyLink[];
|
||||
@@ -168,7 +169,8 @@ export const CustomNode = ({
|
||||
const color = colors[nodeName] || "var(--color-text-neutral-tertiary)";
|
||||
const isHidden = nodeName === "";
|
||||
const hasTooltip = !isHidden && payload.newFindings;
|
||||
const isClickable = SEVERITY_FILTER_MAP[nodeName] !== undefined;
|
||||
const isClickable =
|
||||
Boolean(onNodeClick) && SEVERITY_FILTER_MAP[nodeName] !== undefined;
|
||||
|
||||
const handleMouseEnter = (e: React.MouseEvent) => {
|
||||
if (!hasTooltip) return;
|
||||
@@ -387,7 +389,10 @@ const CustomLink = ({
|
||||
fill={color}
|
||||
fillOpacity={getOpacity()}
|
||||
stroke="none"
|
||||
style={{ cursor: "pointer", transition: "fill-opacity 0.2s" }}
|
||||
style={{
|
||||
cursor: onLinkClick ? "pointer" : "default",
|
||||
transition: "fill-opacity 0.2s",
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
@@ -401,6 +406,7 @@ export function SankeyChart({
|
||||
data,
|
||||
zeroDataProviders = [],
|
||||
height = 400,
|
||||
disableNavigation = false,
|
||||
}: SankeyChartProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -510,7 +516,7 @@ export function SankeyChart({
|
||||
onNodeHover={handleNodeHover}
|
||||
onNodeMove={handleNodeMove}
|
||||
onNodeLeave={handleNodeLeave}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeClick={disableNavigation ? undefined : handleNodeClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -532,7 +538,7 @@ export function SankeyChart({
|
||||
onLinkHover={handleLinkHover}
|
||||
onLinkMove={handleLinkMove}
|
||||
onLinkLeave={handleLinkLeave}
|
||||
onLinkClick={handleLinkClick}
|
||||
onLinkClick={disableNavigation ? undefined : handleLinkClick}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ function LocationDetails({
|
||||
onBarClick,
|
||||
}: {
|
||||
location: Pick<LocationPoint, "name" | "totalFindings" | "severityData">;
|
||||
onBarClick: (dataPoint: BarDataPoint) => void;
|
||||
onBarClick?: (dataPoint: BarDataPoint) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex w-full flex-col">
|
||||
@@ -109,6 +109,7 @@ function LocationDetails({
|
||||
export function ThreatMap({
|
||||
data,
|
||||
height = MAP_CONFIG.defaultHeight,
|
||||
disableNavigation = false,
|
||||
}: ThreatMapProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -489,26 +490,34 @@ export function ThreatMap({
|
||||
{selectedLocation ? (
|
||||
<LocationDetails
|
||||
location={selectedLocation}
|
||||
onBarClick={(dp) => {
|
||||
const status = STATUS_FILTER_MAP[dp.name];
|
||||
if (status && selectedLocation.providerType) {
|
||||
navigateToFindings(
|
||||
status,
|
||||
selectedLocation.regionCode,
|
||||
selectedLocation.providerType,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onBarClick={
|
||||
disableNavigation
|
||||
? undefined
|
||||
: (dp) => {
|
||||
const status = STATUS_FILTER_MAP[dp.name];
|
||||
if (status && selectedLocation.providerType) {
|
||||
navigateToFindings(
|
||||
status,
|
||||
selectedLocation.regionCode,
|
||||
selectedLocation.providerType,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : isGlobalSelected && globalAggregatedData ? (
|
||||
<LocationDetails
|
||||
location={globalAggregatedData}
|
||||
onBarClick={(dp) => {
|
||||
const status = STATUS_FILTER_MAP[dp.name];
|
||||
if (status) {
|
||||
navigateToFindings(status, "global");
|
||||
}
|
||||
}}
|
||||
onBarClick={
|
||||
disableNavigation
|
||||
? undefined
|
||||
: (dp) => {
|
||||
const status = STATUS_FILTER_MAP[dp.name];
|
||||
if (status) {
|
||||
navigateToFindings(status, "global");
|
||||
}
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[400px] w-full items-center justify-center">
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface ThreatMapData {
|
||||
}
|
||||
|
||||
export interface ThreatMapProps {
|
||||
disableNavigation?: boolean;
|
||||
data: ThreatMapData;
|
||||
height?: number;
|
||||
onLocationSelect?: (location: LocationPoint | null) => void;
|
||||
|
||||
@@ -71,6 +71,18 @@ describe("IssueCompliance", () => {
|
||||
getFrameworksMock.mockResolvedValue({ frameworks: [], unavailable: false });
|
||||
});
|
||||
|
||||
it("shows sample frameworks without linking to real compliance reports", async () => {
|
||||
getFrameworksMock.mockResolvedValue({
|
||||
frameworks: [framework("cis_aws", "CIS")],
|
||||
unavailable: false,
|
||||
});
|
||||
renderCompliance([finding("sample-finding-1", "check-a")], "sample-scan");
|
||||
expect(await screen.findByAltText("CIS")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /compliance details/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("looks up one finding per check, up to the limit", async () => {
|
||||
const findings = [
|
||||
finding("f1", "check-a"),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "@/components/compliance/compliance-framework-chip";
|
||||
import { IssuesLoadError } from "@/components/issues/issues-load-error";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-guards";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import { IssueFinding } from "@/types/issues";
|
||||
|
||||
@@ -103,7 +104,7 @@ export const IssueCompliance = ({
|
||||
(state.partial || truncated || checks.size > COMPLIANCE_LOOKUP_LIMIT);
|
||||
|
||||
const open = (framework: FindingComplianceFramework) => {
|
||||
if (!scanId) return;
|
||||
if (!scanId || isSampleId(scanId)) return;
|
||||
window.open(
|
||||
buildComplianceDetailHref({
|
||||
complianceId: framework.complianceId,
|
||||
@@ -150,7 +151,7 @@ export const IssueCompliance = ({
|
||||
<ComplianceFrameworkChip
|
||||
key={framework.complianceId}
|
||||
framework={framework}
|
||||
isNavigable={Boolean(scanId)}
|
||||
isNavigable={Boolean(scanId) && !isSampleId(scanId)}
|
||||
onOpen={open}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "@/lib/issues/verdicts";
|
||||
import { buildJiraActionLabel } from "@/lib/jira-dispatch-action";
|
||||
import { createJiraDispatchPayload } from "@/lib/jira-dispatch-selection";
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-guards";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
import {
|
||||
ATTACKER_START_LABEL,
|
||||
@@ -118,9 +119,11 @@ const IssuePanelContent = ({
|
||||
const finding = useFindingDetails();
|
||||
|
||||
const activeScanId = evidence.status === "loaded" ? evidence.scanId : scanId;
|
||||
const scanFindingsHref =
|
||||
activeScanId &&
|
||||
`/findings?${new URLSearchParams({ "filter[scan__in]": activeScanId, "filter[status__in]": "FAIL" })}`;
|
||||
const scanFindingsParams = new URLSearchParams();
|
||||
if (activeScanId && !isSampleId(activeScanId))
|
||||
scanFindingsParams.set("filter[scan__in]", activeScanId);
|
||||
scanFindingsParams.set("filter[status__in]", "FAIL");
|
||||
const scanFindingsHref = activeScanId && `/findings?${scanFindingsParams}`;
|
||||
const loadedFindings =
|
||||
evidence.status === "loaded" ? evidence.findings : undefined;
|
||||
// The findings request returns one row per finding and resource.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { IssueResources } from "./issue-resources";
|
||||
|
||||
const UID = "arn:aws:s3:::acme-customer-exports";
|
||||
|
||||
describe("IssueResources", () => {
|
||||
it("links each resource to the Resources page of its provider", () => {
|
||||
render(<IssueResources resources={[UID]} providerId="provider-1" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Open in Resources: ${UID}` }),
|
||||
).toHaveAttribute(
|
||||
"href",
|
||||
`/resources?filter%5Buid%5D=${encodeURIComponent(UID)}&filter%5Bprovider_id__in%5D=provider-1`,
|
||||
);
|
||||
});
|
||||
|
||||
it("lists sample resources without a link, since Resources has no sample data", () => {
|
||||
render(<IssueResources resources={[UID]} providerId="sample-provider" />);
|
||||
|
||||
expect(screen.getByText(UID)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/shadcn/table/table";
|
||||
import { isSampleId } from "@/lib/sample-preview/sample-guards";
|
||||
|
||||
const RESOURCE_PAGE_SIZE = 12;
|
||||
|
||||
@@ -70,14 +71,17 @@ export const IssueResources = ({
|
||||
<CodeSnippet value={uid} ariaLabel={`Copy ${uid}`} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
<Button asChild variant="link" size="link-sm">
|
||||
<Link
|
||||
href={resourceHref(uid, providerId)}
|
||||
aria-label={`Open in Resources: ${uid}`}
|
||||
>
|
||||
Open in Resources
|
||||
</Link>
|
||||
</Button>
|
||||
{/* The Resources page has no sample data to open. */}
|
||||
{!isSampleId(providerId) && (
|
||||
<Button asChild variant="link" size="link-sm">
|
||||
<Link
|
||||
href={resourceHref(uid, providerId)}
|
||||
aria-label={`Open in Resources: ${uid}`}
|
||||
>
|
||||
Open in Resources
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
|
||||
import { PRICING_URL } from "@/lib/external-urls";
|
||||
import { SAMPLE_DATA_READ_ONLY_MESSAGE } from "@/lib/sample-preview/sample-guards";
|
||||
|
||||
import { IssueTriage } from "./issue-triage";
|
||||
|
||||
@@ -72,6 +73,19 @@ describe("IssueTriage", () => {
|
||||
expect(screen.getByText("Oct 20, 2026")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains why sample triage and notes are disabled before editing", async () => {
|
||||
render(<IssueTriage id="sample-issue-1" status="open" />);
|
||||
expect(
|
||||
screen.getByRole("combobox", { name: "Triage status" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole("textbox", { name: "Triage note" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Save triage" })).toBeDisabled();
|
||||
expect(screen.getByText(SAMPLE_DATA_READ_ONLY_MESSAGE)).toBeInTheDocument();
|
||||
expect(getIssueNotesMock).not.toHaveBeenCalled();
|
||||
expect(updateIssueTriageMock).not.toHaveBeenCalled();
|
||||
expect(addIssueNoteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saves a new status together with its note", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -16,6 +16,10 @@ import { useToast } from "@/components/shadcn/toast/use-toast";
|
||||
import { TriageStatusPicker } from "@/components/shared/triage/triage-status-picker";
|
||||
import { usageLimitToastAction } from "@/components/shared/usage-limit-message";
|
||||
import { isUsageLimitError } from "@/lib/action-errors";
|
||||
import {
|
||||
isSampleId,
|
||||
SAMPLE_DATA_READ_ONLY_MESSAGE,
|
||||
} from "@/lib/sample-preview/sample-guards";
|
||||
import {
|
||||
FINDING_TRIAGE_MANUAL_STATUS_VALUES,
|
||||
FINDING_TRIAGE_NOTE_MAX_LENGTH,
|
||||
@@ -33,6 +37,7 @@ import type { IssueTriageProps } from "./types";
|
||||
* accepting a risk does not make a path unexploitable.
|
||||
*/
|
||||
export const IssueTriage = ({ id, status }: IssueTriageProps) => {
|
||||
const sample = isSampleId(id);
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [pending, startTransition] = useTransition();
|
||||
@@ -50,6 +55,7 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (sample) return;
|
||||
let cancelled = false;
|
||||
getIssueNotes(id).then((loaded) => {
|
||||
if (!cancelled) setNotes(loaded);
|
||||
@@ -57,13 +63,14 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id, reload]);
|
||||
}, [id, reload, sample]);
|
||||
|
||||
const statusChanged = nextStatus !== status;
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
const save = () =>
|
||||
startTransition(async () => {
|
||||
if (sample) return;
|
||||
const result =
|
||||
statusChanged && isManualStatus(nextStatus)
|
||||
? await updateIssueTriage(id, {
|
||||
@@ -89,10 +96,15 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
|
||||
return (
|
||||
<section aria-label="Triage" className="flex flex-col gap-3">
|
||||
<h4 className="text-sm font-semibold">Triage</h4>
|
||||
{sample && (
|
||||
<p className="text-text-neutral-secondary text-sm">
|
||||
{SAMPLE_DATA_READ_ONLY_MESSAGE}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="w-40">
|
||||
<TriageStatusPicker
|
||||
disabled={pending}
|
||||
disabled={pending || sample}
|
||||
value={nextStatus}
|
||||
statusValues={FINDING_TRIAGE_MANUAL_STATUS_VALUES}
|
||||
onValueChange={setNextStatus}
|
||||
@@ -101,7 +113,7 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={save}
|
||||
disabled={pending || (!statusChanged && !trimmedNote)}
|
||||
disabled={sample || pending || (!statusChanged && !trimmedNote)}
|
||||
>
|
||||
{pending ? "Saving…" : "Save triage"}
|
||||
</Button>
|
||||
@@ -113,7 +125,7 @@ export const IssueTriage = ({ id, status }: IssueTriageProps) => {
|
||||
maxLength={FINDING_TRIAGE_NOTE_MAX_LENGTH}
|
||||
rows={3}
|
||||
// A successful save clears the note: text typed meanwhile would be lost.
|
||||
disabled={pending}
|
||||
disabled={pending || sample}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
/>
|
||||
{notes === undefined ? (
|
||||
|
||||
@@ -77,4 +77,22 @@ describe("LighthouseContextContributor", () => {
|
||||
useLighthouseContextStore.getState().contributions["findings-total"],
|
||||
).toMatchObject({ total: 17 });
|
||||
});
|
||||
|
||||
it("should never register sample records", () => {
|
||||
render(
|
||||
<LighthouseContextContributor
|
||||
contributorId="finding-group-sample"
|
||||
item={{
|
||||
kind: "finding",
|
||||
id: "sample-group-s3_bucket_public_access",
|
||||
source: "selection",
|
||||
scopeKey: "findings:/findings",
|
||||
label: "Ensure S3 buckets are not publicly accessible",
|
||||
findingId: "sample-group-s3_bucket_public_access",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(useLighthouseContextStore.getState().contributions).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/shadcn";
|
||||
|
||||
export function FindingSeverityOverTimeCard({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card variant="base" className="flex h-full flex-1 flex-col">
|
||||
<CardHeader className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Findings Severity Over Time</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col px-6">
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -150,6 +150,8 @@ describe("ProvidersAccountsView", () => {
|
||||
window.removeEventListener(PROVIDER_FUNNEL_EVENT, recordFunnelSignal);
|
||||
vi.restoreAllMocks();
|
||||
providersAccountsTableSpy.mockClear();
|
||||
replaceMock.mockClear();
|
||||
refreshMock.mockClear();
|
||||
searchParamsValue.current = "";
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
@@ -288,6 +290,128 @@ describe("ProvidersAccountsView", () => {
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a new tenant to the sample Overview when the first-run wizard closes", async () => {
|
||||
// Given: the first-run redirect opened the wizard while the sample is shown.
|
||||
searchParamsValue.current = "addProvider=true&addProviderSource=first_run";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud
|
||||
samplePreviewActive
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then: an empty Providers page shows nothing; the Overview shows the sample.
|
||||
expect(replaceMock).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("sends the tenant to the sample Overview after launching a scan from the sidebar CTA", async () => {
|
||||
// Given: the sidebar Launch Scan CTA opened the wizard, which closes after the launch.
|
||||
searchParamsValue.current =
|
||||
"addProvider=true&addProviderSource=sidebar_cta";
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud
|
||||
samplePreviewActive
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then
|
||||
expect(replaceMock).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("sends a new tenant to the sample Overview when the empty-state wizard closes", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud
|
||||
samplePreviewActive
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={providers}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /open add provider modal/i }),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then
|
||||
expect(replaceMock).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("keeps the user on Providers when a row action wizard closes", async () => {
|
||||
// Given: a row action opens the wizard to edit an existing provider.
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud
|
||||
samplePreviewActive
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
const { onOpenProviderWizard } = providersAccountsTableSpy.mock
|
||||
.lastCall![0] as {
|
||||
onOpenProviderWizard: () => void;
|
||||
};
|
||||
|
||||
// When
|
||||
act(() => onOpenProviderWizard());
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends the tenant to the sample Overview when the page-button wizard closes", async () => {
|
||||
// Given: a provider exists but no scan has completed yet.
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<ProvidersAccountsView
|
||||
isCloud
|
||||
samplePreviewActive
|
||||
filters={filters}
|
||||
metadata={metadata}
|
||||
providers={disconnectedProviders}
|
||||
rows={rows}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Add Provider" }));
|
||||
await user.click(screen.getByRole("button", { name: /close/i }));
|
||||
|
||||
// Then
|
||||
expect(replaceMock).toHaveBeenCalledWith("/");
|
||||
});
|
||||
|
||||
it("signals which control opened the wizard", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user