mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
feat(ui): add compliance watchlist (#12300)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
a6d5dbacd9
commit
5285d25cfd
Binary file not shown.
|
Before Width: | Height: | Size: 79 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB |
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
adaptCatalogResponse,
|
||||
adaptWatchlistBulkSummary,
|
||||
mergeCatalogPages,
|
||||
} from "./compliance-watchlist.adapter";
|
||||
import type {
|
||||
ComplianceCatalogEntryAttributes,
|
||||
ComplianceCatalogResponse,
|
||||
} from "./compliance-watchlist.types";
|
||||
|
||||
const catalogPage = (
|
||||
overrides: Partial<ComplianceCatalogResponse> = {},
|
||||
): ComplianceCatalogResponse => ({
|
||||
data: [
|
||||
{
|
||||
type: "compliance-catalog-entries",
|
||||
id: "aws:cis_1.4_aws",
|
||||
attributes: {
|
||||
compliance_id: "cis_1.4_aws",
|
||||
provider_type: "aws",
|
||||
scope: "provider",
|
||||
provider_types: ["aws"],
|
||||
framework: "CIS",
|
||||
name: "CIS Amazon Web Services Foundations Benchmark",
|
||||
version: "1.4",
|
||||
description: "desc",
|
||||
total_requirements: 60,
|
||||
requirements_passed: 30,
|
||||
requirements_failed: 20,
|
||||
requirements_manual: 10,
|
||||
score: 50,
|
||||
has_data: true,
|
||||
in_watchlist: true,
|
||||
watchlist_entry_id: "entry-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: { page: 1, pages: 1, count: 1 },
|
||||
total_entries: 42,
|
||||
watchlist_count: 6,
|
||||
eligible_provider_types: ["aws", "azure"],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("adaptCatalogResponse", () => {
|
||||
it("maps a catalog entry onto the UI shape", () => {
|
||||
const { entries } = adaptCatalogResponse(catalogPage());
|
||||
|
||||
expect(entries[0]).toEqual({
|
||||
id: "aws:cis_1.4_aws",
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
scope: "provider",
|
||||
providerTypes: ["aws"],
|
||||
framework: "CIS",
|
||||
name: "CIS Amazon Web Services Foundations Benchmark",
|
||||
version: "1.4",
|
||||
description: "desc",
|
||||
totalRequirements: 60,
|
||||
requirementsPassed: 30,
|
||||
requirementsFailed: 20,
|
||||
requirementsManual: 10,
|
||||
score: 50,
|
||||
hasData: true,
|
||||
inWatchlist: true,
|
||||
watchlistEntryId: "entry-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a universal card onto the `*` key it is pinned under", () => {
|
||||
const response = catalogPage();
|
||||
const attributes = response.data![0].attributes;
|
||||
attributes.compliance_id = "dora_2022_2554";
|
||||
attributes.provider_type = "*";
|
||||
attributes.scope = "universal";
|
||||
attributes.provider_types = ["aws", "azure", "gcp"];
|
||||
response.data![0].id = "*:dora_2022_2554";
|
||||
|
||||
const { entries } = adaptCatalogResponse(response);
|
||||
|
||||
expect(entries[0].providerType).toBe("*");
|
||||
expect(entries[0].scope).toBe("universal");
|
||||
expect(entries[0].providerTypes).toEqual(["aws", "azure", "gcp"]);
|
||||
});
|
||||
|
||||
it("falls back to a provider-scoped card when the API omits the scope", () => {
|
||||
// An older API has no `scope`/`provider_types`; reading those cards as
|
||||
// provider-scoped keeps them keyed exactly as they were before.
|
||||
const response = catalogPage();
|
||||
const attributes = response.data![0]
|
||||
.attributes as Partial<ComplianceCatalogEntryAttributes>;
|
||||
delete attributes.scope;
|
||||
delete attributes.provider_types;
|
||||
|
||||
const { entries } = adaptCatalogResponse(response);
|
||||
|
||||
expect(entries[0].scope).toBe("provider");
|
||||
expect(entries[0].providerTypes).toEqual(["aws"]);
|
||||
});
|
||||
|
||||
it("keeps a never-scanned framework's null score instead of coercing it to 0", () => {
|
||||
const response = catalogPage();
|
||||
response.data![0].attributes.score = null;
|
||||
response.data![0].attributes.has_data = false;
|
||||
|
||||
const { entries } = adaptCatalogResponse(response);
|
||||
|
||||
expect(entries[0].score).toBeNull();
|
||||
expect(entries[0].hasData).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the counters from root meta, not from the page length", () => {
|
||||
const { meta } = adaptCatalogResponse(catalogPage());
|
||||
|
||||
expect(meta.totalEntries).toBe(42);
|
||||
expect(meta.watchlistCount).toBe(6);
|
||||
expect(meta.eligibleProviderTypes).toEqual(["aws", "azure"]);
|
||||
});
|
||||
|
||||
it("degrades to an empty catalog when the response has no data", () => {
|
||||
const { entries, meta } = adaptCatalogResponse({});
|
||||
|
||||
expect(entries).toEqual([]);
|
||||
expect(meta).toEqual({
|
||||
totalEntries: 0,
|
||||
watchlistCount: 0,
|
||||
eligibleProviderTypes: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeCatalogPages", () => {
|
||||
it("concatenates entries across pages and keeps the first page's meta", () => {
|
||||
const first = adaptCatalogResponse(catalogPage());
|
||||
const second = adaptCatalogResponse(
|
||||
catalogPage({
|
||||
data: [
|
||||
{
|
||||
...catalogPage().data![0],
|
||||
id: "azure:cis_2.0_azure",
|
||||
attributes: {
|
||||
...catalogPage().data![0].attributes,
|
||||
compliance_id: "cis_2.0_azure",
|
||||
provider_type: "azure",
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: { page: 2, pages: 2, count: 2 },
|
||||
total_entries: 0,
|
||||
watchlist_count: 0,
|
||||
eligible_provider_types: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const merged = mergeCatalogPages([first, second]);
|
||||
|
||||
expect(merged.entries).toHaveLength(2);
|
||||
expect(merged.meta.totalEntries).toBe(42);
|
||||
});
|
||||
|
||||
it("returns an empty catalog when no page loaded", () => {
|
||||
expect(mergeCatalogPages([])).toEqual({
|
||||
entries: [],
|
||||
meta: { totalEntries: 0, watchlistCount: 0, eligibleProviderTypes: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("adaptWatchlistBulkSummary", () => {
|
||||
it("maps the bulk root meta", () => {
|
||||
expect(
|
||||
adaptWatchlistBulkSummary({
|
||||
meta: {
|
||||
added: 3,
|
||||
already_present: 1,
|
||||
removed: 2,
|
||||
not_present: 0,
|
||||
watchlist_count: 8,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
added: 3,
|
||||
alreadyPresent: 1,
|
||||
removed: 2,
|
||||
notPresent: 0,
|
||||
watchlistCount: 8,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults every counter when the meta is missing", () => {
|
||||
expect(adaptWatchlistBulkSummary({})).toEqual({
|
||||
added: 0,
|
||||
alreadyPresent: 0,
|
||||
removed: 0,
|
||||
notPresent: 0,
|
||||
watchlistCount: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
ComplianceCatalog,
|
||||
ComplianceCatalogEntry,
|
||||
ComplianceCatalogMeta,
|
||||
ComplianceWatchlistBulkSummary,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import { WATCHLIST_SCOPE } from "@/types/compliance-watchlist";
|
||||
|
||||
import type {
|
||||
ComplianceCatalogEntryResource,
|
||||
ComplianceCatalogResponse,
|
||||
ComplianceWatchlistBulkResponse,
|
||||
} from "./compliance-watchlist.types";
|
||||
|
||||
const EMPTY_META: ComplianceCatalogMeta = {
|
||||
totalEntries: 0,
|
||||
watchlistCount: 0,
|
||||
eligibleProviderTypes: [],
|
||||
};
|
||||
|
||||
export const adaptCatalogEntry = (
|
||||
resource: ComplianceCatalogEntryResource,
|
||||
): ComplianceCatalogEntry => {
|
||||
const attributes = resource.attributes;
|
||||
return {
|
||||
id: resource.id,
|
||||
complianceId: attributes.compliance_id,
|
||||
providerType: attributes.provider_type,
|
||||
// Anything the API does not label `universal` is a provider-scoped card,
|
||||
// which is also the safe reading of a response from an older API.
|
||||
scope:
|
||||
attributes.scope === WATCHLIST_SCOPE.UNIVERSAL
|
||||
? WATCHLIST_SCOPE.UNIVERSAL
|
||||
: WATCHLIST_SCOPE.PROVIDER,
|
||||
providerTypes: Array.isArray(attributes.provider_types)
|
||||
? attributes.provider_types
|
||||
: [attributes.provider_type],
|
||||
framework: attributes.framework,
|
||||
name: attributes.name,
|
||||
version: attributes.version,
|
||||
description: attributes.description,
|
||||
totalRequirements: attributes.total_requirements,
|
||||
requirementsPassed: attributes.requirements_passed,
|
||||
requirementsFailed: attributes.requirements_failed,
|
||||
requirementsManual: attributes.requirements_manual,
|
||||
// `score` stays null for a never-scanned framework so the card can render
|
||||
// "not scanned yet" instead of a red 0%.
|
||||
score: attributes.score ?? null,
|
||||
hasData: attributes.has_data === true,
|
||||
inWatchlist: attributes.in_watchlist === true,
|
||||
watchlistEntryId: attributes.watchlist_entry_id ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const adaptCatalogResponse = (
|
||||
response: ComplianceCatalogResponse | undefined,
|
||||
): ComplianceCatalog => {
|
||||
const data = Array.isArray(response?.data) ? response.data : [];
|
||||
const meta = response?.meta;
|
||||
return {
|
||||
entries: data.map(adaptCatalogEntry),
|
||||
meta: {
|
||||
totalEntries: meta?.total_entries ?? 0,
|
||||
watchlistCount: meta?.watchlist_count ?? 0,
|
||||
eligibleProviderTypes: meta?.eligible_provider_types ?? [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/** The catalog is paginated (10 per page by default, 100 max), so a tenant
|
||||
* with several provider types needs more than one request. Root meta is
|
||||
* identical on every page — counted before filtering — so the first page's
|
||||
* copy is authoritative. */
|
||||
export const mergeCatalogPages = (
|
||||
pages: ComplianceCatalog[],
|
||||
): ComplianceCatalog => ({
|
||||
entries: pages.flatMap((page) => page.entries),
|
||||
meta: pages[0]?.meta ?? EMPTY_META,
|
||||
});
|
||||
|
||||
export const adaptWatchlistBulkSummary = (
|
||||
response: ComplianceWatchlistBulkResponse | undefined,
|
||||
): ComplianceWatchlistBulkSummary => {
|
||||
const meta = response?.meta;
|
||||
return {
|
||||
added: meta?.added ?? 0,
|
||||
alreadyPresent: meta?.already_present ?? 0,
|
||||
removed: meta?.removed ?? 0,
|
||||
notPresent: meta?.not_present ?? 0,
|
||||
watchlistCount: meta?.watchlist_count ?? 0,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,424 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock, getAuthHeadersMock, revalidatePathMock } = vi.hoisted(
|
||||
() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
revalidatePathMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: revalidatePathMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
addComplianceToWatchlist,
|
||||
bulkUpdateComplianceWatchlist,
|
||||
getComplianceCatalog,
|
||||
removeComplianceFromWatchlist,
|
||||
} from "./compliance-watchlist";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
const catalogPage = (
|
||||
complianceId: string,
|
||||
pagination: { page: number; pages: number },
|
||||
) => ({
|
||||
data: [
|
||||
{
|
||||
type: "compliance-catalog-entries",
|
||||
id: `aws:${complianceId}`,
|
||||
attributes: {
|
||||
compliance_id: complianceId,
|
||||
provider_type: "aws",
|
||||
framework: "CIS",
|
||||
name: "CIS",
|
||||
version: "1.4",
|
||||
description: "",
|
||||
total_requirements: 10,
|
||||
requirements_passed: 5,
|
||||
requirements_failed: 5,
|
||||
requirements_manual: 0,
|
||||
score: 50,
|
||||
has_data: true,
|
||||
in_watchlist: false,
|
||||
watchlist_entry_id: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
pagination: { ...pagination, count: pagination.pages },
|
||||
total_entries: pagination.pages,
|
||||
watchlist_count: 0,
|
||||
eligible_provider_types: ["aws"],
|
||||
},
|
||||
});
|
||||
|
||||
const lastFetchUrl = (): URL => {
|
||||
const call = fetchMock.mock.calls.at(-1);
|
||||
if (!call) throw new Error("fetch was not called");
|
||||
return new URL(String(call[0]));
|
||||
};
|
||||
|
||||
const lastFetchBody = (): unknown => {
|
||||
const call = fetchMock.mock.calls.at(-1);
|
||||
if (!call) throw new Error("fetch was not called");
|
||||
return JSON.parse(String(call[1].body));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("getComplianceCatalog", () => {
|
||||
it("rejects a null Server Action payload before fetching", async () => {
|
||||
const catalog = await getComplianceCatalog(null as never);
|
||||
|
||||
expect(catalog).toEqual({
|
||||
entries: [],
|
||||
meta: {
|
||||
totalEntries: 0,
|
||||
watchlistCount: 0,
|
||||
eligibleProviderTypes: [],
|
||||
},
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requests the maximum page size so a whole catalog needs as few calls as possible", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 1 })),
|
||||
);
|
||||
|
||||
await getComplianceCatalog();
|
||||
|
||||
expect(lastFetchUrl().searchParams.get("page[size]")).toBe("100");
|
||||
});
|
||||
|
||||
it("follows every page and returns the merged catalog", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 3 })),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(catalogPage("gdpr_aws", { page: 2, pages: 3 })),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(catalogPage("iso27001_aws", { page: 3, pages: 3 })),
|
||||
);
|
||||
|
||||
const catalog = await getComplianceCatalog();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(catalog.entries.map((entry) => entry.complianceId)).toEqual([
|
||||
"cis_1.4_aws",
|
||||
"gdpr_aws",
|
||||
"iso27001_aws",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops after the first page when there is only one", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 1 })),
|
||||
);
|
||||
|
||||
await getComplianceCatalog();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("narrows the catalog to the requested provider types", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 1 })),
|
||||
);
|
||||
|
||||
await getComplianceCatalog({ providerTypes: ["aws", "azure"] });
|
||||
|
||||
expect(lastFetchUrl().searchParams.get("filter[provider_type__in]")).toBe(
|
||||
"aws,azure",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the provider type filter when none is requested", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 1 })),
|
||||
);
|
||||
|
||||
await getComplianceCatalog({ providerTypes: [] });
|
||||
|
||||
expect(lastFetchUrl().searchParams.has("filter[provider_type__in]")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("degrades to an empty catalog when the request fails", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 500));
|
||||
|
||||
const catalog = await getComplianceCatalog();
|
||||
|
||||
expect(catalog).toEqual({
|
||||
entries: [],
|
||||
meta: {
|
||||
totalEntries: 0,
|
||||
watchlistCount: 0,
|
||||
eligibleProviderTypes: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("degrades to an empty catalog when fetch throws", async () => {
|
||||
fetchMock.mockRejectedValue(new Error("network down"));
|
||||
|
||||
const catalog = await getComplianceCatalog();
|
||||
|
||||
expect(catalog.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the rest of the catalog when a single page fails", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 3 })),
|
||||
)
|
||||
.mockRejectedValueOnce(new Error("timed out"))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse(catalogPage("iso27001_aws", { page: 3, pages: 3 })),
|
||||
);
|
||||
|
||||
const catalog = await getComplianceCatalog();
|
||||
|
||||
expect(catalog.entries.map((entry) => entry.complianceId)).toEqual([
|
||||
"cis_1.4_aws",
|
||||
"iso27001_aws",
|
||||
]);
|
||||
});
|
||||
|
||||
it("bounds how many pages it requests at once", async () => {
|
||||
// A page-1 response reporting a large page count would otherwise open one
|
||||
// socket per page against an API that re-runs the whole roll-up for each.
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
fetchMock.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
setTimeout(() => {
|
||||
inFlight -= 1;
|
||||
resolve(
|
||||
jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 40 })),
|
||||
);
|
||||
}, 0);
|
||||
}),
|
||||
);
|
||||
|
||||
await getComplianceCatalog();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(40);
|
||||
expect(peak).toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("bounds every request with a timeout", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(catalogPage("cis_1.4_aws", { page: 1, pages: 1 })),
|
||||
);
|
||||
|
||||
await getComplianceCatalog();
|
||||
|
||||
expect(fetchMock.mock.calls.at(-1)?.[1].signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addComplianceToWatchlist", () => {
|
||||
it("rejects non-string target fields before fetching", async () => {
|
||||
const result = await addComplianceToWatchlist({
|
||||
complianceId: 42,
|
||||
providerType: "aws",
|
||||
} as never);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("posts a single JSON:API entry and revalidates the compliance route", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ data: {} }, 201));
|
||||
|
||||
const result = await addComplianceToWatchlist({
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
});
|
||||
|
||||
expect(lastFetchBody()).toEqual({
|
||||
data: {
|
||||
type: "compliance-watchlist-entries",
|
||||
attributes: {
|
||||
compliance_id: "cis_1.4_aws",
|
||||
provider_type: "aws",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBeDefined();
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/compliance");
|
||||
});
|
||||
|
||||
it("surfaces the API error detail and does not revalidate", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{
|
||||
errors: [
|
||||
{
|
||||
detail: "The tenant has no provider of type azure.",
|
||||
code: "provider_type_not_available",
|
||||
},
|
||||
],
|
||||
},
|
||||
400,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await addComplianceToWatchlist({
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "azure",
|
||||
});
|
||||
|
||||
expect(result.error).toContain("azure");
|
||||
expect(revalidatePathMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty compliance id before hitting the API", async () => {
|
||||
const result = await addComplianceToWatchlist({
|
||||
complianceId: "",
|
||||
providerType: "aws",
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeComplianceFromWatchlist", () => {
|
||||
it("deletes the entry by id", async () => {
|
||||
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
|
||||
const result = await removeComplianceFromWatchlist(
|
||||
"3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
);
|
||||
|
||||
expect(lastFetchUrl().pathname).toContain(
|
||||
"/compliance-watchlist-entries/3fa85f64-5717-4562-b3fc-2c963f66afa6",
|
||||
);
|
||||
expect(result.success).toBeDefined();
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/compliance");
|
||||
});
|
||||
|
||||
it("refuses a non-UUID id instead of interpolating it into the URL", async () => {
|
||||
const result = await removeComplianceFromWatchlist("../../providers");
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("bulkUpdateComplianceWatchlist", () => {
|
||||
it.each([null, {}, { add: [], remove: null }])(
|
||||
"rejects a malformed Server Action diff %# before fetching",
|
||||
async (diff) => {
|
||||
const result = await bulkUpdateComplianceWatchlist(diff as never);
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("sends one call carrying both lists and reports the meta summary", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [],
|
||||
meta: {
|
||||
added: 3,
|
||||
already_present: 0,
|
||||
removed: 1,
|
||||
not_present: 0,
|
||||
watchlist_count: 8,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await bulkUpdateComplianceWatchlist({
|
||||
add: [{ complianceId: "cis_1.4_aws", providerType: "aws" }],
|
||||
remove: [{ complianceId: "dora_2022_2554", providerType: "azure" }],
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(lastFetchUrl().pathname).toContain(
|
||||
"/compliance-watchlist-entries/bulk",
|
||||
);
|
||||
expect(lastFetchBody()).toEqual({
|
||||
data: {
|
||||
type: "compliance-watchlist-bulk",
|
||||
attributes: {
|
||||
add: [{ compliance_id: "cis_1.4_aws", provider_type: "aws" }],
|
||||
remove: [{ compliance_id: "dora_2022_2554", provider_type: "azure" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.summary).toEqual({
|
||||
added: 3,
|
||||
alreadyPresent: 0,
|
||||
removed: 1,
|
||||
notPresent: 0,
|
||||
watchlistCount: 8,
|
||||
});
|
||||
expect(result.success).toContain("3 added");
|
||||
});
|
||||
|
||||
it("refuses an empty diff without calling the API", async () => {
|
||||
const result = await bulkUpdateComplianceWatchlist({
|
||||
add: [],
|
||||
remove: [],
|
||||
});
|
||||
|
||||
expect(result.error).toBeDefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("guards the 200-item limit client side", async () => {
|
||||
const add = Array.from({ length: 201 }, (_, index) => ({
|
||||
complianceId: `framework_${index}`,
|
||||
providerType: "aws",
|
||||
}));
|
||||
|
||||
const result = await bulkUpdateComplianceWatchlist({ add, remove: [] });
|
||||
|
||||
expect(result.error).toContain("200");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the API error when the bulk call fails", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse(
|
||||
{ errors: [{ detail: "Nope.", code: "bulk_limit_exceeded" }] },
|
||||
400,
|
||||
),
|
||||
);
|
||||
|
||||
const result = await bulkUpdateComplianceWatchlist({
|
||||
add: [{ complianceId: "cis_1.4_aws", providerType: "aws" }],
|
||||
remove: [],
|
||||
});
|
||||
|
||||
expect(result.error).toBe("Nope.");
|
||||
expect(revalidatePathMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import {
|
||||
exceedsWatchlistBulkLimit,
|
||||
formatWatchlistBulkSummary,
|
||||
isEmptyWatchlistDiff,
|
||||
MAX_WATCHLIST_BULK,
|
||||
} from "@/lib/compliance/watchlist";
|
||||
import type {
|
||||
ComplianceCatalog,
|
||||
ComplianceWatchlistActionResult,
|
||||
ComplianceWatchlistBulkDiff,
|
||||
ComplianceWatchlistTarget,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import {
|
||||
COMPLIANCE_WATCHLIST_BULK_TYPE,
|
||||
COMPLIANCE_WATCHLIST_ENTRY_TYPE,
|
||||
} from "@/types/compliance-watchlist";
|
||||
|
||||
import {
|
||||
adaptCatalogResponse,
|
||||
adaptWatchlistBulkSummary,
|
||||
mergeCatalogPages,
|
||||
} from "./compliance-watchlist.adapter";
|
||||
import type {
|
||||
ComplianceCatalogResponse,
|
||||
ComplianceWatchlistBulkResponse,
|
||||
} from "./compliance-watchlist.types";
|
||||
|
||||
const REVALIDATED_PATHS = ["/compliance", "/"];
|
||||
const CATALOG_ENDPOINT = "/compliance-catalog";
|
||||
const ENTRIES_ENDPOINT = "/compliance-watchlist-entries";
|
||||
|
||||
const CATALOG_PAGE_SIZE = 100;
|
||||
const MAX_CATALOG_PAGES = 100;
|
||||
const CATALOG_FETCH_CONCURRENCY = 5;
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
const EMPTY_CATALOG: ComplianceCatalog = {
|
||||
entries: [],
|
||||
meta: { totalEntries: 0, watchlistCount: 0, eligibleProviderTypes: [] },
|
||||
};
|
||||
|
||||
const GENERIC_ERROR = "Could not update the compliance watchlist.";
|
||||
|
||||
const watchlistTargetSchema = z.object({
|
||||
complianceId: z.string().trim().min(1),
|
||||
providerType: z.string().trim().min(1),
|
||||
});
|
||||
const complianceCatalogInputSchema = z.object({
|
||||
providerTypes: z.array(z.string().trim().min(1)).optional(),
|
||||
});
|
||||
const complianceWatchlistBulkDiffSchema = z.object({
|
||||
add: z.array(watchlistTargetSchema),
|
||||
remove: z.array(watchlistTargetSchema),
|
||||
});
|
||||
const watchlistEntryIdSchema = z.uuid();
|
||||
|
||||
interface CatalogPageResult {
|
||||
catalog: ComplianceCatalog;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
/** Pull the API's user-facing error detail out of a JSON:API error document.
|
||||
* Anything unparseable (5xx, HTML error pages) collapses into a generic
|
||||
* message so no internals reach the user. */
|
||||
const readApiError = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const body = await response.json();
|
||||
const detail = Array.isArray(body?.errors)
|
||||
? body.errors[0]?.detail || body.errors[0]?.title
|
||||
: undefined;
|
||||
return typeof detail === "string" && detail.trim().length > 0
|
||||
? detail
|
||||
: fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const buildCatalogUrl = (page: number, providerTypes?: string[]): string => {
|
||||
const url = new URL(`${apiBaseUrl}${CATALOG_ENDPOINT}`);
|
||||
url.searchParams.set("page[size]", String(CATALOG_PAGE_SIZE));
|
||||
url.searchParams.set("page[number]", String(page));
|
||||
if (providerTypes && providerTypes.length > 0) {
|
||||
url.searchParams.set("filter[provider_type__in]", providerTypes.join(","));
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const getComplianceCatalog = async (
|
||||
input: { providerTypes?: string[] } = {},
|
||||
): Promise<ComplianceCatalog> => {
|
||||
const parsedInput = complianceCatalogInputSchema.safeParse(input);
|
||||
if (!parsedInput.success) return EMPTY_CATALOG;
|
||||
|
||||
const { providerTypes } = parsedInput.data;
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
const fetchPage = async (
|
||||
page: number,
|
||||
): Promise<CatalogPageResult | null> => {
|
||||
try {
|
||||
const pageResponse = await fetch(buildCatalogUrl(page, providerTypes), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!pageResponse.ok) return null;
|
||||
const body = (await pageResponse.json()) as ComplianceCatalogResponse;
|
||||
return {
|
||||
catalog: adaptCatalogResponse(body),
|
||||
pageCount: body.meta?.pagination?.pages ?? 1,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching compliance catalog page ${page}:`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const firstPage = await fetchPage(1);
|
||||
if (!firstPage) return EMPTY_CATALOG;
|
||||
|
||||
const pageCount = Math.min(
|
||||
Math.max(1, firstPage.pageCount),
|
||||
MAX_CATALOG_PAGES,
|
||||
);
|
||||
if (pageCount <= 1) return firstPage.catalog;
|
||||
|
||||
const rest: ComplianceCatalog[] = [];
|
||||
for (let page = 2; page <= pageCount; page += CATALOG_FETCH_CONCURRENCY) {
|
||||
const batch = await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(CATALOG_FETCH_CONCURRENCY, pageCount - page + 1) },
|
||||
(_, index) => fetchPage(page + index),
|
||||
),
|
||||
);
|
||||
rest.push(...batch.flatMap((page) => (page ? [page.catalog] : [])));
|
||||
}
|
||||
|
||||
return mergeCatalogPages([firstPage.catalog, ...rest]);
|
||||
} catch (error) {
|
||||
console.error("Error fetching compliance catalog:", error);
|
||||
return EMPTY_CATALOG;
|
||||
}
|
||||
};
|
||||
|
||||
export const addComplianceToWatchlist = async (
|
||||
target: ComplianceWatchlistTarget,
|
||||
): Promise<ComplianceWatchlistActionResult> => {
|
||||
const parsedTarget = watchlistTargetSchema.safeParse(target);
|
||||
if (!parsedTarget.success) {
|
||||
return { error: "A framework and its provider type are required." };
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const response = await fetch(`${apiBaseUrl}${ENTRIES_ENDPOINT}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: COMPLIANCE_WATCHLIST_ENTRY_TYPE,
|
||||
attributes: {
|
||||
compliance_id: parsedTarget.data.complianceId,
|
||||
provider_type: parsedTarget.data.providerType,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: await readApiError(
|
||||
response,
|
||||
"Could not add the framework to the watchlist.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
REVALIDATED_PATHS.forEach((path) => revalidatePath(path));
|
||||
return { success: "Added to watchlist." };
|
||||
} catch (error) {
|
||||
console.error("Error adding framework to the watchlist:", error);
|
||||
return { error: GENERIC_ERROR };
|
||||
}
|
||||
};
|
||||
|
||||
export const removeComplianceFromWatchlist = async (
|
||||
entryId: string,
|
||||
): Promise<ComplianceWatchlistActionResult> => {
|
||||
const parsed = watchlistEntryIdSchema.safeParse(entryId);
|
||||
if (!parsed.success) {
|
||||
return { error: "Invalid watchlist entry." };
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const response = await fetch(
|
||||
`${apiBaseUrl}${ENTRIES_ENDPOINT}/${parsed.data}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: await readApiError(
|
||||
response,
|
||||
"Could not remove the framework from the watchlist.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
REVALIDATED_PATHS.forEach((path) => revalidatePath(path));
|
||||
return { success: "Removed from watchlist." };
|
||||
} catch (error) {
|
||||
console.error("Error removing framework from the watchlist:", error);
|
||||
return { error: GENERIC_ERROR };
|
||||
}
|
||||
};
|
||||
|
||||
export const bulkUpdateComplianceWatchlist = async (
|
||||
diff: ComplianceWatchlistBulkDiff,
|
||||
): Promise<ComplianceWatchlistActionResult> => {
|
||||
const parsedDiff = complianceWatchlistBulkDiffSchema.safeParse(diff);
|
||||
if (!parsedDiff.success) {
|
||||
return { error: "Invalid compliance watchlist update." };
|
||||
}
|
||||
|
||||
if (isEmptyWatchlistDiff(parsedDiff.data)) {
|
||||
return { error: "Select at least one framework to add or remove." };
|
||||
}
|
||||
if (exceedsWatchlistBulkLimit(parsedDiff.data)) {
|
||||
return {
|
||||
error: `A single update may reference at most ${MAX_WATCHLIST_BULK} frameworks.`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const response = await fetch(`${apiBaseUrl}${ENTRIES_ENDPOINT}/bulk`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: COMPLIANCE_WATCHLIST_BULK_TYPE,
|
||||
attributes: {
|
||||
add: parsedDiff.data.add.map((target) => ({
|
||||
compliance_id: target.complianceId,
|
||||
provider_type: target.providerType,
|
||||
})),
|
||||
remove: parsedDiff.data.remove.map((target) => ({
|
||||
compliance_id: target.complianceId,
|
||||
provider_type: target.providerType,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { error: await readApiError(response, GENERIC_ERROR) };
|
||||
}
|
||||
|
||||
const body = (await response.json()) as ComplianceWatchlistBulkResponse;
|
||||
const summary = adaptWatchlistBulkSummary(body);
|
||||
|
||||
REVALIDATED_PATHS.forEach((path) => revalidatePath(path));
|
||||
return { success: formatWatchlistBulkSummary(summary), summary };
|
||||
} catch (error) {
|
||||
console.error("Error updating the compliance watchlist:", error);
|
||||
return { error: GENERIC_ERROR };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { JsonApiDocument, JsonApiResource } from "@/types/jsonapi";
|
||||
|
||||
export interface ComplianceCatalogEntryAttributes {
|
||||
compliance_id: string;
|
||||
/** `*` for universal frameworks, which are one card across every type. */
|
||||
provider_type: string;
|
||||
scope: string;
|
||||
provider_types: string[];
|
||||
framework: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
total_requirements: number;
|
||||
requirements_passed: number;
|
||||
requirements_failed: number;
|
||||
requirements_manual: number;
|
||||
score: number | null;
|
||||
has_data: boolean;
|
||||
in_watchlist: boolean;
|
||||
watchlist_entry_id: string | null;
|
||||
}
|
||||
|
||||
export type ComplianceCatalogEntryResource =
|
||||
JsonApiResource<ComplianceCatalogEntryAttributes>;
|
||||
|
||||
export interface ComplianceCatalogPaginationMeta {
|
||||
page?: number;
|
||||
pages?: number;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface ComplianceCatalogResponseMeta {
|
||||
pagination?: ComplianceCatalogPaginationMeta;
|
||||
total_entries?: number;
|
||||
watchlist_count?: number;
|
||||
eligible_provider_types?: string[];
|
||||
}
|
||||
|
||||
export type ComplianceCatalogResponse = JsonApiDocument<
|
||||
ComplianceCatalogEntryResource[],
|
||||
ComplianceCatalogResponseMeta
|
||||
>;
|
||||
|
||||
export interface ComplianceWatchlistBulkResponseMeta {
|
||||
added?: number;
|
||||
already_present?: number;
|
||||
removed?: number;
|
||||
not_present?: number;
|
||||
watchlist_count?: number;
|
||||
}
|
||||
|
||||
export type ComplianceWatchlistBulkResponse = JsonApiDocument<
|
||||
unknown[],
|
||||
ComplianceWatchlistBulkResponseMeta
|
||||
>;
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
addComplianceToWatchlist,
|
||||
bulkUpdateComplianceWatchlist,
|
||||
getComplianceCatalog,
|
||||
removeComplianceFromWatchlist,
|
||||
} from "./compliance-watchlist";
|
||||
@@ -0,0 +1,31 @@
|
||||
import { WATCHLIST_SCOPE } from "@/types/compliance-watchlist";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
|
||||
import type { FindingComplianceFrameworksResponse } from "./finding-compliance-frameworks.types";
|
||||
|
||||
export const adaptFindingComplianceFrameworks = (
|
||||
response: FindingComplianceFrameworksResponse | undefined,
|
||||
): FindingComplianceFramework[] => {
|
||||
const data = Array.isArray(response?.data) ? response.data : [];
|
||||
|
||||
return data.map((resource) => {
|
||||
const attributes = resource.attributes;
|
||||
return {
|
||||
id: resource.id,
|
||||
complianceId: attributes.compliance_id,
|
||||
providerType: attributes.provider_type,
|
||||
// Anything the API does not label `universal` is provider-scoped, which
|
||||
// is also the safe reading of a response from an older API.
|
||||
scope:
|
||||
attributes.scope === WATCHLIST_SCOPE.UNIVERSAL
|
||||
? WATCHLIST_SCOPE.UNIVERSAL
|
||||
: WATCHLIST_SCOPE.PROVIDER,
|
||||
framework: attributes.framework,
|
||||
// `name` falls back to the id server-side, so it is never empty; the
|
||||
// display name can be, for a framework the SDK exposes no metadata for.
|
||||
name: attributes.name,
|
||||
version: attributes.version,
|
||||
inWatchlist: attributes.in_watchlist === true,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock, getAuthHeadersMock, isCloudMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
isCloudMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
import { getFindingComplianceFrameworks } from "./finding-compliance-frameworks";
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
const framework = (attributes: Record<string, unknown>) => ({
|
||||
type: "finding-compliance-frameworks",
|
||||
id: `${attributes.provider_type}:${attributes.compliance_id}`,
|
||||
attributes: {
|
||||
compliance_id: "cis_1.4_aws",
|
||||
provider_type: "aws",
|
||||
scope: "provider",
|
||||
framework: "CIS",
|
||||
name: "CIS",
|
||||
version: "1.4",
|
||||
in_watchlist: true,
|
||||
...attributes,
|
||||
},
|
||||
});
|
||||
|
||||
const lastFetchUrl = (): URL => {
|
||||
const call = fetchMock.mock.calls.at(-1);
|
||||
if (!call) throw new Error("fetch was not called");
|
||||
return new URL(String(call[0]));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockResolvedValue(jsonResponse({ data: [] }));
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
isCloudMock.mockReturnValue(true);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
describe("getFindingComplianceFrameworks", () => {
|
||||
it("rejects a null options payload before fetching", async () => {
|
||||
const result = await getFindingComplianceFrameworks(
|
||||
"finding-1",
|
||||
null as never,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ frameworks: [], unavailable: true });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks only for the watchlisted frameworks when told to", async () => {
|
||||
await getFindingComplianceFrameworks("finding-1", { inWatchlist: true });
|
||||
|
||||
const url = lastFetchUrl();
|
||||
expect(url.pathname).toBe(
|
||||
"/api/v1/findings/finding-1/compliance-frameworks",
|
||||
);
|
||||
expect(url.searchParams.get("filter[in_watchlist]")).toBe("true");
|
||||
});
|
||||
|
||||
it("omits the filter by default, so the endpoint keeps returning every framework", async () => {
|
||||
await getFindingComplianceFrameworks("finding-1");
|
||||
|
||||
expect(lastFetchUrl().searchParams.has("filter[in_watchlist]")).toBe(false);
|
||||
});
|
||||
|
||||
it("adapts the response, keeping the compliance id that makes navigation exact", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
framework({}),
|
||||
framework({
|
||||
compliance_id: "dora_2022_2554",
|
||||
provider_type: "*",
|
||||
scope: "universal",
|
||||
framework: "DORA",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await getFindingComplianceFrameworks("finding-1", {
|
||||
inWatchlist: true,
|
||||
});
|
||||
|
||||
expect(result.frameworks).toHaveLength(2);
|
||||
expect(result.unavailable).toBe(false);
|
||||
expect(result.frameworks[0].complianceId).toBe("cis_1.4_aws");
|
||||
expect(result.frameworks[0].scope).toBe("provider");
|
||||
expect(result.frameworks[1].scope).toBe("universal");
|
||||
expect(result.frameworks[1].providerType).toBe("*");
|
||||
});
|
||||
|
||||
it("reports the endpoint as unavailable when it answers an error", async () => {
|
||||
// The endpoint is Cloud-only, so a self-hosted install 404s here. The
|
||||
// caller has to tell that apart from an empty watchlist, or the strip
|
||||
// disappears from every finding.
|
||||
fetchMock.mockResolvedValue(jsonResponse({ errors: [] }, 404));
|
||||
|
||||
expect(await getFindingComplianceFrameworks("finding-1")).toEqual({
|
||||
frameworks: [],
|
||||
unavailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports an empty watchlist as a real answer, not as unavailable", async () => {
|
||||
fetchMock.mockResolvedValue(jsonResponse({ data: [] }));
|
||||
|
||||
expect(
|
||||
await getFindingComplianceFrameworks("finding-1", { inWatchlist: true }),
|
||||
).toEqual({ frameworks: [], unavailable: false });
|
||||
});
|
||||
|
||||
it("does not call the API without a finding id", async () => {
|
||||
expect(await getFindingComplianceFrameworks("")).toEqual({
|
||||
frameworks: [],
|
||||
unavailable: false,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not call the API at all off Cloud", async () => {
|
||||
// The endpoint only exists in Cloud, and the drawer asks once per finding
|
||||
// opened — so this is a request that can only ever 404, on every open.
|
||||
isCloudMock.mockReturnValue(false);
|
||||
|
||||
expect(await getFindingComplianceFrameworks("finding-1")).toEqual({
|
||||
frameworks: [],
|
||||
// `unavailable`, so the caller still falls back to the check's own
|
||||
// framework names instead of dropping the strip.
|
||||
unavailable: true,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { IN_WATCHLIST_FILTER_KEY } from "@/lib/compliance/watchlist";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { FindingComplianceFrameworksResult } from "@/types/compliance-watchlist";
|
||||
|
||||
import { adaptFindingComplianceFrameworks } from "./finding-compliance-frameworks.adapter";
|
||||
import type { FindingComplianceFrameworksResponse } from "./finding-compliance-frameworks.types";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
const findingIdSchema = z.string().trim().min(1);
|
||||
const findingComplianceFrameworkOptionsSchema = z.object({
|
||||
inWatchlist: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const getFindingComplianceFrameworks = async (
|
||||
findingId: string,
|
||||
options: { inWatchlist?: boolean } = {},
|
||||
): Promise<FindingComplianceFrameworksResult> => {
|
||||
const parsedFindingId = findingIdSchema.safeParse(findingId);
|
||||
if (!parsedFindingId.success) {
|
||||
return { frameworks: [], unavailable: false };
|
||||
}
|
||||
|
||||
const parsedOptions =
|
||||
findingComplianceFrameworkOptionsSchema.safeParse(options);
|
||||
if (!parsedOptions.success) {
|
||||
return { frameworks: [], unavailable: true };
|
||||
}
|
||||
|
||||
const { inWatchlist = false } = parsedOptions.data;
|
||||
if (!isCloud()) return { frameworks: [], unavailable: true };
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(
|
||||
`${apiBaseUrl}/findings/${encodeURIComponent(parsedFindingId.data)}/compliance-frameworks`,
|
||||
);
|
||||
if (inWatchlist) {
|
||||
url.searchParams.set(IN_WATCHLIST_FILTER_KEY, "true");
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return { frameworks: [], unavailable: true };
|
||||
}
|
||||
|
||||
const body = (await response.json()) as FindingComplianceFrameworksResponse;
|
||||
return {
|
||||
frameworks: adaptFindingComplianceFrameworks(body),
|
||||
unavailable: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching the finding's compliance frameworks:", error);
|
||||
return { frameworks: [], unavailable: true };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { JsonApiDocument, JsonApiResource } from "@/types/jsonapi";
|
||||
|
||||
export interface FindingComplianceFrameworkAttributes {
|
||||
compliance_id: string;
|
||||
provider_type: string;
|
||||
scope: string;
|
||||
framework: string;
|
||||
name: string;
|
||||
version: string;
|
||||
in_watchlist: boolean;
|
||||
}
|
||||
|
||||
export type FindingComplianceFrameworkResource =
|
||||
JsonApiResource<FindingComplianceFrameworkAttributes>;
|
||||
|
||||
export type FindingComplianceFrameworksResponse = JsonApiDocument<
|
||||
FindingComplianceFrameworkResource[]
|
||||
>;
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./finding-compliance-frameworks";
|
||||
export * from "./findings";
|
||||
export * from "./findings-by-resource";
|
||||
export * from "./findings-by-resource.adapter";
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchMock, getAuthHeadersMock } = vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
// Stubbed out because the real helper reaches into next-auth and Sentry, which
|
||||
// this suite has no interest in: every assertion is about the request URL.
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: async (response: Response) => response.json(),
|
||||
}));
|
||||
|
||||
import { getComplianceWatchlist } from "./compliance-watchlist";
|
||||
|
||||
const emptyResponse = () =>
|
||||
new Response(JSON.stringify({ data: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
const lastFetchUrl = (): URL => {
|
||||
const call = fetchMock.mock.calls.at(-1);
|
||||
if (!call) throw new Error("fetch was not called");
|
||||
return new URL(String(call[0]));
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
fetchMock.mockResolvedValue(emptyResponse());
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
});
|
||||
|
||||
describe("getComplianceWatchlist", () => {
|
||||
it("rejects a null Server Action payload before fetching", async () => {
|
||||
const result = await getComplianceWatchlist(null as never);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("narrows the response to the watchlist when asked to", async () => {
|
||||
await getComplianceWatchlist({ inWatchlist: true });
|
||||
|
||||
expect(lastFetchUrl().searchParams.get("filter[in_watchlist]")).toBe(
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the filter by default, leaving the endpoint's own behavior intact", async () => {
|
||||
// The API treats the parameter as opt-in, so a caller that wants the full
|
||||
// framework ranking must not send it at all — `false` would be a filter.
|
||||
await getComplianceWatchlist();
|
||||
|
||||
expect(lastFetchUrl().searchParams.has("filter[in_watchlist]")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the provider filters alongside the watchlist one", async () => {
|
||||
await getComplianceWatchlist({
|
||||
filters: { "filter[provider_type]": "aws" },
|
||||
inWatchlist: true,
|
||||
});
|
||||
|
||||
const url = lastFetchUrl();
|
||||
expect(url.searchParams.get("filter[provider_type]")).toBe("aws");
|
||||
expect(url.searchParams.get("filter[in_watchlist]")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,37 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { IN_WATCHLIST_FILTER_KEY } from "@/lib/compliance/watchlist";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import type { ApiResult } from "@/types/server-actions";
|
||||
|
||||
import { ComplianceWatchlistResponse } from "./compliance-watchlist.types";
|
||||
|
||||
export const getComplianceWatchlist = async ({
|
||||
filters = {},
|
||||
}: {
|
||||
interface ComplianceWatchlistInput {
|
||||
filters?: Record<string, string | string[] | undefined>;
|
||||
} = {}): Promise<ApiResult<ComplianceWatchlistResponse> | undefined> => {
|
||||
inWatchlist?: boolean;
|
||||
}
|
||||
|
||||
const complianceWatchlistInputSchema = z.object({
|
||||
filters: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([z.string(), z.array(z.string()), z.undefined()]),
|
||||
)
|
||||
.default({}),
|
||||
inWatchlist: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const getComplianceWatchlist = async (
|
||||
input: ComplianceWatchlistInput = {},
|
||||
): Promise<ApiResult<ComplianceWatchlistResponse> | undefined> => {
|
||||
const parsedInput = complianceWatchlistInputSchema.safeParse(input);
|
||||
if (!parsedInput.success) return undefined;
|
||||
|
||||
const { filters, inWatchlist } = parsedInput.data;
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}/overviews/compliance-watchlist`);
|
||||
|
||||
@@ -19,6 +39,10 @@ export const getComplianceWatchlist = async ({
|
||||
// Exclude filter[search] as this endpoint doesn't support text search
|
||||
appendSanitizedProviderTypeFilters(url, filters);
|
||||
|
||||
if (inWatchlist) {
|
||||
url.searchParams.set(IN_WATCHLIST_FILTER_KEY, "true");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
return handleApiResponse(response);
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
import Image, { type StaticImageData } from "next/image";
|
||||
import { useState } from "react";
|
||||
|
||||
import { buildPerScanComplianceHref } from "@/lib/compliance/compliance-tab-url";
|
||||
import {
|
||||
buildMultipleScansComplianceHref,
|
||||
buildPerScanComplianceHref,
|
||||
} from "@/lib/compliance/compliance-tab-url";
|
||||
|
||||
import { SortToggleButton } from "./sort-toggle-button";
|
||||
import { WatchlistCard } from "./watchlist-card";
|
||||
import { WATCHLIST_CARD_HEIGHT, WatchlistCard } from "./watchlist-card";
|
||||
|
||||
export interface ComplianceData {
|
||||
id: string;
|
||||
@@ -16,13 +19,17 @@ export interface ComplianceData {
|
||||
score: number;
|
||||
}
|
||||
|
||||
// Display 7 items to match the card's min-height (405px) without scrolling
|
||||
const ITEMS_TO_DISPLAY = 7;
|
||||
|
||||
export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
|
||||
export const ComplianceWatchlist = ({
|
||||
items,
|
||||
hasWatchlist = true,
|
||||
}: {
|
||||
items: ComplianceData[];
|
||||
hasWatchlist?: boolean;
|
||||
}) => {
|
||||
const [isAsc, setIsAsc] = useState(true);
|
||||
|
||||
// Sort all items and take top 7 based on current sort order
|
||||
const sortedItems = [...items]
|
||||
.sort((a, b) => (isAsc ? a.score - b.score : b.score - a.score))
|
||||
.slice(0, ITEMS_TO_DISPLAY)
|
||||
@@ -48,8 +55,17 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
|
||||
<WatchlistCard
|
||||
title="Compliance Watchlist"
|
||||
items={sortedItems}
|
||||
ctaLabel="Explore Compliance for Each Scan"
|
||||
ctaHref={buildPerScanComplianceHref()}
|
||||
height={WATCHLIST_CARD_HEIGHT.FIT}
|
||||
ctaLabel={
|
||||
hasWatchlist
|
||||
? "Explore Compliance for Multiple Scans"
|
||||
: "Explore Compliance for Each Scan"
|
||||
}
|
||||
ctaHref={
|
||||
hasWatchlist
|
||||
? buildMultipleScansComplianceHref()
|
||||
: buildPerScanComplianceHref()
|
||||
}
|
||||
headerAction={
|
||||
<SortToggleButton
|
||||
isAscending={isAsc}
|
||||
@@ -58,11 +74,15 @@ export const ComplianceWatchlist = ({ items }: { items: ComplianceData[] }) => {
|
||||
descendingLabel="Sort by lowest score"
|
||||
/>
|
||||
}
|
||||
// TODO: Enable full emptyState with description once API endpoint is implemented
|
||||
// Full emptyState: { message: "...", description: "to add compliance frameworks to your watchlist.", linkText: "Compliance Dashboard" }
|
||||
emptyState={{
|
||||
message: "No compliance data available.",
|
||||
}}
|
||||
emptyState={
|
||||
hasWatchlist
|
||||
? {
|
||||
message: "No frameworks pinned yet.",
|
||||
description: "to add compliance frameworks to your watchlist.",
|
||||
linkText: "Compliance Dashboard",
|
||||
}
|
||||
: { message: "No compliance data available." }
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,6 +52,22 @@ export interface WatchlistItem {
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* How tall the card is before its content is taken into account.
|
||||
*
|
||||
* `fixed` reserves room for a full seven-row list, which is right for a card
|
||||
* whose list is always full — a top-N ranking. `fit` sizes to the content, for
|
||||
* a list whose length the user controls: reserving seven rows for a watchlist
|
||||
* with two entries leaves half a card of void.
|
||||
*/
|
||||
export const WATCHLIST_CARD_HEIGHT = {
|
||||
FIXED: "fixed",
|
||||
FIT: "fit",
|
||||
} as const;
|
||||
|
||||
export type WatchlistCardHeight =
|
||||
(typeof WATCHLIST_CARD_HEIGHT)[keyof typeof WATCHLIST_CARD_HEIGHT];
|
||||
|
||||
export interface WatchlistCardProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title: string;
|
||||
@@ -70,6 +86,7 @@ export interface WatchlistCardProps
|
||||
* When false (default), uses score-based coloring (0-30 red, 31-60 yellow, 61-100 green).
|
||||
*/
|
||||
useFailureColoring?: boolean;
|
||||
height?: WatchlistCardHeight;
|
||||
}
|
||||
|
||||
export const WatchlistCard = ({
|
||||
@@ -81,13 +98,17 @@ export const WatchlistCard = ({
|
||||
emptyState,
|
||||
onItemClick,
|
||||
useFailureColoring = false,
|
||||
height = WATCHLIST_CARD_HEIGHT.FIXED,
|
||||
}: WatchlistCardProps) => {
|
||||
const isEmpty = items.length === 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
className="flex min-h-[405px] w-full flex-col overflow-hidden"
|
||||
className={cn(
|
||||
"flex w-full flex-col overflow-hidden",
|
||||
height === WATCHLIST_CARD_HEIGHT.FIXED && "min-h-[405px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>{title}</CardTitle>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ComplianceWatchlistSSR } from "./compliance-watchlist.ssr";
|
||||
|
||||
const { getComplianceWatchlistMock, isCloudMock } = vi.hoisted(() => ({
|
||||
getComplianceWatchlistMock: vi.fn(async () => ({})),
|
||||
isCloudMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/overview/compliance-watchlist", () => ({
|
||||
getComplianceWatchlist: vi.fn(async () => ({})),
|
||||
getComplianceWatchlist: getComplianceWatchlistMock,
|
||||
adaptComplianceWatchlistResponse: vi.fn(() => [
|
||||
{
|
||||
id: "1",
|
||||
@@ -37,6 +42,10 @@ vi.mock("@/actions/overview/compliance-watchlist", () => ({
|
||||
]),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/lighthouse/context-contributor", () => ({
|
||||
LighthouseContextContributor: ({ item }: { item: unknown }) => (
|
||||
<output data-testid="watchlist-context">{JSON.stringify(item)}</output>
|
||||
@@ -47,16 +56,55 @@ vi.mock("./_components/compliance-watchlist", () => ({
|
||||
ComplianceWatchlist: () => <div>watchlist</div>,
|
||||
}));
|
||||
|
||||
describe("ComplianceWatchlistSSR", () => {
|
||||
describe("ComplianceWatchlistSSR in Cloud", () => {
|
||||
beforeEach(() => isCloudMock.mockReturnValue(true));
|
||||
|
||||
it("asks the API for the watchlist only", async () => {
|
||||
// Without this the endpoint answers with every framework that has data, and
|
||||
// pinning one changes nothing on the overview card.
|
||||
render(await ComplianceWatchlistSSR({ searchParams: {} }));
|
||||
|
||||
expect(getComplianceWatchlistMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ inWatchlist: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes the two lowest-scoring frameworks as Lighthouse context", async () => {
|
||||
render(await ComplianceWatchlistSSR({ searchParams: {} }));
|
||||
|
||||
const contexts = screen.getAllByTestId("watchlist-context");
|
||||
expect(contexts).toHaveLength(2);
|
||||
expect(contexts[0]).toHaveTextContent('"framework":"ENS RD2022"');
|
||||
expect(contexts[0]).toHaveTextContent('"score":30');
|
||||
// ThreatScore is no longer dropped: the response only carries frameworks
|
||||
// the organization pinned, so hiding one would contradict that choice.
|
||||
expect(contexts[0]).toHaveTextContent('"framework":"ThreatScore"');
|
||||
expect(contexts[0]).toHaveTextContent('"score":10');
|
||||
expect(contexts[0]).toHaveTextContent('"scopeKey":"overview:/"');
|
||||
expect(contexts[1]).toHaveTextContent('"framework":"ENS RD2022"');
|
||||
expect(contexts[1]).toHaveTextContent('"score":30');
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplianceWatchlistSSR in OSS", () => {
|
||||
beforeEach(() => isCloudMock.mockReturnValue(false));
|
||||
|
||||
it("does not send the Cloud-only watchlist filter", async () => {
|
||||
// The filter is a Cloud addition to a shared endpoint; sending it where it
|
||||
// does not exist is at best ignored and at worst a 400.
|
||||
render(await ComplianceWatchlistSSR({ searchParams: {} }));
|
||||
|
||||
expect(getComplianceWatchlistMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ inWatchlist: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps ThreatScore out of the ranking", async () => {
|
||||
// Unfiltered the card is a ranking of everything, which is what it always
|
||||
// was — and ThreatScore has never belonged in it.
|
||||
render(await ComplianceWatchlistSSR({ searchParams: {} }));
|
||||
|
||||
const contexts = screen.getAllByTestId("watchlist-context");
|
||||
expect(contexts).toHaveLength(2);
|
||||
expect(contexts[0]).toHaveTextContent('"framework":"ENS RD2022"');
|
||||
expect(contexts[1]).toHaveTextContent('"framework":"CIS AWS 1.5"');
|
||||
expect(contexts[1]).toHaveTextContent('"score":45');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,26 +4,32 @@ import {
|
||||
} from "@/actions/overview/compliance-watchlist";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
import { pickFilterParams } from "../_lib/filter-params";
|
||||
import { SSRComponentProps } from "../_types";
|
||||
|
||||
import { ComplianceWatchlist } from "./_components/compliance-watchlist";
|
||||
|
||||
// Bounded so watchlist items stay within the shared context item budget.
|
||||
const MAX_WATCHLIST_CONTEXT_ITEMS = 2;
|
||||
|
||||
export const ComplianceWatchlistSSR = async ({
|
||||
searchParams,
|
||||
}: SSRComponentProps) => {
|
||||
const filters = pickFilterParams(searchParams);
|
||||
const response = await getComplianceWatchlist({ filters });
|
||||
const isWatchlistFiltered = isCloud();
|
||||
const response = await getComplianceWatchlist({
|
||||
filters,
|
||||
inWatchlist: isWatchlistFiltered,
|
||||
});
|
||||
const enrichedData = adaptComplianceWatchlistResponse(response);
|
||||
|
||||
// Filter out ProwlerThreatScore and pass all items to client
|
||||
// Client handles sorting and limiting to display count
|
||||
const items = enrichedData
|
||||
.filter((item) => !item.complianceId.toLowerCase().includes("threatscore"))
|
||||
.filter(
|
||||
(item) =>
|
||||
isWatchlistFiltered ||
|
||||
!item.complianceId.toLowerCase().includes("threatscore"),
|
||||
)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
framework: item.complianceId,
|
||||
@@ -50,7 +56,7 @@ export const ComplianceWatchlistSSR = async ({
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
<ComplianceWatchlist items={items} />
|
||||
<ComplianceWatchlist items={items} hasWatchlist={isWatchlistFiltered} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AggregatedFrameworkCard } from "./aggregated-framework-card";
|
||||
|
||||
describe("AggregatedFrameworkCard", () => {
|
||||
it("keeps the logo canvas light in dark mode", () => {
|
||||
// Given / When
|
||||
render(
|
||||
<AggregatedFrameworkCard
|
||||
frameworkTitle="CIS"
|
||||
formattedTitle="CIS"
|
||||
ariaLabel="Open CIS"
|
||||
onActivate={vi.fn()}
|
||||
subtitle={<span>Framework summary</span>}
|
||||
>
|
||||
<span>Framework details</span>
|
||||
</AggregatedFrameworkCard>,
|
||||
);
|
||||
|
||||
// Then
|
||||
const logoCanvas = screen.getByRole("img", {
|
||||
name: "CIS logo",
|
||||
}).parentElement;
|
||||
expect(logoCanvas).toHaveClass("bg-slate-50");
|
||||
expect(logoCanvas).not.toHaveClass("bg-bg-neutral-tertiary");
|
||||
});
|
||||
|
||||
it("keeps card actions outside the navigation control", () => {
|
||||
// Given
|
||||
render(
|
||||
<AggregatedFrameworkCard
|
||||
frameworkTitle="custom-framework"
|
||||
formattedTitle="Custom Framework"
|
||||
ariaLabel="Open Custom Framework"
|
||||
onActivate={vi.fn()}
|
||||
subtitle={<span>Framework summary</span>}
|
||||
actions={<button type="button">Pin framework</button>}
|
||||
>
|
||||
<span>Framework details</span>
|
||||
</AggregatedFrameworkCard>,
|
||||
);
|
||||
|
||||
// When
|
||||
const navigation = screen.getByRole("button", {
|
||||
name: "Open Custom Framework",
|
||||
});
|
||||
const action = screen.getByRole("button", { name: "Pin framework" });
|
||||
|
||||
// Then
|
||||
expect(navigation).not.toContainElement(action);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,14 @@
|
||||
import Image from "next/image";
|
||||
import type { KeyboardEventHandler, ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { getComplianceIcon } from "@/components/icons/compliance/IconCompliance";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { Card, CardAction, CardContent } from "@/components/shadcn/card/card";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AggregatedFrameworkCardProps {
|
||||
frameworkTitle: string;
|
||||
@@ -16,6 +17,7 @@ interface AggregatedFrameworkCardProps {
|
||||
onActivate: () => void;
|
||||
subtitle: ReactNode;
|
||||
tooltip?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -26,60 +28,58 @@ export const AggregatedFrameworkCard = ({
|
||||
onActivate,
|
||||
subtitle,
|
||||
tooltip,
|
||||
actions,
|
||||
children,
|
||||
}: AggregatedFrameworkCardProps) => {
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLDivElement> = (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onActivate();
|
||||
}
|
||||
};
|
||||
const logo = getComplianceIcon(frameworkTitle);
|
||||
const title = (
|
||||
<h4 className="truncate text-sm leading-5 font-bold">{formattedTitle}</h4>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="md"
|
||||
interactive
|
||||
onClick={onActivate}
|
||||
role="button"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<CardContent>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{logo && (
|
||||
<div className="border-border-neutral-tertiary flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={`${frameworkTitle} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
sizes="32px"
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{title}</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
title
|
||||
<Card variant="base" padding="none" interactive className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
onClick={onActivate}
|
||||
className="focus-visible:ring-border-neutral-secondary/50 w-full rounded-xl bg-transparent px-4 py-3 text-left outline-none focus-visible:ring-2"
|
||||
>
|
||||
<CardContent>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className={cn("flex items-center gap-3", actions && "pr-8")}>
|
||||
{logo && (
|
||||
<div className="border-border-neutral-tertiary flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={logo}
|
||||
alt={`${frameworkTitle} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
sizes="32px"
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{subtitle}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{title}</TooltipTrigger>
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</CardContent>
|
||||
</CardContent>
|
||||
</button>
|
||||
{actions && (
|
||||
<CardAction className="absolute top-2 right-2 z-10">
|
||||
{actions}
|
||||
</CardAction>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -69,6 +69,24 @@ describe("CompliancePageTabs", () => {
|
||||
).toStrictEqual(["Multiple Scans", "Single Scan"]);
|
||||
});
|
||||
|
||||
it("keeps the watchlist controls on the tab bar, outside either panel", () => {
|
||||
render(
|
||||
<CompliancePageTabs
|
||||
activeTab={COMPLIANCE_TAB.CROSS_PROVIDER}
|
||||
crossProviderEnabled
|
||||
watchlistControls={<div data-testid="watchlist-controls" />}
|
||||
perScanContent={<div>Per scan content</div>}
|
||||
crossProviderContent={<div>Cross provider content</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Outside the panels is what makes the filter survive a tab switch: a
|
||||
// control inside `TabsContent` would unmount with its tab.
|
||||
const controls = screen.getByTestId("watchlist-controls");
|
||||
expect(controls).toBeInTheDocument();
|
||||
expect(controls.closest('[role="tabpanel"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("navigates with ?tab=per-scan and back to the bare route", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
|
||||
@@ -22,6 +22,9 @@ interface CompliancePageTabsProps {
|
||||
crossProviderEnabled: boolean;
|
||||
perScanContent: ReactNode;
|
||||
crossProviderContent: ReactNode;
|
||||
/** Watchlist filter + editor. Sits on the tab bar rather than inside a tab
|
||||
* because both of them read and write the same tenant-wide list. */
|
||||
watchlistControls?: ReactNode;
|
||||
}
|
||||
|
||||
export const CompliancePageTabs = ({
|
||||
@@ -29,6 +32,7 @@ export const CompliancePageTabs = ({
|
||||
crossProviderEnabled,
|
||||
perScanContent,
|
||||
crossProviderContent,
|
||||
watchlistControls,
|
||||
}: CompliancePageTabsProps) => {
|
||||
const router = useRouter();
|
||||
const openCloudUpgrade = useCloudUpgradeStore(
|
||||
@@ -59,22 +63,27 @@ export const CompliancePageTabs = ({
|
||||
return (
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<div className="flex flex-col gap-[18px]">
|
||||
<div data-tour-id="view-compliance-tabs" className="overflow-x-auto">
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value={COMPLIANCE_TAB.CROSS_PROVIDER}
|
||||
adornment={
|
||||
!crossProviderEnabled ? (
|
||||
<Badge variant="cloud">Cloud</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Multiple Scans
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={COMPLIANCE_TAB.PER_SCAN}>
|
||||
Single Scan
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Wraps below the tabs on narrow viewports instead of squeezing the
|
||||
triggers, which are the primary navigation of the page. */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div data-tour-id="view-compliance-tabs" className="overflow-x-auto">
|
||||
<TabsList>
|
||||
<TabsTrigger
|
||||
value={COMPLIANCE_TAB.CROSS_PROVIDER}
|
||||
adornment={
|
||||
!crossProviderEnabled ? (
|
||||
<Badge variant="cloud">Cloud</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Multiple Scans
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={COMPLIANCE_TAB.PER_SCAN}>
|
||||
Single Scan
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{watchlistControls}
|
||||
</div>
|
||||
|
||||
<TabsContent value={COMPLIANCE_TAB.CROSS_PROVIDER}>
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { WatchlistToggle } from "@/components/compliance/watchlist/watchlist-toggle";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { formatComplianceFrameworkTitle } from "@/lib/compliance/framework-title";
|
||||
import type { WatchlistPinState } from "@/types/compliance-watchlist";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
|
||||
import { buildCrossAccountDetailHref } from "../_lib/cross-account-frameworks";
|
||||
@@ -10,6 +13,15 @@ import type { CrossAccountFrameworkEntry } from "../_types";
|
||||
|
||||
import { AggregatedFrameworkCard } from "./aggregated-framework-card";
|
||||
|
||||
interface CrossAccountFrameworkCardProps extends CrossAccountFrameworkEntry {
|
||||
/** Pinned state of this `(compliance_id, provider_type)` pair. A regular
|
||||
* framework belongs to a single provider type, so it is never partial. */
|
||||
watchlistState?: WatchlistPinState;
|
||||
watchlistEntryId?: string | null;
|
||||
/** MANAGE_SCANS. Without it the toggle is not rendered. */
|
||||
canManageWatchlist?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card for a regular per-provider framework in the Cross-Provider tab's
|
||||
* "across accounts" section. Deliberately lightweight — no roll-up numbers:
|
||||
@@ -23,11 +35,14 @@ export const CrossAccountFrameworkCard = ({
|
||||
version,
|
||||
providerType,
|
||||
accountCount,
|
||||
}: CrossAccountFrameworkEntry) => {
|
||||
watchlistState,
|
||||
watchlistEntryId,
|
||||
canManageWatchlist = false,
|
||||
}: CrossAccountFrameworkCardProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const formattedTitle = `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`;
|
||||
const formattedTitle = formatComplianceFrameworkTitle(title, version);
|
||||
|
||||
const navigateToDetail = () => {
|
||||
router.push(
|
||||
@@ -44,6 +59,15 @@ export const CrossAccountFrameworkCard = ({
|
||||
formattedTitle={formattedTitle}
|
||||
ariaLabel={`${formattedTitle} across ${PROVIDER_DISPLAY_NAMES[providerType]} providers`}
|
||||
onActivate={navigateToDetail}
|
||||
actions={
|
||||
watchlistState && canManageWatchlist ? (
|
||||
<WatchlistToggle
|
||||
target={{ complianceId, providerType }}
|
||||
state={watchlistState}
|
||||
entryId={watchlistEntryId}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
subtitle={
|
||||
<small className="text-text-neutral-secondary truncate text-xs">
|
||||
View across providers
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
|
||||
import { WatchlistEmptyState } from "@/components/compliance/watchlist/watchlist-empty-state";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { Accordion } from "@/components/shadcn/accordion/Accordion";
|
||||
import { useShowOnlyWatchlist } from "@/hooks/use-show-only-watchlist";
|
||||
import { WATCHLIST_PIN_STATE } from "@/types/compliance-watchlist";
|
||||
import {
|
||||
type KnownProviderType,
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
} from "@/types/providers";
|
||||
|
||||
import type { CrossAccountFrameworkEntry } from "../_types";
|
||||
|
||||
import { CrossAccountFrameworkCard } from "./cross-account-framework-card";
|
||||
|
||||
export interface CrossAccountListEntry extends CrossAccountFrameworkEntry {
|
||||
pinned: boolean;
|
||||
watchlistEntryId: string | null;
|
||||
}
|
||||
|
||||
export interface CrossAccountGroup {
|
||||
providerType: KnownProviderType;
|
||||
accountCount: number;
|
||||
entries: CrossAccountListEntry[];
|
||||
}
|
||||
|
||||
interface CrossAccountFrameworkListProps {
|
||||
groups: CrossAccountGroup[];
|
||||
canManageWatchlist: boolean;
|
||||
watchlistEnabled: boolean;
|
||||
}
|
||||
|
||||
const NOTHING_PINNED_HINT =
|
||||
"No single-provider framework is pinned. Pin one from its card or the watchlist selector, or clear the filter to browse every provider type.";
|
||||
|
||||
export const CrossAccountFrameworkList = ({
|
||||
groups,
|
||||
canManageWatchlist,
|
||||
watchlistEnabled,
|
||||
}: CrossAccountFrameworkListProps) => {
|
||||
const showOnlyWatchlist = useShowOnlyWatchlist();
|
||||
|
||||
const renderCard = (entry: CrossAccountListEntry) => (
|
||||
<CrossAccountFrameworkCard
|
||||
key={`${entry.providerType}-${entry.complianceId}`}
|
||||
complianceId={entry.complianceId}
|
||||
title={entry.title}
|
||||
version={entry.version}
|
||||
providerType={entry.providerType}
|
||||
accountCount={entry.accountCount}
|
||||
watchlistState={
|
||||
watchlistEnabled
|
||||
? entry.pinned
|
||||
? WATCHLIST_PIN_STATE.PINNED
|
||||
: WATCHLIST_PIN_STATE.UNPINNED
|
||||
: undefined
|
||||
}
|
||||
watchlistEntryId={entry.watchlistEntryId}
|
||||
canManageWatchlist={canManageWatchlist}
|
||||
/>
|
||||
);
|
||||
|
||||
const filterToWatchlist = watchlistEnabled && showOnlyWatchlist;
|
||||
|
||||
const visibleGroups = filterToWatchlist
|
||||
? groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
entries: group.entries.filter((entry) => entry.pinned),
|
||||
}))
|
||||
.filter((group) => group.entries.length > 0)
|
||||
: groups;
|
||||
|
||||
if (filterToWatchlist && visibleGroups.length === 0) {
|
||||
return <WatchlistEmptyState message={NOTHING_PINNED_HINT} />;
|
||||
}
|
||||
|
||||
const accordionItems: AccordionItemProps[] = visibleGroups.map((group) => ({
|
||||
key: group.providerType,
|
||||
title: (
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex shrink-0 items-center gap-2 text-sm font-medium">
|
||||
<ProviderTypeIcon type={group.providerType} size={18} />
|
||||
{PROVIDER_DISPLAY_NAMES[group.providerType]}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary truncate text-xs">
|
||||
{group.entries.length}{" "}
|
||||
{group.entries.length === 1 ? "framework" : "frameworks"} ·{" "}
|
||||
{group.accountCount} providers
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
content: (
|
||||
<ComplianceFrameworkGrid>
|
||||
{group.entries.map(renderCard)}
|
||||
</ComplianceFrameworkGrid>
|
||||
),
|
||||
items: [],
|
||||
}));
|
||||
|
||||
return (
|
||||
<Accordion
|
||||
key={filterToWatchlist ? "watchlist" : "catalog"}
|
||||
items={accordionItems}
|
||||
selectionMode="multiple"
|
||||
defaultExpandedKeys={
|
||||
filterToWatchlist
|
||||
? visibleGroups.map((group) => group.providerType)
|
||||
: []
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
|
||||
import { loadComplianceWatchlistContext } from "../_lib/watchlist-context";
|
||||
|
||||
import { CrossAccountOverviewSection } from "./cross-account-overview-section";
|
||||
|
||||
@@ -20,6 +24,25 @@ vi.mock("@/actions/compliances", () => ({
|
||||
getCompliancesOverview: vi.fn(),
|
||||
}));
|
||||
|
||||
// The watchlist server actions pull in `@/lib`, which imports next-auth and
|
||||
// cannot be loaded in this environment. They have their own tests.
|
||||
vi.mock("@/actions/compliance-watchlist", () => ({
|
||||
getComplianceCatalog: vi.fn(),
|
||||
addComplianceToWatchlist: vi.fn(),
|
||||
removeComplianceFromWatchlist: vi.fn(),
|
||||
bulkUpdateComplianceWatchlist: vi.fn(),
|
||||
}));
|
||||
|
||||
// The watchlist context reads the session through next-auth, which cannot be
|
||||
// imported in this environment; the watchlist behaviour has its own tests.
|
||||
vi.mock("../_lib/watchlist-context", () => ({
|
||||
loadComplianceWatchlistContext: vi.fn(async () => ({
|
||||
entries: [],
|
||||
eligibleProviderTypes: [],
|
||||
canManage: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/icons/providers-badge/provider-type-icon", () => ({
|
||||
ProviderTypeIcon: () => <span aria-hidden="true" />,
|
||||
}));
|
||||
@@ -28,11 +51,13 @@ vi.mock("./cross-account-framework-card", () => ({
|
||||
CrossAccountFrameworkCard: ({
|
||||
complianceId,
|
||||
providerType,
|
||||
watchlistState,
|
||||
}: {
|
||||
complianceId: string;
|
||||
providerType: string;
|
||||
watchlistState?: string;
|
||||
}) => (
|
||||
<div data-testid="cross-account-card">
|
||||
<div data-testid="cross-account-card" data-pin-state={watchlistState}>
|
||||
{providerType}:{complianceId}
|
||||
</div>
|
||||
),
|
||||
@@ -257,3 +282,111 @@ describe("CrossAccountOverviewSection", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const catalogEntry = (
|
||||
complianceId: string,
|
||||
providerType: string,
|
||||
inWatchlist: boolean,
|
||||
) =>
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId,
|
||||
providerType,
|
||||
inWatchlist,
|
||||
watchlistEntryId: inWatchlist
|
||||
? "3fa85f64-5717-4562-b3fc-2c963f66afa6"
|
||||
: null,
|
||||
});
|
||||
|
||||
describe("CrossAccountOverviewSection watchlist", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false });
|
||||
vi.mocked(getAllProviders).mockResolvedValue(
|
||||
providersResponse([
|
||||
{ id: "aws-1", type: "aws" },
|
||||
{ id: "aws-2", type: "aws" },
|
||||
]),
|
||||
);
|
||||
vi.mocked(getScans).mockResolvedValue(
|
||||
scansFor([{ id: "scan-1", providerId: "aws-1" }]),
|
||||
);
|
||||
vi.mocked(getCompliancesOverview).mockResolvedValue({
|
||||
data: [
|
||||
{ id: "cis_2.0_aws", attributes: { framework: "CIS", version: "2.0" } },
|
||||
{
|
||||
id: "gdpr_aws",
|
||||
attributes: { framework: "GDPR", version: "1.0" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
const withWatchlist = (
|
||||
entries: ReturnType<typeof catalogEntry>[],
|
||||
canManage = true,
|
||||
) =>
|
||||
vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({
|
||||
entries,
|
||||
eligibleProviderTypes: ["aws"],
|
||||
canManage,
|
||||
});
|
||||
|
||||
it("keeps the provider-type grouping when the filter is on", async () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
withWatchlist([
|
||||
catalogEntry("cis_2.0_aws", "aws", true),
|
||||
catalogEntry("gdpr_aws", "aws", false),
|
||||
]);
|
||||
|
||||
await renderSection();
|
||||
|
||||
// The AWS group survives, narrowed to its single pinned framework and
|
||||
// expanded on arrival — a curated list is short enough to show outright.
|
||||
expect(screen.getByText("AWS")).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 framework\b/)).toBeInTheDocument();
|
||||
const cards = screen.getAllByTestId("cross-account-card");
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toHaveTextContent("aws:cis_2.0_aws");
|
||||
expect(cards[0]).toHaveAttribute("data-pin-state", "pinned");
|
||||
});
|
||||
|
||||
it("explains the blank section when nothing is pinned", async () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
withWatchlist([
|
||||
catalogEntry("cis_2.0_aws", "aws", false),
|
||||
catalogEntry("gdpr_aws", "aws", false),
|
||||
]);
|
||||
|
||||
await renderSection();
|
||||
|
||||
expect(
|
||||
screen.getByText(/no single-provider framework is pinned/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("cross-account-card")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores the filter without a catalog, so OSS never blanks out", async () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
withWatchlist([], false);
|
||||
|
||||
await renderSection();
|
||||
|
||||
expect(screen.getByText(/2 frameworks/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports no pin state at all when the catalog is missing", async () => {
|
||||
// A degraded catalog with the permission still granted: every state the
|
||||
// card could report would be invented, and "unpinned" is itself enough to
|
||||
// render a control backed by no catalog row.
|
||||
withWatchlist([], true);
|
||||
|
||||
await renderSection();
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(screen.getByRole("button", { name: "Item aws" }));
|
||||
|
||||
screen
|
||||
.getAllByTestId("cross-account-card")
|
||||
.forEach((card) => expect(card).not.toHaveAttribute("data-pin-state"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
import { getAllProviders } from "@/actions/providers";
|
||||
import { getScans } from "@/actions/scans";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { Accordion } from "@/components/shadcn/accordion/Accordion";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
@@ -11,36 +8,23 @@ import {
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/shadcn/section/section";
|
||||
import {
|
||||
buildWatchlistIndex,
|
||||
resolveCatalogEntry,
|
||||
} from "@/lib/compliance/watchlist";
|
||||
import type { SearchParamsProps } from "@/types";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import {
|
||||
isKnownProviderType,
|
||||
type KnownProviderType,
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
} from "@/types/providers";
|
||||
import { isKnownProviderType, type KnownProviderType } from "@/types/providers";
|
||||
|
||||
import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks";
|
||||
import { loadComplianceWatchlistContext } from "../_lib/watchlist-context";
|
||||
import type { CrossAccountFrameworkEntry } from "../_types";
|
||||
|
||||
import { CrossAccountFrameworkCard } from "./cross-account-framework-card";
|
||||
import type { CrossAccountGroup } from "./cross-account-framework-list";
|
||||
import { CrossAccountFrameworkList } from "./cross-account-framework-list";
|
||||
|
||||
/** Only provider types with at least this many accounts get cross-account
|
||||
* cards — with a single account the view is identical to the per-scan one. */
|
||||
const MIN_ACCOUNTS = 2;
|
||||
|
||||
/**
|
||||
* Server island for the "across accounts" section of the Cross-Provider tab:
|
||||
* for every provider type with 2+ accounts, lists the regular (per-provider)
|
||||
* frameworks that can be viewed aggregated across that type's accounts.
|
||||
*
|
||||
* The framework list per type comes from a completed scan of any account of
|
||||
* that type (frameworks are a property of the provider type, not of the
|
||||
* account). Universal frameworks are excluded — they already have
|
||||
* their own cross-provider cards above. Renders nothing when no provider
|
||||
* type qualifies, keeping the tab unchanged for single-account tenants.
|
||||
* Best-effort by design: a type whose scan or framework list fails to load
|
||||
* is dropped from the section rather than failing the tab.
|
||||
*/
|
||||
export const CrossAccountOverviewSection = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -142,40 +126,25 @@ export const CrossAccountOverviewSection = async ({
|
||||
.sort((a, b) => a[0].providerType.localeCompare(b[0].providerType));
|
||||
if (groups.length === 0) return null;
|
||||
|
||||
// One collapsed group per provider type instead of a flat grid: with
|
||||
// several multi-account types connected, the flat grid piles up dozens of
|
||||
// cards (each type ships 20-40 frameworks) and buries the universal
|
||||
// section's hierarchy. Collapsed-by-default keeps the catalog scannable —
|
||||
// the header carries the counts, expanding reveals that type's cards.
|
||||
const accordionItems: AccordionItemProps[] = groups.map((entries) => {
|
||||
const { providerType, accountCount } = entries[0];
|
||||
return {
|
||||
key: providerType,
|
||||
title: (
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex shrink-0 items-center gap-2 text-sm font-medium">
|
||||
<ProviderTypeIcon type={providerType} size={18} />
|
||||
{PROVIDER_DISPLAY_NAMES[providerType]}
|
||||
</span>
|
||||
<span className="text-text-neutral-tertiary truncate text-xs">
|
||||
{entries.length} {entries.length === 1 ? "framework" : "frameworks"}{" "}
|
||||
· {accountCount} providers
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
content: (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{entries.map((entry) => (
|
||||
<CrossAccountFrameworkCard
|
||||
key={`${entry.providerType}-${entry.complianceId}`}
|
||||
{...entry}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
items: [],
|
||||
};
|
||||
});
|
||||
const watchlist = await loadComplianceWatchlistContext();
|
||||
const catalogIndex = buildWatchlistIndex(watchlist.entries);
|
||||
|
||||
const listGroups: CrossAccountGroup[] = groups.map((entries) => ({
|
||||
providerType: entries[0].providerType,
|
||||
accountCount: entries[0].accountCount,
|
||||
entries: entries.map((entry) => {
|
||||
const catalogEntry = resolveCatalogEntry(catalogIndex, {
|
||||
complianceId: entry.complianceId,
|
||||
providerType: entry.providerType,
|
||||
});
|
||||
|
||||
return {
|
||||
...entry,
|
||||
pinned: catalogEntry?.inWatchlist === true,
|
||||
watchlistEntryId: catalogEntry?.watchlistEntryId ?? null,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Section>
|
||||
@@ -188,7 +157,11 @@ export const CrossAccountOverviewSection = async ({
|
||||
</SectionDescription>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<Accordion items={accordionItems} selectionMode="multiple" />
|
||||
<CrossAccountFrameworkList
|
||||
groups={listGroups}
|
||||
canManageWatchlist={watchlist.canManage}
|
||||
watchlistEnabled={watchlist.entries.length > 0}
|
||||
/>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { WatchlistToggle } from "@/components/compliance/watchlist/watchlist-toggle";
|
||||
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
|
||||
import { Progress } from "@/components/shadcn/progress";
|
||||
import {
|
||||
@@ -9,15 +10,26 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { formatComplianceFrameworkTitle } from "@/lib/compliance/framework-title";
|
||||
import type { ScoreColorVariant } from "@/lib/compliance/score-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "@/types/providers";
|
||||
|
||||
import { buildCrossProviderDetailHref } from "../_lib/cross-provider-frameworks";
|
||||
import type { UniversalWatchlistState } from "../_lib/universal-watchlist";
|
||||
import type { CrossProviderFrameworkSummary } from "../_types";
|
||||
|
||||
import { AggregatedFrameworkCard } from "./aggregated-framework-card";
|
||||
|
||||
interface CrossProviderFrameworkCardProps
|
||||
extends CrossProviderFrameworkSummary {
|
||||
/** Resolved watchlist state of this universal framework's single card
|
||||
* (see `resolveUniversalWatchlistState`). */
|
||||
watchlist?: UniversalWatchlistState;
|
||||
/** MANAGE_SCANS. Without it the toggle is not rendered. */
|
||||
canManageWatchlist?: boolean;
|
||||
}
|
||||
|
||||
export const CrossProviderFrameworkCard = ({
|
||||
complianceId,
|
||||
title,
|
||||
@@ -28,11 +40,13 @@ export const CrossProviderFrameworkCard = ({
|
||||
requirementsManual,
|
||||
totalRequirements,
|
||||
providerBreakdown,
|
||||
}: CrossProviderFrameworkSummary) => {
|
||||
watchlist,
|
||||
canManageWatchlist = false,
|
||||
}: CrossProviderFrameworkCardProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const formattedTitle = `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`;
|
||||
const formattedTitle = formatComplianceFrameworkTitle(title, version);
|
||||
|
||||
const ratingPercentage =
|
||||
totalRequirements > 0
|
||||
@@ -62,6 +76,15 @@ export const CrossProviderFrameworkCard = ({
|
||||
ariaLabel={formattedTitle}
|
||||
onActivate={navigateToDetail}
|
||||
tooltip={description}
|
||||
actions={
|
||||
watchlist?.target && canManageWatchlist ? (
|
||||
<WatchlistToggle
|
||||
target={watchlist.target}
|
||||
state={watchlist.state}
|
||||
entryId={watchlist.entryId}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
subtitle={
|
||||
<small className="truncate">
|
||||
<span className="mr-1 text-xs font-semibold">
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
|
||||
import { WatchlistEmptyState } from "@/components/compliance/watchlist/watchlist-empty-state";
|
||||
import { useShowOnlyWatchlist } from "@/hooks/use-show-only-watchlist";
|
||||
import { WATCHLIST_PIN_STATE } from "@/types/compliance-watchlist";
|
||||
|
||||
import type { UniversalWatchlistState } from "../_lib/universal-watchlist";
|
||||
import type { CrossProviderFrameworkSummary } from "../_types";
|
||||
|
||||
import { CrossProviderFrameworkCard } from "./cross-provider-framework-card";
|
||||
|
||||
interface CrossProviderCard {
|
||||
summary: CrossProviderFrameworkSummary;
|
||||
watchlist: UniversalWatchlistState;
|
||||
}
|
||||
|
||||
/** Copy for the one thing this grid can filter away. */
|
||||
const NOTHING_PINNED_HINT =
|
||||
"No universal framework is pinned. Pin one from its card or the watchlist selector, or clear the filter to see them all.";
|
||||
|
||||
interface CrossProviderFrameworkGridProps {
|
||||
cards: CrossProviderCard[];
|
||||
/** MANAGE_SCANS, forwarded to each card's pin. */
|
||||
canManageWatchlist: boolean;
|
||||
/** False when the tenant has no catalog at all (OSS), in which case the
|
||||
* stored filter must not be able to blank the grid. */
|
||||
watchlistEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client shell for the universal frameworks grid: the cards themselves are
|
||||
* fully resolved server-side, and this only decides which of them the stored
|
||||
* watchlist filter lets through — the filter is a viewing preference shared
|
||||
* with the other tab, so it cannot be applied during the server render.
|
||||
*/
|
||||
export const CrossProviderFrameworkGrid = ({
|
||||
cards,
|
||||
canManageWatchlist,
|
||||
watchlistEnabled,
|
||||
}: CrossProviderFrameworkGridProps) => {
|
||||
const showOnlyWatchlist = useShowOnlyWatchlist();
|
||||
|
||||
const filterToWatchlist = watchlistEnabled && showOnlyWatchlist;
|
||||
const isPinned = (card: CrossProviderCard) =>
|
||||
card.watchlist.state === WATCHLIST_PIN_STATE.PINNED;
|
||||
const visibleCards = filterToWatchlist ? cards.filter(isPinned) : cards;
|
||||
|
||||
if (filterToWatchlist && visibleCards.length === 0) {
|
||||
return <WatchlistEmptyState message={NOTHING_PINNED_HINT} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ComplianceFrameworkGrid>
|
||||
{visibleCards.map((card) => (
|
||||
<CrossProviderFrameworkCard
|
||||
key={card.summary.complianceId}
|
||||
{...card.summary}
|
||||
watchlist={card.watchlist}
|
||||
canManageWatchlist={canManageWatchlist}
|
||||
/>
|
||||
))}
|
||||
</ComplianceFrameworkGrid>
|
||||
);
|
||||
};
|
||||
@@ -2,9 +2,12 @@ import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ACTION_ERROR_STATUS, USAGE_LIMIT_MESSAGE } from "@/lib/action-errors";
|
||||
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
|
||||
import { getCrossProviderComplianceOverview } from "../_actions/cross-provider";
|
||||
import { CROSS_PROVIDER_FRAMEWORKS } from "../_lib/cross-provider-frameworks";
|
||||
import { loadComplianceWatchlistContext } from "../_lib/watchlist-context";
|
||||
import type { CrossProviderOverviewResult } from "../_types";
|
||||
import {
|
||||
CROSS_PROVIDER_OVERVIEW_LOAD_ERROR_MESSAGE,
|
||||
@@ -26,13 +29,46 @@ vi.mock("@/actions/manage-groups/manage-groups", () => ({
|
||||
getAllProviderGroups: vi.fn().mockResolvedValue({ data: [] }),
|
||||
}));
|
||||
|
||||
// The watchlist server actions pull in `@/lib`, which imports next-auth and
|
||||
// cannot be loaded in this environment. They have their own tests.
|
||||
vi.mock("@/actions/compliance-watchlist", () => ({
|
||||
getComplianceCatalog: vi.fn(),
|
||||
addComplianceToWatchlist: vi.fn(),
|
||||
removeComplianceFromWatchlist: vi.fn(),
|
||||
bulkUpdateComplianceWatchlist: vi.fn(),
|
||||
}));
|
||||
|
||||
// The watchlist context reads the session through next-auth, which cannot be
|
||||
// imported in this environment; the watchlist behaviour has its own tests.
|
||||
vi.mock("../_lib/watchlist-context", () => ({
|
||||
loadComplianceWatchlistContext: vi.fn(async () => ({
|
||||
entries: [],
|
||||
eligibleProviderTypes: [],
|
||||
canManage: false,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./cross-provider-filters", () => ({
|
||||
CrossProviderFilters: () => <div data-testid="cross-provider-filters" />,
|
||||
}));
|
||||
|
||||
vi.mock("./cross-provider-framework-card", () => ({
|
||||
CrossProviderFrameworkCard: ({ title }: { title: string }) => (
|
||||
<div data-testid="framework-card">{title}</div>
|
||||
CrossProviderFrameworkCard: ({
|
||||
title,
|
||||
watchlist,
|
||||
canManageWatchlist,
|
||||
}: {
|
||||
title: string;
|
||||
watchlist?: { state: string };
|
||||
canManageWatchlist?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="framework-card"
|
||||
data-pin-state={watchlist?.state}
|
||||
data-can-manage={String(Boolean(canManageWatchlist))}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -136,3 +172,87 @@ describe("CrossProviderOverview", () => {
|
||||
expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// DORA's compatible provider types, per the static catalog.
|
||||
const DORA_ID = "dora_2022_2554";
|
||||
|
||||
const catalogEntry = (
|
||||
complianceId: string,
|
||||
providerType: string,
|
||||
inWatchlist: boolean,
|
||||
) =>
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId,
|
||||
providerType,
|
||||
inWatchlist,
|
||||
watchlistEntryId: inWatchlist ? `entry-${providerType}` : null,
|
||||
});
|
||||
|
||||
describe("CrossProviderOverview watchlist", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false });
|
||||
vi.mocked(getCrossProviderComplianceOverview).mockImplementation(
|
||||
async ({ complianceId }) => successResult(complianceId),
|
||||
);
|
||||
});
|
||||
|
||||
const withWatchlist = (
|
||||
entries: ReturnType<typeof catalogEntry>[],
|
||||
eligibleProviderTypes: string[],
|
||||
canManage = true,
|
||||
) =>
|
||||
vi.mocked(loadComplianceWatchlistContext).mockResolvedValue({
|
||||
entries,
|
||||
eligibleProviderTypes,
|
||||
canManage,
|
||||
});
|
||||
|
||||
it("keeps the configured framework order when one is pinned", async () => {
|
||||
// One card, one entry: the catalog keys a universal framework under `*`.
|
||||
withWatchlist([catalogEntry(DORA_ID, "*", true)], ["aws", "azure"]);
|
||||
|
||||
await renderOverview();
|
||||
|
||||
const cards = screen.getAllByTestId("framework-card");
|
||||
expect(cards).toHaveLength(CROSS_PROVIDER_FRAMEWORKS.length);
|
||||
expect(cards.map((card) => card.textContent)).toEqual(
|
||||
CROSS_PROVIDER_FRAMEWORKS.map((framework) => framework.title),
|
||||
);
|
||||
expect(cards[2]).toHaveAttribute("data-pin-state", "pinned");
|
||||
});
|
||||
|
||||
it("narrows the grid to the pinned frameworks when the filter is on", async () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
withWatchlist([catalogEntry(DORA_ID, "*", true)], ["aws", "azure"]);
|
||||
|
||||
await renderOverview();
|
||||
|
||||
const cards = screen.getAllByTestId("framework-card");
|
||||
expect(cards).toHaveLength(1);
|
||||
expect(cards[0]).toHaveTextContent("DORA");
|
||||
});
|
||||
|
||||
it("explains the blank grid when nothing universal is pinned", async () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
withWatchlist([catalogEntry(DORA_ID, "*", false)], ["aws"]);
|
||||
|
||||
await renderOverview();
|
||||
|
||||
expect(
|
||||
screen.getByText(/no universal framework is pinned/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("framework-card")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores the filter without a catalog, so OSS never blanks out", async () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
withWatchlist([], [], false);
|
||||
|
||||
await renderOverview();
|
||||
|
||||
expect(screen.getAllByTestId("framework-card")).toHaveLength(
|
||||
CROSS_PROVIDER_FRAMEWORKS.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/shadcn/section/section";
|
||||
import { buildWatchlistIndex } from "@/lib/compliance/watchlist";
|
||||
import {
|
||||
LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE,
|
||||
LIGHTHOUSE_CONTEXT_CONTRIBUTOR_LIMIT,
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
type CrossProviderFrameworkEntry,
|
||||
parseCrossProviderFilters,
|
||||
} from "../_lib/cross-provider-frameworks";
|
||||
import { resolveUniversalWatchlistState } from "../_lib/universal-watchlist";
|
||||
import { loadComplianceWatchlistContext } from "../_lib/watchlist-context";
|
||||
import type { CrossProviderFrameworkSummary } from "../_types";
|
||||
import { CROSS_PROVIDER_OVERVIEW_RESULT_STATUS } from "../_types";
|
||||
|
||||
@@ -35,10 +38,8 @@ import type {
|
||||
CrossProviderGroupOption,
|
||||
} from "./cross-provider-filters";
|
||||
import { CrossProviderFilters } from "./cross-provider-filters";
|
||||
import { CrossProviderFrameworkCard } from "./cross-provider-framework-card";
|
||||
import { CrossProviderFrameworkGrid } from "./cross-provider-framework-grid";
|
||||
|
||||
/** Zero-state summary: the framework renders with every compatible provider
|
||||
* chip dimmed when the API returned nothing usable (e.g. no scans yet). */
|
||||
const emptySummary = (
|
||||
entry: CrossProviderFrameworkEntry,
|
||||
): CrossProviderFrameworkSummary => ({
|
||||
@@ -61,12 +62,6 @@ const emptySummary = (
|
||||
})),
|
||||
});
|
||||
|
||||
/**
|
||||
* Server island for the Cross-Provider tab: fetches the roll-up for every
|
||||
* catalog framework in parallel and renders the filter row plus the cards
|
||||
* grid. Rendered only in Prowler Cloud with the tab active, so OSS and the
|
||||
* Per Scan tab never pay for these aggregation calls.
|
||||
*/
|
||||
export const CrossProviderOverview = async ({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -74,18 +69,22 @@ export const CrossProviderOverview = async ({
|
||||
}) => {
|
||||
const filters = parseCrossProviderFilters(searchParams);
|
||||
|
||||
const [responses, providersData, providerGroupsData] = await Promise.all([
|
||||
Promise.all(
|
||||
CROSS_PROVIDER_FRAMEWORKS.map((entry) =>
|
||||
getCrossProviderComplianceOverview({
|
||||
complianceId: entry.complianceId,
|
||||
filters,
|
||||
}).then((result) => ({ entry, result })),
|
||||
const [responses, providersData, providerGroupsData, watchlist] =
|
||||
await Promise.all([
|
||||
Promise.all(
|
||||
CROSS_PROVIDER_FRAMEWORKS.map((entry) =>
|
||||
getCrossProviderComplianceOverview({
|
||||
complianceId: entry.complianceId,
|
||||
filters,
|
||||
}).then((result) => ({ entry, result })),
|
||||
),
|
||||
),
|
||||
),
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
]);
|
||||
getAllProviders(),
|
||||
getAllProviderGroups(),
|
||||
// No provider type narrowing: a universal framework spans many types and
|
||||
// its pinned state depends on all of them.
|
||||
loadComplianceWatchlistContext(),
|
||||
]);
|
||||
|
||||
// Action errors (402 usage limit, 403) gate the whole feature, not one
|
||||
// framework, so any of them replaces the tab instead of degrading it.
|
||||
@@ -162,6 +161,21 @@ export const CrossProviderOverview = async ({
|
||||
providerGroupsData?.data || []
|
||||
).map((group) => ({ id: group.id, name: group.attributes.name }));
|
||||
|
||||
const catalogIndex = buildWatchlistIndex(watchlist.entries);
|
||||
|
||||
const cards = summaries.map((summary) => ({
|
||||
summary,
|
||||
watchlist: resolveUniversalWatchlistState({
|
||||
complianceId: summary.complianceId,
|
||||
compatibleProviders:
|
||||
CROSS_PROVIDER_FRAMEWORKS.find(
|
||||
(entry) => entry.complianceId === summary.complianceId,
|
||||
)?.compatibleProviders ?? [],
|
||||
eligibleProviderTypes: watchlist.eligibleProviderTypes,
|
||||
catalogIndex,
|
||||
}),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{summaries
|
||||
@@ -220,14 +234,11 @@ export const CrossProviderOverview = async ({
|
||||
</SectionDescription>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{summaries.map((summary) => (
|
||||
<CrossProviderFrameworkCard
|
||||
key={summary.complianceId}
|
||||
{...summary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<CrossProviderFrameworkGrid
|
||||
cards={cards}
|
||||
canManageWatchlist={watchlist.canManage}
|
||||
watchlistEnabled={watchlist.entries.length > 0}
|
||||
/>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
|
||||
import type { AccordionItemProps } from "@/components/shadcn/accordion/Accordion";
|
||||
import { Accordion } from "@/components/shadcn/accordion/Accordion";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
@@ -97,13 +98,13 @@ export const CrossProviderOverviewSkeleton = () => (
|
||||
</SectionDescription>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
<ComplianceFrameworkGrid>
|
||||
{Array.from({ length: FRAMEWORK_CARD_SKELETON_COUNT }).map(
|
||||
(_, index) => (
|
||||
<FrameworkCardSkeleton key={index} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</ComplianceFrameworkGrid>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildWatchlistIndex } from "@/lib/compliance/watchlist";
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
|
||||
import {
|
||||
UNIVERSAL_PROVIDER_TYPE,
|
||||
WATCHLIST_PIN_STATE,
|
||||
} from "@/types/compliance-watchlist";
|
||||
|
||||
import { resolveUniversalWatchlistState } from "../universal-watchlist";
|
||||
|
||||
const COMPLIANCE_ID = "dora_2022_2554";
|
||||
|
||||
const universalEntry = (
|
||||
inWatchlist: boolean,
|
||||
providerTypes = ["aws", "azure", "gcp"],
|
||||
): ComplianceCatalogEntry =>
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: COMPLIANCE_ID,
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
providerTypes,
|
||||
framework: "DORA",
|
||||
name: "DORA",
|
||||
version: "2022/2554",
|
||||
inWatchlist,
|
||||
watchlistEntryId: inWatchlist ? "entry-universal" : null,
|
||||
});
|
||||
|
||||
const resolve = (
|
||||
entries: ComplianceCatalogEntry[],
|
||||
eligibleProviderTypes: string[],
|
||||
compatibleProviders = ["aws", "azure", "gcp"],
|
||||
) =>
|
||||
resolveUniversalWatchlistState({
|
||||
complianceId: COMPLIANCE_ID,
|
||||
compatibleProviders,
|
||||
eligibleProviderTypes,
|
||||
catalogIndex: buildWatchlistIndex(entries),
|
||||
});
|
||||
|
||||
describe("resolveUniversalWatchlistState", () => {
|
||||
it("is pinned when the universal card is pinned", () => {
|
||||
const result = resolve([universalEntry(true)], ["aws", "azure", "gcp"]);
|
||||
|
||||
expect(result.state).toBe(WATCHLIST_PIN_STATE.PINNED);
|
||||
expect(result.entryId).toBe("entry-universal");
|
||||
});
|
||||
|
||||
it("is unpinned when the universal card is not pinned", () => {
|
||||
const result = resolve([universalEntry(false)], ["aws", "azure", "gcp"]);
|
||||
|
||||
expect(result.state).toBe(WATCHLIST_PIN_STATE.UNPINNED);
|
||||
expect(result.entryId).toBeNull();
|
||||
});
|
||||
|
||||
it("targets one universal row", () => {
|
||||
const result = resolve([universalEntry(false)], ["aws", "azure"]);
|
||||
|
||||
expect(result.target).toEqual({
|
||||
complianceId: COMPLIANCE_ID,
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays pinned when the tenant has only some of the compatible types", () => {
|
||||
// One entry covers every type, so there is no partial state to fall into.
|
||||
const result = resolve([universalEntry(true, ["aws"])], ["aws"]);
|
||||
|
||||
expect(result.state).toBe(WATCHLIST_PIN_STATE.PINNED);
|
||||
expect(result.eligibleCount).toBe(1);
|
||||
});
|
||||
|
||||
it("is not pinnable when no compatible provider is eligible", () => {
|
||||
const result = resolve([], []);
|
||||
|
||||
expect(result.target).toBeNull();
|
||||
expect(result.state).toBe(WATCHLIST_PIN_STATE.UNPINNED);
|
||||
expect(result.eligibleCount).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps a pinned card removable after the last compatible type is offboarded", () => {
|
||||
const result = resolve([universalEntry(true)], []);
|
||||
|
||||
expect(result.state).toBe(WATCHLIST_PIN_STATE.PINNED);
|
||||
expect(result.eligibleCount).toBe(0);
|
||||
expect(result.target).toEqual({
|
||||
complianceId: COMPLIANCE_ID,
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
});
|
||||
expect(result.entryId).toBe("entry-universal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
import type { ComplianceCatalog } from "@/types/compliance-watchlist";
|
||||
|
||||
const { getComplianceCatalogMock, authMock, isCloudMock } = vi.hoisted(() => ({
|
||||
getComplianceCatalogMock: vi.fn(),
|
||||
authMock: vi.fn(),
|
||||
isCloudMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliance-watchlist", () => ({
|
||||
getComplianceCatalog: getComplianceCatalogMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/auth.config", () => ({
|
||||
auth: authMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
EMPTY_WATCHLIST_CONTEXT,
|
||||
loadComplianceWatchlistContext,
|
||||
} from "../watchlist-context";
|
||||
|
||||
const catalog: ComplianceCatalog = {
|
||||
entries: [
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
framework: "CIS",
|
||||
name: "CIS",
|
||||
version: "1.4",
|
||||
inWatchlist: true,
|
||||
watchlistEntryId: "entry-1",
|
||||
}),
|
||||
],
|
||||
meta: {
|
||||
totalEntries: 1,
|
||||
watchlistCount: 1,
|
||||
eligibleProviderTypes: ["aws", "azure"],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getComplianceCatalogMock.mockResolvedValue(catalog);
|
||||
authMock.mockResolvedValue({
|
||||
user: { permissions: { manage_scans: true } },
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadComplianceWatchlistContext in OSS", () => {
|
||||
beforeEach(() => isCloudMock.mockReturnValue(false));
|
||||
|
||||
it("returns the empty context without requesting Cloud data", async () => {
|
||||
expect(await loadComplianceWatchlistContext()).toEqual(
|
||||
EMPTY_WATCHLIST_CONTEXT,
|
||||
);
|
||||
expect(getComplianceCatalogMock).not.toHaveBeenCalled();
|
||||
expect(authMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadComplianceWatchlistContext in Cloud", () => {
|
||||
beforeEach(() => isCloudMock.mockReturnValue(true));
|
||||
|
||||
it("exposes catalog data and curation permission", async () => {
|
||||
const context = await loadComplianceWatchlistContext();
|
||||
|
||||
expect(context.entries).toHaveLength(1);
|
||||
expect(context.eligibleProviderTypes).toEqual(["aws", "azure"]);
|
||||
expect(context.canManage).toBe(true);
|
||||
});
|
||||
|
||||
it("denies curation without the manage scans permission", async () => {
|
||||
authMock.mockResolvedValue({
|
||||
user: { permissions: { manage_scans: false } },
|
||||
});
|
||||
|
||||
expect((await loadComplianceWatchlistContext()).canManage).toBe(false);
|
||||
});
|
||||
|
||||
it("denies curation when there is no session", async () => {
|
||||
authMock.mockResolvedValue(null);
|
||||
|
||||
expect((await loadComplianceWatchlistContext()).canManage).toBe(false);
|
||||
});
|
||||
|
||||
it("degrades to the empty context when the session lookup rejects", async () => {
|
||||
// The catalog already swallows its own failures; `auth()` rejecting has to
|
||||
// cost the page its watchlist affordances rather than its compliance data,
|
||||
// since every surface awaits this loader during the server render.
|
||||
authMock.mockRejectedValue(new Error("session unavailable"));
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(await loadComplianceWatchlistContext()).toEqual(
|
||||
EMPTY_WATCHLIST_CONTEXT,
|
||||
);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("normalizes the provider types so an equivalent list hits one cache entry", async () => {
|
||||
// `cache()` keys on argument identity, so the memoized call takes a single
|
||||
// normalized string: two surfaces asking for the same narrowed catalog in a
|
||||
// different order must not fetch it twice.
|
||||
await loadComplianceWatchlistContext({
|
||||
providerTypes: ["azure", "aws", "aws"],
|
||||
});
|
||||
|
||||
expect(getComplianceCatalogMock).toHaveBeenCalledWith({
|
||||
providerTypes: ["aws", "azure"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { ComplianceCatalogIndex } from "@/lib/compliance/watchlist";
|
||||
import {
|
||||
isFrameworkPinned,
|
||||
resolveWatchlistEntryId,
|
||||
} from "@/lib/compliance/watchlist";
|
||||
import type {
|
||||
ComplianceWatchlistTarget,
|
||||
WatchlistPinState,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import {
|
||||
UNIVERSAL_PROVIDER_TYPE,
|
||||
WATCHLIST_PIN_STATE,
|
||||
} from "@/types/compliance-watchlist";
|
||||
|
||||
export interface UniversalWatchlistState {
|
||||
state: WatchlistPinState;
|
||||
target: ComplianceWatchlistTarget | null;
|
||||
eligibleCount: number;
|
||||
entryId: string | null;
|
||||
}
|
||||
|
||||
interface ResolveUniversalWatchlistStateArgs {
|
||||
complianceId: string;
|
||||
compatibleProviders: string[];
|
||||
eligibleProviderTypes: string[];
|
||||
catalogIndex: ComplianceCatalogIndex;
|
||||
}
|
||||
|
||||
export const resolveUniversalWatchlistState = ({
|
||||
complianceId,
|
||||
compatibleProviders,
|
||||
eligibleProviderTypes,
|
||||
catalogIndex,
|
||||
}: ResolveUniversalWatchlistStateArgs): UniversalWatchlistState => {
|
||||
const eligible = new Set(eligibleProviderTypes);
|
||||
const eligibleCount = compatibleProviders.filter((providerType) =>
|
||||
eligible.has(providerType),
|
||||
).length;
|
||||
|
||||
const target = {
|
||||
complianceId,
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
};
|
||||
|
||||
// A row remains removable after its last compatible provider is offboarded.
|
||||
const pinned = isFrameworkPinned(catalogIndex, target);
|
||||
|
||||
return {
|
||||
state: pinned ? WATCHLIST_PIN_STATE.PINNED : WATCHLIST_PIN_STATE.UNPINNED,
|
||||
target: eligibleCount === 0 && !pinned ? null : target,
|
||||
eligibleCount,
|
||||
entryId: resolveWatchlistEntryId(catalogIndex, target),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { getComplianceCatalog } from "@/actions/compliance-watchlist";
|
||||
import { auth } from "@/auth.config";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
|
||||
|
||||
export interface ComplianceWatchlistContext {
|
||||
entries: ComplianceCatalogEntry[];
|
||||
eligibleProviderTypes: string[];
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
export const EMPTY_WATCHLIST_CONTEXT: ComplianceWatchlistContext = {
|
||||
entries: [],
|
||||
eligibleProviderTypes: [],
|
||||
canManage: false,
|
||||
};
|
||||
|
||||
// One cached catalog/session lookup feeds every compliance surface in a render.
|
||||
const loadContextForKey = cache(
|
||||
async (providerTypesKey: string): Promise<ComplianceWatchlistContext> => {
|
||||
try {
|
||||
const providerTypes = providerTypesKey
|
||||
? providerTypesKey.split(",")
|
||||
: undefined;
|
||||
|
||||
const [catalog, session] = await Promise.all([
|
||||
getComplianceCatalog({ providerTypes }),
|
||||
auth(),
|
||||
]);
|
||||
|
||||
return {
|
||||
entries: catalog.entries,
|
||||
eligibleProviderTypes: catalog.meta.eligibleProviderTypes,
|
||||
canManage: Boolean(session?.user?.permissions?.manage_scans),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error loading the compliance watchlist context:", error);
|
||||
return EMPTY_WATCHLIST_CONTEXT;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const buildProviderTypesKey = (providerTypes?: string[]): string =>
|
||||
providerTypes && providerTypes.length > 0
|
||||
? Array.from(new Set(providerTypes)).sort().join(",")
|
||||
: "";
|
||||
|
||||
export const loadComplianceWatchlistContext = ({
|
||||
providerTypes,
|
||||
}: {
|
||||
providerTypes?: string[];
|
||||
} = {}): Promise<ComplianceWatchlistContext> => {
|
||||
if (!isCloud()) return Promise.resolve(EMPTY_WATCHLIST_CONTEXT);
|
||||
|
||||
return loadContextForKey(buildProviderTypesKey(providerTypes));
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@/components/compliance";
|
||||
import { ComplianceFilters } from "@/components/compliance/compliance-header/compliance-filters";
|
||||
import { ComplianceOverviewGrid } from "@/components/compliance/compliance-overview-grid";
|
||||
import { WatchlistControls } from "@/components/compliance/watchlist/watchlist-controls";
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { ContentLayout } from "@/components/shadcn/content-layout";
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
CrossAccountOverviewSkeleton,
|
||||
CrossProviderOverviewSkeleton,
|
||||
} from "./_components/multiple-scans-skeleton";
|
||||
import type { ComplianceWatchlistContext } from "./_lib/watchlist-context";
|
||||
import { loadComplianceWatchlistContext } from "./_lib/watchlist-context";
|
||||
|
||||
export default async function Compliance({
|
||||
searchParams,
|
||||
@@ -53,6 +56,13 @@ export default async function Compliance({
|
||||
? getComplianceTab(resolvedSearchParams.tab, resolvedSearchParams.scanId)
|
||||
: COMPLIANCE_TAB.PER_SCAN;
|
||||
|
||||
const watchlistPromise = loadComplianceWatchlistContext();
|
||||
const watchlistControls = (
|
||||
<Suspense fallback={null}>
|
||||
<ComplianceWatchlistControls watchlistPromise={watchlistPromise} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
// Only the active tab's payload is built: switching tabs is a real
|
||||
// navigation, so pre-building the inactive tab buys nothing.
|
||||
if (activeTab === COMPLIANCE_TAB.CROSS_PROVIDER) {
|
||||
@@ -82,6 +92,7 @@ export default async function Compliance({
|
||||
<CompliancePageTabs
|
||||
activeTab={activeTab}
|
||||
crossProviderEnabled={crossProviderEnabled}
|
||||
watchlistControls={watchlistControls}
|
||||
perScanContent={null}
|
||||
crossProviderContent={
|
||||
// gap-6 = the app-wide 24px below a filter row (Findings and the
|
||||
@@ -137,6 +148,7 @@ export default async function Compliance({
|
||||
<CompliancePageTabs
|
||||
activeTab={activeTab}
|
||||
crossProviderEnabled={crossProviderEnabled}
|
||||
watchlistControls={watchlistControls}
|
||||
perScanContent={<NoScansAvailable />}
|
||||
crossProviderContent={null}
|
||||
/>
|
||||
@@ -259,6 +271,7 @@ export default async function Compliance({
|
||||
searchParams={resolvedSearchParams}
|
||||
scanId={selectedScanId}
|
||||
selectedScan={selectedScanData}
|
||||
watchlistPromise={watchlistPromise}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
@@ -275,6 +288,7 @@ export default async function Compliance({
|
||||
<CompliancePageTabs
|
||||
activeTab={activeTab}
|
||||
crossProviderEnabled={crossProviderEnabled}
|
||||
watchlistControls={watchlistControls}
|
||||
perScanContent={perScanContent}
|
||||
crossProviderContent={null}
|
||||
/>
|
||||
@@ -286,10 +300,12 @@ const SSRComplianceGrid = async ({
|
||||
searchParams,
|
||||
scanId,
|
||||
selectedScan,
|
||||
watchlistPromise,
|
||||
}: {
|
||||
searchParams: SearchParamsProps;
|
||||
scanId: string | null;
|
||||
selectedScan?: ScanEntity;
|
||||
watchlistPromise: Promise<ComplianceWatchlistContext>;
|
||||
}) => {
|
||||
const regionFilter = searchParams["filter[region__in]"]?.toString() || "";
|
||||
|
||||
@@ -343,6 +359,11 @@ const SSRComplianceGrid = async ({
|
||||
),
|
||||
);
|
||||
|
||||
// The watchlist is keyed by `(compliance_id, provider_type)`, and on this
|
||||
// surface the provider type is fixed by the selected scan.
|
||||
const providerType = selectedScan?.providerInfo.provider;
|
||||
const watchlist = await watchlistPromise;
|
||||
|
||||
return (
|
||||
<ComplianceOverviewPanel>
|
||||
<ComplianceOverviewGrid
|
||||
@@ -350,11 +371,29 @@ const SSRComplianceGrid = async ({
|
||||
scanId={scanId ?? ""}
|
||||
selectedScan={selectedScan}
|
||||
latestCisIds={latestCisIds}
|
||||
catalogEntries={watchlist.entries}
|
||||
providerType={providerType}
|
||||
canManageWatchlist={watchlist.canManage}
|
||||
/>
|
||||
</ComplianceOverviewPanel>
|
||||
);
|
||||
};
|
||||
|
||||
const ComplianceWatchlistControls = async ({
|
||||
watchlistPromise,
|
||||
}: {
|
||||
watchlistPromise: Promise<ComplianceWatchlistContext>;
|
||||
}) => {
|
||||
const watchlist = await watchlistPromise;
|
||||
|
||||
return (
|
||||
<WatchlistControls
|
||||
entries={watchlist.entries}
|
||||
canManageWatchlist={watchlist.canManage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ComplianceOverviewPanel = ({
|
||||
children,
|
||||
}: {
|
||||
|
||||
@@ -112,8 +112,18 @@ export default async function Home({
|
||||
|
||||
<div className="mt-6 flex flex-col gap-6 xl:flex-row">
|
||||
{/* Watchlists: stacked on mobile, row on tablet, stacked on desktop */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:flex-wrap sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-auto [&>*]:h-full">
|
||||
{/* No `flex-wrap` here: a multi-line flex container sizes its items to
|
||||
the line's max-content rather than to its own 312px, and a card wider
|
||||
than the column loses its right border and padding to
|
||||
`overflow-hidden`. It never wrapped anyway. */}
|
||||
<div className="flex min-w-0 flex-col gap-6 overflow-hidden sm:flex-row sm:items-stretch xl:w-[312px] xl:shrink-0 xl:flex-col">
|
||||
{/* Sized to its own list, unlike the service card below: this one is
|
||||
as long as the organization made its watchlist, so growing it to
|
||||
fill the column puts a hole under two pinned frameworks. */}
|
||||
{/* `h-full` only where the cards sit side by side and share a row
|
||||
height. In the column it would hand the card the stretched
|
||||
wrapper's height and undo the fit-to-content sizing. */}
|
||||
<div className="min-w-0 sm:flex-1 xl:flex-none sm:[&>*]:h-full xl:[&>*]:h-auto">
|
||||
<Suspense fallback={<WatchlistCardSkeleton />}>
|
||||
<ComplianceWatchlistSSR searchParams={resolvedSearchParams} />
|
||||
</Suspense>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Compliance watchlist: pin frameworks from any compliance view and filter every view down to the pinned ones, including the overview card and a finding's compliance chips (Prowler Cloud only)
|
||||
@@ -9,17 +9,12 @@ describe("ComplianceCard", () => {
|
||||
const filePath = path.join(currentDir, "compliance-card.tsx");
|
||||
const source = readFileSync(filePath, "utf8");
|
||||
|
||||
it("keeps the shadcn Card base variant", () => {
|
||||
expect(source).toContain('variant="base"');
|
||||
});
|
||||
it("keeps the logo canvas light in dark mode", () => {
|
||||
// Given
|
||||
const darkThemeSurface = "bg-bg-neutral-tertiary";
|
||||
|
||||
it("uses a single-column stacked layout", () => {
|
||||
expect(source).toContain("flex-col");
|
||||
expect(source).not.toContain("sm:flex-row");
|
||||
});
|
||||
|
||||
it("places compact actions in the icon column on larger screens", () => {
|
||||
expect(source).toContain('orientation="column"');
|
||||
expect(source).toContain('buttonWidth="icon"');
|
||||
// When / Then
|
||||
expect(source).toContain("bg-slate-50");
|
||||
expect(source).not.toContain(darkThemeSurface);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent } from "@/components/shadcn/card/card";
|
||||
import { Card, CardAction, CardContent } from "@/components/shadcn/card/card";
|
||||
import { Progress } from "@/components/shadcn/progress";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -12,10 +13,12 @@ import {
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
|
||||
import { getReportTypeForCompliance } from "@/lib/compliance/compliance-report-types";
|
||||
import { formatComplianceFrameworkTitle } from "@/lib/compliance/framework-title";
|
||||
import {
|
||||
getScoreIndicatorClass,
|
||||
type ScoreColorVariant,
|
||||
} from "@/lib/compliance/score-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { getComplianceIcon } from "../icons";
|
||||
@@ -40,6 +43,13 @@ interface ComplianceCardProps {
|
||||
* Ignored for non-CIS frameworks.
|
||||
*/
|
||||
isLatestCisForProvider?: boolean;
|
||||
/**
|
||||
* Watchlist control rendered in the card's top-right corner, beside the
|
||||
* export action, so it costs no vertical room. Cloud-only and gated on
|
||||
* MANAGE_SCANS, so it is absent (rather than disabled) whenever the
|
||||
* viewer cannot curate the organization's watchlist.
|
||||
*/
|
||||
watchlistAction?: ReactNode;
|
||||
}
|
||||
|
||||
export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
@@ -51,14 +61,13 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
complianceId,
|
||||
id,
|
||||
isLatestCisForProvider = false,
|
||||
watchlistAction,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const hasRegionFilter = searchParams.has("filter[region__in]");
|
||||
|
||||
const formatTitle = (title: string) => {
|
||||
return title.split("-").join(" ");
|
||||
};
|
||||
const formattedTitle = formatComplianceFrameworkTitle(title, version);
|
||||
|
||||
const ratingPercentage = Math.floor(
|
||||
(passingRequirements / totalRequirements) * 100,
|
||||
@@ -83,22 +92,74 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
variant="base"
|
||||
padding="md"
|
||||
className="relative cursor-pointer transition-shadow hover:shadow-md"
|
||||
onClick={navigateToDetail}
|
||||
>
|
||||
<div
|
||||
className="absolute top-2 right-2 z-10"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
<Card variant="base" padding="none" interactive className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${formattedTitle} compliance details`}
|
||||
onClick={navigateToDetail}
|
||||
className="focus-visible:ring-border-neutral-secondary/50 w-full rounded-xl bg-transparent px-4 py-3 text-left outline-none focus-visible:ring-2"
|
||||
>
|
||||
<CardContent>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3",
|
||||
watchlistAction ? "pr-16" : "pr-9",
|
||||
)}
|
||||
>
|
||||
{getComplianceIcon(title) && (
|
||||
<div className="border-border-neutral-tertiary flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h4 className="truncate text-sm leading-5 font-bold">
|
||||
{formattedTitle}
|
||||
</h4>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{formattedTitle}</TooltipContent>
|
||||
</Tooltip>
|
||||
<small className="truncate">
|
||||
<span className="mr-1 text-xs font-semibold">
|
||||
{passingRequirements} / {totalRequirements}
|
||||
</span>
|
||||
Passing Requirements
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-text-neutral-secondary font-medium tracking-wider">
|
||||
Score:
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary">
|
||||
{ratingPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="Compliance score"
|
||||
value={ratingPercentage}
|
||||
className="border-border-neutral-secondary h-2.5 border drop-shadow-sm"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getRatingVariant(ratingPercentage),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</button>
|
||||
<CardAction
|
||||
className="absolute top-2 right-2 z-10 flex items-center gap-1"
|
||||
role="group"
|
||||
tabIndex={0}
|
||||
aria-label="Compliance actions"
|
||||
>
|
||||
<ComplianceDownloadContainer
|
||||
compact
|
||||
@@ -114,62 +175,8 @@ export const ComplianceCard: React.FC<ComplianceCardProps> = ({
|
||||
)}
|
||||
disabled={hasRegionFilter}
|
||||
/>
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center gap-3 pr-9">
|
||||
{getComplianceIcon(title) && (
|
||||
<div className="border-border-neutral-tertiary flex h-10 w-10 min-w-10 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={getComplianceIcon(title)}
|
||||
alt={`${title} logo`}
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<h4 className="truncate text-sm leading-5 font-bold">
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</h4>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{formatTitle(title)}
|
||||
{version ? ` - ${version}` : ""}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<small className="truncate">
|
||||
<span className="mr-1 text-xs font-semibold">
|
||||
{passingRequirements} / {totalRequirements}
|
||||
</span>
|
||||
Passing Requirements
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<span className="text-text-neutral-secondary font-medium tracking-wider">
|
||||
Score:
|
||||
</span>
|
||||
<span className="text-text-neutral-secondary">
|
||||
{ratingPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="Compliance score"
|
||||
value={ratingPercentage}
|
||||
className="border-border-neutral-secondary h-2.5 border drop-shadow-sm"
|
||||
indicatorClassName={getScoreIndicatorClass(
|
||||
getRatingVariant(ratingPercentage),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
{watchlistAction}
|
||||
</CardAction>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export const ComplianceFrameworkGrid = ({ children }: PropsWithChildren) => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,314 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { TourStepHandlers } from "@/lib/tours/tour-types";
|
||||
import type { ViewComplianceTourTarget } from "@/lib/tours/view-compliance.tour";
|
||||
import { VIEW_COMPLIANCE_TOUR_TARGETS } from "@/lib/tours/view-compliance.tour";
|
||||
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import { UNIVERSAL_PROVIDER_TYPE } from "@/types/compliance-watchlist";
|
||||
|
||||
import { ComplianceOverviewGrid } from "./compliance-overview-grid";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
usePathname: () => "/compliance",
|
||||
}));
|
||||
|
||||
vi.mock("@/components/lighthouse/context-contributor", () => ({
|
||||
LighthouseContextContributor: () => null,
|
||||
}));
|
||||
|
||||
type ViewComplianceStepHandlers = {
|
||||
[K in ViewComplianceTourTarget]?: TourStepHandlers<ViewComplianceTourTarget>;
|
||||
};
|
||||
|
||||
// Captured so the tour's own step handlers can be exercised: the trigger is a
|
||||
// driver.js host, and the handlers are the only part of it this grid owns.
|
||||
const capturedStepHandlers = vi.hoisted(() => ({
|
||||
current: {} as ViewComplianceStepHandlers,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/onboarding", () => ({
|
||||
OnboardingTrigger: ({
|
||||
stepHandlers,
|
||||
}: {
|
||||
stepHandlers: ViewComplianceStepHandlers;
|
||||
}) => {
|
||||
capturedStepHandlers.current = stepHandlers;
|
||||
return null;
|
||||
},
|
||||
PageReady: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliance-watchlist", () => ({
|
||||
addComplianceToWatchlist: vi.fn(),
|
||||
bulkUpdateComplianceWatchlist: vi.fn(),
|
||||
removeComplianceFromWatchlist: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./compliance-card", () => ({
|
||||
ComplianceCard: ({
|
||||
title,
|
||||
watchlistAction,
|
||||
}: {
|
||||
title: string;
|
||||
watchlistAction?: ReactNode;
|
||||
}) => (
|
||||
<div data-testid={`card-${title}`}>
|
||||
{title}
|
||||
{watchlistAction}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const framework = (id: string, frameworkName: string): ComplianceOverviewData =>
|
||||
({
|
||||
id,
|
||||
type: "compliance-overviews",
|
||||
attributes: {
|
||||
framework: frameworkName,
|
||||
version: "1.0",
|
||||
requirements_passed: 5,
|
||||
requirements_failed: 5,
|
||||
total_requirements: 10,
|
||||
},
|
||||
}) as unknown as ComplianceOverviewData;
|
||||
|
||||
const FRAMEWORKS = [
|
||||
framework("cis_1.4_aws", "CIS"),
|
||||
framework("gdpr_aws", "GDPR"),
|
||||
framework("iso27001_aws", "ISO27001"),
|
||||
];
|
||||
|
||||
const catalogEntry = (complianceId: string, inWatchlist: boolean) =>
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId,
|
||||
providerType: "aws",
|
||||
framework: complianceId,
|
||||
name: complianceId,
|
||||
inWatchlist,
|
||||
watchlistEntryId: inWatchlist
|
||||
? "3fa85f64-5717-4562-b3fc-2c963f66afa6"
|
||||
: null,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false });
|
||||
});
|
||||
|
||||
const renderGrid = (
|
||||
overrides: Partial<Parameters<typeof ComplianceOverviewGrid>[0]> = {},
|
||||
) =>
|
||||
render(
|
||||
<ComplianceOverviewGrid
|
||||
frameworks={FRAMEWORKS}
|
||||
scanId="scan-1"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
describe("ComplianceOverviewGrid without the watchlist (OSS / no catalog)", () => {
|
||||
it("keeps the full grid without watchlist affordances", () => {
|
||||
renderGrid();
|
||||
|
||||
expect(screen.getByTestId("card-CIS")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Watchlist" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("3 Total Entries")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplianceOverviewGrid with a curated watchlist", () => {
|
||||
const catalogEntries = [
|
||||
catalogEntry("cis_1.4_aws", true),
|
||||
catalogEntry("gdpr_aws", false),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
];
|
||||
|
||||
it("keeps the framework order when a later card is pinned", () => {
|
||||
renderGrid({
|
||||
catalogEntries: [
|
||||
catalogEntry("cis_1.4_aws", false),
|
||||
catalogEntry("gdpr_aws", true),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
],
|
||||
providerType: "aws",
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByTestId(/^card-/).map((card) => card.textContent),
|
||||
).toEqual(["CIS", "GDPR", "ISO27001"]);
|
||||
});
|
||||
|
||||
it("narrows the grid to the pinned frameworks when the filter is on", () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
|
||||
renderGrid({ catalogEntries, providerType: "aws" });
|
||||
|
||||
expect(screen.getByTestId("card-CIS")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("card-GDPR")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("1 Total Entries")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the pin action on every card when the user can manage the account", () => {
|
||||
renderGrid({
|
||||
catalogEntries,
|
||||
providerType: "aws",
|
||||
canManageWatchlist: true,
|
||||
});
|
||||
|
||||
// One toggle per card, told apart by `aria-pressed` rather than by a
|
||||
// swapped label — which is what a screen reader actually announces.
|
||||
const toggles = screen.getAllByRole("button", { name: "Watchlist" });
|
||||
expect(toggles).toHaveLength(3);
|
||||
expect(
|
||||
toggles.some((toggle) => toggle.getAttribute("aria-pressed") === "true"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
toggles.some((toggle) => toggle.getAttribute("aria-pressed") === "false"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("filters read-only viewers without exposing write controls", () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
|
||||
renderGrid({
|
||||
catalogEntries,
|
||||
providerType: "aws",
|
||||
canManageWatchlist: false,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("card-CIS")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("card-GDPR")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Watchlist" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplianceOverviewGrid with an empty watchlist", () => {
|
||||
const catalogEntries = [
|
||||
catalogEntry("cis_1.4_aws", false),
|
||||
catalogEntry("gdpr_aws", false),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
];
|
||||
|
||||
it("explains the blank grid when the filter hides everything", () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
|
||||
renderGrid({
|
||||
catalogEntries,
|
||||
providerType: "aws",
|
||||
canManageWatchlist: true,
|
||||
});
|
||||
|
||||
expect(screen.getByText(/no frameworks pinned yet/i)).toBeVisible();
|
||||
expect(screen.queryByTestId("card-CIS")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplianceOverviewGrid with a universal framework", () => {
|
||||
// The catalog keys a universal framework under `*`, while this per-scan grid
|
||||
// only knows the scan's own provider type — the wildcard fallback is what
|
||||
// makes the two agree.
|
||||
const universalEntries = [
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_controls_8.1",
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
providerTypes: ["aws", "azure"],
|
||||
inWatchlist: true,
|
||||
}),
|
||||
catalogEntry("gdpr_aws", false),
|
||||
];
|
||||
const universalFrameworks = [
|
||||
framework("cis_controls_8.1", "CIS-Controls"),
|
||||
framework("gdpr_aws", "GDPR"),
|
||||
];
|
||||
|
||||
it("resolves and filters the wildcard row", () => {
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
|
||||
render(
|
||||
<ComplianceOverviewGrid
|
||||
frameworks={universalFrameworks}
|
||||
scanId="scan-1"
|
||||
catalogEntries={universalEntries}
|
||||
providerType="aws"
|
||||
canManageWatchlist
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("card-CIS-Controls")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("card-GDPR")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Watchlist" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ComplianceOverviewGrid tour anchor", () => {
|
||||
const searchHandlers = () =>
|
||||
capturedStepHandlers.current[VIEW_COMPLIANCE_TOUR_TARGETS.SEARCH];
|
||||
|
||||
it("keeps the anchor on the first rendered card regardless of pin state", () => {
|
||||
renderGrid({
|
||||
catalogEntries: [
|
||||
catalogEntry("cis_1.4_aws", false),
|
||||
catalogEntry("gdpr_aws", true),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
],
|
||||
providerType: "aws",
|
||||
});
|
||||
|
||||
expect(
|
||||
document.querySelector('[data-tour-id="view-compliance-frameworks"]'),
|
||||
).toHaveTextContent("CIS");
|
||||
});
|
||||
|
||||
it("waits for the framework card when one will render", async () => {
|
||||
const waitForStep = vi
|
||||
.fn()
|
||||
.mockResolvedValue(document.createElement("div"));
|
||||
|
||||
renderGrid();
|
||||
const onNext = searchHandlers()?.onNext;
|
||||
if (!onNext) throw new Error("Expected a search step handler");
|
||||
await onNext({ waitForStep });
|
||||
|
||||
expect(waitForStep).toHaveBeenCalledWith("frameworks");
|
||||
});
|
||||
|
||||
it("skips the wait when the persisted filter leaves no card to anchor to", async () => {
|
||||
// The filter survives reloads, so the tour can start on a grid that renders
|
||||
// the empty state instead of a card — and waiting for an anchor that never
|
||||
// mounts would hang it there.
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
const waitForStep = vi
|
||||
.fn()
|
||||
.mockResolvedValue(document.createElement("div"));
|
||||
|
||||
renderGrid({
|
||||
catalogEntries: [
|
||||
catalogEntry("cis_1.4_aws", false),
|
||||
catalogEntry("gdpr_aws", false),
|
||||
catalogEntry("iso27001_aws", false),
|
||||
],
|
||||
providerType: "aws",
|
||||
});
|
||||
const onNext = searchHandlers()?.onNext;
|
||||
if (!onNext) throw new Error("Expected a search step handler");
|
||||
await onNext({ waitForStep });
|
||||
|
||||
expect(waitForStep).not.toHaveBeenCalled();
|
||||
expect(
|
||||
document.querySelector('[data-tour-id="view-compliance-frameworks"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -4,10 +4,17 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useState } from "react";
|
||||
|
||||
import { ComplianceCard } from "@/components/compliance/compliance-card";
|
||||
import { ComplianceFrameworkGrid } from "@/components/compliance/compliance-framework-grid";
|
||||
import { LighthouseContextContributor } from "@/components/lighthouse/context-contributor";
|
||||
import { OnboardingTrigger, PageReady } from "@/components/onboarding";
|
||||
import { DataTableSearch } from "@/components/shadcn/table/data-table-search";
|
||||
import { useShowOnlyWatchlist } from "@/hooks/use-show-only-watchlist";
|
||||
import { buildComplianceDetailPath } from "@/lib/compliance/compliance-detail-url";
|
||||
import {
|
||||
buildWatchlistIndex,
|
||||
isFrameworkPinned,
|
||||
resolveWatchlistEntryId,
|
||||
} from "@/lib/compliance/watchlist";
|
||||
import {
|
||||
LIGHTHOUSE_COMPLIANCE_CONTEXT_MODE,
|
||||
LIGHTHOUSE_CONTEXT_CONTRIBUTOR_LIMIT,
|
||||
@@ -16,8 +23,13 @@ import { buildComplianceContext } from "@/lib/lighthouse/context/contributions";
|
||||
import { getFlowById } from "@/lib/onboarding";
|
||||
import { createViewComplianceTourStepHandlers } from "@/lib/tours/view-compliance.tour";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
|
||||
import { WATCHLIST_PIN_STATE } from "@/types/compliance-watchlist";
|
||||
import type { ScanEntity } from "@/types/scans";
|
||||
|
||||
import { WatchlistEmptyState } from "./watchlist/watchlist-empty-state";
|
||||
import { WatchlistToggle } from "./watchlist/watchlist-toggle";
|
||||
|
||||
const viewComplianceFlow = getFlowById("view-compliance")!;
|
||||
|
||||
// Module-level so the identity is stable: `configOverrides` is an effect dependency in
|
||||
@@ -31,12 +43,10 @@ interface ComplianceOverviewGridProps {
|
||||
frameworks: ComplianceOverviewData[];
|
||||
scanId: string;
|
||||
selectedScan?: ScanEntity;
|
||||
/**
|
||||
* Subset of compliance_ids that represent the latest CIS variant per
|
||||
* provider. Only those cards expose the PDF download button, matching
|
||||
* the backend's latest-only CIS PDF generation.
|
||||
*/
|
||||
latestCisIds?: ReadonlySet<string>;
|
||||
catalogEntries?: ComplianceCatalogEntry[];
|
||||
providerType?: string;
|
||||
canManageWatchlist?: boolean;
|
||||
}
|
||||
|
||||
export const ComplianceOverviewGrid = ({
|
||||
@@ -44,10 +54,14 @@ export const ComplianceOverviewGrid = ({
|
||||
scanId,
|
||||
selectedScan,
|
||||
latestCisIds,
|
||||
catalogEntries,
|
||||
providerType,
|
||||
canManageWatchlist = false,
|
||||
}: ComplianceOverviewGridProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const showOnlyWatchlist = useShowOnlyWatchlist();
|
||||
|
||||
const filteredFrameworks = frameworks.filter((compliance) =>
|
||||
compliance.attributes.framework
|
||||
@@ -55,13 +69,44 @@ export const ComplianceOverviewGrid = ({
|
||||
.includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
const catalogIndex = buildWatchlistIndex(catalogEntries ?? []);
|
||||
const watchlistEnabled =
|
||||
Boolean(providerType) && (catalogEntries?.length ?? 0) > 0;
|
||||
|
||||
const isPinned = (complianceId: string) =>
|
||||
watchlistEnabled &&
|
||||
isFrameworkPinned(catalogIndex, {
|
||||
complianceId,
|
||||
providerType: providerType!,
|
||||
});
|
||||
|
||||
// Counted before the search, so a term that matches nothing pinned does not
|
||||
// make the empty state claim the organization has pinned nothing.
|
||||
const pinnedTotal = watchlistEnabled
|
||||
? frameworks.filter((compliance) => isPinned(compliance.id)).length
|
||||
: 0;
|
||||
|
||||
const filterToWatchlist = watchlistEnabled && showOnlyWatchlist;
|
||||
const visibleFrameworks = filterToWatchlist
|
||||
? filteredFrameworks.filter((compliance) => isPinned(compliance.id))
|
||||
: filteredFrameworks;
|
||||
const tourAnchorId = visibleFrameworks[0]?.id;
|
||||
|
||||
const resetSearch = () => {
|
||||
setSearchTerm("");
|
||||
return frameworks.length > 0;
|
||||
// Clearing the search does not bring the anchor back while the persisted
|
||||
// watchlist filter is on and nothing is pinned: the grid renders the empty
|
||||
// state instead, so the tour has to skip the step rather than wait for a
|
||||
// selector that never mounts.
|
||||
return filterToWatchlist ? pinnedTotal > 0 : frameworks.length > 0;
|
||||
};
|
||||
|
||||
const openFirstFramework = () => {
|
||||
const first = frameworks[0];
|
||||
// The fallback covers a search that filtered every card away — never the
|
||||
// watchlist filter, where opening a hidden framework would contradict the
|
||||
// list the user is looking at.
|
||||
const first =
|
||||
visibleFrameworks[0] ?? (filterToWatchlist ? undefined : frameworks[0]);
|
||||
if (!first) return;
|
||||
router.push(
|
||||
buildComplianceDetailPath({
|
||||
@@ -74,6 +119,56 @@ export const ComplianceOverviewGrid = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderGrid = (items: ComplianceOverviewData[]) => (
|
||||
<ComplianceFrameworkGrid>
|
||||
{items.map((compliance) => {
|
||||
const { attributes, id } = compliance;
|
||||
const { framework, version, requirements_passed, total_requirements } =
|
||||
attributes;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
{...(id === tourAnchorId
|
||||
? { "data-tour-id": "view-compliance-frameworks" }
|
||||
: {})}
|
||||
className="h-full [&>*]:h-full"
|
||||
>
|
||||
<ComplianceCard
|
||||
title={framework}
|
||||
version={version}
|
||||
passingRequirements={requirements_passed}
|
||||
totalRequirements={total_requirements}
|
||||
prevPassingRequirements={requirements_passed}
|
||||
prevTotalRequirements={total_requirements}
|
||||
scanId={scanId}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={selectedScan}
|
||||
isLatestCisForProvider={latestCisIds?.has(id) ?? false}
|
||||
watchlistAction={
|
||||
watchlistEnabled && canManageWatchlist ? (
|
||||
<WatchlistToggle
|
||||
target={{ complianceId: id, providerType: providerType! }}
|
||||
state={
|
||||
isPinned(id)
|
||||
? WATCHLIST_PIN_STATE.PINNED
|
||||
: WATCHLIST_PIN_STATE.UNPINNED
|
||||
}
|
||||
entryId={resolveWatchlistEntryId(catalogIndex, {
|
||||
complianceId: id,
|
||||
providerType: providerType!,
|
||||
})}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ComplianceFrameworkGrid>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{filteredFrameworks
|
||||
@@ -119,52 +214,23 @@ export const ComplianceOverviewGrid = ({
|
||||
/>
|
||||
</div>
|
||||
<span className="text-text-neutral-secondary shrink-0 text-sm">
|
||||
{filteredFrameworks.length.toLocaleString()} Total Entries
|
||||
{visibleFrameworks.length.toLocaleString()} Total Entries
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
{filteredFrameworks.map((compliance, index) => {
|
||||
const { attributes, id } = compliance;
|
||||
const {
|
||||
framework,
|
||||
version,
|
||||
requirements_passed,
|
||||
total_requirements,
|
||||
} = attributes;
|
||||
|
||||
const card = (
|
||||
<ComplianceCard
|
||||
title={framework}
|
||||
version={version}
|
||||
passingRequirements={requirements_passed}
|
||||
totalRequirements={total_requirements}
|
||||
prevPassingRequirements={requirements_passed}
|
||||
prevTotalRequirements={total_requirements}
|
||||
scanId={scanId}
|
||||
complianceId={id}
|
||||
id={id}
|
||||
selectedScan={selectedScan}
|
||||
isLatestCisForProvider={latestCisIds?.has(id) ?? false}
|
||||
/>
|
||||
);
|
||||
|
||||
// Anchor the tour to a single card, not the whole grid: highlighting the
|
||||
// grid lit up the entire viewport and scrolled the page to the bottom.
|
||||
return index === 0 ? (
|
||||
<div
|
||||
key={id}
|
||||
data-tour-id="view-compliance-frameworks"
|
||||
className="h-full [&>*]:h-full"
|
||||
>
|
||||
{card}
|
||||
</div>
|
||||
) : (
|
||||
<div key={id} className="h-full [&>*]:h-full">
|
||||
{card}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filterToWatchlist && visibleFrameworks.length === 0 ? (
|
||||
<WatchlistEmptyState
|
||||
message={
|
||||
// A search term is the likelier culprit than an uncurated
|
||||
// watchlist, so it gets its own copy instead of telling someone
|
||||
// who has already pinned frameworks that they have pinned none.
|
||||
pinnedTotal > 0
|
||||
? "No pinned framework matches your search."
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
renderGrid(visibleFrameworks)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./compliance-accordion/client-accordion-wrapper";
|
||||
export * from "./compliance-accordion/compliance-accordion-requeriment-title";
|
||||
export * from "./compliance-accordion/compliance-accordion-title";
|
||||
export * from "./compliance-card";
|
||||
export * from "./compliance-framework-grid";
|
||||
export * from "./compliance-charts/chart-skeletons";
|
||||
export * from "./compliance-charts/heatmap-chart";
|
||||
export * from "./compliance-charts/requirements-status-card";
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Skeleton } from "@/components/shadcn/skeleton/skeleton";
|
||||
|
||||
import { ComplianceFrameworkGrid } from "../compliance-framework-grid";
|
||||
|
||||
export const ComplianceSkeletonGrid = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
|
||||
<ComplianceFrameworkGrid>
|
||||
{[...Array(28)].map((_, index) => (
|
||||
<Skeleton key={index} className="h-28 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</ComplianceFrameworkGrid>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./watchlist-controls";
|
||||
export * from "./watchlist-empty-state";
|
||||
export * from "./watchlist-filter-toggle";
|
||||
export * from "./watchlist-multi-select";
|
||||
export * from "./watchlist-toggle";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
|
||||
import { WatchlistControls } from "./watchlist-controls";
|
||||
|
||||
vi.mock("./watchlist-filter-toggle", () => ({
|
||||
WatchlistFilterToggle: () => <div data-testid="filter-toggle" />,
|
||||
}));
|
||||
|
||||
vi.mock("./watchlist-multi-select", () => ({
|
||||
WatchlistMultiSelect: () => <div data-testid="multi-select" />,
|
||||
}));
|
||||
|
||||
const ENTRY = makeComplianceCatalogEntry({
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
framework: "CIS",
|
||||
name: "CIS",
|
||||
version: "1.4",
|
||||
});
|
||||
|
||||
describe("WatchlistControls", () => {
|
||||
it("renders nothing without a catalog, keeping the feature Cloud-only", () => {
|
||||
const { container } = render(
|
||||
<WatchlistControls entries={[]} canManageWatchlist />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("offers both the filter and the editor to a curator", () => {
|
||||
render(<WatchlistControls entries={[ENTRY]} canManageWatchlist />);
|
||||
|
||||
expect(screen.getByTestId("filter-toggle")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("multi-select")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the filter but drops the editor without MANAGE_SCANS", () => {
|
||||
// Rendering the editor disabled would be worse: the write 403s, so the
|
||||
// affordance is removed rather than teased.
|
||||
render(<WatchlistControls entries={[ENTRY]} canManageWatchlist={false} />);
|
||||
|
||||
expect(screen.getByTestId("filter-toggle")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("multi-select")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
|
||||
|
||||
import { WatchlistFilterToggle } from "./watchlist-filter-toggle";
|
||||
import { WatchlistMultiSelect } from "./watchlist-multi-select";
|
||||
|
||||
interface WatchlistControlsProps {
|
||||
entries: ComplianceCatalogEntry[];
|
||||
canManageWatchlist: boolean;
|
||||
}
|
||||
|
||||
export const WatchlistControls = ({
|
||||
entries,
|
||||
canManageWatchlist,
|
||||
}: WatchlistControlsProps) => {
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-4">
|
||||
<WatchlistFilterToggle />
|
||||
{canManageWatchlist && (
|
||||
<div className="w-56">
|
||||
<WatchlistMultiSelect entries={entries} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Pin } from "lucide-react";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/shadcn/alert";
|
||||
|
||||
/** Default copy: what an uncurated watchlist reads like on any surface. The
|
||||
* two Multiple Scans sections override it to name what is missing there. */
|
||||
const WATCHLIST_FILTER_EMPTY_HINT =
|
||||
"No frameworks pinned yet. Pin the ones your organization tracks — from a card or the watchlist selector — or clear the filter to browse the full catalog.";
|
||||
|
||||
interface WatchlistEmptyStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a surface renders when the watchlist filter hides everything it had.
|
||||
*
|
||||
* `role="status"` overrides the component's default `role="alert"`: this is
|
||||
* the expected result of a filter the user just applied, not an error, and an
|
||||
* assertive live region would interrupt to announce it — twice over on the
|
||||
* Multiple Scans tab, which renders two sections.
|
||||
*/
|
||||
export const WatchlistEmptyState = ({
|
||||
message = WATCHLIST_FILTER_EMPTY_HINT,
|
||||
}: WatchlistEmptyStateProps) => (
|
||||
<Alert role="status" variant="info">
|
||||
<Pin aria-hidden />
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
|
||||
|
||||
import { WatchlistFilterToggle } from "./watchlist-filter-toggle";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false });
|
||||
});
|
||||
|
||||
describe("WatchlistFilterToggle", () => {
|
||||
it("updates the shared filter in both directions", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(<WatchlistFilterToggle />);
|
||||
const checkbox = screen.getByRole("checkbox", {
|
||||
name: "Show only watchlist",
|
||||
});
|
||||
|
||||
// When
|
||||
await user.click(checkbox);
|
||||
|
||||
// Then
|
||||
expect(useComplianceWatchlistViewStore.getState().showOnlyWatchlist).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(checkbox);
|
||||
|
||||
// Then
|
||||
expect(useComplianceWatchlistViewStore.getState().showOnlyWatchlist).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { Pin } from "lucide-react";
|
||||
|
||||
import { Checkbox } from "@/components/shadcn";
|
||||
import { useShowOnlyWatchlist } from "@/hooks/use-show-only-watchlist";
|
||||
import { useComplianceWatchlistViewStore } from "@/store";
|
||||
|
||||
const CHECKBOX_ID = "show-only-watchlist";
|
||||
|
||||
/**
|
||||
* Filters every compliance surface down to the pinned frameworks, mirroring
|
||||
* "Include muted findings" on the Findings page.
|
||||
*
|
||||
* The state lives in the store rather than the URL because the two compliance
|
||||
* tabs are separate navigations: the filter has to survive a tab switch, and
|
||||
* both tabs read the very same flag so a curated view stays curated across
|
||||
* them.
|
||||
*/
|
||||
export const WatchlistFilterToggle = () => {
|
||||
const showOnlyWatchlist = useShowOnlyWatchlist();
|
||||
const setShowOnlyWatchlist = useComplianceWatchlistViewStore(
|
||||
(state) => state.setShowOnlyWatchlist,
|
||||
);
|
||||
|
||||
return (
|
||||
// `text-nowrap`: wrapping the label would push the tab bar's height
|
||||
// around, and it is short enough that it never needs to.
|
||||
<div className="flex items-center gap-2 text-nowrap">
|
||||
<Checkbox
|
||||
id={CHECKBOX_ID}
|
||||
checked={showOnlyWatchlist}
|
||||
onCheckedChange={(checked) => setShowOnlyWatchlist(checked === true)}
|
||||
/>
|
||||
<Pin aria-hidden className="text-text-neutral-tertiary size-3 shrink-0" />
|
||||
{/* No `aria-label` on the checkbox: it would override this label, and a
|
||||
screen reader would then announce something the user cannot see. */}
|
||||
<label
|
||||
htmlFor={CHECKBOX_ID}
|
||||
className="cursor-pointer text-sm leading-none"
|
||||
>
|
||||
Show only watchlist
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ReactNode } from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
|
||||
import { UNIVERSAL_PROVIDER_TYPE } from "@/types/compliance-watchlist";
|
||||
|
||||
import { WatchlistMultiSelect } from "./watchlist-multi-select";
|
||||
|
||||
const { bulkUpdateComplianceWatchlistMock, toastMock } = vi.hoisted(() => ({
|
||||
bulkUpdateComplianceWatchlistMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliance-watchlist", () => ({
|
||||
bulkUpdateComplianceWatchlist: bulkUpdateComplianceWatchlistMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast/use-toast", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
// Faithful-enough stand-in for the popover primitive: it keeps the controlled
|
||||
// `values`/`open` contract (which is what this component actually drives) and
|
||||
// drops cmdk's virtualised listbox, which does not render in jsdom.
|
||||
vi.mock("@/components/shadcn/select/multiselect", async () => {
|
||||
const { createContext, useContext } = await import("react");
|
||||
|
||||
const Ctx = createContext<{
|
||||
values: string[];
|
||||
onValuesChange: (values: string[]) => void;
|
||||
}>({ values: [], onValuesChange: () => {} });
|
||||
|
||||
return {
|
||||
MultiSelect: ({
|
||||
children,
|
||||
values,
|
||||
onValuesChange,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
values: string[];
|
||||
onValuesChange: (values: string[]) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => (
|
||||
<Ctx.Provider value={{ values, onValuesChange }}>
|
||||
<button type="button" onClick={() => onOpenChange(true)}>
|
||||
open-dropdown
|
||||
</button>
|
||||
<button type="button" onClick={() => onOpenChange(false)}>
|
||||
close-dropdown
|
||||
</button>
|
||||
{children}
|
||||
</Ctx.Provider>
|
||||
),
|
||||
MultiSelectTrigger: ({ children, ...props }: { children: ReactNode }) => (
|
||||
<button role="combobox" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
MultiSelectContent: ({ children }: { children: ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
MultiSelectGroup: ({
|
||||
heading,
|
||||
children,
|
||||
}: {
|
||||
heading: string;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<div role="group" aria-label={heading}>
|
||||
<span>{heading}</span>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
MultiSelectItem: ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
const { values, onValuesChange } = useContext(Ctx);
|
||||
const selected = values.includes(value);
|
||||
return (
|
||||
<button
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
onClick={() =>
|
||||
onValuesChange(
|
||||
selected
|
||||
? values.filter((current) => current !== value)
|
||||
: [...values, value],
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const ENTRIES: ComplianceCatalogEntry[] = [
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_controls_8.1",
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
framework: "CIS Controls",
|
||||
}),
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
framework: "CIS",
|
||||
inWatchlist: true,
|
||||
}),
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_2.0_azure",
|
||||
providerType: "azure",
|
||||
framework: "CIS",
|
||||
}),
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
bulkUpdateComplianceWatchlistMock.mockReset();
|
||||
bulkUpdateComplianceWatchlistMock.mockResolvedValue({ success: "ok" });
|
||||
toastMock.mockReset();
|
||||
});
|
||||
|
||||
describe("WatchlistMultiSelect grouping", () => {
|
||||
it("labels the universal band separately from each provider type", () => {
|
||||
render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
const universal = screen.getByRole("group", { name: "Universal" });
|
||||
expect(
|
||||
within(universal).getByText("CIS Controls - 1.0"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
within(screen.getByRole("group", { name: "AWS" })).getByText("CIS - 1.0"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("group", { name: "Azure" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WatchlistMultiSelect editing", () => {
|
||||
it("submits one diff for every change when the dropdown closes", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
// When: pin Azure, unpin AWS, then close
|
||||
await user.click(screen.getByRole("button", { name: "open-dropdown" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Azure" })).getByRole("option"),
|
||||
);
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "AWS" })).getByRole("option"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "close-dropdown" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(bulkUpdateComplianceWatchlistMock).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(bulkUpdateComplianceWatchlistMock).toHaveBeenCalledWith({
|
||||
add: [{ complianceId: "cis_2.0_azure", providerType: "azure" }],
|
||||
remove: [{ complianceId: "cis_1.4_aws", providerType: "aws" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not call the API when the selection is unchanged", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "open-dropdown" }));
|
||||
await user.click(screen.getByRole("button", { name: "close-dropdown" }));
|
||||
|
||||
// Then
|
||||
expect(bulkUpdateComplianceWatchlistMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls the selection back when the write fails", async () => {
|
||||
bulkUpdateComplianceWatchlistMock.mockResolvedValue({ error: "403" });
|
||||
const user = userEvent.setup();
|
||||
render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "open-dropdown" }));
|
||||
await user.click(
|
||||
within(screen.getByRole("group", { name: "Azure" })).getByRole("option"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "close-dropdown" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variant: "destructive" }),
|
||||
),
|
||||
);
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent(
|
||||
"Watchlist · 1 pinned",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-reads the server state when the dropdown is reopened", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<WatchlistMultiSelect entries={ENTRIES} />);
|
||||
|
||||
// When: a card pin lands from elsewhere while the dropdown is closed
|
||||
rerender(
|
||||
<WatchlistMultiSelect
|
||||
entries={ENTRIES.map((item) => ({ ...item, inWatchlist: true }))}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "open-dropdown" }));
|
||||
|
||||
// Then
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent(
|
||||
"Watchlist · 3 pinned",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
|
||||
import { bulkUpdateComplianceWatchlist } from "@/actions/compliance-watchlist";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectGroup,
|
||||
MultiSelectItem,
|
||||
MultiSelectTrigger,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useToast } from "@/components/shadcn/toast/use-toast";
|
||||
import {
|
||||
computeWatchlistDiff,
|
||||
exceedsWatchlistBulkLimit,
|
||||
isEmptyWatchlistDiff,
|
||||
MAX_WATCHLIST_BULK,
|
||||
watchlistKey,
|
||||
} from "@/lib/compliance/watchlist";
|
||||
import type {
|
||||
ComplianceCatalogEntry,
|
||||
ComplianceWatchlistTarget,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import { WATCHLIST_SCOPE } from "@/types/compliance-watchlist";
|
||||
import { getProviderDisplayName } from "@/types/providers";
|
||||
|
||||
interface WatchlistMultiSelectProps {
|
||||
entries: ComplianceCatalogEntry[];
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const UNIVERSAL_GROUP_LABEL = "Universal";
|
||||
|
||||
interface WatchlistGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
entries: ComplianceCatalogEntry[];
|
||||
}
|
||||
|
||||
const frameworkLabel = (entry: ComplianceCatalogEntry): string =>
|
||||
`${entry.framework}${entry.version ? ` - ${entry.version}` : ""}`;
|
||||
|
||||
const buildGroups = (entries: ComplianceCatalogEntry[]): WatchlistGroup[] => {
|
||||
const universal = entries.filter(
|
||||
(entry) => entry.scope === WATCHLIST_SCOPE.UNIVERSAL,
|
||||
);
|
||||
|
||||
const byProviderType = new Map<string, ComplianceCatalogEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (entry.scope === WATCHLIST_SCOPE.UNIVERSAL) continue;
|
||||
byProviderType.set(entry.providerType, [
|
||||
...(byProviderType.get(entry.providerType) ?? []),
|
||||
entry,
|
||||
]);
|
||||
}
|
||||
|
||||
const sortEntries = (group: ComplianceCatalogEntry[]) =>
|
||||
[...group].sort((a, b) =>
|
||||
frameworkLabel(a).localeCompare(frameworkLabel(b)),
|
||||
);
|
||||
|
||||
const providerGroups: WatchlistGroup[] = Array.from(byProviderType.entries())
|
||||
.map(([providerType, group]) => ({
|
||||
key: providerType,
|
||||
label: getProviderDisplayName(providerType),
|
||||
entries: sortEntries(group),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
return [
|
||||
...(universal.length > 0
|
||||
? [
|
||||
{
|
||||
key: WATCHLIST_SCOPE.UNIVERSAL,
|
||||
label: UNIVERSAL_GROUP_LABEL,
|
||||
entries: sortEntries(universal),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...providerGroups,
|
||||
];
|
||||
};
|
||||
|
||||
const pinnedKeys = (entries: ComplianceCatalogEntry[]): string[] =>
|
||||
entries
|
||||
.filter((entry) => entry.inWatchlist)
|
||||
.map((entry) => watchlistKey(entry));
|
||||
|
||||
const toTarget = (
|
||||
entry: ComplianceCatalogEntry,
|
||||
): ComplianceWatchlistTarget => ({
|
||||
complianceId: entry.complianceId,
|
||||
providerType: entry.providerType,
|
||||
});
|
||||
|
||||
export const WatchlistMultiSelect = ({
|
||||
entries,
|
||||
id = "compliance-watchlist-selector",
|
||||
}: WatchlistMultiSelectProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [open, setOpen] = useState(false);
|
||||
const labelId = `${id}-label`;
|
||||
// Local state needed: selections are pending edits, applied only on close.
|
||||
const [selectedKeys, setSelectedKeys] = useState<string[]>(() =>
|
||||
pinnedKeys(entries),
|
||||
);
|
||||
|
||||
const groups = buildGroups(entries);
|
||||
|
||||
const submit = (nextKeys: string[]) => {
|
||||
const selected = new Set(nextKeys);
|
||||
const diff = computeWatchlistDiff(
|
||||
entries.filter((entry) => entry.inWatchlist).map(toTarget),
|
||||
entries
|
||||
.filter((entry) => selected.has(watchlistKey(entry)))
|
||||
.map(toTarget),
|
||||
);
|
||||
|
||||
if (isEmptyWatchlistDiff(diff)) return;
|
||||
|
||||
if (exceedsWatchlistBulkLimit(diff)) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Too many changes at once",
|
||||
description: `A single update may reference at most ${MAX_WATCHLIST_BULK} frameworks. Apply the changes in smaller batches.`,
|
||||
});
|
||||
setSelectedKeys(pinnedKeys(entries));
|
||||
return;
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await bulkUpdateComplianceWatchlist(diff);
|
||||
|
||||
if (result.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: result.error,
|
||||
});
|
||||
setSelectedKeys(pinnedKeys(entries));
|
||||
return;
|
||||
}
|
||||
|
||||
toast({ title: "Watchlist updated", description: result.success });
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
setSelectedKeys(pinnedKeys(entries));
|
||||
} else {
|
||||
submit(selectedKeys);
|
||||
}
|
||||
setOpen(nextOpen);
|
||||
};
|
||||
|
||||
const pinnedCount = selectedKeys.length;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<label htmlFor={id} className="sr-only" id={labelId}>
|
||||
Compliance watchlist. Select the frameworks your organization wants to
|
||||
keep an eye on.
|
||||
</label>
|
||||
<MultiSelect
|
||||
values={selectedKeys}
|
||||
onValuesChange={setSelectedKeys}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<MultiSelectTrigger
|
||||
id={id}
|
||||
size="sm"
|
||||
disabled={isPending}
|
||||
aria-labelledby={labelId}
|
||||
>
|
||||
<span className="truncate">
|
||||
{pinnedCount > 0
|
||||
? `Watchlist · ${pinnedCount.toLocaleString()} pinned`
|
||||
: "Watchlist · none pinned"}
|
||||
</span>
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent
|
||||
width="wide"
|
||||
search={{
|
||||
placeholder: "Search frameworks...",
|
||||
emptyMessage: "No frameworks match your search.",
|
||||
}}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<MultiSelectGroup key={group.key} heading={group.label}>
|
||||
{group.entries.map((entry) => {
|
||||
const label = frameworkLabel(entry);
|
||||
|
||||
return (
|
||||
<MultiSelectItem
|
||||
key={watchlistKey(entry)}
|
||||
value={watchlistKey(entry)}
|
||||
badgeLabel={label}
|
||||
keywords={[entry.framework, entry.name, entry.complianceId]}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
</MultiSelectItem>
|
||||
);
|
||||
})}
|
||||
</MultiSelectGroup>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,149 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WATCHLIST_PIN_STATE } from "@/types/compliance-watchlist";
|
||||
|
||||
import { WatchlistToggle } from "./watchlist-toggle";
|
||||
|
||||
const {
|
||||
addComplianceToWatchlistMock,
|
||||
removeComplianceFromWatchlistMock,
|
||||
bulkUpdateComplianceWatchlistMock,
|
||||
toastMock,
|
||||
} = vi.hoisted(() => ({
|
||||
addComplianceToWatchlistMock: vi.fn(),
|
||||
removeComplianceFromWatchlistMock: vi.fn(),
|
||||
bulkUpdateComplianceWatchlistMock: vi.fn(),
|
||||
toastMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/compliance-watchlist", () => ({
|
||||
addComplianceToWatchlist: addComplianceToWatchlistMock,
|
||||
removeComplianceFromWatchlist: removeComplianceFromWatchlistMock,
|
||||
bulkUpdateComplianceWatchlist: bulkUpdateComplianceWatchlistMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/shadcn/toast/use-toast", () => ({
|
||||
useToast: () => ({ toast: toastMock }),
|
||||
}));
|
||||
|
||||
const TARGET = { complianceId: "cis_1.4_aws", providerType: "aws" };
|
||||
const ENTRY_ID = "3fa85f64-5717-4562-b3fc-2c963f66afa6";
|
||||
|
||||
beforeEach(() => {
|
||||
addComplianceToWatchlistMock.mockResolvedValue({ success: "ok" });
|
||||
removeComplianceFromWatchlistMock.mockResolvedValue({ success: "ok" });
|
||||
bulkUpdateComplianceWatchlistMock.mockResolvedValue({ success: "ok" });
|
||||
});
|
||||
|
||||
describe("WatchlistToggle", () => {
|
||||
it("adds an unpinned framework", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WatchlistToggle target={TARGET} state={WATCHLIST_PIN_STATE.UNPINNED} />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(addComplianceToWatchlistMock).toHaveBeenCalledWith(TARGET),
|
||||
);
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: "Success!" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a pinned framework by entry id", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WatchlistToggle
|
||||
target={TARGET}
|
||||
state={WATCHLIST_PIN_STATE.PINNED}
|
||||
entryId={ENTRY_ID}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(removeComplianceFromWatchlistMock).toHaveBeenCalledWith(ENTRY_ID),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to bulk removal when the entry id is unavailable", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<WatchlistToggle target={TARGET} state={WATCHLIST_PIN_STATE.PINNED} />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(bulkUpdateComplianceWatchlistMock).toHaveBeenCalledWith({
|
||||
add: [],
|
||||
remove: [TARGET],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes the optimistic state while the request is pending", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
let resolveAction: (value: { success: string }) => void = () => {};
|
||||
addComplianceToWatchlistMock.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAction = resolve;
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<WatchlistToggle target={TARGET} state={WATCHLIST_PIN_STATE.UNPINNED} />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Watchlist" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
),
|
||||
);
|
||||
resolveAction({ success: "ok" });
|
||||
});
|
||||
|
||||
it("rolls back and reports an API error", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
addComplianceToWatchlistMock.mockResolvedValue({ error: "Forbidden" });
|
||||
render(
|
||||
<WatchlistToggle target={TARGET} state={WATCHLIST_PIN_STATE.UNPINNED} />,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(screen.getByRole("button", { name: "Watchlist" }));
|
||||
|
||||
// Then
|
||||
await waitFor(() =>
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ variant: "destructive" }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Watchlist" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"false",
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { Pin } from "lucide-react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useOptimistic, useTransition } from "react";
|
||||
|
||||
import {
|
||||
addComplianceToWatchlist,
|
||||
bulkUpdateComplianceWatchlist,
|
||||
removeComplianceFromWatchlist,
|
||||
} from "@/actions/compliance-watchlist";
|
||||
import { Button } from "@/components/shadcn/button/button";
|
||||
import { useToast } from "@/components/shadcn/toast/use-toast";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
ComplianceWatchlistActionResult,
|
||||
ComplianceWatchlistTarget,
|
||||
WatchlistPinState,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import { WATCHLIST_PIN_STATE } from "@/types/compliance-watchlist";
|
||||
|
||||
interface WatchlistToggleProps {
|
||||
target: ComplianceWatchlistTarget;
|
||||
state: WatchlistPinState;
|
||||
entryId?: string | null;
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
[WATCHLIST_PIN_STATE.UNPINNED]: "Add to Watchlist",
|
||||
[WATCHLIST_PIN_STATE.PINNED]: "Remove From Watchlist",
|
||||
} as const satisfies Record<WatchlistPinState, string>;
|
||||
|
||||
export const WatchlistToggle = ({
|
||||
target,
|
||||
state,
|
||||
entryId,
|
||||
}: WatchlistToggleProps) => {
|
||||
const { toast } = useToast();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
// useOptimistic (not useState) so the override is scoped to the transition:
|
||||
// it reverts by itself when the action settles, whether the server accepted
|
||||
// the change or rejected it.
|
||||
const [optimisticState, setOptimisticState] = useOptimistic(state);
|
||||
|
||||
const isPinned = optimisticState === WATCHLIST_PIN_STATE.PINNED;
|
||||
const label = LABELS[optimisticState];
|
||||
|
||||
const mutate = (): Promise<ComplianceWatchlistActionResult> => {
|
||||
if (isPinned) {
|
||||
if (entryId) {
|
||||
return removeComplianceFromWatchlist(entryId);
|
||||
}
|
||||
return bulkUpdateComplianceWatchlist({ add: [], remove: [target] });
|
||||
}
|
||||
return addComplianceToWatchlist(target);
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
if (isPending) return;
|
||||
|
||||
startTransition(async () => {
|
||||
setOptimisticState(
|
||||
isPinned ? WATCHLIST_PIN_STATE.UNPINNED : WATCHLIST_PIN_STATE.PINNED,
|
||||
);
|
||||
const result = await mutate();
|
||||
|
||||
if (result.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Oops! Something went wrong",
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: "Success!",
|
||||
description: isPinned
|
||||
? "The framework was removed from the watchlist."
|
||||
: "The framework was added to the watchlist.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="bare"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
data-pin-state={optimisticState}
|
||||
aria-label="Watchlist"
|
||||
aria-pressed={isPinned}
|
||||
disabled={isPending}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Pin aria-hidden className={cn(isPinned && "fill-current")} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
+147
-51
@@ -520,6 +520,8 @@ vi.mock("../../muted", () => ({
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import { WATCHLIST_SCOPE } from "@/types/compliance-watchlist";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
@@ -545,12 +547,41 @@ afterEach(() => {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A watchlisted framework as the API reports it for a finding. Provider-scoped
|
||||
* by default, which is the case that navigates without a lookup. */
|
||||
const complianceFramework = (
|
||||
overrides: Partial<FindingComplianceFramework> = {},
|
||||
): FindingComplianceFramework => ({
|
||||
id: "aws:cis_1.4_aws",
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
scope: WATCHLIST_SCOPE.PROVIDER,
|
||||
framework: "CIS-1.4",
|
||||
name: "CIS",
|
||||
version: "1.4",
|
||||
inWatchlist: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const mockCheckMeta: CheckMeta = {
|
||||
checkId: "s3_check",
|
||||
checkTitle: "S3 Check",
|
||||
risk: "High",
|
||||
description: "S3 description",
|
||||
complianceFrameworks: ["CIS-1.4", "PCI-DSS"],
|
||||
complianceFrameworks: [
|
||||
complianceFramework({
|
||||
id: "aws:cis_1.4_aws",
|
||||
complianceId: "cis_1.4_aws",
|
||||
framework: "CIS-1.4",
|
||||
version: "1.4",
|
||||
}),
|
||||
complianceFramework({
|
||||
id: "aws:pci_dss_4.0_aws",
|
||||
complianceId: "pci_dss_4.0_aws",
|
||||
framework: "PCI-DSS",
|
||||
version: "4.0",
|
||||
}),
|
||||
],
|
||||
categories: ["security"],
|
||||
remediation: {
|
||||
recommendation: { text: "Fix it", url: "https://example.com" },
|
||||
@@ -1176,28 +1207,40 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("should resolve the clicked framework against the selected scan and navigate to compliance detail", async () => {
|
||||
it("should keep compliance logo canvases light in every navigation state", () => {
|
||||
// Given
|
||||
mockGetComplianceIcon.mockReturnValue("/compliance.svg");
|
||||
const props = {
|
||||
isLoading: false,
|
||||
isNavigating: false,
|
||||
checkMeta: mockCheckMeta,
|
||||
currentIndex: 0,
|
||||
totalResources: 1,
|
||||
currentFinding: mockFinding,
|
||||
otherFindings: [],
|
||||
onNavigatePrev: vi.fn(),
|
||||
onNavigateNext: vi.fn(),
|
||||
onMuteComplete: vi.fn(),
|
||||
};
|
||||
const { rerender } = render(<ResourceDetailDrawerContent {...props} />);
|
||||
|
||||
// When / Then - No scan: static logo
|
||||
const staticLogo = screen.getByRole("img", { name: "PCI-DSS 4.0" });
|
||||
expect(staticLogo.parentElement).toHaveClass("bg-slate-50");
|
||||
|
||||
// When / Then - Selected scan: navigable logo
|
||||
mockSearchParamsState.value = "filter[scan__in]=scan-selected";
|
||||
rerender(<ResourceDetailDrawerContent {...props} />);
|
||||
const navigableLogo = screen.getByRole("img", { name: "PCI-DSS 4.0" });
|
||||
expect(navigableLogo.parentElement).toHaveClass("bg-slate-50");
|
||||
});
|
||||
|
||||
it("should navigate straight to the framework the API identified, without querying the scan's overview", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
mockSearchParamsState.value =
|
||||
"filter[scan__in]=scan-selected&filter[region__in]=eu-west-1";
|
||||
mockGetCompliancesOverview.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "compliance-1",
|
||||
type: "compliance-overviews",
|
||||
attributes: {
|
||||
framework: "PCI-DSS",
|
||||
version: "4.0",
|
||||
requirements_passed: 10,
|
||||
requirements_failed: 2,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
@@ -1217,16 +1260,14 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open PCI-DSS compliance details",
|
||||
name: "Open PCI-DSS 4.0 compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-selected",
|
||||
});
|
||||
expect(mockGetCompliancesOverview).not.toHaveBeenCalled();
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/PCI-DSS?complianceId=compliance-1&version=4.0&scanId=scan-selected&filter%5Bregion__in%5D=eu-west-1",
|
||||
"/compliance/PCI-DSS?complianceId=pci_dss_4.0_aws&version=4.0&scanId=scan-selected&filter%5Bregion__in%5D=eu-west-1",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
@@ -1236,22 +1277,6 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
mockGetCompliancesOverview.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "compliance-2",
|
||||
type: "compliance-overviews",
|
||||
attributes: {
|
||||
framework: "PCI-DSS",
|
||||
version: "4.0",
|
||||
requirements_passed: 10,
|
||||
requirements_failed: 2,
|
||||
requirements_manual: 0,
|
||||
total_requirements: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const findingWithScan = {
|
||||
...mockFinding,
|
||||
scan: {
|
||||
@@ -1287,22 +1312,19 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open PCI-DSS compliance details",
|
||||
name: "Open PCI-DSS 4.0 compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-from-finding",
|
||||
});
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/PCI-DSS?complianceId=compliance-2&version=4.0&scanId=scan-from-finding",
|
||||
"/compliance/PCI-DSS?complianceId=pci_dss_4.0_aws&version=4.0&scanId=scan-from-finding",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("should navigate when the finding framework is a short alias of the compliance overview framework", async () => {
|
||||
it("should navigate a universal framework by its own id too, without a lookup", async () => {
|
||||
// Given
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
@@ -1348,7 +1370,16 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
isNavigating={false}
|
||||
checkMeta={{
|
||||
...mockCheckMeta,
|
||||
complianceFrameworks: ["KISA"],
|
||||
complianceFrameworks: [
|
||||
complianceFramework({
|
||||
id: "*:kisa_isms_p",
|
||||
complianceId: "kisa_isms_p",
|
||||
providerType: "*",
|
||||
scope: WATCHLIST_SCOPE.UNIVERSAL,
|
||||
framework: "KISA",
|
||||
version: "1.0",
|
||||
}),
|
||||
],
|
||||
}}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
@@ -1363,16 +1394,81 @@ describe("ResourceDetailDrawerContent — compliance navigation", () => {
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open KISA compliance details",
|
||||
name: "Open KISA 1.0 compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockGetCompliancesOverview).toHaveBeenCalledWith({
|
||||
scanId: "scan-from-finding",
|
||||
});
|
||||
// A universal framework's id is the SDK's file stem, which the per-scan
|
||||
// detail page keys on just like any other, so there is no lookup and no
|
||||
// `window.open` after an await for a pop-up blocker to swallow.
|
||||
expect(mockGetCompliancesOverview).not.toHaveBeenCalled();
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/KISA-ISMS-P?complianceId=compliance-kisa&version=1.0&scanId=scan-from-finding",
|
||||
"/compliance/KISA?complianceId=kisa_isms_p&version=1.0&scanId=scan-from-finding",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("should fall back to the framework's name for the URL, as the label does", async () => {
|
||||
// Given: a framework the SDK exposes no metadata for, so `framework` is
|
||||
// empty. It is a path segment, so without the same fallback the label uses
|
||||
// the destination collapses to `/compliance/`.
|
||||
const user = userEvent.setup();
|
||||
vi.stubGlobal("open", mockWindowOpen);
|
||||
const findingWithScan = {
|
||||
...mockFinding,
|
||||
scan: {
|
||||
id: "scan-from-finding",
|
||||
name: "Nightly scan",
|
||||
trigger: "manual",
|
||||
state: "completed",
|
||||
uniqueResourceCount: 25,
|
||||
progress: 100,
|
||||
duration: 300,
|
||||
startedAt: "2026-03-30T10:00:00Z",
|
||||
completedAt: "2026-03-30T10:05:00Z",
|
||||
insertedAt: "2026-03-30T09:59:00Z",
|
||||
scheduledAt: null,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<ResourceDetailDrawerContent
|
||||
isLoading={false}
|
||||
isNavigating={false}
|
||||
checkMeta={{
|
||||
...mockCheckMeta,
|
||||
complianceFrameworks: [
|
||||
complianceFramework({
|
||||
id: "aws:mitre_attack_aws",
|
||||
complianceId: "mitre_attack_aws",
|
||||
framework: "",
|
||||
name: "MITRE-ATTACK",
|
||||
version: "1.0",
|
||||
}),
|
||||
],
|
||||
}}
|
||||
currentIndex={0}
|
||||
totalResources={1}
|
||||
currentFinding={findingWithScan}
|
||||
otherFindings={[]}
|
||||
onNavigatePrev={vi.fn()}
|
||||
onNavigateNext={vi.fn()}
|
||||
onMuteComplete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// When
|
||||
await user.click(
|
||||
screen.getByRole("button", {
|
||||
name: "Open MITRE-ATTACK 1.0 compliance details",
|
||||
}),
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(mockWindowOpen).toHaveBeenCalledWith(
|
||||
"/compliance/MITRE-ATTACK?complianceId=mitre_attack_aws&version=1.0&scanId=scan-from-finding",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
|
||||
+87
-225
@@ -15,7 +15,6 @@ import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getCompliancesOverview } from "@/actions/compliances";
|
||||
import {
|
||||
loadLatestFindingTriageNote,
|
||||
type ResourceDrawerFinding,
|
||||
@@ -80,7 +79,7 @@ import { getRegionFlag } from "@/lib/region-flags";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { getRecommendationLinkLabel } from "@/lib/vulnerability-references";
|
||||
import { SIDE_PANEL_TAB, useSidePanelStore } from "@/store/side-panel";
|
||||
import type { ComplianceOverviewData } from "@/types/compliance";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import type { FindingResourceRow } from "@/types/findings-table";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
import { JIRA_DISPATCH_TARGET } from "@/types/integrations";
|
||||
@@ -168,94 +167,66 @@ function renderRemediationCodeBlock({
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeComplianceFrameworkName(framework: string): string {
|
||||
return framework
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
/** Frameworks are not uniquely named — eight AWS ones are called "CIS" — so the
|
||||
* version is part of the label, not decoration. */
|
||||
function complianceFrameworkLabel(
|
||||
framework: FindingComplianceFramework,
|
||||
): string {
|
||||
const name = framework.framework || framework.name;
|
||||
return framework.version ? `${name} ${framework.version}` : name;
|
||||
}
|
||||
|
||||
function stripComplianceVersionSuffix(framework: string): string {
|
||||
return framework.replace(/-\d+(?:\.\d+)*$/g, "");
|
||||
interface ComplianceFrameworkChipProps {
|
||||
framework: FindingComplianceFramework;
|
||||
isNavigable: boolean;
|
||||
onOpen: (framework: FindingComplianceFramework) => void;
|
||||
}
|
||||
|
||||
function canonicalComplianceKey(framework: string): string {
|
||||
return stripComplianceVersionSuffix(
|
||||
normalizeComplianceFrameworkName(framework),
|
||||
)
|
||||
.replace(/[^a-z0-9]+/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function complianceTokens(framework: string): string[] {
|
||||
return stripComplianceVersionSuffix(
|
||||
normalizeComplianceFrameworkName(framework),
|
||||
)
|
||||
.split("-")
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean)
|
||||
.filter((token) => !/^\d+(?:\.\d+)*$/.test(token));
|
||||
}
|
||||
|
||||
function complianceMatchScore(
|
||||
sourceFramework: string,
|
||||
targetFramework: string,
|
||||
): number {
|
||||
const normalizedSource = normalizeComplianceFrameworkName(sourceFramework);
|
||||
const normalizedTarget = normalizeComplianceFrameworkName(targetFramework);
|
||||
|
||||
if (normalizedSource === normalizedTarget) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
const canonicalSource = canonicalComplianceKey(sourceFramework);
|
||||
const canonicalTarget = canonicalComplianceKey(targetFramework);
|
||||
|
||||
if (canonicalSource === canonicalTarget) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (canonicalSource && canonicalTarget) {
|
||||
const sourceTokens = canonicalSource.split("-");
|
||||
const targetTokens = canonicalTarget.split("-");
|
||||
if (
|
||||
sourceTokens.length !== targetTokens.length &&
|
||||
(sourceTokens.every((t) => targetTokens.includes(t)) ||
|
||||
targetTokens.every((t) => sourceTokens.includes(t)))
|
||||
) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceTokens = complianceTokens(sourceFramework);
|
||||
const targetTokens = complianceTokens(targetFramework);
|
||||
if (!sourceTokens.length || !targetTokens.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const sourceMatchesTarget = sourceTokens.every((token) =>
|
||||
targetTokens.includes(token),
|
||||
);
|
||||
const targetMatchesSource = targetTokens.every((token) =>
|
||||
sourceTokens.includes(token),
|
||||
function ComplianceFrameworkChip({
|
||||
framework,
|
||||
isNavigable,
|
||||
onOpen,
|
||||
}: ComplianceFrameworkChipProps) {
|
||||
const icon = getComplianceIcon(framework.complianceId);
|
||||
const label = complianceFrameworkLabel(framework);
|
||||
const content = icon ? (
|
||||
<span className="border-border-neutral-tertiary flex size-7 shrink-0 items-center justify-center rounded-md border bg-slate-50">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={label}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
);
|
||||
|
||||
if (sourceMatchesTarget || targetMatchesSource) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (
|
||||
sourceTokens.some((token) => targetTokens.includes(token)) &&
|
||||
canonicalSource &&
|
||||
canonicalTarget &&
|
||||
(canonicalTarget.includes(canonicalSource) ||
|
||||
canonicalSource.includes(canonicalTarget))
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant={icon ? "bare" : "outline"}
|
||||
size={icon ? "icon-xs" : "sm"}
|
||||
aria-label={`Open ${label} compliance details`}
|
||||
onClick={() => onOpen(framework)}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
) : icon ? (
|
||||
content
|
||||
) : (
|
||||
<Badge variant="tag" size="sm" aria-label={label}>
|
||||
{content}
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function parseSelectedScanIds(scanFilterValue: string | null): string[] {
|
||||
@@ -269,37 +240,6 @@ function parseSelectedScanIds(scanFilterValue: string | null): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function resolveComplianceMatch(
|
||||
compliances: ComplianceOverviewData[] | undefined,
|
||||
framework: string,
|
||||
): {
|
||||
complianceId: string;
|
||||
framework: string;
|
||||
version: string;
|
||||
} | null {
|
||||
if (!compliances?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = compliances
|
||||
.map((compliance) => ({
|
||||
compliance,
|
||||
score: complianceMatchScore(framework, compliance.attributes.framework),
|
||||
}))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)[0]?.compliance;
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
complianceId: match.id,
|
||||
framework: match.attributes.framework,
|
||||
version: match.attributes.version,
|
||||
};
|
||||
}
|
||||
|
||||
function buildComplianceDetailHref({
|
||||
complianceId,
|
||||
framework,
|
||||
@@ -368,9 +308,6 @@ export function ResourceDetailDrawerContent({
|
||||
const openSidePanel = useSidePanelStore((state) => state.openPanel);
|
||||
const lighthouseContext = useLighthouseCurrentContext();
|
||||
const [isMuteModalOpen, setIsMuteModalOpen] = useState(false);
|
||||
const [resolvingFramework, setResolvingFramework] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [optimisticallyMutedIds, setOptimisticallyMutedIds] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
@@ -499,42 +436,34 @@ export function ResourceDetailDrawerContent({
|
||||
requestPanelChatMessage("Analyze this finding", lighthouseContext.context);
|
||||
};
|
||||
|
||||
const handleOpenCompliance = async (framework: string) => {
|
||||
if (!complianceScanId || resolvingFramework) {
|
||||
/**
|
||||
* The API hands us the framework's `complianceId`, which is the same string
|
||||
* the per-scan detail page keys on — universal frameworks included, since
|
||||
* their id is the SDK's file stem and the provider template carries it
|
||||
* verbatim. So the destination is known up front: no lookup against the
|
||||
* scan's overview, no matching by display name, and `window.open` stays
|
||||
* inside the click gesture instead of running after an `await`, where a
|
||||
* pop-up blocker would eat it.
|
||||
*/
|
||||
const handleOpenCompliance = (framework: FindingComplianceFramework) => {
|
||||
if (!complianceScanId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvingFramework(framework);
|
||||
|
||||
try {
|
||||
const compliancesOverview = await getCompliancesOverview({
|
||||
window.open(
|
||||
buildComplianceDetailHref({
|
||||
complianceId: framework.complianceId,
|
||||
// Same fallback the chip's label uses: `framework` is empty for one the
|
||||
// SDK exposes no metadata for, and it is a path segment here, so
|
||||
// without it the destination collapses to `/compliance/`.
|
||||
framework: framework.framework || framework.name,
|
||||
version: framework.version,
|
||||
scanId: complianceScanId,
|
||||
});
|
||||
const complianceMatch = resolveComplianceMatch(
|
||||
compliancesOverview?.data,
|
||||
framework,
|
||||
);
|
||||
|
||||
if (!complianceMatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(
|
||||
buildComplianceDetailHref({
|
||||
complianceId: complianceMatch.complianceId,
|
||||
framework: complianceMatch.framework,
|
||||
version: complianceMatch.version,
|
||||
scanId: complianceScanId,
|
||||
regionFilter,
|
||||
}),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error resolving compliance detail:", error);
|
||||
} finally {
|
||||
setResolvingFramework(null);
|
||||
}
|
||||
regionFilter,
|
||||
}),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -584,81 +513,14 @@ export function ResourceDetailDrawerContent({
|
||||
Compliance Frameworks:
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{checkMeta.complianceFrameworks.map((framework) => {
|
||||
const icon = getComplianceIcon(framework);
|
||||
const isNavigable = Boolean(complianceScanId);
|
||||
const isResolving = resolvingFramework === framework;
|
||||
|
||||
return icon ? (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${framework} compliance details`}
|
||||
onClick={() =>
|
||||
void handleOpenCompliance(framework)
|
||||
}
|
||||
disabled={Boolean(resolvingFramework)}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5 transition-shadow hover:shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
<Image
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
{isResolving && (
|
||||
<span className="sr-only">
|
||||
Opening compliance
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-md border border-gray-300 bg-white p-0.5">
|
||||
<Image
|
||||
src={icon}
|
||||
alt={framework}
|
||||
width={20}
|
||||
height={20}
|
||||
className="size-5 object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{framework}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip key={framework}>
|
||||
<TooltipTrigger asChild>
|
||||
{isNavigable ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Open ${framework} compliance details`}
|
||||
onClick={() =>
|
||||
void handleOpenCompliance(framework)
|
||||
}
|
||||
disabled={Boolean(resolvingFramework)}
|
||||
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 transition-shadow hover:shadow-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-wait disabled:opacity-70"
|
||||
>
|
||||
{framework}
|
||||
{isResolving && (
|
||||
<span className="sr-only">
|
||||
Opening compliance
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<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">
|
||||
{framework}
|
||||
</span>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{framework}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
{checkMeta.complianceFrameworks.map((framework) => (
|
||||
<ComplianceFrameworkChip
|
||||
key={framework.id}
|
||||
framework={framework}
|
||||
isNavigable={Boolean(complianceScanId)}
|
||||
onOpen={handleOpenCompliance}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
+116
@@ -9,28 +9,46 @@ const {
|
||||
getFindingByIdMock,
|
||||
getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponseMock,
|
||||
getFindingComplianceFrameworksMock,
|
||||
isCloudMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getFindingByIdMock: vi.fn(),
|
||||
getLatestFindingsByResourceUidMock: vi.fn(),
|
||||
adaptFindingsByResourceResponseMock: vi.fn(),
|
||||
// Shaped like the action's real result, not a bare array: the hook
|
||||
// destructures it, and a mock that lies about that hides the strip never
|
||||
// being populated.
|
||||
getFindingComplianceFrameworksMock: vi.fn(async () => ({
|
||||
frameworks: [] as FindingComplianceFramework[],
|
||||
unavailable: false,
|
||||
})),
|
||||
isCloudMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/findings", () => ({
|
||||
getFindingById: getFindingByIdMock,
|
||||
getLatestFindingsByResourceUid: getLatestFindingsByResourceUidMock,
|
||||
adaptFindingsByResourceResponse: adaptFindingsByResourceResponseMock,
|
||||
getFindingComplianceFrameworks: getFindingComplianceFrameworksMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// The setup file clears UI_CLOUD_ENABLED, so without this every test would run
|
||||
// the OSS branch and the Cloud one would go uncovered.
|
||||
vi.mock("@/lib/shared/env", () => ({
|
||||
isCloud: isCloudMock,
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import after mocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import type { ResourceDrawerFinding } from "@/actions/findings";
|
||||
import type { FindingResourceRow } from "@/types";
|
||||
import type { FindingComplianceFramework } from "@/types/compliance-watchlist";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS,
|
||||
type FindingTriageSummary,
|
||||
@@ -137,6 +155,11 @@ describe("useResourceDetailDrawer — unmount cleanup", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
getLatestFindingsByResourceUidMock.mockResolvedValue({ data: [] });
|
||||
getFindingComplianceFrameworksMock.mockResolvedValue({
|
||||
frameworks: [],
|
||||
unavailable: false,
|
||||
});
|
||||
isCloudMock.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should abort the in-flight fetch controller when the hook unmounts", async () => {
|
||||
@@ -911,4 +934,97 @@ describe("useResourceDetailDrawer — other findings filtering", () => {
|
||||
resourceFetchCount,
|
||||
);
|
||||
});
|
||||
|
||||
describe("compliance frameworks strip", () => {
|
||||
const openWithFinding = async (
|
||||
finding: Partial<ResourceDrawerFinding> = {},
|
||||
) => {
|
||||
getFindingByIdMock.mockResolvedValue({ data: [] });
|
||||
adaptFindingsByResourceResponseMock.mockReturnValue([
|
||||
makeDrawerFinding(finding),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useResourceDetailDrawer({ resources: [makeResource()] }),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openDrawer(0);
|
||||
await Promise.resolve();
|
||||
});
|
||||
// The strip is fetched after the panel has its data, so it lands a tick
|
||||
// later than everything else the drawer renders.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
it("fills the strip from the API in Cloud", async () => {
|
||||
getFindingComplianceFrameworksMock.mockResolvedValue({
|
||||
frameworks: [
|
||||
{
|
||||
id: "*:dora_2022_2554",
|
||||
complianceId: "dora_2022_2554",
|
||||
providerType: "*",
|
||||
scope: "universal",
|
||||
framework: "DORA",
|
||||
name: "DORA",
|
||||
version: "",
|
||||
inWatchlist: true,
|
||||
},
|
||||
],
|
||||
unavailable: false,
|
||||
});
|
||||
|
||||
const result = await openWithFinding();
|
||||
|
||||
expect(getFindingComplianceFrameworksMock).toHaveBeenCalledWith(
|
||||
"finding-1",
|
||||
{ inWatchlist: true },
|
||||
);
|
||||
expect(result.current.checkMeta?.complianceFrameworks).toHaveLength(1);
|
||||
expect(
|
||||
result.current.checkMeta?.complianceFrameworks[0].complianceId,
|
||||
).toBe("dora_2022_2554");
|
||||
});
|
||||
|
||||
it("falls back to the check's own framework names when the endpoint cannot answer", async () => {
|
||||
// Cloud, but the request failed. `unavailable` is what tells this apart
|
||||
// from a genuinely empty watchlist, which must leave the strip empty.
|
||||
getFindingComplianceFrameworksMock.mockResolvedValue({
|
||||
frameworks: [],
|
||||
unavailable: true,
|
||||
});
|
||||
|
||||
const result = await openWithFinding({
|
||||
complianceFrameworks: ["CIS", "SOC2"],
|
||||
});
|
||||
|
||||
expect(getFindingComplianceFrameworksMock).toHaveBeenCalled();
|
||||
expect(
|
||||
result.current.checkMeta?.complianceFrameworks.map(
|
||||
(entry) => entry.framework,
|
||||
),
|
||||
).toEqual(["CIS", "SOC2"]);
|
||||
});
|
||||
|
||||
it("falls back to the check's own framework names off Cloud", async () => {
|
||||
// No request is made at all there — the endpoint does not exist — but the
|
||||
// strip must keep showing what the finding already carries.
|
||||
isCloudMock.mockReturnValue(false);
|
||||
|
||||
const result = await openWithFinding({
|
||||
complianceFrameworks: ["CIS", "SOC2"],
|
||||
});
|
||||
|
||||
expect(getFindingComplianceFrameworksMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
result.current.checkMeta?.complianceFrameworks.map(
|
||||
(entry) => entry.framework,
|
||||
),
|
||||
).toEqual(["CIS", "SOC2"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
adaptFindingsByResourceResponse,
|
||||
getFindingById,
|
||||
getFindingComplianceFrameworks,
|
||||
getLatestFindingsByResourceUid,
|
||||
type ResourceDrawerFinding,
|
||||
} from "@/actions/findings";
|
||||
@@ -13,7 +14,12 @@ import {
|
||||
getOptimisticTriageMutedReason,
|
||||
shouldMarkFindingMutedForTriageUpdate,
|
||||
} from "@/lib/finding-triage";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
import { FindingResourceRow } from "@/types";
|
||||
import {
|
||||
type FindingComplianceFramework,
|
||||
WATCHLIST_SCOPE,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import type { UpdateFindingTriageInput } from "@/types/findings-triage";
|
||||
|
||||
// Keep fast carousel navigations in a loading state for one short beat so
|
||||
@@ -29,19 +35,48 @@ export interface CheckMeta {
|
||||
checkTitle: string;
|
||||
risk: string;
|
||||
description: string;
|
||||
complianceFrameworks: string[];
|
||||
/**
|
||||
* Only the frameworks the organization pinned, resolved by the API rather
|
||||
* than derived from the check's metadata: the watchlist is keyed by
|
||||
* `compliance_id`, and the display names the metadata carries cannot be
|
||||
* matched against it without guessing.
|
||||
*/
|
||||
complianceFrameworks: FindingComplianceFramework[];
|
||||
categories: string[];
|
||||
remediation: ResourceDrawerFinding["remediation"];
|
||||
additionalUrls: string[];
|
||||
}
|
||||
|
||||
function extractCheckMeta(finding: ResourceDrawerFinding): CheckMeta {
|
||||
/**
|
||||
* A framework name the check's own metadata carries, dressed as an API entry.
|
||||
*
|
||||
* Only for deployments without the watchlist endpoint. There is no
|
||||
* `compliance_id` behind these names, so `complianceId` holds the display name:
|
||||
* enough for the logo, which resolves by substring, and for the by-name lookup
|
||||
* the universal branch already does. `inWatchlist` is false because on such a
|
||||
* deployment there is no watchlist to be in.
|
||||
*/
|
||||
const fallbackFramework = (framework: string): FindingComplianceFramework => ({
|
||||
id: `fallback:${framework}`,
|
||||
complianceId: framework,
|
||||
providerType: "",
|
||||
scope: WATCHLIST_SCOPE.PROVIDER,
|
||||
framework,
|
||||
name: framework,
|
||||
version: "",
|
||||
inWatchlist: false,
|
||||
});
|
||||
|
||||
function extractCheckMeta(
|
||||
finding: ResourceDrawerFinding,
|
||||
complianceFrameworks: FindingComplianceFramework[],
|
||||
): CheckMeta {
|
||||
return {
|
||||
checkId: finding.checkId,
|
||||
checkTitle: finding.checkTitle,
|
||||
risk: finding.risk,
|
||||
description: finding.description,
|
||||
complianceFrameworks: finding.complianceFrameworks,
|
||||
complianceFrameworks,
|
||||
categories: finding.categories,
|
||||
remediation: finding.remediation,
|
||||
additionalUrls: finding.additionalUrls,
|
||||
@@ -104,10 +139,16 @@ export function useResourceDetailDrawer({
|
||||
const currentFindingCacheRef = useRef<
|
||||
Map<string, ResourceDrawerFinding | null>
|
||||
>(new Map());
|
||||
const complianceFrameworksCacheRef = useRef<
|
||||
Map<string, FindingComplianceFramework[]>
|
||||
>(new Map());
|
||||
const otherFindingsCacheRef = useRef<Map<string, ResourceDrawerFinding[]>>(
|
||||
new Map(),
|
||||
);
|
||||
const checkMetaRef = useRef<CheckMeta | null>(null);
|
||||
// State, not a ref: the compliance frameworks land after the panel has
|
||||
// already painted, so the strip has to re-render on its own rather than
|
||||
// depend on some other setState happening to fire in the same tick.
|
||||
const [checkMeta, setCheckMeta] = useState<CheckMeta | null>(null);
|
||||
const fetchControllerRef = useRef<AbortController | null>(null);
|
||||
const navigationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
@@ -221,6 +262,34 @@ export function useResourceDetailDrawer({
|
||||
return adapted;
|
||||
};
|
||||
|
||||
const fetchComplianceFrameworks = async (
|
||||
finding: ResourceDrawerFinding | null,
|
||||
) => {
|
||||
const cached = complianceFrameworksCacheRef.current.get(findingId);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// The whole strip is a Cloud feature; off Cloud there is nothing to ask
|
||||
// for, and the server action would still cost a round trip on the single
|
||||
// queue every other action in this drawer waits behind.
|
||||
const { frameworks, unavailable } = isCloud()
|
||||
? await getFindingComplianceFrameworks(findingId, { inWatchlist: true })
|
||||
: { frameworks: [], unavailable: true };
|
||||
|
||||
// The watchlist endpoint is Cloud-only. Where it does not exist, keep
|
||||
// showing what the check's own metadata already carries rather than
|
||||
// silently dropping the strip for every finding.
|
||||
const resolved =
|
||||
unavailable && finding
|
||||
? finding.complianceFrameworks.map(fallbackFramework)
|
||||
: frameworks;
|
||||
|
||||
complianceFrameworksCacheRef.current.set(findingId, resolved);
|
||||
|
||||
return resolved;
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [nextCurrentFinding, nextOtherFindings] = await Promise.all([
|
||||
@@ -231,9 +300,16 @@ export function useResourceDetailDrawer({
|
||||
// Discard stale response if a newer request was started
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
checkMetaRef.current = nextCurrentFinding
|
||||
? extractCheckMeta(nextCurrentFinding)
|
||||
: null;
|
||||
setCheckMeta(
|
||||
nextCurrentFinding
|
||||
? extractCheckMeta(
|
||||
nextCurrentFinding,
|
||||
// Already resolved when navigating back to a visited finding, so
|
||||
// the strip does not blink empty on the way.
|
||||
complianceFrameworksCacheRef.current.get(findingId) ?? [],
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
||||
setCurrentFinding(nextCurrentFinding);
|
||||
// The API already filters to status=FAIL (see getLatestFindingsByResourceUid).
|
||||
@@ -243,7 +319,7 @@ export function useResourceDetailDrawer({
|
||||
);
|
||||
} catch (_error) {
|
||||
if (!controller.signal.aborted) {
|
||||
checkMetaRef.current = null;
|
||||
setCheckMeta(null);
|
||||
setCurrentFinding(null);
|
||||
setOtherFindings([]);
|
||||
}
|
||||
@@ -252,6 +328,25 @@ export function useResourceDetailDrawer({
|
||||
finishNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
// Deliberately after the panel has its data, and deliberately not inside
|
||||
// the `Promise.all` above. Server actions dispatched from a client
|
||||
// component share one queue and run strictly one at a time, so bundling
|
||||
// this one added a whole round-trip to opening any finding. It is
|
||||
// supporting detail: it must never delay the panel, and its failure must
|
||||
// never empty it — hence its own `catch`, outside the block that nulls
|
||||
// everything.
|
||||
try {
|
||||
const frameworks = await fetchComplianceFrameworks(
|
||||
currentFindingCacheRef.current.get(findingId) ?? null,
|
||||
);
|
||||
if (controller.signal.aborted) return;
|
||||
setCheckMeta((current) =>
|
||||
current ? { ...current, complianceFrameworks: frameworks } : current,
|
||||
);
|
||||
} catch (_error) {
|
||||
// Leaves the strip empty; the panel stays as it is.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -289,6 +384,7 @@ export function useResourceDetailDrawer({
|
||||
const resource = resources[currentIndex];
|
||||
if (!resource) return;
|
||||
currentFindingCacheRef.current.delete(resource.findingId);
|
||||
complianceFrameworksCacheRef.current.delete(resource.findingId);
|
||||
otherFindingsCacheRef.current.delete(resource.resourceUid);
|
||||
startNavigation();
|
||||
resetCurrentResourceState();
|
||||
@@ -388,7 +484,7 @@ export function useResourceDetailDrawer({
|
||||
isOpen,
|
||||
isLoading,
|
||||
isNavigating,
|
||||
checkMeta: checkMetaRef.current,
|
||||
checkMeta,
|
||||
currentIndex,
|
||||
totalResources: totalResourceCount ?? resources.length,
|
||||
currentResource: currentResource ?? null,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { useShowOnlyWatchlist } from "@/hooks/use-show-only-watchlist";
|
||||
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
|
||||
|
||||
describe("useShowOnlyWatchlist", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false });
|
||||
});
|
||||
|
||||
it("reads the stored value only after mount, so the first render matches the server", async () => {
|
||||
// The compliance surfaces are server-rendered with the filter off, so a
|
||||
// stored `true` reaching the first client render would be a hydration
|
||||
// mismatch.
|
||||
localStorage.setItem(
|
||||
"compliance-watchlist-view",
|
||||
JSON.stringify({ state: { showOnlyWatchlist: true }, version: 1 }),
|
||||
);
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: true });
|
||||
|
||||
// Every render is recorded: the value only settles after mount, and
|
||||
// asserting on the final one would pass even without the deferral.
|
||||
const rendered: boolean[] = [];
|
||||
renderHook(() => {
|
||||
rendered.push(useShowOnlyWatchlist());
|
||||
});
|
||||
|
||||
expect(rendered[0]).toBe(false);
|
||||
await waitFor(() => expect(rendered.at(-1)).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
|
||||
|
||||
import { useStore } from "./use-store";
|
||||
|
||||
/**
|
||||
* The compliance watchlist filter, read the only way it is safe to read it.
|
||||
*
|
||||
* The three compliance surfaces are server-rendered with the filter off, while
|
||||
* `persist` reads localStorage as the store is created — so a stored `true`
|
||||
* would make the first client render disagree with the server's HTML. `useStore`
|
||||
* defers the persisted value to after hydration; until then the flag reads as
|
||||
* off, which is the same thing the server sent.
|
||||
*/
|
||||
export const useShowOnlyWatchlist = (): boolean =>
|
||||
useStore(
|
||||
useComplianceWatchlistViewStore,
|
||||
(state) => state.showOnlyWatchlist,
|
||||
) ?? false;
|
||||
@@ -1,17 +1,8 @@
|
||||
import { COMPLIANCE_TAB } from "@/types/compliance";
|
||||
|
||||
/**
|
||||
* Builds a `/compliance` URL pinned to the Single Scan tab.
|
||||
*
|
||||
* Multiple Scans is the default landing tab and owns the bare `/compliance`
|
||||
* route, so every link that targets one concrete scan has to pin Single Scan
|
||||
* explicitly or it lands on the aggregated view instead.
|
||||
*/
|
||||
export function buildPerScanComplianceHref(
|
||||
params?: Record<string, string>,
|
||||
): string {
|
||||
// `tab` is owned by this helper: a caller-supplied one would silently defeat
|
||||
// the pin the function name promises, so it is dropped rather than merged.
|
||||
const extras = new URLSearchParams(params);
|
||||
extras.delete("tab");
|
||||
|
||||
@@ -20,3 +11,7 @@ export function buildPerScanComplianceHref(
|
||||
|
||||
return `/compliance?${search.toString()}`;
|
||||
}
|
||||
|
||||
export function buildMultipleScansComplianceHref(): string {
|
||||
return "/compliance";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export const formatComplianceFrameworkTitle = (
|
||||
title: string,
|
||||
version?: string,
|
||||
): string => `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`;
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { makeComplianceCatalogEntry } from "@/test-utils/compliance-watchlist";
|
||||
import { UNIVERSAL_PROVIDER_TYPE } from "@/types/compliance-watchlist";
|
||||
|
||||
import {
|
||||
buildWatchlistIndex,
|
||||
computeWatchlistDiff,
|
||||
exceedsWatchlistBulkLimit,
|
||||
formatWatchlistBulkSummary,
|
||||
isFrameworkPinned,
|
||||
MAX_WATCHLIST_BULK,
|
||||
resolveWatchlistEntryId,
|
||||
resolveWatchlistTarget,
|
||||
} from "./watchlist";
|
||||
|
||||
describe("watchlist catalog lookup", () => {
|
||||
const index = buildWatchlistIndex([
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
inWatchlist: true,
|
||||
watchlistEntryId: "entry-aws",
|
||||
}),
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "gdpr_aws",
|
||||
providerType: "aws",
|
||||
}),
|
||||
]);
|
||||
|
||||
it.each([
|
||||
["cis_1.4_aws", true],
|
||||
["gdpr_aws", false],
|
||||
["unknown", false],
|
||||
])("resolves %s pinned state", (complianceId, expected) => {
|
||||
expect(
|
||||
isFrameworkPinned(index, { complianceId, providerType: "aws" }),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns the entry id only for catalog rows that have one", () => {
|
||||
expect(
|
||||
resolveWatchlistEntryId(index, {
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
}),
|
||||
).toBe("entry-aws");
|
||||
expect(
|
||||
resolveWatchlistEntryId(index, {
|
||||
complianceId: "gdpr_aws",
|
||||
providerType: "aws",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("universal framework lookup", () => {
|
||||
const index = buildWatchlistIndex([
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "csa_ccm_4.0",
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
inWatchlist: true,
|
||||
watchlistEntryId: "entry-universal",
|
||||
}),
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
}),
|
||||
]);
|
||||
|
||||
it("resolves concrete and legacy ids onto the universal row", () => {
|
||||
expect(
|
||||
isFrameworkPinned(index, {
|
||||
complianceId: "csa_ccm_4.0",
|
||||
providerType: "aws",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveWatchlistEntryId(index, {
|
||||
complianceId: "csa_ccm_4.0_aws",
|
||||
providerType: "aws",
|
||||
}),
|
||||
).toBe("entry-universal");
|
||||
});
|
||||
|
||||
it("does not peel a provider-scoped framework suffix", () => {
|
||||
expect(
|
||||
resolveWatchlistEntryId(index, {
|
||||
complianceId: "cis_1.4_aws",
|
||||
providerType: "aws",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers an exact provider row over a colliding universal row", () => {
|
||||
const collision = buildWatchlistIndex([
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "shared_id",
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
inWatchlist: true,
|
||||
}),
|
||||
makeComplianceCatalogEntry({
|
||||
complianceId: "shared_id",
|
||||
providerType: "aws",
|
||||
}),
|
||||
]);
|
||||
const target = { complianceId: "shared_id", providerType: "aws" };
|
||||
|
||||
expect(isFrameworkPinned(collision, target)).toBe(false);
|
||||
expect(resolveWatchlistTarget(collision, target)).toEqual(target);
|
||||
});
|
||||
|
||||
it("normalizes writes to the universal target", () => {
|
||||
expect(
|
||||
resolveWatchlistTarget(index, {
|
||||
complianceId: "csa_ccm_4.0",
|
||||
providerType: "aws",
|
||||
}),
|
||||
).toEqual({
|
||||
complianceId: "csa_ccm_4.0",
|
||||
providerType: UNIVERSAL_PROVIDER_TYPE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeWatchlistDiff", () => {
|
||||
const aws = { complianceId: "cis_1.4_aws", providerType: "aws" };
|
||||
const azure = { complianceId: "dora_2022_2554", providerType: "azure" };
|
||||
const gdpr = { complianceId: "gdpr_aws", providerType: "aws" };
|
||||
|
||||
it("returns an empty diff when nothing changed", () => {
|
||||
expect(computeWatchlistDiff([aws, azure], [aws, azure])).toEqual({
|
||||
add: [],
|
||||
remove: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("computes additions and removals together", () => {
|
||||
expect(computeWatchlistDiff([aws, azure], [azure, gdpr])).toEqual({
|
||||
add: [gdpr],
|
||||
remove: [aws],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps identical compliance ids from different providers separate", () => {
|
||||
const source = { complianceId: "dora", providerType: "aws" };
|
||||
const target = { complianceId: "dora", providerType: "azure" };
|
||||
|
||||
expect(computeWatchlistDiff([source], [target])).toEqual({
|
||||
add: [target],
|
||||
remove: [source],
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates repeated targets", () => {
|
||||
expect(computeWatchlistDiff([], [gdpr, gdpr]).add).toEqual([gdpr]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("watchlist bulk boundaries", () => {
|
||||
const targets = Array.from({ length: MAX_WATCHLIST_BULK }, (_, index) => ({
|
||||
complianceId: `framework_${index}`,
|
||||
providerType: "aws",
|
||||
}));
|
||||
|
||||
it.each([
|
||||
[{ add: targets, remove: [] }, false],
|
||||
[
|
||||
{
|
||||
add: targets,
|
||||
remove: [{ complianceId: "overflow", providerType: "aws" }],
|
||||
},
|
||||
true,
|
||||
],
|
||||
])("detects whether a diff exceeds the limit", (diff, expected) => {
|
||||
expect(exceedsWatchlistBulkLimit(diff)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatWatchlistBulkSummary", () => {
|
||||
it.each([
|
||||
[{ added: 2, removed: 1 }, "2 added · 1 removed"],
|
||||
[{ added: 3, removed: 0 }, "3 added"],
|
||||
[{ added: 0, removed: 4 }, "4 removed"],
|
||||
[{ added: 0, removed: 0 }, "No changes"],
|
||||
])("formats the applied changes", (summary, expected) => {
|
||||
expect(formatWatchlistBulkSummary(summary)).toBe(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
ComplianceCatalogEntry,
|
||||
ComplianceWatchlistBulkDiff,
|
||||
ComplianceWatchlistTarget,
|
||||
} from "@/types/compliance-watchlist";
|
||||
import { UNIVERSAL_PROVIDER_TYPE } from "@/types/compliance-watchlist";
|
||||
|
||||
export const MAX_WATCHLIST_BULK = 200;
|
||||
|
||||
export const IN_WATCHLIST_FILTER_KEY = "filter[in_watchlist]";
|
||||
|
||||
export const watchlistKey = (target: ComplianceWatchlistTarget): string =>
|
||||
`${target.providerType}:${target.complianceId}`;
|
||||
|
||||
export type ComplianceCatalogIndex = Map<string, ComplianceCatalogEntry>;
|
||||
|
||||
export const buildWatchlistIndex = (
|
||||
entries: ComplianceCatalogEntry[],
|
||||
): ComplianceCatalogIndex =>
|
||||
new Map(entries.map((entry) => [watchlistKey(entry), entry]));
|
||||
|
||||
const universalKey = (complianceId: string): string =>
|
||||
watchlistKey({ complianceId, providerType: UNIVERSAL_PROVIDER_TYPE });
|
||||
|
||||
// Exact keys win. Universal and legacy suffixed ids fall back to the `*` row.
|
||||
export const resolveCatalogEntry = (
|
||||
index: ComplianceCatalogIndex,
|
||||
target: ComplianceWatchlistTarget,
|
||||
): ComplianceCatalogEntry | undefined => {
|
||||
const exact =
|
||||
index.get(watchlistKey(target)) ??
|
||||
index.get(universalKey(target.complianceId));
|
||||
if (exact) return exact;
|
||||
|
||||
const legacySuffix = `_${target.providerType}`;
|
||||
if (!target.providerType || !target.complianceId.endsWith(legacySuffix)) {
|
||||
return undefined;
|
||||
}
|
||||
return index.get(
|
||||
universalKey(target.complianceId.slice(0, -legacySuffix.length)),
|
||||
);
|
||||
};
|
||||
|
||||
export const isFrameworkPinned = (
|
||||
index: ComplianceCatalogIndex,
|
||||
target: ComplianceWatchlistTarget,
|
||||
): boolean => resolveCatalogEntry(index, target)?.inWatchlist === true;
|
||||
|
||||
export const resolveWatchlistEntryId = (
|
||||
index: ComplianceCatalogIndex,
|
||||
target: ComplianceWatchlistTarget,
|
||||
): string | null =>
|
||||
resolveCatalogEntry(index, target)?.watchlistEntryId ?? null;
|
||||
|
||||
export const resolveWatchlistTarget = (
|
||||
index: ComplianceCatalogIndex,
|
||||
target: ComplianceWatchlistTarget,
|
||||
): ComplianceWatchlistTarget => {
|
||||
const entry = resolveCatalogEntry(index, target);
|
||||
return entry
|
||||
? { complianceId: entry.complianceId, providerType: entry.providerType }
|
||||
: target;
|
||||
};
|
||||
|
||||
const dedupeByKey = (
|
||||
targets: ComplianceWatchlistTarget[],
|
||||
): Map<string, ComplianceWatchlistTarget> =>
|
||||
new Map(targets.map((target) => [watchlistKey(target), target]));
|
||||
|
||||
// Submit a diff so concurrent edits outside this selection are preserved.
|
||||
export const computeWatchlistDiff = (
|
||||
initial: ComplianceWatchlistTarget[],
|
||||
selected: ComplianceWatchlistTarget[],
|
||||
): ComplianceWatchlistBulkDiff => {
|
||||
const initialByKey = dedupeByKey(initial);
|
||||
const selectedByKey = dedupeByKey(selected);
|
||||
|
||||
const add: ComplianceWatchlistTarget[] = [];
|
||||
selectedByKey.forEach((target, key) => {
|
||||
if (!initialByKey.has(key)) add.push(target);
|
||||
});
|
||||
|
||||
const remove: ComplianceWatchlistTarget[] = [];
|
||||
initialByKey.forEach((target, key) => {
|
||||
if (!selectedByKey.has(key)) remove.push(target);
|
||||
});
|
||||
|
||||
return { add, remove };
|
||||
};
|
||||
|
||||
export const exceedsWatchlistBulkLimit = (
|
||||
diff: ComplianceWatchlistBulkDiff,
|
||||
): boolean => diff.add.length + diff.remove.length > MAX_WATCHLIST_BULK;
|
||||
|
||||
export const isEmptyWatchlistDiff = (
|
||||
diff: ComplianceWatchlistBulkDiff,
|
||||
): boolean => diff.add.length === 0 && diff.remove.length === 0;
|
||||
|
||||
export const formatWatchlistBulkSummary = (summary: {
|
||||
added: number;
|
||||
removed: number;
|
||||
}): string => {
|
||||
const parts: string[] = [];
|
||||
if (summary.added > 0) parts.push(`${summary.added} added`);
|
||||
if (summary.removed > 0) parts.push(`${summary.removed} removed`);
|
||||
return parts.length > 0 ? parts.join(" · ") : "No changes";
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { useComplianceWatchlistViewStore } from "@/store/compliance/store";
|
||||
|
||||
describe("useComplianceWatchlistViewStore", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useComplianceWatchlistViewStore.setState({ showOnlyWatchlist: false });
|
||||
});
|
||||
|
||||
it("persists only the filter value", () => {
|
||||
// When
|
||||
useComplianceWatchlistViewStore.getState().setShowOnlyWatchlist(true);
|
||||
|
||||
// Then
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem("compliance-watchlist-view") ?? "{}")
|
||||
.state,
|
||||
).toEqual({ showOnlyWatchlist: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
interface ComplianceWatchlistViewState {
|
||||
showOnlyWatchlist: boolean;
|
||||
setShowOnlyWatchlist: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useComplianceWatchlistViewStore =
|
||||
create<ComplianceWatchlistViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
showOnlyWatchlist: false,
|
||||
setShowOnlyWatchlist: (value) => set({ showOnlyWatchlist: value }),
|
||||
}),
|
||||
{
|
||||
name: "compliance-watchlist-view",
|
||||
version: 1,
|
||||
partialize: (state) => ({
|
||||
showOnlyWatchlist: state.showOnlyWatchlist,
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./cloud-upgrade/store";
|
||||
export * from "./compliance/store";
|
||||
export * from "./jira-dispatch/store";
|
||||
export * from "./organizations/store";
|
||||
export * from "./provider-wizard/store";
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ComplianceCatalogEntry } from "@/types/compliance-watchlist";
|
||||
import {
|
||||
UNIVERSAL_PROVIDER_TYPE,
|
||||
WATCHLIST_SCOPE,
|
||||
} from "@/types/compliance-watchlist";
|
||||
|
||||
type CatalogEntryOverrides = Partial<ComplianceCatalogEntry> &
|
||||
Pick<ComplianceCatalogEntry, "complianceId" | "providerType">;
|
||||
|
||||
export const makeComplianceCatalogEntry = ({
|
||||
complianceId,
|
||||
providerType,
|
||||
...overrides
|
||||
}: CatalogEntryOverrides): ComplianceCatalogEntry => {
|
||||
const inWatchlist = overrides.inWatchlist ?? false;
|
||||
|
||||
return {
|
||||
id: `${providerType}:${complianceId}`,
|
||||
complianceId,
|
||||
providerType,
|
||||
scope:
|
||||
providerType === UNIVERSAL_PROVIDER_TYPE
|
||||
? WATCHLIST_SCOPE.UNIVERSAL
|
||||
: WATCHLIST_SCOPE.PROVIDER,
|
||||
providerTypes:
|
||||
providerType === UNIVERSAL_PROVIDER_TYPE
|
||||
? ["aws", "azure", "gcp"]
|
||||
: [providerType],
|
||||
framework: complianceId,
|
||||
name: complianceId,
|
||||
version: "1.0",
|
||||
description: "",
|
||||
totalRequirements: 10,
|
||||
requirementsPassed: 5,
|
||||
requirementsFailed: 5,
|
||||
requirementsManual: 0,
|
||||
score: 50,
|
||||
hasData: true,
|
||||
inWatchlist,
|
||||
watchlistEntryId: inWatchlist ? "entry-1" : null,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
export const COMPLIANCE_CATALOG_ENTRY_TYPE =
|
||||
"compliance-catalog-entries" as const;
|
||||
|
||||
export const COMPLIANCE_WATCHLIST_ENTRY_TYPE =
|
||||
"compliance-watchlist-entries" as const;
|
||||
|
||||
export const COMPLIANCE_WATCHLIST_BULK_TYPE =
|
||||
"compliance-watchlist-bulk" as const;
|
||||
|
||||
export const UNIVERSAL_PROVIDER_TYPE = "*";
|
||||
|
||||
export const WATCHLIST_SCOPE = {
|
||||
UNIVERSAL: "universal",
|
||||
PROVIDER: "provider",
|
||||
} as const;
|
||||
|
||||
export type WatchlistScope =
|
||||
(typeof WATCHLIST_SCOPE)[keyof typeof WATCHLIST_SCOPE];
|
||||
|
||||
export interface ComplianceWatchlistTarget {
|
||||
complianceId: string;
|
||||
providerType: string;
|
||||
}
|
||||
|
||||
export interface ComplianceCatalogEntry extends ComplianceWatchlistTarget {
|
||||
id: string;
|
||||
scope: WatchlistScope;
|
||||
providerTypes: string[];
|
||||
framework: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
totalRequirements: number;
|
||||
requirementsPassed: number;
|
||||
requirementsFailed: number;
|
||||
requirementsManual: number;
|
||||
score: number | null;
|
||||
hasData: boolean;
|
||||
inWatchlist: boolean;
|
||||
watchlistEntryId: string | null;
|
||||
}
|
||||
|
||||
export interface ComplianceCatalogMeta {
|
||||
totalEntries: number;
|
||||
watchlistCount: number;
|
||||
eligibleProviderTypes: string[];
|
||||
}
|
||||
|
||||
export interface ComplianceCatalog {
|
||||
entries: ComplianceCatalogEntry[];
|
||||
meta: ComplianceCatalogMeta;
|
||||
}
|
||||
|
||||
export interface FindingComplianceFramework {
|
||||
id: string;
|
||||
complianceId: string;
|
||||
providerType: string;
|
||||
scope: WatchlistScope;
|
||||
framework: string;
|
||||
name: string;
|
||||
version: string;
|
||||
inWatchlist: boolean;
|
||||
}
|
||||
|
||||
export interface FindingComplianceFrameworksResult {
|
||||
frameworks: FindingComplianceFramework[];
|
||||
unavailable: boolean;
|
||||
}
|
||||
|
||||
export interface ComplianceWatchlistBulkDiff {
|
||||
add: ComplianceWatchlistTarget[];
|
||||
remove: ComplianceWatchlistTarget[];
|
||||
}
|
||||
|
||||
export interface ComplianceWatchlistBulkSummary {
|
||||
added: number;
|
||||
alreadyPresent: number;
|
||||
removed: number;
|
||||
notPresent: number;
|
||||
watchlistCount: number;
|
||||
}
|
||||
|
||||
export const WATCHLIST_PIN_STATE = {
|
||||
PINNED: "pinned",
|
||||
UNPINNED: "unpinned",
|
||||
} as const;
|
||||
|
||||
export type WatchlistPinState =
|
||||
(typeof WATCHLIST_PIN_STATE)[keyof typeof WATCHLIST_PIN_STATE];
|
||||
|
||||
interface ComplianceWatchlistActionSuccess {
|
||||
success: string;
|
||||
error?: never;
|
||||
summary?: ComplianceWatchlistBulkSummary;
|
||||
}
|
||||
|
||||
interface ComplianceWatchlistActionError {
|
||||
success?: never;
|
||||
error: string;
|
||||
summary?: never;
|
||||
}
|
||||
|
||||
export type ComplianceWatchlistActionResult =
|
||||
| ComplianceWatchlistActionSuccess
|
||||
| ComplianceWatchlistActionError;
|
||||
+4
-4
@@ -1,15 +1,15 @@
|
||||
// Generic JSON:API v1.1 shells shared by feature adapters; feature code
|
||||
// models its attribute payloads and reuses these instead of re-declaring them.
|
||||
export interface JsonApiResource<TAttributes> {
|
||||
export interface JsonApiResource<TAttributes, TMeta = Record<string, unknown>> {
|
||||
id: string;
|
||||
type: string;
|
||||
attributes: TAttributes;
|
||||
meta?: Record<string, unknown>;
|
||||
meta?: TMeta;
|
||||
}
|
||||
|
||||
export interface JsonApiDocument<TData> {
|
||||
export interface JsonApiDocument<TData, TMeta = Record<string, unknown>> {
|
||||
data?: TData;
|
||||
meta?: Record<string, unknown>;
|
||||
meta?: TMeta;
|
||||
links?: Record<string, string | null>;
|
||||
error?: string;
|
||||
errors?: unknown[];
|
||||
|
||||
Reference in New Issue
Block a user