feat(ui): add compliance watchlist (#12300)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2026-08-05 11:18:45 +02:00
committed by GitHub
co-authored by alejandrobailo
parent a6d5dbacd9
commit 5285d25cfd
72 changed files with 5064 additions and 625 deletions
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
>;
+6
View File
@@ -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
View File
@@ -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));
};
+39
View File
@@ -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,
}: {
+12 -2
View File
@@ -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);
});
});
+82 -75
View File
@@ -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)
)}
</>
);
};
+1
View File
@@ -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>
);
};
@@ -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",
);
@@ -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>
)}
@@ -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,
+33
View File
@@ -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));
});
});
+18
View File
@@ -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;
+4 -9
View File
@@ -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";
}
+4
View File
@@ -0,0 +1,4 @@
export const formatComplianceFrameworkTitle = (
title: string,
version?: string,
): string => `${title.split("-").join(" ")}${version ? ` - ${version}` : ""}`;
+189
View File
@@ -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);
});
});
+107
View File
@@ -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";
};
+21
View File
@@ -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 });
});
});
+24
View File
@@ -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
View File
@@ -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";
+43
View File
@@ -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,
};
};
+105
View File
@@ -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
View File
@@ -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[];