mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-04-16 17:47:47 +00:00
Merge branch 'master' of github.com:prowler-cloud/prowler into changelog-v5.23.0
This commit is contained in:
@@ -105,6 +105,102 @@ If a query requires no parameters, the form displays a message confirming that t
|
||||
width="700"
|
||||
/>
|
||||
|
||||
## Writing Custom openCypher Queries
|
||||
|
||||
In addition to the built-in queries, Attack Paths supports custom read-only [openCypher](https://opencypher.org/) queries. Custom queries provide direct access to the underlying graph so security teams can answer ad-hoc questions, prototype detections, or extend coverage beyond the built-in catalogue.
|
||||
|
||||
To write a custom query, select **Custom openCypher query** from the query dropdown. A code editor with syntax highlighting and line numbers appears, ready to receive the query.
|
||||
|
||||
### Constraints and Safety Limits
|
||||
|
||||
Custom queries are sandboxed to keep the graph database safe and responsive:
|
||||
|
||||
- **Read-only:** Only read operations are allowed. Statements that mutate the graph (`CREATE`, `MERGE`, `SET`, `DELETE`, `REMOVE`, `DROP`, `LOAD CSV`, `CALL { ... }` writes, etc.) are rejected before execution.
|
||||
- **Length limit:** Each query is capped at **10,000 characters**.
|
||||
- **Scoped to the selected scan:** Results are automatically scoped to the provider and scan selected on the left panel. There is no need to filter by tenant or scan identifier in the query body.
|
||||
|
||||
### Example Queries
|
||||
|
||||
The following examples are read-only and can be pasted directly into the editor. Each one demonstrates a different graph traversal pattern.
|
||||
|
||||
**Internet-exposed EC2 instances with their security group rules:**
|
||||
|
||||
```cypher
|
||||
MATCH (i:EC2Instance)--(sg:EC2SecurityGroup)--(rule:IpPermissionInbound)
|
||||
WHERE i.exposed_internet = true
|
||||
RETURN i.instanceid AS instance, sg.name AS security_group,
|
||||
rule.fromport AS from_port, rule.toport AS to_port
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
**EC2 instances that can assume IAM roles:**
|
||||
|
||||
```cypher
|
||||
MATCH (i:EC2Instance)-[:STS_ASSUMEROLE_ALLOW]->(r:AWSRole)
|
||||
WHERE i.exposed_internet = true
|
||||
RETURN i.instanceid AS instance, r.name AS role_name, r.arn AS role_arn
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
**IAM principals with wildcard Allow statements:**
|
||||
|
||||
```cypher
|
||||
MATCH (principal:AWSPrincipal)--(policy:AWSPolicy)--(stmt:AWSPolicyStatement)
|
||||
WHERE stmt.effect = 'Allow'
|
||||
AND ANY(action IN stmt.action WHERE action = '*')
|
||||
RETURN principal.arn AS principal, policy.arn AS policy,
|
||||
stmt.action AS actions, stmt.resource AS resources
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
**Critical findings on internet-exposed resources:**
|
||||
|
||||
```cypher
|
||||
MATCH (i:EC2Instance)-[:HAS_FINDING]->(f:ProwlerFinding)
|
||||
WHERE i.exposed_internet = true AND f.status = 'FAIL'
|
||||
AND f.severity IN ['critical', 'high']
|
||||
RETURN i.instanceid AS instance, f.check_id AS check,
|
||||
f.severity AS severity, f.status AS status
|
||||
LIMIT 50
|
||||
```
|
||||
|
||||
**Roles trusting an AWS service (building block for PassRole escalation):**
|
||||
|
||||
```cypher
|
||||
MATCH (r:AWSRole)-[:TRUSTS_AWS_PRINCIPAL]->(p:AWSPrincipal)
|
||||
WHERE p.arn ENDS WITH '.amazonaws.com'
|
||||
RETURN r.name AS role_name, r.arn AS role_arn, p.arn AS trusted_service
|
||||
LIMIT 25
|
||||
```
|
||||
|
||||
### Tips for Writing Queries
|
||||
|
||||
- Start small with `LIMIT` to inspect the shape of the data before broadening the pattern.
|
||||
- Use `RETURN` projections (`RETURN n.name, n.region`) instead of returning whole nodes to keep responses compact.
|
||||
- Combine resource nodes with `ProwlerFinding` nodes via `HAS_FINDING` to correlate misconfigurations with the affected resources.
|
||||
- When a query times out or returns no rows, simplify the pattern step by step until the first variant runs successfully, then add constraints back.
|
||||
|
||||
### Cartography Schema Reference
|
||||
|
||||
Attack Paths graphs are populated by [Cartography](https://github.com/cartography-cncf/cartography), an open-source graph ingestion framework. The node labels, relationship types, and properties available in custom queries follow the upstream Cartography schema for the corresponding provider.
|
||||
|
||||
For the complete catalogue of node labels and relationships available in custom queries, refer to the official Cartography schema documentation:
|
||||
|
||||
- **AWS:** [Cartography AWS Schema](https://cartography-cncf.github.io/cartography/modules/aws/schema.html)
|
||||
|
||||
In addition to the upstream schema, Prowler enriches the graph with:
|
||||
|
||||
- **`ProwlerFinding`** nodes representing Prowler check results, linked to affected resources via `HAS_FINDING` relationships.
|
||||
- **`Internet`** nodes used to model exposure paths from the public internet to internal resources.
|
||||
|
||||
<Note>
|
||||
AI assistants connected through Prowler MCP Server can fetch the exact
|
||||
Cartography schema for the active scan via the
|
||||
`prowler_app_get_attack_paths_cartography_schema` tool. This guarantees that
|
||||
generated queries match the schema version pinned by the running Prowler
|
||||
release.
|
||||
</Note>
|
||||
|
||||
## Executing a Query
|
||||
|
||||
To run the selected query against the scan data, click **Execute Query**. The button displays a loading state while the query processes.
|
||||
|
||||
@@ -17,6 +17,7 @@ All notable changes to the **Prowler UI** are documented in this file.
|
||||
### 🔄 Changed
|
||||
|
||||
- Attack Paths custom openCypher queries now use a code editor with syntax highlighting and line numbers [(#10445)](https://github.com/prowler-cloud/prowler/pull/10445)
|
||||
- Attack Paths custom openCypher queries now link to the Prowler documentation with examples and how-to guidance instead of the upstream Cartography schema URL
|
||||
- Filter summary strip: removed redundant "Clear all" link next to pills (use top-bar Clear Filters instead) and switched chip variant from `outline` to `tag` for consistency [(#10481)](https://github.com/prowler-cloud/prowler/pull/10481)
|
||||
|
||||
### 🐞 Fixed
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathCartographySchemaAttributes,
|
||||
type AttackPathQuery,
|
||||
} from "@/types/attack-paths";
|
||||
|
||||
@@ -22,20 +22,9 @@ const presetQuery: AttackPathQuery = {
|
||||
};
|
||||
|
||||
describe("buildAttackPathQueries", () => {
|
||||
it("prepends a custom query with a schema documentation link", () => {
|
||||
// Given
|
||||
const schema: AttackPathCartographySchemaAttributes = {
|
||||
id: "aws-0.129.0",
|
||||
provider: "aws",
|
||||
cartography_version: "0.129.0",
|
||||
schema_url:
|
||||
"https://github.com/cartography-cncf/cartography/blob/0.129.0/docs/root/modules/aws/schema.md",
|
||||
raw_schema_url:
|
||||
"https://raw.githubusercontent.com/cartography-cncf/cartography/refs/tags/0.129.0/docs/root/modules/aws/schema.md",
|
||||
};
|
||||
|
||||
it("prepends a custom query that links to the Prowler documentation", () => {
|
||||
// When
|
||||
const result = buildAttackPathQueries([presetQuery], schema);
|
||||
const result = buildAttackPathQueries([presetQuery]);
|
||||
|
||||
// Then
|
||||
expect(result[0]).toMatchObject({
|
||||
@@ -44,8 +33,8 @@ describe("buildAttackPathQueries", () => {
|
||||
name: "Custom openCypher query",
|
||||
short_description: "Write and run your own read-only query",
|
||||
documentation_link: {
|
||||
text: "Cartography schema used by Prowler for AWS graphs",
|
||||
link: schema.schema_url,
|
||||
text: "Learn how to write custom openCypher queries",
|
||||
link: DOCS_URLS.ATTACK_PATHS_CUSTOM_QUERIES,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DOCS_URLS } from "@/lib/external-urls";
|
||||
import { MetaDataProps } from "@/types";
|
||||
import {
|
||||
ATTACK_PATH_QUERY_IDS,
|
||||
type AttackPathCartographySchemaAttributes,
|
||||
AttackPathQueriesResponse,
|
||||
AttackPathQuery,
|
||||
QUERY_PARAMETER_INPUT_TYPES,
|
||||
@@ -61,15 +61,12 @@ const CUSTOM_QUERY_PLACEHOLDER = `MATCH (n)
|
||||
RETURN n
|
||||
LIMIT 25`;
|
||||
|
||||
const formatSchemaDocumentationLinkText = (
|
||||
schema: AttackPathCartographySchemaAttributes,
|
||||
): string => {
|
||||
return `Cartography schema used by Prowler for ${schema.provider.toUpperCase()} graphs`;
|
||||
};
|
||||
const CUSTOM_QUERY_DOCUMENTATION_LINK = {
|
||||
text: "Learn how to write custom openCypher queries",
|
||||
link: DOCS_URLS.ATTACK_PATHS_CUSTOM_QUERIES,
|
||||
} as const;
|
||||
|
||||
const createCustomQuery = (
|
||||
schema?: AttackPathCartographySchemaAttributes,
|
||||
): AttackPathQuery => ({
|
||||
const createCustomQuery = (): AttackPathQuery => ({
|
||||
type: "attack-paths-scans",
|
||||
id: ATTACK_PATH_QUERY_IDS.CUSTOM,
|
||||
attributes: {
|
||||
@@ -79,12 +76,7 @@ const createCustomQuery = (
|
||||
"Run a read-only openCypher query against the selected Attack Paths scan. Results are automatically scoped to the selected provider.",
|
||||
provider: "custom",
|
||||
attribution: null,
|
||||
documentation_link: schema
|
||||
? {
|
||||
text: formatSchemaDocumentationLinkText(schema),
|
||||
link: schema.schema_url,
|
||||
}
|
||||
: null,
|
||||
documentation_link: { ...CUSTOM_QUERY_DOCUMENTATION_LINK },
|
||||
parameters: [
|
||||
{
|
||||
name: "query",
|
||||
@@ -103,7 +95,6 @@ const createCustomQuery = (
|
||||
|
||||
export const buildAttackPathQueries = (
|
||||
queries: AttackPathQuery[],
|
||||
schema?: AttackPathCartographySchemaAttributes,
|
||||
): AttackPathQuery[] => {
|
||||
return [createCustomQuery(schema), ...queries];
|
||||
return [createCustomQuery(), ...queries];
|
||||
};
|
||||
|
||||
@@ -78,14 +78,31 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
|
||||
check_description: null,
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
muted: true,
|
||||
impacted_providers: ["aws"],
|
||||
resources_total: 5,
|
||||
resources_fail: 3,
|
||||
pass_count: 2,
|
||||
fail_count: 3,
|
||||
manual_count: 1,
|
||||
pass_muted_count: 0,
|
||||
fail_muted_count: 3,
|
||||
manual_muted_count: 0,
|
||||
muted_count: 0,
|
||||
new_count: 1,
|
||||
changed_count: 0,
|
||||
new_fail_count: 0,
|
||||
new_fail_muted_count: 1,
|
||||
new_pass_count: 0,
|
||||
new_pass_muted_count: 0,
|
||||
new_manual_count: 0,
|
||||
new_manual_muted_count: 0,
|
||||
changed_fail_count: 0,
|
||||
changed_fail_muted_count: 0,
|
||||
changed_pass_count: 0,
|
||||
changed_pass_muted_count: 0,
|
||||
changed_manual_count: 0,
|
||||
changed_manual_muted_count: 0,
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
failing_since: null,
|
||||
@@ -101,6 +118,9 @@ describe("adaptFindingGroupsResponse — malformed input", () => {
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].checkId).toBe("s3_bucket_public_access");
|
||||
expect(result[0].checkTitle).toBe("S3 Bucket Public Access");
|
||||
expect(result[0].muted).toBe(true);
|
||||
expect(result[0].manualCount).toBe(1);
|
||||
expect(result[0].newFailMutedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +169,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
id: "resource-row-1",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
finding_id: "real-finding-uuid",
|
||||
resource: {
|
||||
uid: "arn:aws:s3:::my-bucket",
|
||||
name: "my-bucket",
|
||||
@@ -163,6 +184,7 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
alias: "production",
|
||||
},
|
||||
status: "FAIL",
|
||||
muted: true,
|
||||
delta: "new",
|
||||
severity: "critical",
|
||||
first_seen_at: null,
|
||||
@@ -177,8 +199,10 @@ describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].findingId).toBe("real-finding-uuid");
|
||||
expect(result[0].checkId).toBe("s3_check");
|
||||
expect(result[0].resourceName).toBe("my-bucket");
|
||||
expect(result[0].delta).toBe("new");
|
||||
expect(result[0].isMuted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,15 +19,32 @@ interface FindingGroupAttributes {
|
||||
check_title: string | null;
|
||||
check_description: string | null;
|
||||
severity: string;
|
||||
status: string; // "FAIL" | "PASS" | "MUTED" (already uppercase)
|
||||
status: string; // "FAIL" | "PASS" | "MANUAL" (already uppercase)
|
||||
muted?: boolean;
|
||||
impacted_providers: string[];
|
||||
resources_total: number;
|
||||
resources_fail: number;
|
||||
pass_count: number;
|
||||
fail_count: number;
|
||||
manual_count?: number;
|
||||
pass_muted_count?: number;
|
||||
fail_muted_count?: number;
|
||||
manual_muted_count?: number;
|
||||
muted_count: number;
|
||||
new_count: number;
|
||||
changed_count: number;
|
||||
new_fail_count?: number;
|
||||
new_fail_muted_count?: number;
|
||||
new_pass_count?: number;
|
||||
new_pass_muted_count?: number;
|
||||
new_manual_count?: number;
|
||||
new_manual_muted_count?: number;
|
||||
changed_fail_count?: number;
|
||||
changed_fail_muted_count?: number;
|
||||
changed_pass_count?: number;
|
||||
changed_pass_muted_count?: number;
|
||||
changed_manual_count?: number;
|
||||
changed_manual_muted_count?: number;
|
||||
first_seen_at: string | null;
|
||||
last_seen_at: string | null;
|
||||
failing_since: string | null;
|
||||
@@ -62,10 +79,33 @@ export function adaptFindingGroupsResponse(
|
||||
checkTitle: item.attributes.check_title || item.attributes.check_id,
|
||||
severity: item.attributes.severity as Severity,
|
||||
status: item.attributes.status as FindingStatus,
|
||||
muted:
|
||||
item.attributes.muted ??
|
||||
(item.attributes.muted_count > 0 &&
|
||||
(item.attributes.muted_count === item.attributes.resources_fail ||
|
||||
item.attributes.muted_count === item.attributes.resources_total)),
|
||||
resourcesTotal: item.attributes.resources_total,
|
||||
resourcesFail: item.attributes.resources_fail,
|
||||
passCount: item.attributes.pass_count,
|
||||
failCount: item.attributes.fail_count,
|
||||
manualCount: item.attributes.manual_count ?? 0,
|
||||
passMutedCount: item.attributes.pass_muted_count ?? 0,
|
||||
failMutedCount: item.attributes.fail_muted_count ?? 0,
|
||||
manualMutedCount: item.attributes.manual_muted_count ?? 0,
|
||||
newCount: item.attributes.new_count,
|
||||
changedCount: item.attributes.changed_count,
|
||||
newFailCount: item.attributes.new_fail_count ?? 0,
|
||||
newFailMutedCount: item.attributes.new_fail_muted_count ?? 0,
|
||||
newPassCount: item.attributes.new_pass_count ?? 0,
|
||||
newPassMutedCount: item.attributes.new_pass_muted_count ?? 0,
|
||||
newManualCount: item.attributes.new_manual_count ?? 0,
|
||||
newManualMutedCount: item.attributes.new_manual_muted_count ?? 0,
|
||||
changedFailCount: item.attributes.changed_fail_count ?? 0,
|
||||
changedFailMutedCount: item.attributes.changed_fail_muted_count ?? 0,
|
||||
changedPassCount: item.attributes.changed_pass_count ?? 0,
|
||||
changedPassMutedCount: item.attributes.changed_pass_muted_count ?? 0,
|
||||
changedManualCount: item.attributes.changed_manual_count ?? 0,
|
||||
changedManualMutedCount: item.attributes.changed_manual_muted_count ?? 0,
|
||||
mutedCount: item.attributes.muted_count,
|
||||
providers: (item.attributes.impacted_providers || []) as ProviderType[],
|
||||
updatedAt: item.attributes.last_seen_at || "",
|
||||
@@ -95,9 +135,11 @@ interface ProviderInfo {
|
||||
}
|
||||
|
||||
interface FindingGroupResourceAttributes {
|
||||
finding_id: string;
|
||||
resource: ResourceInfo;
|
||||
provider: ProviderInfo;
|
||||
status: string;
|
||||
muted?: boolean;
|
||||
delta?: string | null;
|
||||
severity: string;
|
||||
first_seen_at: string | null;
|
||||
@@ -132,7 +174,7 @@ export function adaptFindingGroupResourcesResponse(
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
rowType: FINDINGS_ROW_TYPE.RESOURCE,
|
||||
findingId: item.id,
|
||||
findingId: item.attributes.finding_id || item.id,
|
||||
checkId,
|
||||
providerType: (item.attributes.provider?.type || "aws") as ProviderType,
|
||||
providerAlias: item.attributes.provider?.alias || "",
|
||||
@@ -146,7 +188,7 @@ export function adaptFindingGroupResourcesResponse(
|
||||
severity: (item.attributes.severity || "informational") as Severity,
|
||||
status: item.attributes.status,
|
||||
delta: item.attributes.delta || null,
|
||||
isMuted: item.attributes.status === "MUTED",
|
||||
isMuted: item.attributes.muted ?? item.attributes.status === "MUTED",
|
||||
mutedReason: item.attributes.muted_reason || undefined,
|
||||
firstSeenAt: item.attributes.first_seen_at,
|
||||
lastSeenAt: item.attributes.last_seen_at,
|
||||
|
||||
@@ -187,7 +187,9 @@ describe("getFindingGroupResources — Blocker 1: FAIL-first sort", () => {
|
||||
// Then — the URL must contain the composite sort
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-delta,-severity,-last_seen_at",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
|
||||
@@ -223,7 +225,9 @@ describe("getLatestFindingGroupResources — Blocker 1: FAIL-first sort", () =>
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-delta,-severity,-last_seen_at",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not force filter[status]=FAIL so PASS resources can also be shown", async () => {
|
||||
@@ -265,7 +269,9 @@ describe("getFindingGroupResources — triangulation: params coexist", () => {
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("page[number]")).toBe("2");
|
||||
expect(url.searchParams.get("page[size]")).toBe("50");
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-delta,-severity,-last_seen_at",
|
||||
);
|
||||
expect(url.searchParams.get("filter[status]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -291,7 +297,9 @@ describe("getLatestFindingGroupResources — triangulation: params coexist", ()
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("page[number]")).toBe("3");
|
||||
expect(url.searchParams.get("page[size]")).toBe("20");
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-delta,-severity,-last_seen_at",
|
||||
);
|
||||
expect(url.searchParams.get("filter[status]")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -360,7 +368,9 @@ describe("getFindingGroupResources — caller filters are preserved", () => {
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-delta,-severity,-last_seen_at",
|
||||
);
|
||||
expect(url.searchParams.get("filter[name__icontains]")).toBe("bucket-prod");
|
||||
expect(url.searchParams.get("filter[severity__in]")).toBe("high");
|
||||
});
|
||||
@@ -426,7 +436,9 @@ describe("getLatestFindingGroupResources — caller filters are preserved", () =
|
||||
// Then
|
||||
const calledUrl = fetchMock.mock.calls[0][0] as string;
|
||||
const url = new URL(calledUrl);
|
||||
expect(url.searchParams.get("sort")).toBe("-severity,-delta,-last_seen_at");
|
||||
expect(url.searchParams.get("sort")).toBe(
|
||||
"-status,-delta,-severity,-last_seen_at",
|
||||
);
|
||||
expect(url.searchParams.get("filter[name__icontains]")).toBe(
|
||||
"instance-prod",
|
||||
);
|
||||
|
||||
@@ -62,7 +62,10 @@ function normalizeFindingGroupResourceFilters(
|
||||
}
|
||||
|
||||
const DEFAULT_FINDING_GROUPS_SORT =
|
||||
"-severity,-delta,-fail_count,-last_seen_at";
|
||||
"-status,-severity,-delta,-fail_count,-last_seen_at";
|
||||
|
||||
const DEFAULT_FINDING_GROUP_RESOURCES_SORT =
|
||||
"-status,-delta,-severity,-last_seen_at";
|
||||
|
||||
interface FetchFindingGroupsParams {
|
||||
page?: number;
|
||||
@@ -133,7 +136,7 @@ async function fetchFindingGroupResourcesEndpoint(
|
||||
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
url.searchParams.append("sort", "-severity,-delta,-last_seen_at");
|
||||
url.searchParams.append("sort", DEFAULT_FINDING_GROUP_RESOURCES_SORT);
|
||||
|
||||
appendSanitizedProviderFilters(url, normalizedFilters);
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ vi.mock("@/actions/finding-groups", () => ({
|
||||
}));
|
||||
|
||||
import {
|
||||
resolveFindingIds,
|
||||
resolveFindingIdsByCheckIds,
|
||||
resolveFindingIdsByVisibleGroupResources,
|
||||
} from "./findings-by-resource";
|
||||
@@ -142,47 +141,6 @@ describe("resolveFindingIdsByCheckIds", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFindingIds", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("should use the dated findings endpoint when date or scan filters are active", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }, { id: "finding-2" }],
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1", "resource-2"],
|
||||
hasDateOrScanFilter: true,
|
||||
filters: {
|
||||
"filter[scan__in]": "scan-1",
|
||||
"filter[inserted_at__gte]": "2026-03-01",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result).toEqual(["finding-1", "finding-2"]);
|
||||
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.pathname).toBe("/api/v1/findings");
|
||||
expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1");
|
||||
expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe(
|
||||
"resource-1,resource-2",
|
||||
);
|
||||
expect(calledUrl.searchParams.get("filter[scan__in]")).toBe("scan-1");
|
||||
expect(calledUrl.searchParams.get("filter[inserted_at__gte]")).toBe(
|
||||
"2026-03-01",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFindingIdsByVisibleGroupResources", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -190,22 +148,18 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("should resolve finding IDs from the group's visible resource UIDs instead of muting the whole check", async () => {
|
||||
// Given
|
||||
it("extracts finding_id directly from group resources without a second resolution round-trip", async () => {
|
||||
// Given — the group resources endpoint returns finding_id in each resource
|
||||
getLatestFindingGroupResourcesMock
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-1",
|
||||
attributes: {
|
||||
resource: { uid: "resource-1" },
|
||||
},
|
||||
attributes: { finding_id: "finding-1" },
|
||||
},
|
||||
{
|
||||
id: "resource-row-2",
|
||||
attributes: {
|
||||
resource: { uid: "resource-2" },
|
||||
},
|
||||
attributes: { finding_id: "finding-2" },
|
||||
},
|
||||
],
|
||||
meta: { pagination: { pages: 2 } },
|
||||
@@ -214,19 +168,12 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-3",
|
||||
attributes: {
|
||||
resource: { uid: "resource-3" },
|
||||
},
|
||||
attributes: { finding_id: "finding-3" },
|
||||
},
|
||||
],
|
||||
meta: { pagination: { pages: 2 } },
|
||||
});
|
||||
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }],
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await resolveFindingIdsByVisibleGroupResources({
|
||||
checkId: "check-1",
|
||||
@@ -236,8 +183,13 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
|
||||
resourceSearch: "visible subset",
|
||||
});
|
||||
|
||||
// Then
|
||||
// Then — finding IDs come directly from the group resources response
|
||||
expect(result).toEqual(["finding-1", "finding-2", "finding-3"]);
|
||||
|
||||
// No second round-trip to /findings/latest
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
// Group resources endpoint was paginated with correct filters
|
||||
expect(getLatestFindingGroupResourcesMock).toHaveBeenCalledTimes(2);
|
||||
expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(1, {
|
||||
checkId: "check-1",
|
||||
@@ -246,6 +198,8 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
|
||||
filters: {
|
||||
"filter[provider_type__in]": "aws",
|
||||
"filter[name__icontains]": "visible subset",
|
||||
"filter[status]": "FAIL",
|
||||
"filter[muted]": "false",
|
||||
},
|
||||
});
|
||||
expect(getLatestFindingGroupResourcesMock).toHaveBeenNthCalledWith(2, {
|
||||
@@ -255,168 +209,56 @@ describe("resolveFindingIdsByVisibleGroupResources", () => {
|
||||
filters: {
|
||||
"filter[provider_type__in]": "aws",
|
||||
"filter[name__icontains]": "visible subset",
|
||||
"filter[status]": "FAIL",
|
||||
"filter[muted]": "false",
|
||||
},
|
||||
});
|
||||
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.pathname).toBe("/api/v1/findings/latest");
|
||||
expect(calledUrl.searchParams.get("filter[check_id]")).toBe("check-1");
|
||||
expect(calledUrl.searchParams.get("filter[check_id__in]")).toBeNull();
|
||||
expect(calledUrl.searchParams.get("filter[resource_uid__in]")).toBe(
|
||||
"resource-1,resource-2,resource-3",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Blocker 3: Muting a group mutes ALL historical findings, not just FAIL ones
|
||||
//
|
||||
// The fix: resolveFindingIds must include filter[status]=FAIL so only active
|
||||
// (failing) findings are resolved for mute, not historical/passing ones.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveFindingIds — Blocker 3: only resolve FAIL findings for mute", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("should include filter[status]=FAIL in the findings resolution URL for mute", async () => {
|
||||
it("deduplicates finding IDs across pages", async () => {
|
||||
// Given — same finding_id appears on both pages
|
||||
getLatestFindingGroupResourcesMock
|
||||
.mockResolvedValueOnce({
|
||||
data: [
|
||||
{ id: "r-1", attributes: { finding_id: "finding-1" } },
|
||||
{ id: "r-2", attributes: { finding_id: "finding-2" } },
|
||||
],
|
||||
meta: { pagination: { pages: 2 } },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
data: [{ id: "r-3", attributes: { finding_id: "finding-2" } }],
|
||||
meta: { pagination: { pages: 2 } },
|
||||
});
|
||||
|
||||
// When
|
||||
const result = await resolveFindingIdsByVisibleGroupResources({
|
||||
checkId: "check-1",
|
||||
});
|
||||
|
||||
// Then — no duplicates
|
||||
expect(result).toEqual(["finding-1", "finding-2"]);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the dated endpoint when date or scan filters are active", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }, { id: "finding-2" }],
|
||||
getFindingGroupResourcesMock.mockResolvedValueOnce({
|
||||
data: [{ id: "r-1", attributes: { finding_id: "finding-1" } }],
|
||||
meta: { pagination: { pages: 1 } },
|
||||
});
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
await resolveFindingIdsByVisibleGroupResources({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1", "resource-2"],
|
||||
});
|
||||
|
||||
// Then — the URL must filter to only FAIL status findings
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
});
|
||||
|
||||
it("should include filter[status]=FAIL even when date or scan filters are active", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }],
|
||||
});
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1"],
|
||||
hasDateOrScanFilter: true,
|
||||
filters: {
|
||||
"filter[inserted_at__gte]": "2026-01-01",
|
||||
"filter[scan__in]": "scan-1",
|
||||
},
|
||||
});
|
||||
|
||||
// Then
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.pathname).toBe("/api/v1/findings");
|
||||
expect(calledUrl.searchParams.get("filter[status]")).toBe("FAIL");
|
||||
});
|
||||
|
||||
it("should override caller filter[status] with FAIL — no duplicate params", async () => {
|
||||
// Given — caller passes filter[status]=PASS via filters dict
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }],
|
||||
});
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1"],
|
||||
filters: {
|
||||
"filter[status]": "PASS",
|
||||
},
|
||||
});
|
||||
|
||||
// Then — hardcoded FAIL must win, exactly 1 value
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
const statusValues = calledUrl.searchParams.getAll("filter[status]");
|
||||
expect(statusValues).toHaveLength(1);
|
||||
expect(statusValues[0]).toBe("FAIL");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 4: Unbounded page[size] cap
|
||||
//
|
||||
// The bug: createResourceFindingResolutionUrl sets page[size]=resourceUids.length
|
||||
// with no upper bound guard. The production fix adds Math.min(resourceUids.length, MAX_PAGE_SIZE)
|
||||
// with MAX_PAGE_SIZE=500 as an explicit defensive cap.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("resolveFindingIds — Fix 4: page[size] explicit cap at MAX_PAGE_SIZE=500", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
it("should use resourceUids.length as page[size] for a small batch (under 500)", async () => {
|
||||
// Given — 3 resources, well under the cap
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({
|
||||
data: [{ id: "finding-1" }, { id: "finding-2" }, { id: "finding-3" }],
|
||||
});
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids: ["resource-1", "resource-2", "resource-3"],
|
||||
});
|
||||
|
||||
// Then — page[size] should equal the number of resourceUids (3)
|
||||
const calledUrl = new URL(fetchMock.mock.calls[0][0]);
|
||||
expect(calledUrl.searchParams.get("page[size]")).toBe("3");
|
||||
});
|
||||
|
||||
it("should cap page[size] at 500 when the chunk has exactly 500 UIDs (boundary value)", async () => {
|
||||
// Given — exactly 500 unique UIDs (at the cap boundary)
|
||||
const resourceUids = Array.from({ length: 500 }, (_, i) => `resource-${i}`);
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids,
|
||||
});
|
||||
|
||||
// Then — page[size] must be exactly 500 (not capped lower)
|
||||
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
expect(firstUrl.searchParams.get("page[size]")).toBe("500");
|
||||
});
|
||||
|
||||
it("should cap page[size] at 500 even when a chunk would exceed 500 — Math.min guard in URL builder", async () => {
|
||||
// Given — 501 UIDs. The chunker splits into [500, 1].
|
||||
// The FIRST chunk has 500 UIDs → page[size] should be 500 (Math.min(500, 500)).
|
||||
// The SECOND chunk has 1 UID → page[size] should be 1 (Math.min(1, 500)).
|
||||
// This proves the Math.min cap fires correctly on every chunk.
|
||||
const resourceUids = Array.from({ length: 501 }, (_, i) => `resource-${i}`);
|
||||
fetchMock.mockResolvedValue(new Response("", { status: 200 }));
|
||||
handleApiResponseMock.mockResolvedValue({ data: [] });
|
||||
|
||||
// When
|
||||
await resolveFindingIds({
|
||||
checkId: "check-1",
|
||||
resourceUids,
|
||||
});
|
||||
|
||||
// Then — two fetch calls: one for 500 UIDs, one for 1 UID
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const firstUrl = new URL(fetchMock.mock.calls[0][0] as string);
|
||||
const secondUrl = new URL(fetchMock.mock.calls[1][0] as string);
|
||||
expect(firstUrl.searchParams.get("page[size]")).toBe("500");
|
||||
expect(secondUrl.searchParams.get("page[size]")).toBe("1");
|
||||
// Then — uses getFindingGroupResources (dated), not getLatestFindingGroupResources
|
||||
expect(getFindingGroupResourcesMock).toHaveBeenCalledTimes(1);
|
||||
expect(getLatestFindingGroupResourcesMock).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,22 +14,12 @@ const FINDING_IDS_RESOLUTION_CONCURRENCY = 4;
|
||||
const FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE = 500;
|
||||
const FINDING_FIELDS = "uid";
|
||||
|
||||
/** Explicit upper bound for page[size] in resource-finding resolution requests. */
|
||||
const MAX_RESOURCE_FINDING_PAGE_SIZE = 500;
|
||||
|
||||
interface ResolveFindingIdsByCheckIdsParams {
|
||||
checkIds: string[];
|
||||
filters?: Record<string, string>;
|
||||
hasDateOrScanFilter?: boolean;
|
||||
}
|
||||
|
||||
interface ResolveFindingIdsParams {
|
||||
checkId: string;
|
||||
resourceUids: string[];
|
||||
filters?: Record<string, string>;
|
||||
hasDateOrScanFilter?: boolean;
|
||||
}
|
||||
|
||||
interface ResolveFindingIdsByVisibleGroupResourcesParams {
|
||||
checkId: string;
|
||||
filters?: Record<string, string>;
|
||||
@@ -42,8 +32,8 @@ interface FindingIdsPageResponse {
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
interface FindingGroupResourceUidsPageResponse {
|
||||
resourceUids: string[];
|
||||
interface FindingGroupResourceFindingIdsPageResponse {
|
||||
findingIds: string[];
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
@@ -100,78 +90,7 @@ async function fetchFindingIdsPage({
|
||||
};
|
||||
}
|
||||
|
||||
function chunkValues<T>(values: T[], chunkSize: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let index = 0; index < values.length; index += chunkSize) {
|
||||
chunks.push(values.slice(index, index + chunkSize));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function createResourceFindingResolutionUrl({
|
||||
checkId,
|
||||
resourceUids,
|
||||
filters = {},
|
||||
hasDateOrScanFilter = false,
|
||||
}: ResolveFindingIdsParams): URL {
|
||||
const endpoint = hasDateOrScanFilter ? "findings" : "findings/latest";
|
||||
const url = new URL(`${apiBaseUrl}/${endpoint}`);
|
||||
|
||||
url.searchParams.append("filter[check_id]", checkId);
|
||||
url.searchParams.append("filter[resource_uid__in]", resourceUids.join(","));
|
||||
url.searchParams.append("filter[muted]", "false");
|
||||
url.searchParams.append(
|
||||
"page[size]",
|
||||
Math.min(resourceUids.length, MAX_RESOURCE_FINDING_PAGE_SIZE).toString(),
|
||||
);
|
||||
|
||||
appendSanitizedProviderTypeFilters(url, filters);
|
||||
|
||||
// Hardcoded FAIL filter AFTER appendSanitizedProviderTypeFilters — .set()
|
||||
// guarantees this wins even if the caller passes filter[status] in filters.
|
||||
url.searchParams.set("filter[status]", "FAIL");
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async function fetchFindingIdsForResourceUids({
|
||||
headers,
|
||||
...params
|
||||
}: ResolveFindingIdsParams & {
|
||||
headers: HeadersInit;
|
||||
}): Promise<string[]> {
|
||||
const response = await fetch(
|
||||
createResourceFindingResolutionUrl(params).toString(),
|
||||
{
|
||||
headers,
|
||||
},
|
||||
);
|
||||
const data = await handleApiResponse(response);
|
||||
|
||||
if (!data?.data || !Array.isArray(data.data)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return data.data
|
||||
.map((item: { id?: string }) => item.id)
|
||||
.filter((id: string | undefined): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
function buildFindingGroupResourceFilters({
|
||||
filters = {},
|
||||
resourceSearch,
|
||||
}: Pick<
|
||||
ResolveFindingIdsByVisibleGroupResourcesParams,
|
||||
"filters" | "resourceSearch"
|
||||
>): Record<string, string> {
|
||||
const nextFilters = { ...filters };
|
||||
if (resourceSearch) {
|
||||
nextFilters["filter[name__icontains]"] = resourceSearch;
|
||||
}
|
||||
return nextFilters;
|
||||
}
|
||||
|
||||
async function fetchFindingGroupResourceUidsPage({
|
||||
async function fetchFindingGroupResourceFindingIdsPage({
|
||||
checkId,
|
||||
filters = {},
|
||||
hasDateOrScanFilter = false,
|
||||
@@ -179,77 +98,44 @@ async function fetchFindingGroupResourceUidsPage({
|
||||
resourceSearch,
|
||||
}: ResolveFindingIdsByVisibleGroupResourcesParams & {
|
||||
page: number;
|
||||
}): Promise<FindingGroupResourceUidsPageResponse> {
|
||||
}): Promise<FindingGroupResourceFindingIdsPageResponse> {
|
||||
const fetchFn = hasDateOrScanFilter
|
||||
? getFindingGroupResources
|
||||
: getLatestFindingGroupResources;
|
||||
|
||||
const resolvedFilters: Record<string, string> = {
|
||||
...filters,
|
||||
"filter[status]": "FAIL",
|
||||
"filter[muted]": "false",
|
||||
};
|
||||
if (resourceSearch) {
|
||||
resolvedFilters["filter[name__icontains]"] = resourceSearch;
|
||||
}
|
||||
|
||||
const response = await fetchFn({
|
||||
checkId,
|
||||
page,
|
||||
pageSize: FINDING_GROUP_RESOURCES_RESOLUTION_PAGE_SIZE,
|
||||
filters: buildFindingGroupResourceFilters({ filters, resourceSearch }),
|
||||
filters: resolvedFilters,
|
||||
});
|
||||
|
||||
const data = response?.data;
|
||||
|
||||
if (!data || !Array.isArray(data)) {
|
||||
return { resourceUids: [], totalPages: 1 };
|
||||
return { findingIds: [], totalPages: 1 };
|
||||
}
|
||||
|
||||
return {
|
||||
resourceUids: data
|
||||
findingIds: data
|
||||
.map(
|
||||
(item: { attributes?: { resource?: { uid?: string } } }) =>
|
||||
item.attributes?.resource?.uid,
|
||||
(item: { attributes?: { finding_id?: string } }) =>
|
||||
item.attributes?.finding_id,
|
||||
)
|
||||
.filter((uid: string | undefined): uid is string => Boolean(uid)),
|
||||
.filter((id: string | undefined): id is string => Boolean(id)),
|
||||
totalPages: response?.meta?.pagination?.pages ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves resource UIDs + check ID into actual finding UUIDs.
|
||||
* Uses /findings/latest (or /findings when date/scan filters are active)
|
||||
* with check_id and resource_uid__in filters to batch-resolve actual finding IDs.
|
||||
*/
|
||||
export const resolveFindingIds = async ({
|
||||
checkId,
|
||||
resourceUids,
|
||||
filters = {},
|
||||
hasDateOrScanFilter = false,
|
||||
}: ResolveFindingIdsParams): Promise<string[]> => {
|
||||
if (resourceUids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const resourceUidChunks = chunkValues(
|
||||
Array.from(new Set(resourceUids)),
|
||||
FINDING_IDS_RESOLUTION_PAGE_SIZE,
|
||||
);
|
||||
|
||||
try {
|
||||
const results = await runWithConcurrencyLimit(
|
||||
resourceUidChunks,
|
||||
FINDING_IDS_RESOLUTION_CONCURRENCY,
|
||||
(resourceUidChunk) =>
|
||||
fetchFindingIdsForResourceUids({
|
||||
checkId,
|
||||
resourceUids: resourceUidChunk,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
headers,
|
||||
}),
|
||||
);
|
||||
|
||||
return Array.from(new Set(results.flat()));
|
||||
} catch (error) {
|
||||
console.error("Error resolving finding IDs:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves check IDs into actual finding UUIDs.
|
||||
* Used at the group level where each row represents a check_id.
|
||||
@@ -305,8 +191,12 @@ export const resolveFindingIdsByCheckIds = async ({
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves a finding-group row to the actual findings for the resources
|
||||
* Resolves a finding-group row to the actual finding UUIDs for the resources
|
||||
* currently visible in that group.
|
||||
*
|
||||
* Extracts finding_id directly from the group resources endpoint response,
|
||||
* filtering server-side by status=FAIL and muted=false. No second resolution
|
||||
* round-trip to /findings/latest is needed.
|
||||
*/
|
||||
export const resolveFindingIdsByVisibleGroupResources = async ({
|
||||
checkId,
|
||||
@@ -315,7 +205,7 @@ export const resolveFindingIdsByVisibleGroupResources = async ({
|
||||
resourceSearch,
|
||||
}: ResolveFindingIdsByVisibleGroupResourcesParams): Promise<string[]> => {
|
||||
try {
|
||||
const firstPage = await fetchFindingGroupResourceUidsPage({
|
||||
const firstPage = await fetchFindingGroupResourceFindingIdsPage({
|
||||
checkId,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
@@ -332,7 +222,7 @@ export const resolveFindingIdsByVisibleGroupResources = async ({
|
||||
remainingPages,
|
||||
FINDING_IDS_RESOLUTION_CONCURRENCY,
|
||||
(page) =>
|
||||
fetchFindingGroupResourceUidsPage({
|
||||
fetchFindingGroupResourceFindingIdsPage({
|
||||
checkId,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
@@ -341,19 +231,12 @@ export const resolveFindingIdsByVisibleGroupResources = async ({
|
||||
}),
|
||||
);
|
||||
|
||||
const resourceUids = Array.from(
|
||||
return Array.from(
|
||||
new Set([
|
||||
...firstPage.resourceUids,
|
||||
...remainingResults.flatMap((result) => result.resourceUids),
|
||||
...firstPage.findingIds,
|
||||
...remainingResults.flatMap((result) => result.findingIds),
|
||||
]),
|
||||
);
|
||||
|
||||
return resolveFindingIds({
|
||||
checkId,
|
||||
resourceUids,
|
||||
filters,
|
||||
hasDateOrScanFilter,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Error resolving finding IDs from visible group resources:",
|
||||
@@ -381,7 +264,7 @@ export const getLatestFindingsByResourceUid = async ({
|
||||
url.searchParams.append("filter[resource_uid]", resourceUid);
|
||||
url.searchParams.append("filter[status]", "FAIL");
|
||||
url.searchParams.append("filter[muted]", "include");
|
||||
url.searchParams.append("sort", "-severity,status,-updated_at");
|
||||
url.searchParams.append("sort", "-severity,-updated_at");
|
||||
if (page) url.searchParams.append("page[number]", page.toString());
|
||||
if (pageSize) url.searchParams.append("page[size]", pageSize.toString());
|
||||
|
||||
|
||||
@@ -16,27 +16,27 @@ const customQuery: AttackPathQuery = {
|
||||
provider: "aws",
|
||||
attribution: null,
|
||||
documentation_link: {
|
||||
text: "Cartography schema used by Prowler for AWS graphs",
|
||||
link: "https://example.com/schema",
|
||||
text: "Learn how to write custom openCypher queries",
|
||||
link: "https://example.com/docs",
|
||||
},
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
|
||||
describe("QueryDescription", () => {
|
||||
it("renders the schema documentation link inside an info alert", () => {
|
||||
it("renders the documentation link inside an info alert", () => {
|
||||
// Given
|
||||
render(<QueryDescription query={customQuery} />);
|
||||
|
||||
// When
|
||||
const alert = screen.getByRole("alert");
|
||||
const link = screen.getByRole("link", {
|
||||
name: /cartography schema used by prowler for aws graphs/i,
|
||||
name: /learn how to write custom opencypher queries/i,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "https://example.com/schema");
|
||||
expect(link).toHaveAttribute("href", "https://example.com/docs");
|
||||
});
|
||||
|
||||
it("does not render unsafe documentation or attribution URLs as clickable links", () => {
|
||||
@@ -46,7 +46,7 @@ describe("QueryDescription", () => {
|
||||
attributes: {
|
||||
...customQuery.attributes,
|
||||
documentation_link: {
|
||||
text: "Cartography schema used by Prowler for AWS graphs",
|
||||
text: "Learn how to write custom openCypher queries",
|
||||
link: "javascript:alert('xss')",
|
||||
},
|
||||
attribution: {
|
||||
@@ -62,14 +62,14 @@ describe("QueryDescription", () => {
|
||||
// Then
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: /cartography schema used by prowler for aws graphs/i,
|
||||
name: /learn how to write custom opencypher queries/i,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /unsafe source/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/cartography schema used by prowler for aws graphs/i),
|
||||
screen.getByText(/learn how to write custom opencypher queries/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/unsafe source/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
executeQuery,
|
||||
getAttackPathScans,
|
||||
getAvailableQueries,
|
||||
getCartographySchema,
|
||||
} from "@/actions/attack-paths";
|
||||
import { adaptQueryResultToGraphData } from "@/actions/attack-paths/query-result.adapter";
|
||||
import { AutoRefresh } from "@/components/scans";
|
||||
@@ -144,14 +143,10 @@ export default function AttackPathsPage() {
|
||||
|
||||
setQueriesLoading(true);
|
||||
try {
|
||||
const [queriesData, schemaData] = await Promise.all([
|
||||
getAvailableQueries(scanId),
|
||||
getCartographySchema(scanId),
|
||||
]);
|
||||
const queriesData = await getAvailableQueries(scanId);
|
||||
|
||||
const availableQueries = buildAttackPathQueries(
|
||||
queriesData?.data ?? [],
|
||||
schemaData?.data.attributes,
|
||||
);
|
||||
|
||||
if (availableQueries.length > 0) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getComplianceRequirements,
|
||||
} from "@/actions/compliances";
|
||||
import { getThreatScore } from "@/actions/overview";
|
||||
import { getScan } from "@/actions/scans";
|
||||
import {
|
||||
ClientAccordionWrapper,
|
||||
ComplianceDownloadContainer,
|
||||
@@ -37,7 +38,6 @@ interface ComplianceDetailSearchParams {
|
||||
complianceId: string;
|
||||
version?: string;
|
||||
scanId?: string;
|
||||
scanData?: string;
|
||||
"filter[region__in]"?: string;
|
||||
"filter[cis_profile_level]"?: string;
|
||||
page?: string;
|
||||
@@ -53,7 +53,7 @@ export default async function ComplianceDetail({
|
||||
}) {
|
||||
const { compliancetitle } = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const { complianceId, version, scanId, scanData } = resolvedSearchParams;
|
||||
const { complianceId, version, scanId } = resolvedSearchParams;
|
||||
const regionFilter = resolvedSearchParams["filter[region__in]"];
|
||||
const cisProfileFilter = resolvedSearchParams["filter[cis_profile_level]"];
|
||||
const logoPath = getComplianceIcon(compliancetitle);
|
||||
@@ -72,21 +72,34 @@ export default async function ComplianceDetail({
|
||||
: `${formattedTitle}`;
|
||||
|
||||
let selectedScan: ScanEntity | null = null;
|
||||
const selectedScanId = scanId || null;
|
||||
|
||||
if (scanData) {
|
||||
selectedScan = JSON.parse(decodeURIComponent(scanData));
|
||||
}
|
||||
const [metadataInfoData, attributesData, selectedScanResponse] =
|
||||
await Promise.all([
|
||||
getComplianceOverviewMetadataInfo({
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId,
|
||||
},
|
||||
}),
|
||||
getComplianceAttributes(complianceId),
|
||||
selectedScanId ? getScan(selectedScanId) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const selectedScanId = scanId || selectedScan?.id || null;
|
||||
|
||||
const [metadataInfoData, attributesData] = await Promise.all([
|
||||
getComplianceOverviewMetadataInfo({
|
||||
filters: {
|
||||
"filter[scan_id]": selectedScanId,
|
||||
if (selectedScanResponse?.data) {
|
||||
const scan = selectedScanResponse.data;
|
||||
selectedScan = {
|
||||
id: scan.id,
|
||||
providerInfo: {
|
||||
provider: scan.providerInfo?.provider || "aws",
|
||||
alias: scan.providerInfo?.alias,
|
||||
uid: scan.providerInfo?.uid,
|
||||
},
|
||||
}),
|
||||
getComplianceAttributes(complianceId),
|
||||
]);
|
||||
attributes: {
|
||||
name: scan.attributes.name,
|
||||
completed_at: scan.attributes.completed_at,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const uniqueRegions = metadataInfoData?.data?.attributes?.regions || [];
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
scanId,
|
||||
complianceId,
|
||||
id,
|
||||
selectedScan,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -65,17 +64,6 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
params.set("version", version);
|
||||
params.set("scanId", scanId);
|
||||
|
||||
if (selectedScan) {
|
||||
params.set(
|
||||
"scanData",
|
||||
JSON.stringify({
|
||||
id: selectedScan.id,
|
||||
providerInfo: selectedScan.providerInfo,
|
||||
attributes: selectedScan.attributes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const regionFilter = searchParams.get("filter[region__in]");
|
||||
if (regionFilter) {
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
|
||||
@@ -40,7 +40,6 @@ export const ThreatScoreBadge = ({
|
||||
score,
|
||||
scanId,
|
||||
provider,
|
||||
selectedScan,
|
||||
sectionScores,
|
||||
}: ThreatScoreBadgeProps) => {
|
||||
const router = useRouter();
|
||||
@@ -62,17 +61,6 @@ export const ThreatScoreBadge = ({
|
||||
params.set("version", version);
|
||||
params.set("scanId", scanId);
|
||||
|
||||
if (selectedScan) {
|
||||
params.set(
|
||||
"scanData",
|
||||
JSON.stringify({
|
||||
id: selectedScan.id,
|
||||
providerInfo: selectedScan.providerInfo,
|
||||
attributes: selectedScan.attributes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const regionFilter = searchParams.get("filter[region__in]");
|
||||
if (regionFilter) {
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
|
||||
@@ -86,7 +86,14 @@ export const FindingsFilters = ({
|
||||
|
||||
// Custom filters for the expandable section (removed Provider - now using AccountsSelector)
|
||||
const customFilters = [
|
||||
...filterFindings,
|
||||
...filterFindings.map((filter) => ({
|
||||
...filter,
|
||||
labelFormatter: (value: string) =>
|
||||
getFindingsFilterDisplayValue(`filter[${filter.key}]`, value, {
|
||||
providers,
|
||||
scans: scanDetails,
|
||||
}),
|
||||
})),
|
||||
{
|
||||
key: FilterType.REGION,
|
||||
labelCheckboxGroup: "Regions",
|
||||
@@ -124,6 +131,11 @@ export const FindingsFilters = ({
|
||||
labelCheckboxGroup: "Scan ID",
|
||||
values: completedScanIds,
|
||||
valueLabelMapping: scanDetails,
|
||||
labelFormatter: (value: string) =>
|
||||
getFindingsFilterDisplayValue(`filter[${FilterType.SCAN}]`, value, {
|
||||
providers,
|
||||
scans: scanDetails,
|
||||
}),
|
||||
index: 7,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -101,6 +101,24 @@ describe("getFindingsFilterDisplayValue", () => {
|
||||
).toBe("Scan Account");
|
||||
});
|
||||
|
||||
it("normalizes finding statuses for display", () => {
|
||||
expect(getFindingsFilterDisplayValue("filter[status__in]", "FAIL")).toBe(
|
||||
"Fail",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes severities for display", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[severity__in]", "critical"),
|
||||
).toBe("Critical");
|
||||
});
|
||||
|
||||
it("formats delta values for display", () => {
|
||||
expect(getFindingsFilterDisplayValue("filter[delta__in]", "new")).toBe(
|
||||
"New",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the scan provider uid when the alias is missing", () => {
|
||||
expect(
|
||||
getFindingsFilterDisplayValue("filter[scan__in]", "scan-2", {
|
||||
|
||||
@@ -9,6 +9,11 @@ interface GetFindingsFilterDisplayValueOptions {
|
||||
scans?: Array<{ [scanId: string]: ScanEntity }>;
|
||||
}
|
||||
|
||||
const FINDING_DELTA_DISPLAY_NAMES: Record<string, string> = {
|
||||
new: "New",
|
||||
changed: "Changed",
|
||||
};
|
||||
|
||||
function getProviderAccountDisplayValue(
|
||||
providerId: string,
|
||||
providers: ProviderProps[],
|
||||
@@ -62,6 +67,11 @@ export function getFindingsFilterDisplayValue(
|
||||
] ?? formatLabel(value)
|
||||
);
|
||||
}
|
||||
if (filterKey === "filter[delta__in]") {
|
||||
return (
|
||||
FINDING_DELTA_DISPLAY_NAMES[value.toLowerCase()] ?? formatLabel(value)
|
||||
);
|
||||
}
|
||||
if (filterKey === "filter[category__in]") {
|
||||
return getCategoryLabel(value);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,17 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
import { FloatingMuteButton } from "./floating-mute-button";
|
||||
|
||||
function deferredPromise<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 3: onBeforeOpen rejection resets isResolving
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -31,7 +42,6 @@ import { FloatingMuteButton } from "./floating-mute-button";
|
||||
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
|
||||
@@ -58,11 +68,9 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should log the error when onBeforeOpen rejects", async () => {
|
||||
it("should show the preparation error in the modal when onBeforeOpen rejects", async () => {
|
||||
// Given
|
||||
const error = new Error("Fetch failed");
|
||||
const onBeforeOpen = vi.fn().mockRejectedValue(error);
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const onBeforeOpen = vi.fn().mockRejectedValue(new Error("Fetch failed"));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
@@ -76,9 +84,26 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then — error was logged
|
||||
// Then
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
const lastCall = (
|
||||
MuteFindingsModalMock.mock.calls as unknown as Array<
|
||||
[
|
||||
{
|
||||
isOpen: boolean;
|
||||
isPreparing?: boolean;
|
||||
preparationError?: string | null;
|
||||
},
|
||||
]
|
||||
>
|
||||
).at(-1);
|
||||
|
||||
expect(lastCall?.[0]).toMatchObject({
|
||||
isOpen: true,
|
||||
isPreparing: false,
|
||||
preparationError:
|
||||
"We couldn't prepare this mute action. Please try again.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,10 +127,76 @@ describe("FloatingMuteButton — onBeforeOpen error handling", () => {
|
||||
await waitFor(() => {
|
||||
const lastCall = (
|
||||
MuteFindingsModalMock.mock.calls as unknown as Array<
|
||||
[{ isOpen: boolean; findingIds: string[] }]
|
||||
[
|
||||
{
|
||||
isOpen: boolean;
|
||||
findingIds: string[];
|
||||
isPreparing?: boolean;
|
||||
},
|
||||
]
|
||||
>
|
||||
).at(-1);
|
||||
expect(lastCall?.[0]?.isOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should open the modal immediately in preparing state while IDs are still resolving", async () => {
|
||||
// Given
|
||||
const deferred = deferredPromise<string[]>();
|
||||
const onBeforeOpen = vi.fn().mockReturnValue(deferred.promise);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={3}
|
||||
selectedFindingIds={["group-1", "group-2", "group-3"]}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then
|
||||
const preparingCall = (
|
||||
MuteFindingsModalMock.mock.calls as unknown as Array<
|
||||
[
|
||||
{
|
||||
isOpen: boolean;
|
||||
findingIds: string[];
|
||||
isPreparing?: boolean;
|
||||
},
|
||||
]
|
||||
>
|
||||
).at(-1);
|
||||
|
||||
expect(preparingCall?.[0]).toMatchObject({
|
||||
isOpen: true,
|
||||
isPreparing: true,
|
||||
findingIds: [],
|
||||
});
|
||||
|
||||
// And when the IDs resolve
|
||||
deferred.resolve(["id-1", "id-2"]);
|
||||
|
||||
await waitFor(() => {
|
||||
const resolvedCall = (
|
||||
MuteFindingsModalMock.mock.calls as unknown as Array<
|
||||
[
|
||||
{
|
||||
isOpen: boolean;
|
||||
findingIds: string[];
|
||||
isPreparing?: boolean;
|
||||
},
|
||||
]
|
||||
>
|
||||
).at(-1);
|
||||
|
||||
expect(resolvedCall?.[0]).toMatchObject({
|
||||
isOpen: true,
|
||||
isPreparing: false,
|
||||
findingIds: ["id-1", "id-2"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,22 +31,46 @@ export function FloatingMuteButton({
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [resolvedIds, setResolvedIds] = useState<string[]>([]);
|
||||
const [isResolving, setIsResolving] = useState(false);
|
||||
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
|
||||
const [mutePreparationError, setMutePreparationError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const handleModalOpenChange = (
|
||||
nextOpen: boolean | ((previousOpen: boolean) => boolean),
|
||||
) => {
|
||||
const resolvedOpen =
|
||||
typeof nextOpen === "function" ? nextOpen(isModalOpen) : nextOpen;
|
||||
setIsModalOpen(resolvedOpen);
|
||||
|
||||
if (!resolvedOpen) {
|
||||
setResolvedIds([]);
|
||||
setIsPreparingMuteModal(false);
|
||||
setMutePreparationError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async () => {
|
||||
if (onBeforeOpen) {
|
||||
setResolvedIds([]);
|
||||
setMutePreparationError(null);
|
||||
setIsPreparingMuteModal(true);
|
||||
setIsModalOpen(true);
|
||||
setIsResolving(true);
|
||||
try {
|
||||
const ids = await onBeforeOpen();
|
||||
setResolvedIds(ids);
|
||||
if (ids.length > 0) {
|
||||
setIsModalOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"FloatingMuteButton: failed to resolve finding IDs",
|
||||
error,
|
||||
setMutePreparationError(
|
||||
ids.length === 0
|
||||
? "No findings could be resolved for this selection. Try refreshing the page and trying again."
|
||||
: null,
|
||||
);
|
||||
} catch {
|
||||
setMutePreparationError(
|
||||
"We couldn't prepare this mute action. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setIsPreparingMuteModal(false);
|
||||
setIsResolving(false);
|
||||
}
|
||||
} else {
|
||||
@@ -65,10 +89,12 @@ export function FloatingMuteButton({
|
||||
<>
|
||||
<MuteFindingsModal
|
||||
isOpen={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
onOpenChange={handleModalOpenChange}
|
||||
findingIds={findingIds}
|
||||
onComplete={handleComplete}
|
||||
isBulkOperation={isBulkOperation}
|
||||
isPreparing={isPreparingMuteModal}
|
||||
preparationError={mutePreparationError}
|
||||
/>
|
||||
|
||||
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-6 bottom-6 z-50 duration-300">
|
||||
|
||||
@@ -5,7 +5,10 @@ import { Dispatch, SetStateAction, useState, useTransition } from "react";
|
||||
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { MuteRuleActionState } from "@/actions/mute-rules/types";
|
||||
import { Button } from "@/components/shadcn";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { useToast } from "@/components/ui";
|
||||
import { FormButtons } from "@/components/ui/form";
|
||||
|
||||
@@ -15,6 +18,8 @@ interface MuteFindingsModalProps {
|
||||
findingIds: string[];
|
||||
onComplete?: () => void;
|
||||
isBulkOperation?: boolean;
|
||||
isPreparing?: boolean;
|
||||
preparationError?: string | null;
|
||||
}
|
||||
|
||||
export function MuteFindingsModal({
|
||||
@@ -23,6 +28,8 @@ export function MuteFindingsModal({
|
||||
findingIds,
|
||||
onComplete,
|
||||
isBulkOperation = false,
|
||||
isPreparing = false,
|
||||
preparationError = null,
|
||||
}: MuteFindingsModalProps) {
|
||||
const { toast } = useToast();
|
||||
const [state, setState] = useState<MuteRuleActionState | null>(null);
|
||||
@@ -32,6 +39,12 @@ export function MuteFindingsModal({
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const isSubmitDisabled =
|
||||
isPending ||
|
||||
isPreparing ||
|
||||
findingIds.length === 0 ||
|
||||
Boolean(preparationError);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen}
|
||||
@@ -43,6 +56,10 @@ export function MuteFindingsModal({
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
|
||||
startTransition(() => {
|
||||
@@ -77,52 +94,121 @@ export function MuteFindingsModal({
|
||||
value={JSON.stringify(findingIds)}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
You are about to mute{" "}
|
||||
<span className="font-semibold text-slate-900 dark:text-white">
|
||||
{findingIds.length}
|
||||
</span>{" "}
|
||||
{findingIds.length === 1 ? "finding" : "findings"}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-500">
|
||||
Muted findings will be hidden by default but can be shown using
|
||||
filters.
|
||||
</p>
|
||||
</div>
|
||||
{isPreparing ? (
|
||||
<>
|
||||
<div className="rounded-lg bg-slate-50 p-4 dark:bg-slate-800/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<Spinner className="mt-0.5 size-5 shrink-0" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
Preparing findings to mute...
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Large finding groups can take a few seconds while we gather
|
||||
the matching findings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
name="name"
|
||||
label="Rule Name"
|
||||
placeholder="e.g., Ignore dev environment S3 buckets"
|
||||
isRequired
|
||||
variant="bordered"
|
||||
isInvalid={!!state?.errors?.name}
|
||||
errorMessage={state?.errors?.name}
|
||||
isDisabled={isPending}
|
||||
description="A descriptive name for this mute rule"
|
||||
/>
|
||||
<div className="space-y-3" aria-hidden="true">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-11 w-full rounded-lg" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
name="reason"
|
||||
label="Reason"
|
||||
placeholder="e.g., These are expected findings in the development environment"
|
||||
isRequired
|
||||
variant="bordered"
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
isInvalid={!!state?.errors?.reason}
|
||||
errorMessage={state?.errors?.reason}
|
||||
isDisabled={isPending}
|
||||
description="Explain why these findings are being muted"
|
||||
/>
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" size="lg" disabled>
|
||||
<Spinner className="size-4" />
|
||||
Preparing...
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : preparationError ? (
|
||||
<>
|
||||
<div className="rounded-lg bg-slate-50 p-4 dark:bg-slate-800/50">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-white">
|
||||
We couldn't prepare this mute action.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{preparationError}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FormButtons
|
||||
setIsOpen={onOpenChange}
|
||||
onCancel={handleCancel}
|
||||
submitText="Mute Findings"
|
||||
isDisabled={isPending}
|
||||
/>
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg bg-slate-50 p-3 dark:bg-slate-800/50">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
You are about to mute{" "}
|
||||
<span className="font-semibold text-slate-900 dark:text-white">
|
||||
{findingIds.length}
|
||||
</span>{" "}
|
||||
{findingIds.length === 1 ? "finding" : "findings"}.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-500">
|
||||
Muted findings will be hidden by default but can be shown using
|
||||
filters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
name="name"
|
||||
label="Rule Name"
|
||||
placeholder="e.g., Ignore dev environment S3 buckets"
|
||||
isRequired
|
||||
variant="bordered"
|
||||
isInvalid={!!state?.errors?.name}
|
||||
errorMessage={state?.errors?.name}
|
||||
isDisabled={isPending}
|
||||
description="A descriptive name for this mute rule"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
name="reason"
|
||||
label="Reason"
|
||||
placeholder="e.g., These are expected findings in the development environment"
|
||||
isRequired
|
||||
variant="bordered"
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
isInvalid={!!state?.errors?.reason}
|
||||
errorMessage={state?.errors?.reason}
|
||||
isDisabled={isPending}
|
||||
description="Explain why these findings are being muted"
|
||||
/>
|
||||
|
||||
<FormButtons
|
||||
setIsOpen={onOpenChange}
|
||||
onCancel={handleCancel}
|
||||
submitText="Mute Findings"
|
||||
isDisabled={isPending}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,10 @@ import userEvent from "@testing-library/user-event";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { notificationIndicatorMock } = vi.hoisted(() => ({
|
||||
notificationIndicatorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks for dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,7 +76,10 @@ vi.mock("./impacted-resources-cell", () => ({
|
||||
|
||||
vi.mock("./notification-indicator", () => ({
|
||||
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" },
|
||||
NotificationIndicator: () => null,
|
||||
NotificationIndicator: (props: unknown) => {
|
||||
notificationIndicatorMock(props);
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -95,10 +102,23 @@ function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
|
||||
checkTitle: "S3 Bucket Public Access",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
muted: false,
|
||||
resourcesTotal: 5,
|
||||
resourcesFail: 3,
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
newFailCount: 0,
|
||||
newFailMutedCount: 0,
|
||||
newPassCount: 0,
|
||||
newPassMutedCount: 0,
|
||||
newManualCount: 0,
|
||||
newManualMutedCount: 0,
|
||||
changedFailCount: 0,
|
||||
changedFailMutedCount: 0,
|
||||
changedPassCount: 0,
|
||||
changedPassMutedCount: 0,
|
||||
changedManualCount: 0,
|
||||
changedManualMutedCount: 0,
|
||||
mutedCount: 0,
|
||||
providers: ["aws"],
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
@@ -325,3 +345,25 @@ describe("column-finding-groups — group selection", () => {
|
||||
expect(screen.getByRole("checkbox", { name: "Select row" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("column-finding-groups — indicators", () => {
|
||||
it("should prefer the new indicator when the new delta exists only in the breakdown fields", () => {
|
||||
notificationIndicatorMock.mockClear();
|
||||
|
||||
renderSelectCell({
|
||||
muted: true,
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
newFailMutedCount: 1,
|
||||
changedFailCount: 2,
|
||||
});
|
||||
|
||||
expect(notificationIndicatorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
delta: "new",
|
||||
isMuted: true,
|
||||
showDeltaWhenMuted: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
StatusFindingBadge,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib";
|
||||
import {
|
||||
getFilteredFindingGroupDelta,
|
||||
isFindingGroupMuted,
|
||||
} from "@/lib/findings-groups";
|
||||
import { FindingGroupRow, ProviderType } from "@/types";
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
@@ -25,6 +29,8 @@ interface GetColumnFindingGroupsOptions {
|
||||
expandedCheckId?: string | null;
|
||||
/** True when the expanded group has individually selected resources */
|
||||
hasResourceSelection?: boolean;
|
||||
/** Active URL filters — used to make the delta indicator status-aware */
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
const VISIBLE_DISABLED_CHECKBOX_CLASS =
|
||||
@@ -36,6 +42,7 @@ export function getColumnFindingGroups({
|
||||
onDrillDown,
|
||||
expandedCheckId,
|
||||
hasResourceSelection = false,
|
||||
filters = {},
|
||||
}: GetColumnFindingGroupsOptions): ColumnDef<FindingGroupRow>[] {
|
||||
const selectedCount = Object.values(rowSelection).filter(Boolean).length;
|
||||
const isAllSelected =
|
||||
@@ -74,14 +81,13 @@ export function getColumnFindingGroups({
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const group = row.original;
|
||||
const allMuted =
|
||||
group.mutedCount > 0 && group.mutedCount === group.resourcesTotal;
|
||||
const allMuted = isFindingGroupMuted(group);
|
||||
const isExpanded = expandedCheckId === group.checkId;
|
||||
|
||||
const deltaKey = getFilteredFindingGroupDelta(group, filters);
|
||||
const delta =
|
||||
group.newCount > 0
|
||||
deltaKey === "new"
|
||||
? DeltaValues.NEW
|
||||
: group.changedCount > 0
|
||||
: deltaKey === "changed"
|
||||
? DeltaValues.CHANGED
|
||||
: DeltaValues.NONE;
|
||||
|
||||
@@ -89,12 +95,17 @@ export function getColumnFindingGroups({
|
||||
const canSelect = canMuteFindingGroup({
|
||||
resourcesFail: group.resourcesFail,
|
||||
resourcesTotal: group.resourcesTotal,
|
||||
muted: group.muted,
|
||||
mutedCount: group.mutedCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationIndicator delta={delta} isMuted={allMuted} />
|
||||
<NotificationIndicator
|
||||
delta={delta}
|
||||
isMuted={allMuted}
|
||||
showDeltaWhenMuted
|
||||
/>
|
||||
{canExpand ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -149,9 +160,7 @@ export function getColumnFindingGroups({
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const rawStatus = row.original.status as string;
|
||||
const status = rawStatus === "MUTED" ? "FAIL" : row.original.status;
|
||||
return <StatusFindingBadge status={status} />;
|
||||
return <StatusFindingBadge status={row.original.status} />;
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -26,7 +27,19 @@ vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
SendToJiraModal: ({
|
||||
findingId,
|
||||
isOpen,
|
||||
}: {
|
||||
findingId: string;
|
||||
isOpen: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="jira-modal"
|
||||
data-finding-id={findingId}
|
||||
data-open={isOpen ? "true" : "false"}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
@@ -37,8 +50,18 @@ vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ActionDropdownItem: ({ label }: { label: string }) => (
|
||||
<button>{label}</button>
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button disabled={disabled} onClick={onSelect}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -200,4 +223,50 @@ describe("column-finding-resources", () => {
|
||||
expect(screen.getByText("my-bucket")).toBeInTheDocument();
|
||||
expect(screen.getByText("arn:aws:s3:::my-bucket")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open Send to Jira modal with finding UUID directly", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
|
||||
const columns = getColumnFindingResources({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
});
|
||||
|
||||
const actionColumn = columns.find(
|
||||
(col) => (col as { id?: string }).id === "actions",
|
||||
);
|
||||
if (!actionColumn?.cell) {
|
||||
throw new Error("actions column not found");
|
||||
}
|
||||
|
||||
const CellComponent = actionColumn.cell as (props: {
|
||||
row: { original: FindingResourceRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(
|
||||
<div>
|
||||
{CellComponent({
|
||||
row: {
|
||||
original: makeResource({
|
||||
findingId: "real-finding-uuid",
|
||||
}),
|
||||
},
|
||||
})}
|
||||
</div>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Send to Jira" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByTestId("jira-modal")).toHaveAttribute(
|
||||
"data-finding-id",
|
||||
"real-finding-uuid",
|
||||
);
|
||||
expect(screen.getByTestId("jira-modal")).toHaveAttribute(
|
||||
"data-open",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,14 @@ const ResourceRowActions = ({ row }: { row: Row<FindingResourceRow> }) => {
|
||||
const handleMuteClick = async () => {
|
||||
const displayIds = getDisplayIds();
|
||||
|
||||
// Single resource: findingId is already a real finding UUID
|
||||
if (displayIds.length === 1) {
|
||||
setResolvedIds(displayIds);
|
||||
setIsMuteModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-select: resolve through context
|
||||
if (resolveMuteIds) {
|
||||
setIsResolving(true);
|
||||
const ids = await resolveMuteIds(displayIds);
|
||||
@@ -179,6 +187,7 @@ export function getColumnFindingResources({
|
||||
delta={row.original.delta as DeltaType | undefined}
|
||||
isMuted={row.original.isMuted}
|
||||
mutedReason={row.original.mutedReason}
|
||||
showDeltaWhenMuted
|
||||
/>
|
||||
<CornerDownRight className="text-text-neutral-tertiary h-4 w-4 shrink-0" />
|
||||
<Checkbox
|
||||
@@ -218,14 +227,9 @@ export function getColumnFindingResources({
|
||||
<DataTableColumnHeader column={column} title="Status" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const rawStatus = row.original.status;
|
||||
const status: FindingStatus =
|
||||
rawStatus === "MUTED" || rawStatus === "FAIL"
|
||||
? "FAIL"
|
||||
: rawStatus === "PASS"
|
||||
? "PASS"
|
||||
: "FAIL";
|
||||
return <StatusFindingBadge status={status} />;
|
||||
return (
|
||||
<StatusFindingBadge status={row.original.status as FindingStatus} />
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
|
||||
@@ -97,6 +97,7 @@ export function getColumnFindings(
|
||||
delta={delta}
|
||||
isMuted={isMuted}
|
||||
mutedReason={mutedReason}
|
||||
showDeltaWhenMuted
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
182
ui/components/findings/table/data-table-row-actions.test.tsx
Normal file
182
ui/components/findings/table/data-table-row-actions.test.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { MuteFindingsModalMock } = vi.hoisted(() => ({
|
||||
MuteFindingsModalMock: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
MuteFindingsModal: MuteFindingsModalMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
JiraIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ActionDropdownItem: ({
|
||||
label,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<button onClick={onSelect} disabled={disabled}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => <span>Loading</span>,
|
||||
}));
|
||||
|
||||
import { DataTableRowActions } from "./data-table-row-actions";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
function deferredPromise<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("DataTableRowActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("opens the mute modal immediately in preparing state for finding groups", async () => {
|
||||
// Given
|
||||
const deferred = deferredPromise<string[]>();
|
||||
const resolveMuteIds = vi.fn().mockReturnValue(deferred.promise);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: [],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
resolveMuteIds,
|
||||
}}
|
||||
>
|
||||
<DataTableRowActions
|
||||
row={
|
||||
{
|
||||
original: {
|
||||
id: "group-row-1",
|
||||
rowType: "group",
|
||||
checkId: "ecs_task_definitions_no_environment_secrets",
|
||||
checkTitle: "ECS task definitions no environment secrets",
|
||||
mutedCount: 0,
|
||||
resourcesFail: 475,
|
||||
resourcesTotal: 475,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Mute Finding Group" }),
|
||||
);
|
||||
|
||||
// Then
|
||||
const preparingCall = (
|
||||
MuteFindingsModalMock.mock.calls as unknown as Array<
|
||||
[
|
||||
{
|
||||
isOpen: boolean;
|
||||
isPreparing?: boolean;
|
||||
findingIds: string[];
|
||||
},
|
||||
]
|
||||
>
|
||||
).at(-1);
|
||||
|
||||
expect(preparingCall?.[0]).toMatchObject({
|
||||
isOpen: true,
|
||||
isPreparing: true,
|
||||
findingIds: [],
|
||||
});
|
||||
|
||||
// And when the resolver finishes
|
||||
deferred.resolve(["finding-1", "finding-2"]);
|
||||
|
||||
await waitFor(() => {
|
||||
const resolvedCall = (
|
||||
MuteFindingsModalMock.mock.calls as unknown as Array<
|
||||
[
|
||||
{
|
||||
isOpen: boolean;
|
||||
isPreparing?: boolean;
|
||||
findingIds: string[];
|
||||
},
|
||||
]
|
||||
>
|
||||
).at(-1);
|
||||
|
||||
expect(resolvedCall?.[0]).toMatchObject({
|
||||
isOpen: true,
|
||||
isPreparing: false,
|
||||
findingIds: ["finding-1", "finding-2"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the mute action for groups without impacted resources", () => {
|
||||
render(
|
||||
<FindingsSelectionContext.Provider
|
||||
value={{
|
||||
selectedFindingIds: [],
|
||||
selectedFindings: [],
|
||||
clearSelection: vi.fn(),
|
||||
isSelected: vi.fn(),
|
||||
resolveMuteIds: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<DataTableRowActions
|
||||
row={
|
||||
{
|
||||
original: {
|
||||
id: "group-row-2",
|
||||
rowType: "group",
|
||||
checkId: "check-with-zero-failures",
|
||||
checkTitle: "Check with zero failures",
|
||||
mutedCount: 0,
|
||||
resourcesFail: 0,
|
||||
resourcesTotal: 42,
|
||||
},
|
||||
} as never
|
||||
}
|
||||
/>
|
||||
</FindingsSelectionContext.Provider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Mute Finding Group" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
ActionDropdownItem,
|
||||
} from "@/components/shadcn/dropdown";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { isFindingGroupMuted } from "@/lib/findings-groups";
|
||||
|
||||
import { canMuteFindingGroup } from "./finding-group-selection";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
|
||||
export interface FindingRowData {
|
||||
@@ -28,7 +30,9 @@ export interface FindingRowData {
|
||||
rowType?: string;
|
||||
checkId?: string;
|
||||
checkTitle?: string;
|
||||
muted?: boolean;
|
||||
mutedCount?: number;
|
||||
resourcesFail?: number;
|
||||
resourcesTotal?: number;
|
||||
}
|
||||
|
||||
@@ -38,15 +42,27 @@ export interface FindingRowData {
|
||||
*/
|
||||
function extractRowInfo(data: FindingRowData) {
|
||||
if (data.rowType === "group") {
|
||||
const allMuted =
|
||||
(data.mutedCount ?? 0) > 0 && data.mutedCount === data.resourcesTotal;
|
||||
const isMuted = isFindingGroupMuted({
|
||||
muted: data.muted,
|
||||
mutedCount: data.mutedCount ?? 0,
|
||||
resourcesFail: data.resourcesFail ?? 0,
|
||||
resourcesTotal: data.resourcesTotal ?? 0,
|
||||
});
|
||||
|
||||
return {
|
||||
isMuted: allMuted,
|
||||
isMuted,
|
||||
canMute: canMuteFindingGroup({
|
||||
resourcesFail: data.resourcesFail ?? 0,
|
||||
resourcesTotal: data.resourcesTotal ?? 0,
|
||||
muted: data.muted,
|
||||
mutedCount: data.mutedCount ?? 0,
|
||||
}),
|
||||
title: data.checkTitle || "Security Finding",
|
||||
};
|
||||
}
|
||||
return {
|
||||
isMuted: data.attributes?.muted ?? false,
|
||||
canMute: !(data.attributes?.muted ?? false),
|
||||
title: data.attributes?.check_metadata?.checktitle || "Security Finding",
|
||||
};
|
||||
}
|
||||
@@ -64,8 +80,12 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
const finding = row.original;
|
||||
const [isJiraModalOpen, setIsJiraModalOpen] = useState(false);
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [isPreparingMuteModal, setIsPreparingMuteModal] = useState(false);
|
||||
const [mutePreparationError, setMutePreparationError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const { isMuted, title: findingTitle } = extractRowInfo(finding);
|
||||
const { isMuted, canMute, title: findingTitle } = extractRowInfo(finding);
|
||||
|
||||
// Get selection context - if there are other selected rows, include them
|
||||
const selectionContext = useContext(FindingsSelectionContext);
|
||||
@@ -103,15 +123,45 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
return isGroup ? "Mute Finding Group" : "Mute Finding";
|
||||
};
|
||||
|
||||
const handleMuteModalOpenChange = (
|
||||
nextOpen: boolean | ((previousOpen: boolean) => boolean),
|
||||
) => {
|
||||
const resolvedOpen =
|
||||
typeof nextOpen === "function" ? nextOpen(isMuteModalOpen) : nextOpen;
|
||||
setIsMuteModalOpen(resolvedOpen);
|
||||
|
||||
if (!resolvedOpen) {
|
||||
setIsPreparingMuteModal(false);
|
||||
setMutePreparationError(null);
|
||||
setResolvedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMuteClick = async () => {
|
||||
const displayIds = getDisplayIds();
|
||||
|
||||
if (resolveMuteIds) {
|
||||
setResolvedIds([]);
|
||||
setMutePreparationError(null);
|
||||
setIsPreparingMuteModal(true);
|
||||
setIsMuteModalOpen(true);
|
||||
setIsResolving(true);
|
||||
const ids = await resolveMuteIds(displayIds);
|
||||
setResolvedIds(ids);
|
||||
setIsResolving(false);
|
||||
if (ids.length > 0) setIsMuteModalOpen(true);
|
||||
try {
|
||||
const ids = await resolveMuteIds(displayIds);
|
||||
setResolvedIds(ids);
|
||||
setMutePreparationError(
|
||||
ids.length === 0
|
||||
? "No findings could be resolved for this group. Try refreshing the page and trying again."
|
||||
: null,
|
||||
);
|
||||
} catch {
|
||||
setMutePreparationError(
|
||||
"We couldn't prepare this mute action. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setIsPreparingMuteModal(false);
|
||||
setIsResolving(false);
|
||||
}
|
||||
} else {
|
||||
// Regular findings — IDs are already valid finding UUIDs
|
||||
setResolvedIds(displayIds);
|
||||
@@ -146,10 +196,12 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
|
||||
<MuteFindingsModal
|
||||
isOpen={isMuteModalOpen}
|
||||
onOpenChange={setIsMuteModalOpen}
|
||||
onOpenChange={handleMuteModalOpenChange}
|
||||
findingIds={resolvedIds}
|
||||
onComplete={handleMuteComplete}
|
||||
isBulkOperation={finding.rowType === "group"}
|
||||
isPreparing={isPreparingMuteModal}
|
||||
preparationError={mutePreparationError}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end">
|
||||
@@ -165,7 +217,7 @@ export function DataTableRowActions<T extends FindingRowData>({
|
||||
)
|
||||
}
|
||||
label={isResolving ? "Resolving..." : getMuteLabel()}
|
||||
disabled={isMuted || isResolving}
|
||||
disabled={!canMute || isResolving}
|
||||
onSelect={handleMuteClick}
|
||||
/>
|
||||
{!isGroup && (
|
||||
|
||||
@@ -7,23 +7,21 @@ describe("canMuteFindingGroup", () => {
|
||||
expect(
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: 0,
|
||||
resourcesTotal: 2,
|
||||
mutedCount: 0,
|
||||
muted: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when all resources are already muted", () => {
|
||||
it("returns false when the explicit muted flag marks the group as fully muted", () => {
|
||||
expect(
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: 3,
|
||||
resourcesTotal: 3,
|
||||
mutedCount: 3,
|
||||
muted: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when all failing resources are muted even if PASS resources exist", () => {
|
||||
it("returns false when legacy counters indicate all failing resources are muted", () => {
|
||||
expect(
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: 2,
|
||||
@@ -37,8 +35,7 @@ describe("canMuteFindingGroup", () => {
|
||||
expect(
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: 2,
|
||||
resourcesTotal: 5,
|
||||
mutedCount: 1,
|
||||
muted: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { isFindingGroupMuted } from "@/lib/findings-groups";
|
||||
|
||||
interface FindingGroupSelectionState {
|
||||
resourcesFail: number;
|
||||
resourcesTotal: number;
|
||||
mutedCount: number;
|
||||
resourcesTotal?: number;
|
||||
muted?: boolean;
|
||||
mutedCount?: number;
|
||||
}
|
||||
|
||||
export function canMuteFindingGroup({
|
||||
resourcesFail,
|
||||
muted,
|
||||
mutedCount,
|
||||
resourcesTotal,
|
||||
}: FindingGroupSelectionState): boolean {
|
||||
const allMuted = mutedCount > 0 && mutedCount === resourcesFail;
|
||||
return resourcesFail > 0 && !allMuted;
|
||||
return (
|
||||
resourcesFail > 0 &&
|
||||
!isFindingGroupMuted({
|
||||
muted,
|
||||
mutedCount: mutedCount ?? 0,
|
||||
resourcesFail,
|
||||
resourcesTotal: resourcesTotal ?? 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { ChevronLeft } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import {
|
||||
Table,
|
||||
@@ -24,6 +23,10 @@ import {
|
||||
import { SeverityBadge, StatusFindingBadge } from "@/components/ui/table";
|
||||
import { useInfiniteResources } from "@/hooks/use-infinite-resources";
|
||||
import { cn, hasDateOrScanFilter } from "@/lib";
|
||||
import {
|
||||
getFilteredFindingGroupDelta,
|
||||
isFindingGroupMuted,
|
||||
} from "@/lib/findings-groups";
|
||||
import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
|
||||
import { FloatingMuteButton } from "../floating-mute-button";
|
||||
@@ -111,18 +114,9 @@ export function FindingsGroupDrillDown({
|
||||
.map((idx) => resources[parseInt(idx)]?.findingId)
|
||||
.filter((id): id is string => id !== null && id !== undefined && id !== "");
|
||||
|
||||
/** Converts resource_ids (display) → resourceUids → finding UUIDs via API. */
|
||||
/** findingId values are already real finding UUIDs — no resolution needed. */
|
||||
const resolveResourceIds = async (ids: string[]) => {
|
||||
const resourceUids = ids
|
||||
.map((id) => resources.find((r) => r.findingId === id)?.resourceUid)
|
||||
.filter(Boolean) as string[];
|
||||
if (resourceUids.length === 0) return [];
|
||||
return resolveFindingIds({
|
||||
checkId: group.checkId,
|
||||
resourceUids,
|
||||
filters,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
});
|
||||
return ids.filter(Boolean);
|
||||
};
|
||||
|
||||
const selectableRowCount = resources.filter(canMuteFindingResource).length;
|
||||
@@ -162,15 +156,15 @@ export function FindingsGroupDrillDown({
|
||||
});
|
||||
|
||||
// Delta for the sticky header
|
||||
const deltaKey = getFilteredFindingGroupDelta(group, filters);
|
||||
const delta =
|
||||
group.newCount > 0
|
||||
deltaKey === "new"
|
||||
? DeltaValues.NEW
|
||||
: group.changedCount > 0
|
||||
: deltaKey === "changed"
|
||||
? DeltaValues.CHANGED
|
||||
: DeltaValues.NONE;
|
||||
|
||||
const allMuted =
|
||||
group.mutedCount > 0 && group.mutedCount === group.resourcesTotal;
|
||||
const allMuted = isFindingGroupMuted(group);
|
||||
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
@@ -205,7 +199,11 @@ export function FindingsGroupDrillDown({
|
||||
</button>
|
||||
|
||||
{/* Notification indicator */}
|
||||
<NotificationIndicator delta={delta} isMuted={allMuted} />
|
||||
<NotificationIndicator
|
||||
delta={delta}
|
||||
isMuted={allMuted}
|
||||
showDeltaWhenMuted
|
||||
/>
|
||||
|
||||
{/* Status badge */}
|
||||
<StatusFindingBadge status={group.status} />
|
||||
|
||||
@@ -4,10 +4,7 @@ import { Row, RowSelectionState } from "@tanstack/react-table";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
resolveFindingIds,
|
||||
resolveFindingIdsByVisibleGroupResources,
|
||||
} from "@/actions/findings/findings-by-resource";
|
||||
import { resolveFindingIdsByVisibleGroupResources } from "@/actions/findings/findings-by-resource";
|
||||
import { DataTable } from "@/components/ui/table";
|
||||
import { hasDateOrScanFilter } from "@/lib";
|
||||
import { FindingGroupRow, MetaDataProps } from "@/types";
|
||||
@@ -93,6 +90,7 @@ export function FindingsGroupTable({
|
||||
canMuteFindingGroup({
|
||||
resourcesFail: g.resourcesFail,
|
||||
resourcesTotal: g.resourcesTotal,
|
||||
muted: g.muted,
|
||||
mutedCount: g.mutedCount,
|
||||
}),
|
||||
).length;
|
||||
@@ -102,6 +100,7 @@ export function FindingsGroupTable({
|
||||
return canMuteFindingGroup({
|
||||
resourcesFail: group.resourcesFail,
|
||||
resourcesTotal: group.resourcesTotal,
|
||||
muted: group.muted,
|
||||
mutedCount: group.mutedCount,
|
||||
});
|
||||
};
|
||||
@@ -174,6 +173,7 @@ export function FindingsGroupTable({
|
||||
onDrillDown: handleDrillDown,
|
||||
expandedCheckId,
|
||||
hasResourceSelection,
|
||||
filters,
|
||||
});
|
||||
|
||||
const renderAfterRow = (row: Row<FindingGroupRow>) => {
|
||||
@@ -238,14 +238,8 @@ export function FindingsGroupTable({
|
||||
selectedCheckIds.length > 0
|
||||
? resolveGroupMuteIds(selectedCheckIds)
|
||||
: Promise.resolve([]),
|
||||
hasResourceSelection && expandedCheckId
|
||||
? resolveFindingIds({
|
||||
checkId: expandedCheckId,
|
||||
resourceUids: resourceSelection,
|
||||
filters,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
// resourceSelection already contains real finding UUIDs
|
||||
Promise.resolve(hasResourceSelection ? resourceSelection : []),
|
||||
]);
|
||||
return [...groupIds, ...resourceIds];
|
||||
}}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { ChevronsDown } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useImperativeHandle, useRef, useState } from "react";
|
||||
|
||||
import { resolveFindingIds } from "@/actions/findings/findings-by-resource";
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
import { Spinner } from "@/components/shadcn/spinner/spinner";
|
||||
import { TableCell, TableRow } from "@/components/ui/table";
|
||||
@@ -24,6 +23,10 @@ import { FindingGroupRow, FindingResourceRow } from "@/types";
|
||||
import { getColumnFindingResources } from "./column-finding-resources";
|
||||
import { canMuteFindingResource } from "./finding-resource-selection";
|
||||
import { FindingsSelectionContext } from "./findings-selection-context";
|
||||
import {
|
||||
getFilteredFindingGroupResourceCount,
|
||||
getFindingGroupSkeletonCount,
|
||||
} from "./inline-resource-container.utils";
|
||||
import {
|
||||
ResourceDetailDrawer,
|
||||
useResourceDetailDrawer,
|
||||
@@ -40,8 +43,8 @@ interface InlineResourceContainerProps {
|
||||
group: FindingGroupRow;
|
||||
resourceSearch: string;
|
||||
columnCount: number;
|
||||
/** Called with selected resource UIDs (not finding IDs) for parent-level mute resolution */
|
||||
onResourceSelectionChange: (resourceUids: string[]) => void;
|
||||
/** Called with selected finding IDs (real UUIDs) for parent-level mute */
|
||||
onResourceSelectionChange: (findingIds: string[]) => void;
|
||||
ref?: React.Ref<InlineResourceContainerHandle>;
|
||||
}
|
||||
|
||||
@@ -55,11 +58,17 @@ interface InlineResourceContainerProps {
|
||||
/** Max skeleton rows that fit in the 440px scroll container */
|
||||
const MAX_SKELETON_ROWS = 7;
|
||||
|
||||
function ResourceSkeletonRow() {
|
||||
function ResourceSkeletonRow({
|
||||
isEmptyStateSized = false,
|
||||
}: {
|
||||
isEmptyStateSized?: boolean;
|
||||
}) {
|
||||
const cellClassName = isEmptyStateSized ? "h-24 py-3" : "py-3";
|
||||
|
||||
return (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
{/* Select: indicator + corner arrow + checkbox */}
|
||||
<TableCell>
|
||||
<TableCell className={cellClassName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-1.5 rounded-full" />
|
||||
<Skeleton className="size-4 rounded" />
|
||||
@@ -67,55 +76,55 @@ function ResourceSkeletonRow() {
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Resource: icon + name + uid */}
|
||||
<TableCell>
|
||||
<TableCell className={cellClassName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3.5 w-32 rounded" />
|
||||
<Skeleton className="h-3 w-20 rounded" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-32 rounded" />
|
||||
<Skeleton className="h-3.5 w-20 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Status */}
|
||||
<TableCell>
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="h-6 w-11 rounded-md" />
|
||||
</TableCell>
|
||||
{/* Service */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded" />
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="h-4.5 w-16 rounded" />
|
||||
</TableCell>
|
||||
{/* Region */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded" />
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="h-4.5 w-20 rounded" />
|
||||
</TableCell>
|
||||
{/* Severity */}
|
||||
<TableCell>
|
||||
<TableCell className={cellClassName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-2 rounded-full" />
|
||||
<Skeleton className="h-4 w-12 rounded" />
|
||||
<Skeleton className="h-4.5 w-12 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Account: provider icon + alias + uid */}
|
||||
<TableCell>
|
||||
<TableCell className={cellClassName}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-4 rounded" />
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-3.5 w-24 rounded" />
|
||||
<Skeleton className="h-3 w-16 rounded" />
|
||||
<div className="space-y-1.5">
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<Skeleton className="h-3.5 w-16 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Last seen */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-24 rounded" />
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="h-4.5 w-24 rounded" />
|
||||
</TableCell>
|
||||
{/* Failing for */}
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-16 rounded" />
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="h-4.5 w-16 rounded" />
|
||||
</TableCell>
|
||||
{/* Actions */}
|
||||
<TableCell>
|
||||
<Skeleton className="size-6 rounded" />
|
||||
<TableCell className={cellClassName}>
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -161,6 +170,16 @@ export function InlineResourceContainer({
|
||||
filters["filter[name__icontains]"] = resourceSearch;
|
||||
}
|
||||
|
||||
const skeletonRowCount = getFindingGroupSkeletonCount(
|
||||
group,
|
||||
filters,
|
||||
MAX_SKELETON_ROWS,
|
||||
);
|
||||
const filteredResourceCount = getFilteredFindingGroupResourceCount(
|
||||
group,
|
||||
filters,
|
||||
);
|
||||
|
||||
const handleSetResources = (
|
||||
newResources: FindingResourceRow[],
|
||||
_hasMore: boolean,
|
||||
@@ -211,16 +230,9 @@ export function InlineResourceContainer({
|
||||
.filter(Boolean);
|
||||
|
||||
const resolveResourceIds = async (ids: string[]) => {
|
||||
const resourceUids = ids
|
||||
.map((id) => resources.find((r) => r.findingId === id)?.resourceUid)
|
||||
.filter(Boolean) as string[];
|
||||
if (resourceUids.length === 0) return [];
|
||||
return resolveFindingIds({
|
||||
checkId: group.checkId,
|
||||
resourceUids,
|
||||
filters,
|
||||
hasDateOrScanFilter: hasDateOrScan,
|
||||
});
|
||||
// findingId values are already real finding UUIDs (from the group
|
||||
// resources endpoint), so no second resolution round-trip is needed.
|
||||
return ids.filter(Boolean);
|
||||
};
|
||||
|
||||
const selectableRowCount = resources.filter(canMuteFindingResource).length;
|
||||
@@ -254,11 +266,11 @@ export function InlineResourceContainer({
|
||||
typeof updater === "function" ? updater(rowSelection) : updater;
|
||||
setRowSelection(newSelection);
|
||||
|
||||
const newResourceUids = Object.keys(newSelection)
|
||||
const newFindingIds = Object.keys(newSelection)
|
||||
.filter((key) => newSelection[key])
|
||||
.map((idx) => resources[parseInt(idx)]?.resourceUid)
|
||||
.map((idx) => resources[parseInt(idx)]?.findingId)
|
||||
.filter(Boolean);
|
||||
onResourceSelectionChange(newResourceUids);
|
||||
onResourceSelectionChange(newFindingIds);
|
||||
};
|
||||
|
||||
const columns = getColumnFindingResources({
|
||||
@@ -310,12 +322,12 @@ export function InlineResourceContainer({
|
||||
<table className="-mt-2.5 w-full border-separate border-spacing-y-4">
|
||||
<tbody>
|
||||
{isLoading && rows.length === 0 ? (
|
||||
Array.from({
|
||||
length: Math.min(
|
||||
group.resourcesTotal,
|
||||
MAX_SKELETON_ROWS,
|
||||
),
|
||||
}).map((_, i) => <ResourceSkeletonRow key={i} />)
|
||||
Array.from({ length: skeletonRowCount }).map((_, i) => (
|
||||
<ResourceSkeletonRow
|
||||
key={i}
|
||||
isEmptyStateSized={filteredResourceCount === 0}
|
||||
/>
|
||||
))
|
||||
) : rows.length > 0 ? (
|
||||
rows.map((row) => (
|
||||
<TableRow
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
import {
|
||||
getFilteredFindingGroupResourceCount,
|
||||
getFindingGroupSkeletonCount,
|
||||
isFailOnlyStatusFilter,
|
||||
} from "./inline-resource-container.utils";
|
||||
|
||||
function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
|
||||
return {
|
||||
id: "group-1",
|
||||
rowType: "group",
|
||||
checkId: "check-1",
|
||||
checkTitle: "Test finding group",
|
||||
severity: "high",
|
||||
status: "FAIL",
|
||||
resourcesTotal: 20,
|
||||
resourcesFail: 6,
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
mutedCount: 0,
|
||||
providers: ["aws"],
|
||||
updatedAt: "2026-04-09T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isFailOnlyStatusFilter", () => {
|
||||
it("returns true when filter[status__in] only contains FAIL", () => {
|
||||
expect(
|
||||
isFailOnlyStatusFilter({
|
||||
"filter[status__in]": "FAIL",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when filter[status__in] includes more than FAIL", () => {
|
||||
expect(
|
||||
isFailOnlyStatusFilter({
|
||||
"filter[status__in]": "FAIL,PASS",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when filter[status] is FAIL", () => {
|
||||
expect(
|
||||
isFailOnlyStatusFilter({
|
||||
"filter[status]": "FAIL",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFilteredFindingGroupResourceCount", () => {
|
||||
it("returns zero filtered resources when FAIL is the only active status and none fail", () => {
|
||||
expect(
|
||||
getFilteredFindingGroupResourceCount(
|
||||
makeGroup({ resourcesFail: 0, resourcesTotal: 4 }),
|
||||
{ "filter[status__in]": "FAIL" },
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFindingGroupSkeletonCount", () => {
|
||||
it("uses the total resource count when FAIL is not the only active status", () => {
|
||||
expect(getFindingGroupSkeletonCount(makeGroup(), {}, 7)).toBe(7);
|
||||
});
|
||||
|
||||
it("uses the failing resource count when FAIL is the only active status", () => {
|
||||
expect(
|
||||
getFindingGroupSkeletonCount(
|
||||
makeGroup(),
|
||||
{ "filter[status__in]": "FAIL" },
|
||||
7,
|
||||
),
|
||||
).toBe(6);
|
||||
});
|
||||
|
||||
it("still caps the skeleton count to the configured maximum", () => {
|
||||
expect(
|
||||
getFindingGroupSkeletonCount(
|
||||
makeGroup({ resourcesFail: 15 }),
|
||||
{ "filter[status__in]": "FAIL" },
|
||||
7,
|
||||
),
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it("reserves one skeleton row when the filtered resource count is zero", () => {
|
||||
expect(
|
||||
getFindingGroupSkeletonCount(
|
||||
makeGroup({ resourcesFail: 0, resourcesTotal: 0 }),
|
||||
{ "filter[status__in]": "FAIL" },
|
||||
7,
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { FindingGroupRow } from "@/types";
|
||||
|
||||
function parseStatusFilterValue(statusFilterValue?: string): string[] {
|
||||
if (!statusFilterValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return statusFilterValue
|
||||
.split(",")
|
||||
.map((status) => status.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isFailOnlyStatusFilter(
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): boolean {
|
||||
const directStatusValues = parseStatusFilterValue(
|
||||
typeof filters["filter[status]"] === "string"
|
||||
? filters["filter[status]"]
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (directStatusValues.length > 0) {
|
||||
return directStatusValues.length === 1 && directStatusValues[0] === "FAIL";
|
||||
}
|
||||
|
||||
const multiStatusValues = parseStatusFilterValue(
|
||||
typeof filters["filter[status__in]"] === "string"
|
||||
? filters["filter[status__in]"]
|
||||
: undefined,
|
||||
);
|
||||
|
||||
return multiStatusValues.length === 1 && multiStatusValues[0] === "FAIL";
|
||||
}
|
||||
|
||||
export function getFilteredFindingGroupResourceCount(
|
||||
group: FindingGroupRow,
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
): number {
|
||||
return isFailOnlyStatusFilter(filters)
|
||||
? group.resourcesFail
|
||||
: group.resourcesTotal;
|
||||
}
|
||||
|
||||
export function getFindingGroupSkeletonCount(
|
||||
group: FindingGroupRow,
|
||||
filters: Record<string, string | string[] | undefined>,
|
||||
maxSkeletonRows: number,
|
||||
): number {
|
||||
const filteredTotal = getFilteredFindingGroupResourceCount(group, filters);
|
||||
|
||||
// Reserve at least one row so the drill-down keeps visual space while the
|
||||
// empty state ("No resources found") replaces the skeleton.
|
||||
return Math.max(1, Math.min(filteredTotal, maxSkeletonRows));
|
||||
}
|
||||
@@ -30,14 +30,26 @@ interface NotificationIndicatorProps {
|
||||
delta?: DeltaType;
|
||||
isMuted?: boolean;
|
||||
mutedReason?: string;
|
||||
showDeltaWhenMuted?: boolean;
|
||||
}
|
||||
|
||||
export const NotificationIndicator = ({
|
||||
delta,
|
||||
isMuted = false,
|
||||
mutedReason,
|
||||
showDeltaWhenMuted = false,
|
||||
}: NotificationIndicatorProps) => {
|
||||
// Muted takes precedence over delta.
|
||||
const hasDelta = delta === DeltaValues.NEW || delta === DeltaValues.CHANGED;
|
||||
|
||||
if (isMuted && hasDelta && showDeltaWhenMuted) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<MutedIndicator mutedReason={mutedReason} />
|
||||
<DeltaIndicator delta={delta} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Uses Popover (not Tooltip) because the content has an interactive link.
|
||||
// Radix Tooltip does not support interactive content — clicks fall through.
|
||||
if (isMuted) {
|
||||
@@ -45,56 +57,64 @@ export const NotificationIndicator = ({
|
||||
}
|
||||
|
||||
// Show dot with tooltip for new or changed findings
|
||||
if (delta === DeltaValues.NEW || delta === DeltaValues.CHANGED) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
delta === DeltaValues.NEW
|
||||
? "bg-system-severity-high"
|
||||
: "bg-system-severity-low",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span>
|
||||
{delta === DeltaValues.NEW
|
||||
? "New finding."
|
||||
: "Status changed since the previous scan."}
|
||||
</span>
|
||||
<Button
|
||||
aria-label="Learn more about findings"
|
||||
variant="link"
|
||||
size="default"
|
||||
className="text-button-primary h-auto min-w-0 p-0 text-xs"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={DOCS_URLS.FINDINGS_ANALYSIS}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
if (hasDelta) {
|
||||
return <DeltaIndicator delta={delta} />;
|
||||
}
|
||||
|
||||
// No indicator - return minimal width placeholder
|
||||
return <div className="w-2 shrink-0" />;
|
||||
};
|
||||
|
||||
function DeltaIndicator({
|
||||
delta,
|
||||
}: {
|
||||
delta: typeof DeltaValues.NEW | typeof DeltaValues.CHANGED;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex w-2 shrink-0 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
delta === DeltaValues.NEW
|
||||
? "bg-system-severity-high"
|
||||
: "bg-system-severity-low",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span>
|
||||
{delta === DeltaValues.NEW
|
||||
? "New finding."
|
||||
: "Status changed since the previous scan."}
|
||||
</span>
|
||||
<Button
|
||||
aria-label="Learn more about findings"
|
||||
variant="link"
|
||||
size="default"
|
||||
className="text-button-primary h-auto min-w-0 p-0 text-xs"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={DOCS_URLS.FINDINGS_ANALYSIS}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/** Muted indicator with hover-triggered Popover for interactive link. */
|
||||
function MutedIndicator({ mutedReason }: { mutedReason?: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -710,7 +710,7 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
scanId: "scan-from-finding",
|
||||
});
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
|
||||
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
@@ -786,7 +786,7 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
scanId: "scan-from-finding",
|
||||
});
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding&scanData=%7B%22id%22%3A%22scan-from-finding%22%2C%22providerInfo%22%3A%7B%22provider%22%3A%22aws%22%2C%22alias%22%3A%22prod%22%2C%22uid%22%3A%22123456789%22%7D%2C%22attributes%22%3A%7B%22name%22%3A%22Nightly+scan%22%2C%22completed_at%22%3A%222026-03-30T10%3A05%3A00Z%22%7D%7D",
|
||||
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
|
||||
@@ -218,16 +218,12 @@ function buildComplianceDetailHref({
|
||||
version,
|
||||
scanId,
|
||||
regionFilter,
|
||||
currentFinding,
|
||||
includeScanData,
|
||||
}: {
|
||||
complianceId: string;
|
||||
framework: string;
|
||||
version: string;
|
||||
scanId: string;
|
||||
regionFilter: string | null;
|
||||
currentFinding: ResourceDrawerFinding | null;
|
||||
includeScanData: boolean;
|
||||
}): string {
|
||||
const params = new URLSearchParams();
|
||||
params.set("complianceId", complianceId);
|
||||
@@ -240,24 +236,6 @@ function buildComplianceDetailHref({
|
||||
params.set("filter[region__in]", regionFilter);
|
||||
}
|
||||
|
||||
if (includeScanData && currentFinding?.scan?.completedAt) {
|
||||
params.set(
|
||||
"scanData",
|
||||
JSON.stringify({
|
||||
id: currentFinding.scan.id,
|
||||
providerInfo: {
|
||||
provider: currentFinding.providerType,
|
||||
alias: currentFinding.providerAlias,
|
||||
uid: currentFinding.providerUid,
|
||||
},
|
||||
attributes: {
|
||||
name: currentFinding.scan.name,
|
||||
completed_at: currentFinding.scan.completedAt,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return `/compliance/${encodeURIComponent(framework)}?${params.toString()}`;
|
||||
}
|
||||
|
||||
@@ -377,8 +355,6 @@ export function ResourceDetailDrawerContent({
|
||||
version: complianceMatch.version,
|
||||
scanId: complianceScanId,
|
||||
regionFilter,
|
||||
currentFinding: f,
|
||||
includeScanData: f?.scan?.id === complianceScanId,
|
||||
}),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
|
||||
@@ -54,6 +54,7 @@ export const getResourceFindingsColumns = (
|
||||
delta={row.original.attributes.delta}
|
||||
isMuted={row.original.attributes.muted}
|
||||
mutedReason={row.original.attributes.muted_reason}
|
||||
showDeltaWhenMuted
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -6,6 +6,8 @@ export const DOCS_URLS = {
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app#step-8:-analyze-the-findings",
|
||||
AWS_ORGANIZATIONS:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-cloud-aws-organizations",
|
||||
ATTACK_PATHS_CUSTOM_QUERIES:
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-attack-paths#writing-custom-opencypher-queries",
|
||||
} as const;
|
||||
|
||||
// CloudFormation template URL for the ProwlerScan role.
|
||||
|
||||
238
ui/lib/findings-groups.test.ts
Normal file
238
ui/lib/findings-groups.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
import {
|
||||
getActiveStatusFilter,
|
||||
getFilteredFindingGroupDelta,
|
||||
getFindingGroupDelta,
|
||||
isFindingGroupMuted,
|
||||
} from "./findings-groups";
|
||||
|
||||
function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
|
||||
return {
|
||||
id: "group-1",
|
||||
rowType: "group",
|
||||
checkId: "check-1",
|
||||
checkTitle: "Test finding group",
|
||||
severity: "high",
|
||||
status: "FAIL",
|
||||
muted: false,
|
||||
resourcesTotal: 5,
|
||||
resourcesFail: 3,
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
mutedCount: 0,
|
||||
providers: ["aws"],
|
||||
updatedAt: "2026-04-10T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("isFindingGroupMuted", () => {
|
||||
it("should prefer the explicit muted flag from the API", () => {
|
||||
expect(
|
||||
isFindingGroupMuted(
|
||||
makeGroup({
|
||||
muted: true,
|
||||
mutedCount: 0,
|
||||
resourcesFail: 3,
|
||||
resourcesTotal: 5,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should fall back to legacy counters when muted is not available", () => {
|
||||
expect(
|
||||
isFindingGroupMuted(
|
||||
makeGroup({
|
||||
muted: undefined,
|
||||
mutedCount: 3,
|
||||
resourcesFail: 3,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFindingGroupDelta", () => {
|
||||
it("should return new when the muted breakdown contains new findings", () => {
|
||||
expect(
|
||||
getFindingGroupDelta(
|
||||
makeGroup({
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
newFailMutedCount: 1,
|
||||
}),
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("should return changed when only changed breakdown counters are present", () => {
|
||||
expect(
|
||||
getFindingGroupDelta(
|
||||
makeGroup({
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
changedManualMutedCount: 2,
|
||||
}),
|
||||
),
|
||||
).toBe("changed");
|
||||
});
|
||||
|
||||
it("should prioritize new over changed when both breakdowns are present", () => {
|
||||
expect(
|
||||
getFindingGroupDelta(
|
||||
makeGroup({
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
newPassCount: 1,
|
||||
changedFailCount: 3,
|
||||
}),
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("should fall back to legacy counters when breakdowns are missing", () => {
|
||||
expect(
|
||||
getFindingGroupDelta(
|
||||
makeGroup({
|
||||
newCount: 1,
|
||||
changedCount: 0,
|
||||
}),
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getActiveStatusFilter", () => {
|
||||
it("returns null when no status filter is active", () => {
|
||||
expect(getActiveStatusFilter({})).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the single value from filter[status]", () => {
|
||||
const result = getActiveStatusFilter({ "filter[status]": "FAIL" });
|
||||
expect(result).toEqual(new Set(["FAIL"]));
|
||||
});
|
||||
|
||||
it("returns the parsed set from filter[status__in]", () => {
|
||||
const result = getActiveStatusFilter({
|
||||
"filter[status__in]": "FAIL,MANUAL",
|
||||
});
|
||||
expect(result).toEqual(new Set(["FAIL", "MANUAL"]));
|
||||
});
|
||||
|
||||
it("prefers filter[status] over filter[status__in] when both are present", () => {
|
||||
const result = getActiveStatusFilter({
|
||||
"filter[status]": "PASS",
|
||||
"filter[status__in]": "FAIL,MANUAL",
|
||||
});
|
||||
expect(result).toEqual(new Set(["PASS"]));
|
||||
});
|
||||
|
||||
it("ignores unknown status values and returns null if nothing remains", () => {
|
||||
expect(
|
||||
getActiveStatusFilter({ "filter[status__in]": "UNKNOWN,FOO" }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFilteredFindingGroupDelta", () => {
|
||||
it("falls back to the aggregate delta when no status filter is active", () => {
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newPassCount: 2,
|
||||
}),
|
||||
{},
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("ignores deltas that belong to filtered-out statuses", () => {
|
||||
// Filter is FAIL, but the only delta is a new PASS → should be hidden.
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newPassCount: 3,
|
||||
}),
|
||||
{ "filter[status__in]": "FAIL" },
|
||||
),
|
||||
).toBe("none");
|
||||
});
|
||||
|
||||
it("surfaces FAIL deltas when the filter is FAIL", () => {
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newFailCount: 1,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("counts muted breakdown counters towards the filtered status", () => {
|
||||
// A muted new FAIL still belongs to the FAIL bucket — a FAIL filter
|
||||
// should still light up the "new" indicator.
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newFailMutedCount: 1,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("sums multiple filtered statuses from filter[status__in]", () => {
|
||||
// Filter is FAIL+MANUAL, new delta is only in MANUAL → should still show.
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newManualCount: 1,
|
||||
}),
|
||||
{ "filter[status__in]": "FAIL,MANUAL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("prefers new over changed within the filtered status", () => {
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newFailCount: 1,
|
||||
changedFailCount: 2,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
|
||||
it("returns changed when only changed counters match the filtered status", () => {
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newPassCount: 2, // filtered out
|
||||
changedFailCount: 1,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("changed");
|
||||
});
|
||||
|
||||
it("falls back to the aggregate delta when breakdowns are missing (legacy API)", () => {
|
||||
// No breakdown fields populated but legacy newCount is set. With a FAIL
|
||||
// filter active we cannot know which status bucket it belongs to, so we
|
||||
// fall back to showing the delta rather than silently hiding it.
|
||||
expect(
|
||||
getFilteredFindingGroupDelta(
|
||||
makeGroup({
|
||||
newCount: 1,
|
||||
}),
|
||||
{ "filter[status]": "FAIL" },
|
||||
),
|
||||
).toBe("new");
|
||||
});
|
||||
});
|
||||
201
ui/lib/findings-groups.ts
Normal file
201
ui/lib/findings-groups.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
type FindingGroupMutedState = Pick<
|
||||
FindingGroupRow,
|
||||
"muted" | "mutedCount" | "resourcesFail" | "resourcesTotal"
|
||||
>;
|
||||
|
||||
type FindingGroupDeltaState = Pick<
|
||||
FindingGroupRow,
|
||||
| "newCount"
|
||||
| "changedCount"
|
||||
| "newFailCount"
|
||||
| "newFailMutedCount"
|
||||
| "newPassCount"
|
||||
| "newPassMutedCount"
|
||||
| "newManualCount"
|
||||
| "newManualMutedCount"
|
||||
| "changedFailCount"
|
||||
| "changedFailMutedCount"
|
||||
| "changedPassCount"
|
||||
| "changedPassMutedCount"
|
||||
| "changedManualCount"
|
||||
| "changedManualMutedCount"
|
||||
>;
|
||||
|
||||
export function isFindingGroupMuted(group: FindingGroupMutedState): boolean {
|
||||
if (typeof group.muted === "boolean") {
|
||||
return group.muted;
|
||||
}
|
||||
|
||||
const mutedCount = group.mutedCount ?? 0;
|
||||
if (mutedCount === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
mutedCount === group.resourcesFail || mutedCount === group.resourcesTotal
|
||||
);
|
||||
}
|
||||
|
||||
function getNewDeltaTotal(group: FindingGroupDeltaState): number {
|
||||
const breakdownTotal =
|
||||
(group.newFailCount ?? 0) +
|
||||
(group.newFailMutedCount ?? 0) +
|
||||
(group.newPassCount ?? 0) +
|
||||
(group.newPassMutedCount ?? 0) +
|
||||
(group.newManualCount ?? 0) +
|
||||
(group.newManualMutedCount ?? 0);
|
||||
|
||||
return breakdownTotal > 0 ? breakdownTotal : group.newCount;
|
||||
}
|
||||
|
||||
function getChangedDeltaTotal(group: FindingGroupDeltaState): number {
|
||||
const breakdownTotal =
|
||||
(group.changedFailCount ?? 0) +
|
||||
(group.changedFailMutedCount ?? 0) +
|
||||
(group.changedPassCount ?? 0) +
|
||||
(group.changedPassMutedCount ?? 0) +
|
||||
(group.changedManualCount ?? 0) +
|
||||
(group.changedManualMutedCount ?? 0);
|
||||
|
||||
return breakdownTotal > 0 ? breakdownTotal : group.changedCount;
|
||||
}
|
||||
|
||||
export function getFindingGroupDelta(
|
||||
group: FindingGroupDeltaState,
|
||||
): "new" | "changed" | "none" {
|
||||
if (getNewDeltaTotal(group) > 0) {
|
||||
return "new";
|
||||
}
|
||||
|
||||
if (getChangedDeltaTotal(group) > 0) {
|
||||
return "changed";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
|
||||
const FINDING_GROUP_STATUSES = ["FAIL", "PASS", "MANUAL"] as const;
|
||||
type FindingGroupStatus = (typeof FINDING_GROUP_STATUSES)[number];
|
||||
|
||||
type FindingGroupFiltersRecord = Record<string, string | string[] | undefined>;
|
||||
|
||||
function parseStatusFilterValue(
|
||||
rawValue: string | string[] | undefined,
|
||||
): FindingGroupStatus[] {
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const joined = Array.isArray(rawValue) ? rawValue.join(",") : rawValue;
|
||||
|
||||
return joined
|
||||
.split(",")
|
||||
.map((status) => status.trim().toUpperCase())
|
||||
.filter((status): status is FindingGroupStatus =>
|
||||
(FINDING_GROUP_STATUSES as readonly string[]).includes(status),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of statuses the user has explicitly narrowed the findings
|
||||
* view to, or null when no status filter is active (→ all statuses should be
|
||||
* considered). Supports both `filter[status]` (single value) and
|
||||
* `filter[status__in]` (comma-separated values).
|
||||
*/
|
||||
export function getActiveStatusFilter(
|
||||
filters: FindingGroupFiltersRecord,
|
||||
): Set<FindingGroupStatus> | null {
|
||||
const direct = parseStatusFilterValue(filters["filter[status]"]);
|
||||
if (direct.length > 0) {
|
||||
return new Set(direct);
|
||||
}
|
||||
|
||||
const multi = parseStatusFilterValue(filters["filter[status__in]"]);
|
||||
if (multi.length > 0) {
|
||||
return new Set(multi);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasAnyDeltaBreakdown(group: FindingGroupDeltaState): boolean {
|
||||
return (
|
||||
(group.newFailCount ?? 0) > 0 ||
|
||||
(group.newFailMutedCount ?? 0) > 0 ||
|
||||
(group.newPassCount ?? 0) > 0 ||
|
||||
(group.newPassMutedCount ?? 0) > 0 ||
|
||||
(group.newManualCount ?? 0) > 0 ||
|
||||
(group.newManualMutedCount ?? 0) > 0 ||
|
||||
(group.changedFailCount ?? 0) > 0 ||
|
||||
(group.changedFailMutedCount ?? 0) > 0 ||
|
||||
(group.changedPassCount ?? 0) > 0 ||
|
||||
(group.changedPassMutedCount ?? 0) > 0 ||
|
||||
(group.changedManualCount ?? 0) > 0 ||
|
||||
(group.changedManualMutedCount ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
function getNewDeltaForStatuses(
|
||||
group: FindingGroupDeltaState,
|
||||
statuses: Set<FindingGroupStatus>,
|
||||
): number {
|
||||
let total = 0;
|
||||
if (statuses.has("FAIL")) {
|
||||
total += (group.newFailCount ?? 0) + (group.newFailMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("PASS")) {
|
||||
total += (group.newPassCount ?? 0) + (group.newPassMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("MANUAL")) {
|
||||
total += (group.newManualCount ?? 0) + (group.newManualMutedCount ?? 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function getChangedDeltaForStatuses(
|
||||
group: FindingGroupDeltaState,
|
||||
statuses: Set<FindingGroupStatus>,
|
||||
): number {
|
||||
let total = 0;
|
||||
if (statuses.has("FAIL")) {
|
||||
total += (group.changedFailCount ?? 0) + (group.changedFailMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("PASS")) {
|
||||
total += (group.changedPassCount ?? 0) + (group.changedPassMutedCount ?? 0);
|
||||
}
|
||||
if (statuses.has("MANUAL")) {
|
||||
total +=
|
||||
(group.changedManualCount ?? 0) + (group.changedManualMutedCount ?? 0);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter-aware variant of {@link getFindingGroupDelta}. When a status filter
|
||||
* is active, only delta counters belonging to the filtered statuses contribute
|
||||
* to the indicator. When no status filter is active, or when the API response
|
||||
* lacks breakdown counters (legacy shape), this falls back to the aggregate
|
||||
* delta so rows still surface deltas correctly.
|
||||
*/
|
||||
export function getFilteredFindingGroupDelta(
|
||||
group: FindingGroupDeltaState,
|
||||
filters: FindingGroupFiltersRecord,
|
||||
): "new" | "changed" | "none" {
|
||||
const activeStatuses = getActiveStatusFilter(filters);
|
||||
|
||||
if (!activeStatuses || !hasAnyDeltaBreakdown(group)) {
|
||||
return getFindingGroupDelta(group);
|
||||
}
|
||||
|
||||
if (getNewDeltaForStatuses(group, activeStatuses) > 0) {
|
||||
return "new";
|
||||
}
|
||||
|
||||
if (getChangedDeltaForStatuses(group, activeStatuses) > 0) {
|
||||
return "changed";
|
||||
}
|
||||
|
||||
return "none";
|
||||
}
|
||||
@@ -16,10 +16,29 @@ export interface FindingGroupRow {
|
||||
checkTitle: string;
|
||||
severity: Severity;
|
||||
status: FindingStatus;
|
||||
muted?: boolean;
|
||||
resourcesTotal: number;
|
||||
resourcesFail: number;
|
||||
passCount?: number;
|
||||
failCount?: number;
|
||||
manualCount?: number;
|
||||
passMutedCount?: number;
|
||||
failMutedCount?: number;
|
||||
manualMutedCount?: number;
|
||||
newCount: number;
|
||||
changedCount: number;
|
||||
newFailCount?: number;
|
||||
newFailMutedCount?: number;
|
||||
newPassCount?: number;
|
||||
newPassMutedCount?: number;
|
||||
newManualCount?: number;
|
||||
newManualMutedCount?: number;
|
||||
changedFailCount?: number;
|
||||
changedFailMutedCount?: number;
|
||||
changedPassCount?: number;
|
||||
changedPassMutedCount?: number;
|
||||
changedManualCount?: number;
|
||||
changedManualMutedCount?: number;
|
||||
mutedCount: number;
|
||||
providers: ProviderType[];
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user