mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-23 20:42:02 +00:00
fix(ui): code quality improvements for findings grouped view (#10515)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
adaptFindingGroupResourcesResponse,
|
||||
adaptFindingGroupsResponse,
|
||||
} from "./finding-groups.adapter";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: adaptFindingGroupsResponse — unknown + type guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("adaptFindingGroupsResponse — malformed input", () => {
|
||||
it("should return [] when apiResponse is null", () => {
|
||||
// Given
|
||||
const input = null;
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse has no data property", () => {
|
||||
// Given
|
||||
const input = { meta: { total: 0 } };
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is not an array", () => {
|
||||
// Given
|
||||
const input = { data: "not-an-array" };
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is null", () => {
|
||||
// Given
|
||||
const input = { data: null };
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is undefined", () => {
|
||||
// Given
|
||||
const input = undefined;
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return mapped rows for valid data", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "group-1",
|
||||
type: "finding-groups",
|
||||
attributes: {
|
||||
check_id: "s3_bucket_public_access",
|
||||
check_title: "S3 Bucket Public Access",
|
||||
check_description: null,
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
impacted_providers: ["aws"],
|
||||
resources_total: 5,
|
||||
resources_fail: 3,
|
||||
pass_count: 2,
|
||||
fail_count: 3,
|
||||
muted_count: 0,
|
||||
new_count: 1,
|
||||
changed_count: 0,
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
failing_since: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupsResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].checkId).toBe("s3_bucket_public_access");
|
||||
expect(result[0].checkTitle).toBe("S3 Bucket Public Access");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: adaptFindingGroupResourcesResponse — unknown + type guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("adaptFindingGroupResourcesResponse — malformed input", () => {
|
||||
it("should return [] when apiResponse is null", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse(null, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse has no data property", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse({ meta: {} }, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is not an array", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse({ data: {} }, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is undefined", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingGroupResourcesResponse(undefined, "check-1");
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return mapped rows for valid data", () => {
|
||||
// Given
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "resource-row-1",
|
||||
type: "finding-group-resources",
|
||||
attributes: {
|
||||
resource: {
|
||||
uid: "arn:aws:s3:::my-bucket",
|
||||
name: "my-bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
type: "Bucket",
|
||||
resource_group: "default",
|
||||
},
|
||||
provider: {
|
||||
type: "aws",
|
||||
uid: "123456789",
|
||||
alias: "production",
|
||||
},
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
first_seen_at: null,
|
||||
last_seen_at: "2024-01-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingGroupResourcesResponse(input, "s3_check");
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].checkId).toBe("s3_check");
|
||||
expect(result[0].resourceName).toBe("my-bucket");
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
import type {
|
||||
FindingGroupRow,
|
||||
FindingResourceRow,
|
||||
FINDINGS_ROW_TYPE,
|
||||
FindingStatus,
|
||||
ProviderType,
|
||||
Severity,
|
||||
} from "@/types";
|
||||
import { FINDINGS_ROW_TYPE } from "@/types";
|
||||
|
||||
/**
|
||||
* API response shape for a finding group (JSON:API).
|
||||
@@ -43,13 +43,19 @@ interface FindingGroupApiItem {
|
||||
* Transforms the API response for finding groups into FindingGroupRow[].
|
||||
*/
|
||||
export function adaptFindingGroupsResponse(
|
||||
apiResponse: any,
|
||||
apiResponse: unknown,
|
||||
): FindingGroupRow[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
if (
|
||||
!apiResponse ||
|
||||
typeof apiResponse !== "object" ||
|
||||
!("data" in apiResponse) ||
|
||||
!Array.isArray((apiResponse as { data: unknown }).data)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return apiResponse.data.map((item: FindingGroupApiItem) => ({
|
||||
const data = (apiResponse as { data: FindingGroupApiItem[] }).data;
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
rowType: FINDINGS_ROW_TYPE.GROUP,
|
||||
checkId: item.attributes.check_id,
|
||||
@@ -109,14 +115,20 @@ interface FindingGroupResourceApiItem {
|
||||
* into FindingResourceRow[].
|
||||
*/
|
||||
export function adaptFindingGroupResourcesResponse(
|
||||
apiResponse: any,
|
||||
apiResponse: unknown,
|
||||
checkId: string,
|
||||
): FindingResourceRow[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
if (
|
||||
!apiResponse ||
|
||||
typeof apiResponse !== "object" ||
|
||||
!("data" in apiResponse) ||
|
||||
!Array.isArray((apiResponse as { data: unknown }).data)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return apiResponse.data.map((item: FindingGroupResourceApiItem) => ({
|
||||
const data = (apiResponse as { data: FindingGroupResourceApiItem[] }).data;
|
||||
return data.map((item) => ({
|
||||
id: item.id,
|
||||
rowType: FINDINGS_ROW_TYPE.RESOURCE,
|
||||
findingId: item.id,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks BEFORE imports that transitively pull next-auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { createDictMock } = vi.hoisted(() => ({
|
||||
createDictMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
createDict: createDictMock,
|
||||
apiBaseUrl: "https://api.example.com",
|
||||
getAuthHeaders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { adaptFindingsByResourceResponse } from "./findings-by-resource.adapter";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 1: adaptFindingsByResourceResponse — unknown + type guard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("adaptFindingsByResourceResponse — malformed input", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// createDict returns empty dict by default for most tests
|
||||
createDictMock.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is null", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse(null);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse is undefined", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse(undefined);
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when apiResponse has no data property", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ meta: {} });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is not an array", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ data: "bad" });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is an empty array", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ data: [], included: [] });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return [] when data is a number", () => {
|
||||
// Given/When
|
||||
const result = adaptFindingsByResourceResponse({ data: 42 });
|
||||
|
||||
// Then
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return mapped findings for valid minimal data", () => {
|
||||
// Given — minimal valid JSON:API shape
|
||||
const input = {
|
||||
data: [
|
||||
{
|
||||
id: "finding-1",
|
||||
attributes: {
|
||||
uid: "uid-1",
|
||||
check_id: "s3_check",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
check_metadata: {
|
||||
checktitle: "S3 Check",
|
||||
},
|
||||
},
|
||||
relationships: {
|
||||
resources: { data: [] },
|
||||
scan: { data: null },
|
||||
},
|
||||
},
|
||||
],
|
||||
included: [],
|
||||
};
|
||||
|
||||
// When
|
||||
const result = adaptFindingsByResourceResponse(input);
|
||||
|
||||
// Then
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("finding-1");
|
||||
expect(result[0].checkId).toBe("s3_check");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createDict } from "@/lib";
|
||||
import { ProviderType, Severity } from "@/types";
|
||||
import type { ProviderType, Severity } from "@/types";
|
||||
|
||||
export interface RemediationRecommendation {
|
||||
text: string;
|
||||
@@ -119,6 +119,44 @@ function extractComplianceFrameworks(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal shape of a finding item returned by the
|
||||
* `/findings/latest?include=resources,scan.provider` endpoint.
|
||||
*/
|
||||
interface FindingApiAttributes {
|
||||
uid: string;
|
||||
check_id: string;
|
||||
status: string;
|
||||
severity: string;
|
||||
delta?: string | null;
|
||||
muted?: boolean;
|
||||
muted_reason?: string | null;
|
||||
first_seen_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
status_extended?: string;
|
||||
compliance?: Record<string, string[]>;
|
||||
check_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FindingApiItem {
|
||||
id: string;
|
||||
attributes: FindingApiAttributes;
|
||||
relationships?: {
|
||||
resources?: { data?: Array<{ id: string }> };
|
||||
scan?: { data?: { id: string } | null };
|
||||
};
|
||||
}
|
||||
|
||||
/** Shape of an included JSON:API resource/scan/provider entry returned by createDict. */
|
||||
interface IncludedItem {
|
||||
id?: string;
|
||||
attributes?: Record<string, unknown>;
|
||||
relationships?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Lookup dict returned by createDict(). */
|
||||
type IncludedDict = Record<string, IncludedItem>;
|
||||
|
||||
/**
|
||||
* Transforms the `/findings/latest?include=resources,scan.provider` response
|
||||
* into a flat ResourceDrawerFinding array.
|
||||
@@ -126,42 +164,82 @@ function extractComplianceFrameworks(
|
||||
* Uses createDict to build lookup maps from the JSON:API `included` array,
|
||||
* then resolves each finding's resource and provider relationships.
|
||||
*/
|
||||
interface JsonApiResponse {
|
||||
data: FindingApiItem[];
|
||||
included?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function isJsonApiResponse(value: unknown): value is JsonApiResponse {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
"data" in value &&
|
||||
Array.isArray((value as { data: unknown }).data)
|
||||
);
|
||||
}
|
||||
|
||||
export function adaptFindingsByResourceResponse(
|
||||
apiResponse: any,
|
||||
apiResponse: unknown,
|
||||
): ResourceDrawerFinding[] {
|
||||
if (!apiResponse?.data || !Array.isArray(apiResponse.data)) {
|
||||
if (!isJsonApiResponse(apiResponse)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const resourcesDict = createDict("resources", apiResponse);
|
||||
const scansDict = createDict("scans", apiResponse);
|
||||
const providersDict = createDict("providers", apiResponse);
|
||||
const resourcesDict = createDict("resources", apiResponse) as IncludedDict;
|
||||
const scansDict = createDict("scans", apiResponse) as IncludedDict;
|
||||
const providersDict = createDict("providers", apiResponse) as IncludedDict;
|
||||
|
||||
return apiResponse.data.map((item: any) => {
|
||||
return apiResponse.data.map((item) => {
|
||||
const attrs = item.attributes;
|
||||
const meta = attrs.check_metadata || {};
|
||||
const remediation = meta.remediation || {
|
||||
const meta = (attrs.check_metadata || {}) as Record<string, unknown>;
|
||||
const remediationRaw = meta.remediation as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const remediation = remediationRaw || {
|
||||
recommendation: { text: "", url: "" },
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
};
|
||||
|
||||
// Resolve resource from included
|
||||
const resourceRel = item.relationships?.resources?.data?.[0];
|
||||
const resource = resourceRel ? resourcesDict[resourceRel.id] : null;
|
||||
const resourceAttrs = resource?.attributes || {};
|
||||
const resource: IncludedItem | null = resourceRel
|
||||
? (resourcesDict[resourceRel.id] ?? null)
|
||||
: null;
|
||||
const resourceAttrs = (resource?.attributes || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Resolve provider via scan → provider (include path: scan.provider)
|
||||
const scanRel = item.relationships?.scan?.data;
|
||||
const scan = scanRel ? scansDict[scanRel.id] : null;
|
||||
const providerRelId = scan?.relationships?.provider?.data?.id ?? null;
|
||||
const provider = providerRelId ? providersDict[providerRelId] : null;
|
||||
const providerAttrs = provider?.attributes || {};
|
||||
const scan: IncludedItem | null = scanRel
|
||||
? (scansDict[scanRel.id] ?? null)
|
||||
: null;
|
||||
const scanRels = scan?.relationships as Record<string, unknown> | undefined;
|
||||
const providerRelId =
|
||||
((
|
||||
(scanRels?.provider as Record<string, unknown> | undefined)?.data as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.id as string | null) ?? null;
|
||||
const provider: IncludedItem | null = providerRelId
|
||||
? (providersDict[providerRelId] ?? null)
|
||||
: null;
|
||||
const providerAttrs = (provider?.attributes || {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const remRec = remediation.recommendation as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const remCode = remediation.code as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
uid: attrs.uid,
|
||||
checkId: attrs.check_id,
|
||||
checkTitle: meta.checktitle || attrs.check_id,
|
||||
checkTitle: (meta.checktitle as string | undefined) || attrs.check_id,
|
||||
status: attrs.status,
|
||||
severity: (attrs.severity || "informational") as Severity,
|
||||
delta: attrs.delta || null,
|
||||
@@ -171,52 +249,59 @@ export function adaptFindingsByResourceResponse(
|
||||
updatedAt: attrs.updated_at || null,
|
||||
// Resource
|
||||
resourceId: resourceRel?.id || "",
|
||||
resourceUid: resourceAttrs.uid || "-",
|
||||
resourceName: resourceAttrs.name || "-",
|
||||
resourceService: resourceAttrs.service || "-",
|
||||
resourceRegion: resourceAttrs.region || "-",
|
||||
resourceType: resourceAttrs.type || "-",
|
||||
resourceGroup: meta.resourcegroup || "-",
|
||||
resourceUid: (resourceAttrs.uid as string | undefined) || "-",
|
||||
resourceName: (resourceAttrs.name as string | undefined) || "-",
|
||||
resourceService: (resourceAttrs.service as string | undefined) || "-",
|
||||
resourceRegion: (resourceAttrs.region as string | undefined) || "-",
|
||||
resourceType: (resourceAttrs.type as string | undefined) || "-",
|
||||
resourceGroup: (meta.resourcegroup as string | undefined) || "-",
|
||||
// Provider
|
||||
providerType: (providerAttrs.provider || "aws") as ProviderType,
|
||||
providerAlias: providerAttrs.alias || "",
|
||||
providerUid: providerAttrs.uid || "",
|
||||
providerType: ((providerAttrs.provider as string | undefined) ||
|
||||
"aws") as ProviderType,
|
||||
providerAlias: (providerAttrs.alias as string | undefined) || "",
|
||||
providerUid: (providerAttrs.uid as string | undefined) || "",
|
||||
// Check metadata
|
||||
risk: meta.risk || "",
|
||||
description: meta.description || "",
|
||||
risk: (meta.risk as string | undefined) || "",
|
||||
description: (meta.description as string | undefined) || "",
|
||||
statusExtended: attrs.status_extended || "",
|
||||
complianceFrameworks: extractComplianceFrameworks(
|
||||
meta.compliance ?? meta.Compliance,
|
||||
(meta.compliance ?? meta.Compliance) as unknown,
|
||||
attrs.compliance,
|
||||
),
|
||||
categories: meta.categories || [],
|
||||
categories: (meta.categories as string[] | undefined) || [],
|
||||
remediation: {
|
||||
recommendation: {
|
||||
text: remediation.recommendation?.text || "",
|
||||
url: remediation.recommendation?.url || "",
|
||||
text: (remRec?.text as string | undefined) || "",
|
||||
url: (remRec?.url as string | undefined) || "",
|
||||
},
|
||||
code: {
|
||||
cli: remediation.code?.cli || "",
|
||||
other: remediation.code?.other || "",
|
||||
nativeiac: remediation.code?.nativeiac || "",
|
||||
terraform: remediation.code?.terraform || "",
|
||||
cli: (remCode?.cli as string | undefined) || "",
|
||||
other: (remCode?.other as string | undefined) || "",
|
||||
nativeiac: (remCode?.nativeiac as string | undefined) || "",
|
||||
terraform: (remCode?.terraform as string | undefined) || "",
|
||||
},
|
||||
},
|
||||
additionalUrls: meta.additionalurls || [],
|
||||
additionalUrls: (meta.additionalurls as string[] | undefined) || [],
|
||||
// Scan
|
||||
scan: scan?.attributes
|
||||
? {
|
||||
id: scan.id || "",
|
||||
name: scan.attributes.name || "",
|
||||
trigger: scan.attributes.trigger || "",
|
||||
state: scan.attributes.state || "",
|
||||
uniqueResourceCount: scan.attributes.unique_resource_count || 0,
|
||||
progress: scan.attributes.progress || 0,
|
||||
duration: scan.attributes.duration || 0,
|
||||
startedAt: scan.attributes.started_at || null,
|
||||
completedAt: scan.attributes.completed_at || null,
|
||||
insertedAt: scan.attributes.inserted_at || null,
|
||||
scheduledAt: scan.attributes.scheduled_at || null,
|
||||
id: (scan.id as string | undefined) || "",
|
||||
name: (scan.attributes.name as string | undefined) || "",
|
||||
trigger: (scan.attributes.trigger as string | undefined) || "",
|
||||
state: (scan.attributes.state as string | undefined) || "",
|
||||
uniqueResourceCount:
|
||||
(scan.attributes.unique_resource_count as number | undefined) ||
|
||||
0,
|
||||
progress: (scan.attributes.progress as number | undefined) || 0,
|
||||
duration: (scan.attributes.duration as number | undefined) || 0,
|
||||
startedAt:
|
||||
(scan.attributes.started_at as string | undefined) || null,
|
||||
completedAt:
|
||||
(scan.attributes.completed_at as string | undefined) || null,
|
||||
insertedAt:
|
||||
(scan.attributes.inserted_at as string | undefined) || null,
|
||||
scheduledAt:
|
||||
(scan.attributes.scheduled_at as string | undefined) || null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks to avoid deep dependency chains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const { MuteFindingsModalMock } = vi.hoisted(() => ({
|
||||
MuteFindingsModalMock: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("./mute-findings-modal", () => ({
|
||||
MuteFindingsModal: MuteFindingsModalMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { FloatingMuteButton } from "./floating-mute-button";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 3: onBeforeOpen rejection resets isResolving
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("FloatingMuteButton — onBeforeOpen error handling", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it("should reset isResolving (re-enable button) when onBeforeOpen rejects", async () => {
|
||||
// Given — onBeforeOpen always throws
|
||||
const onBeforeOpen = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={3}
|
||||
selectedFindingIds={[]}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole("button");
|
||||
|
||||
// When — click the button (triggers onBeforeOpen which rejects)
|
||||
await user.click(button);
|
||||
|
||||
// Then — button should NOT be disabled (isResolving reset to false)
|
||||
await waitFor(() => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should log the error 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 user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={2}
|
||||
selectedFindingIds={[]}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then — error was logged
|
||||
await waitFor(() => {
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should open modal when onBeforeOpen resolves successfully", async () => {
|
||||
// Given
|
||||
const onBeforeOpen = vi.fn().mockResolvedValue(["id-1", "id-2"]);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<FloatingMuteButton
|
||||
selectedCount={2}
|
||||
selectedFindingIds={[]}
|
||||
onBeforeOpen={onBeforeOpen}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button"));
|
||||
|
||||
// Then — modal opened (MuteFindingsModal called with isOpen=true)
|
||||
await waitFor(() => {
|
||||
const lastCall = (
|
||||
MuteFindingsModalMock.mock.calls as unknown as Array<
|
||||
[{ isOpen: boolean; findingIds: string[] }]
|
||||
>
|
||||
).at(-1);
|
||||
expect(lastCall?.[0]?.isOpen).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -35,11 +35,19 @@ export function FloatingMuteButton({
|
||||
const handleClick = async () => {
|
||||
if (onBeforeOpen) {
|
||||
setIsResolving(true);
|
||||
const ids = await onBeforeOpen();
|
||||
setResolvedIds(ids);
|
||||
setIsResolving(false);
|
||||
if (ids.length > 0) {
|
||||
setIsModalOpen(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,
|
||||
);
|
||||
} finally {
|
||||
setIsResolving(false);
|
||||
}
|
||||
} else {
|
||||
setIsModalOpen(true);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks for dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
usePathname: () => "/findings",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn", () => ({
|
||||
Checkbox: ({
|
||||
"aria-label": ariaLabel,
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement> & {
|
||||
"aria-label"?: string;
|
||||
size?: string;
|
||||
}) => <input type="checkbox" aria-label={ariaLabel} {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
DataTableColumnHeader: ({
|
||||
title,
|
||||
}: {
|
||||
column: unknown;
|
||||
title: string;
|
||||
param?: string;
|
||||
}) => <span>{title}</span>,
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
cn: (...args: (string | undefined | false | null)[]) =>
|
||||
args.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("./data-table-row-actions", () => ({
|
||||
DataTableRowActions: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./impacted-providers-cell", () => ({
|
||||
ImpactedProvidersCell: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./impacted-resources-cell", () => ({
|
||||
ImpactedResourcesCell: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./notification-indicator", () => ({
|
||||
DeltaValues: { NEW: "new", CHANGED: "changed", NONE: "none" },
|
||||
NotificationIndicator: () => null,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { FindingGroupRow } from "@/types";
|
||||
|
||||
import { getColumnFindingGroups } from "./column-finding-groups";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeGroup(overrides?: Partial<FindingGroupRow>): FindingGroupRow {
|
||||
return {
|
||||
id: "group-1",
|
||||
rowType: "group" as const,
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Bucket Public Access",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
resourcesTotal: 5,
|
||||
resourcesFail: 3,
|
||||
newCount: 0,
|
||||
changedCount: 0,
|
||||
mutedCount: 0,
|
||||
providers: ["aws"],
|
||||
updatedAt: "2024-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
} as FindingGroupRow;
|
||||
}
|
||||
|
||||
function renderFindingCell(
|
||||
checkTitle: string,
|
||||
onDrillDown: (checkId: string, group: FindingGroupRow) => void,
|
||||
) {
|
||||
const columns = getColumnFindingGroups({
|
||||
rowSelection: {},
|
||||
selectableRowCount: 1,
|
||||
onDrillDown,
|
||||
});
|
||||
|
||||
// Find the "finding" column (index 2 — the title column)
|
||||
const findingColumn = columns.find(
|
||||
(col) => (col as { accessorKey?: string }).accessorKey === "finding",
|
||||
);
|
||||
if (!findingColumn?.cell) throw new Error("finding column not found");
|
||||
|
||||
const group = makeGroup({ checkTitle });
|
||||
// Render the cell directly with a minimal row mock
|
||||
const CellComponent = findingColumn.cell as (props: {
|
||||
row: { original: FindingGroupRow };
|
||||
}) => ReactNode;
|
||||
|
||||
render(<div>{CellComponent({ row: { original: group } })}</div>);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 5: Accessibility — <p onClick> → <button>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("column-finding-groups — accessibility of check title cell", () => {
|
||||
it("should render the check title as a button element (not a <p>)", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
// When
|
||||
renderFindingCell("S3 Bucket Public Access", onDrillDown);
|
||||
|
||||
// Then — there should be a button with the check title text
|
||||
const button = screen.getByRole("button", {
|
||||
name: "S3 Bucket Public Access",
|
||||
});
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.tagName.toLowerCase()).toBe("button");
|
||||
});
|
||||
|
||||
it("should NOT render the check title as a <p> element", () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
|
||||
// When
|
||||
renderFindingCell("S3 Bucket Public Access", onDrillDown);
|
||||
|
||||
// Then — <p> should not exist as the interactive element
|
||||
const paragraphs = document.querySelectorAll("p");
|
||||
const clickableParagraph = Array.from(paragraphs).find(
|
||||
(p) => p.textContent === "S3 Bucket Public Access",
|
||||
);
|
||||
expect(clickableParagraph).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should call onDrillDown when the button is clicked", async () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderFindingCell("S3 Bucket Public Access", onDrillDown);
|
||||
|
||||
// When
|
||||
const button = screen.getByRole("button", {
|
||||
name: "S3 Bucket Public Access",
|
||||
});
|
||||
await user.click(button);
|
||||
|
||||
// Then
|
||||
expect(onDrillDown).toHaveBeenCalledTimes(1);
|
||||
expect(onDrillDown).toHaveBeenCalledWith(
|
||||
"s3_check",
|
||||
expect.objectContaining({ checkId: "s3_check" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onDrillDown when Enter key is pressed on the button", async () => {
|
||||
// Given
|
||||
const onDrillDown =
|
||||
vi.fn<(checkId: string, group: FindingGroupRow) => void>();
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderFindingCell("My Check Title", onDrillDown);
|
||||
|
||||
// When — tab to button and press Enter
|
||||
const button = screen.getByRole("button", { name: "My Check Title" });
|
||||
button.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
// Then — native button handles Enter natively
|
||||
expect(onDrillDown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -152,12 +152,13 @@ export function getColumnFindingGroups({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p
|
||||
className="text-text-neutral-primary hover:text-button-tertiary cursor-pointer text-left text-sm break-words whitespace-normal hover:underline"
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-neutral-primary hover:text-button-tertiary w-full cursor-pointer border-none bg-transparent p-0 text-left text-sm break-words whitespace-normal hover:underline"
|
||||
onClick={() => onDrillDown(group.checkId, group)}
|
||||
>
|
||||
{group.checkTitle}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks for components that pull in next-auth transitively
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ refresh: vi.fn() }),
|
||||
usePathname: () => "/findings",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/image", () => ({
|
||||
default: ({ alt }: { alt: string }) => <span role="img" aria-label={alt} />,
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ children, href }: { children: ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the entire shadcn barrel to avoid auth import chain
|
||||
vi.mock("@/components/shadcn", () => {
|
||||
const Passthrough = ({ children }: { children?: ReactNode }) => (
|
||||
<>{children}</>
|
||||
);
|
||||
return {
|
||||
Badge: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) => <span className={className}>{children}</span>,
|
||||
Button: ({
|
||||
children,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: string;
|
||||
size?: string;
|
||||
asChild?: boolean;
|
||||
}) => <button {...props}>{children}</button>,
|
||||
InfoField: ({
|
||||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
label: string;
|
||||
variant?: string;
|
||||
}) => (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Tabs: Passthrough,
|
||||
TabsContent: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
}) => <div data-value={value}>{children}</div>,
|
||||
TabsList: Passthrough,
|
||||
TabsTrigger: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
}) => <button data-value={value}>{children}</button>,
|
||||
Tooltip: Passthrough,
|
||||
TooltipContent: Passthrough,
|
||||
TooltipTrigger: Passthrough,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/shadcn/card/card", () => ({
|
||||
Card: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/dropdown", () => ({
|
||||
ActionDropdown: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
ActionDropdownItem: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/skeleton/skeleton", () => ({
|
||||
Skeleton: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/spinner/spinner", () => ({
|
||||
Spinner: () => <div data-testid="spinner" />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/mute-findings-modal", () => ({
|
||||
MuteFindingsModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/send-to-jira-modal", () => ({
|
||||
SendToJiraModal: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/findings/markdown-container", () => ({
|
||||
MarkdownContainer: ({ children }: { children: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons", () => ({
|
||||
getComplianceIcon: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/services/IconServices", () => ({
|
||||
JiraIcon: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/code-snippet/code-snippet", () => ({
|
||||
CodeSnippet: ({ value }: { value: string }) => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/custom/custom-link", () => ({
|
||||
CustomLink: ({ children, href }: { children: ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/date-with-time", () => ({
|
||||
DateWithTime: ({ dateTime }: { dateTime: string }) => <span>{dateTime}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/entities/entity-info", () => ({
|
||||
EntityInfo: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table", () => ({
|
||||
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||
TableBody: ({ children }: { children: ReactNode }) => (
|
||||
<tbody>{children}</tbody>
|
||||
),
|
||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||
TableHead: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||
TableHeader: ({ children }: { children: ReactNode }) => (
|
||||
<thead>{children}</thead>
|
||||
),
|
||||
TableRow: ({ children, ...props }: HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr {...props}>{children}</tr>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/severity-badge", () => ({
|
||||
SeverityBadge: ({ severity }: { severity: string }) => (
|
||||
<span>{severity}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/table/status-finding-badge", () => ({
|
||||
FindingStatus: {},
|
||||
StatusFindingBadge: ({ status }: { status: string }) => <span>{status}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shared/events-timeline/events-timeline", () => ({
|
||||
EventsTimeline: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/region-flags", () => ({
|
||||
getRegionFlag: vi.fn(() => "🇺🇸"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/date-utils", () => ({
|
||||
getFailingForLabel: vi.fn(() => "2 days"),
|
||||
formatDuration: vi.fn(() => "5m"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils", () => ({
|
||||
cn: (...args: (string | undefined | false | null)[]) =>
|
||||
args.filter(Boolean).join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("../delta-indicator", () => ({
|
||||
DeltaIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../notification-indicator", () => ({
|
||||
NotificationIndicator: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./resource-detail-skeleton", () => ({
|
||||
ResourceDetailSkeleton: () => <div data-testid="skeleton" />,
|
||||
}));
|
||||
|
||||
vi.mock("../../muted", () => ({
|
||||
Muted: () => null,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
|
||||
import { ResourceDetailDrawerContent } from "./resource-detail-drawer-content";
|
||||
import type { CheckMeta } from "./use-resource-detail-drawer";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockCheckMeta: CheckMeta = {
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Check",
|
||||
risk: "High",
|
||||
description: "S3 description",
|
||||
complianceFrameworks: ["CIS-1.4", "PCI-DSS"],
|
||||
categories: ["security"],
|
||||
remediation: {
|
||||
recommendation: { text: "Fix it", url: "https://example.com" },
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
},
|
||||
additionalUrls: [],
|
||||
};
|
||||
|
||||
const mockFinding: ResourceDrawerFinding = {
|
||||
id: "finding-1",
|
||||
uid: "uid-1",
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Check",
|
||||
status: "FAIL",
|
||||
severity: "critical",
|
||||
delta: null,
|
||||
isMuted: false,
|
||||
mutedReason: null,
|
||||
firstSeenAt: null,
|
||||
updatedAt: null,
|
||||
resourceId: "res-1",
|
||||
resourceUid: "arn:aws:s3:::bucket",
|
||||
resourceName: "my-bucket",
|
||||
resourceService: "s3",
|
||||
resourceRegion: "us-east-1",
|
||||
resourceType: "Bucket",
|
||||
resourceGroup: "default",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123456789",
|
||||
risk: "High",
|
||||
description: "Description",
|
||||
statusExtended: "Status extended",
|
||||
complianceFrameworks: [],
|
||||
categories: [],
|
||||
remediation: {
|
||||
recommendation: { text: "Fix", url: "" },
|
||||
code: { cli: "", other: "", nativeiac: "", terraform: "" },
|
||||
},
|
||||
additionalUrls: [],
|
||||
scan: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 4: Dark mode — no hardcoded color classes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("ResourceDetailDrawerContent — dark mode token classes", () => {
|
||||
it("should NOT use hardcoded bg-white class anywhere in the component", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When — collect class strings from HTML elements only (skip SVG)
|
||||
const allElements = container.querySelectorAll("[class]");
|
||||
const classStrings = Array.from(allElements)
|
||||
.map((el) => (typeof el.className === "string" ? el.className : ""))
|
||||
.filter(Boolean);
|
||||
const hasBgWhite = classStrings.some((c) => c.includes("bg-white"));
|
||||
|
||||
// Then — no hardcoded bg-white
|
||||
expect(hasBgWhite).toBe(false);
|
||||
});
|
||||
|
||||
it("should NOT use hardcoded border-gray-300 class anywhere in the component", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const allElements = container.querySelectorAll("[class]");
|
||||
const classStrings = Array.from(allElements)
|
||||
.map((el) => (typeof el.className === "string" ? el.className : ""))
|
||||
.filter(Boolean);
|
||||
const hasBorderGray = classStrings.some((c) =>
|
||||
c.includes("border-gray-300"),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(hasBorderGray).toBe(false);
|
||||
});
|
||||
|
||||
it("should NOT use hardcoded text-slate-950 class anywhere", () => {
|
||||
// Given
|
||||
const { container } = render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={mockCheckMeta}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={mockFinding}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
const allElements = container.querySelectorAll("[class]");
|
||||
const classStrings = Array.from(allElements)
|
||||
.map((el) => (typeof el.className === "string" ? el.className : ""))
|
||||
.filter(Boolean);
|
||||
const hasTextSlate = classStrings.some((c) => c.includes("text-slate-950"));
|
||||
|
||||
// Then
|
||||
expect(hasTextSlate).toBe(false);
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -200,7 +200,7 @@ export function ResourceDetailDrawerContent({
|
||||
return icon ? (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5">
|
||||
<div className="border-default-200 bg-background flex size-7 shrink-0 items-center justify-center rounded-md border p-0.5">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={framework}
|
||||
@@ -215,7 +215,7 @@ export function ResourceDetailDrawerContent({
|
||||
) : (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-text-neutral-secondary inline-flex h-7 shrink-0 items-center rounded-md border border-gray-300 bg-white px-1.5 text-xs">
|
||||
<span className="text-text-neutral-secondary border-default-200 bg-background inline-flex h-7 shrink-0 items-center rounded-md border px-1.5 text-xs">
|
||||
{framework}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -684,7 +684,7 @@ export function ResourceDetailDrawerContent({
|
||||
{/* Lighthouse AI button */}
|
||||
<a
|
||||
href={`/lighthouse?${new URLSearchParams({ prompt: `Analyze this security finding and provide remediation guidance:\n\n- **Finding**: ${checkMeta.checkTitle}\n- **Check ID**: ${checkMeta.checkId}\n- **Severity**: ${f?.severity ?? "unknown"}\n- **Status**: ${f?.status ?? "unknown"}${f?.statusExtended ? `\n- **Detail**: ${f.statusExtended}` : ""}${checkMeta.risk ? `\n- **Risk**: ${checkMeta.risk}` : ""}` }).toString()}`}
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-950 transition-opacity hover:opacity-90"
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-3 text-sm font-bold text-slate-900 transition-opacity hover:opacity-90"
|
||||
style={{
|
||||
background: "var(--gradient-lighthouse)",
|
||||
}}
|
||||
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hoist mocks before imports that chain to next-auth
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const {
|
||||
getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponseMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getLatestFindingsByResourceUidMock: vi.fn(),
|
||||
adaptFindingsByResourceResponseMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
getLatestFindingsByResourceUid: getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponse: adaptFindingsByResourceResponseMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
|
||||
import { useResourceDetailDrawer } from "./use-resource-detail-drawer";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeResource(
|
||||
overrides?: Partial<FindingResourceRow>,
|
||||
): FindingResourceRow {
|
||||
return {
|
||||
id: "row-1",
|
||||
rowType: "resource" as const,
|
||||
findingId: "finding-1",
|
||||
checkId: "s3_check",
|
||||
providerType: "aws",
|
||||
providerAlias: "prod",
|
||||
providerUid: "123",
|
||||
resourceName: "my-bucket",
|
||||
resourceGroup: "default",
|
||||
resourceUid: "arn:aws:s3:::my-bucket",
|
||||
service: "s3",
|
||||
region: "us-east-1",
|
||||
severity: "critical",
|
||||
status: "FAIL",
|
||||
isMuted: false,
|
||||
mutedReason: undefined,
|
||||
firstSeenAt: null,
|
||||
lastSeenAt: null,
|
||||
...overrides,
|
||||
} as FindingResourceRow;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 2: AbortController cleanup on unmount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should abort the in-flight fetch controller when the hook unmounts", async () => {
|
||||
// Given — spy on AbortController.prototype.abort to detect abort calls
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
|
||||
// never-resolving fetch to simulate in-flight request
|
||||
getLatestFindingsByResourceUidMock.mockImplementation(
|
||||
() => new Promise(() => {}),
|
||||
);
|
||||
adaptFindingsByResourceResponseMock.mockReturnValue([]);
|
||||
|
||||
const resources = [makeResource()];
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
// When — trigger a fetch by opening the drawer
|
||||
act(() => {
|
||||
result.current.openDrawer(0);
|
||||
});
|
||||
|
||||
// Verify a fetch was started
|
||||
expect(getLatestFindingsByResourceUidMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Reset spy count to detect only the unmount abort
|
||||
abortSpy.mockClear();
|
||||
|
||||
// Then — unmount while fetch is in flight
|
||||
unmount();
|
||||
|
||||
// The abort should have been called on unmount
|
||||
expect(abortSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not abort when no fetch has been started yet", () => {
|
||||
// Given — spy on abort
|
||||
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
|
||||
|
||||
const resources = [makeResource()];
|
||||
|
||||
// When — render without opening drawer (no fetch started)
|
||||
const { unmount } = renderHook(() =>
|
||||
useResourceDetailDrawer({
|
||||
resources,
|
||||
checkId: "s3_check",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then — unmount without any fetch
|
||||
unmount();
|
||||
|
||||
// abort should NOT have been called (fetchControllerRef.current is null)
|
||||
expect(abortSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
adaptFindingsByResourceResponse,
|
||||
@@ -84,6 +84,14 @@ export function useResourceDetailDrawer({
|
||||
const checkMetaRef = useRef<CheckMeta | null>(null);
|
||||
const fetchControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Abort any in-flight request on unmount to prevent state updates
|
||||
// on an already-unmounted component.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
fetchControllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchFindings = async (resourceUid: string) => {
|
||||
// Abort any in-flight request to prevent stale data from out-of-order responses
|
||||
fetchControllerRef.current?.abort();
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getRegionFlag } from "./region-flags";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fix 6: Taiwan (asia-east1) mapped correctly vs Hong Kong (asia-east2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("getRegionFlag — Taiwan vs Hong Kong disambiguation", () => {
|
||||
it("should return 🇹🇼 for GCP asia-east1 (Taiwan)", () => {
|
||||
// Given/When
|
||||
const result = getRegionFlag("asia-east1");
|
||||
|
||||
// Then
|
||||
expect(result).toBe("🇹🇼");
|
||||
});
|
||||
|
||||
it("should return 🇭🇰 for GCP asia-east2 (Hong Kong)", () => {
|
||||
// Given/When
|
||||
const result = getRegionFlag("asia-east2");
|
||||
|
||||
// Then
|
||||
expect(result).toBe("🇭🇰");
|
||||
});
|
||||
|
||||
it("should return 🇭🇰 for regions containing 'hongkong'", () => {
|
||||
// Given/When
|
||||
const result = getRegionFlag("hongkong");
|
||||
|
||||
// Then
|
||||
expect(result).toBe("🇭🇰");
|
||||
});
|
||||
|
||||
it("should NOT return 🇭🇰 for asia-east1", () => {
|
||||
// Given/When
|
||||
const result = getRegionFlag("asia-east1");
|
||||
|
||||
// Then — confirm it's not Hong Kong flag
|
||||
expect(result).not.toBe("🇭🇰");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRegionFlag — existing regions not broken", () => {
|
||||
it("should return 🇺🇸 for us-east-1 (AWS)", () => {
|
||||
expect(getRegionFlag("us-east-1")).toBe("🇺🇸");
|
||||
});
|
||||
|
||||
it("should return 🇪🇺 for eu-west-1 (AWS)", () => {
|
||||
expect(getRegionFlag("eu-west-1")).toBe("🇪🇺");
|
||||
});
|
||||
|
||||
it("should return 🇯🇵 for ap-northeast-1 (Japan)", () => {
|
||||
expect(getRegionFlag("ap-northeast-1")).toBe("🇯🇵");
|
||||
});
|
||||
|
||||
it("should return 🇦🇺 for ap-southeast-2 (Australia)", () => {
|
||||
expect(getRegionFlag("ap-southeast-2")).toBe("🇦🇺");
|
||||
});
|
||||
|
||||
it("should return 🇸🇬 for ap-southeast-1 (Singapore)", () => {
|
||||
expect(getRegionFlag("ap-southeast-1")).toBe("🇸🇬");
|
||||
});
|
||||
|
||||
it("should return empty string for '-' (unknown/no region)", () => {
|
||||
expect(getRegionFlag("-")).toBe("");
|
||||
});
|
||||
|
||||
it("should return empty string for empty string", () => {
|
||||
expect(getRegionFlag("")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -56,8 +56,10 @@ const REGION_FLAG_RULES: [RegExp, string][] = [
|
||||
/\bap[-_]?south[-_]?1|india|centralindia|southindia|westindia|mumbai|hyderabad/i,
|
||||
"🇮🇳",
|
||||
],
|
||||
// Hong Kong
|
||||
[/\bap[-_]?east[-_]?1|hongkong/i, "🇭🇰"],
|
||||
// Taiwan — GCP asia-east1 (must come BEFORE Hong Kong rule)
|
||||
[/\basia[-_]east[-_]?1\b/i, "🇹🇼"],
|
||||
// Hong Kong — GCP asia-east2 (ap-east-1 is AWS HK)
|
||||
[/\bap[-_]?east[-_]?1|\basia[-_]east[-_]?2\b|hongkong/i, "🇭🇰"],
|
||||
// Indonesia
|
||||
[/\bap[-_]?southeast[-_]?3|indonesia|jakarta/i, "🇮🇩"],
|
||||
// China
|
||||
|
||||
Reference in New Issue
Block a user