mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-11-09 04:38:49 +00:00
409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
/**
|
|
* MSW handlers for the alerts pages. The Slack shapes follow the signed
|
|
* contract, section 2 and the section 6 addendum
|
|
* (`openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`).
|
|
*
|
|
* State is per-call: a create is visible to the next rules read. Wire them
|
|
* per test via `worker.use(...handlersForAlerts(fx))`.
|
|
*/
|
|
|
|
import { http, HttpResponse } from "msw";
|
|
|
|
import {
|
|
ALERTS_LIST_SERVER_ERROR_DETAIL,
|
|
ALERTS_READ_FORBIDDEN_DETAIL,
|
|
ALERTS_SLACK_CHANNEL_NOT_ELIGIBLE_CODE,
|
|
ALERTS_SLACK_NOT_CONNECTED_DETAIL,
|
|
alertsChannelNotAuthorizedDetail,
|
|
alertsChannelNotConfirmedDetail,
|
|
} from "./alerts.fixtures";
|
|
import type {
|
|
AlertRuleFixture,
|
|
AlertsFixture,
|
|
AlertsSlackChannelFixture,
|
|
} from "./alerts.fixtures";
|
|
|
|
const API = process.env.UI_API_BASE_URL;
|
|
const TS = "2026-08-20T09:00:00Z";
|
|
|
|
/** `status` is a string, per the JSON:API spec. */
|
|
const errorBody = (detail: string, status: number, code?: string) => ({
|
|
errors: [
|
|
{
|
|
status: String(status),
|
|
...(code ? { code } : {}),
|
|
detail,
|
|
source: { pointer: "/data" },
|
|
},
|
|
],
|
|
});
|
|
|
|
/** `5xx` throws out of `handleApiResponse`; `4xx` returns an error payload. */
|
|
const readFailure = (status: number): Response =>
|
|
HttpResponse.json(
|
|
errorBody(
|
|
status >= 500
|
|
? ALERTS_LIST_SERVER_ERROR_DETAIL
|
|
: ALERTS_READ_FORBIDDEN_DETAIL,
|
|
status,
|
|
),
|
|
{ status },
|
|
);
|
|
|
|
const collection = (data: unknown[]) => ({
|
|
data,
|
|
meta: {
|
|
version: "v1",
|
|
pagination: { page: 1, pages: 1, count: data.length },
|
|
},
|
|
});
|
|
|
|
const storedChannelAttribute = (channel: AlertsSlackChannelFixture) => ({
|
|
id: channel.id,
|
|
name: channel.name,
|
|
is_private: channel.isPrivate,
|
|
});
|
|
|
|
interface RuleWriteAttributes {
|
|
name?: string;
|
|
description?: string;
|
|
enabled?: boolean;
|
|
trigger?: AlertRuleFixture["trigger"];
|
|
condition?: AlertRuleFixture["condition"];
|
|
recipient_emails?: string[];
|
|
/** Ids only; name and privacy are server-derived. */
|
|
slack_channels?: { id: string }[];
|
|
}
|
|
|
|
const parseRuleAttributes = async (
|
|
request: Request,
|
|
): Promise<RuleWriteAttributes> => {
|
|
const body = (await request.json().catch(() => null)) as {
|
|
data?: { attributes?: RuleWriteAttributes };
|
|
} | null;
|
|
return body?.data?.attributes ?? {};
|
|
};
|
|
|
|
export const handlersForAlerts = (fx: AlertsFixture) => {
|
|
// Mutable copies: writes must not reach through to the caller's fixture.
|
|
const rules: AlertRuleFixture[] = fx.rules.map((rule) => ({
|
|
...rule,
|
|
recipientEmails: [...rule.recipientEmails],
|
|
slackChannelIds: [...rule.slackChannelIds],
|
|
}));
|
|
let createdCount = 0;
|
|
|
|
const isConnected = fx.slackIntegration?.connected === true;
|
|
|
|
const configuredChannel = (
|
|
channelId: string,
|
|
): AlertsSlackChannelFixture | undefined =>
|
|
fx.slackIntegration?.channels.find(
|
|
(candidate) => candidate.id === channelId,
|
|
);
|
|
|
|
/**
|
|
* Only the confirmed channels of the enabled and connected integration
|
|
* (section 6.2).
|
|
*/
|
|
const eligibleChannels = (): AlertsSlackChannelFixture[] =>
|
|
isConnected
|
|
? (fx.slackIntegration?.channels ?? []).filter(
|
|
(channel) => channel.confirmationSentAt !== null,
|
|
)
|
|
: [];
|
|
|
|
/** Every ineligible condition answers the one signed pair (section 6.1). */
|
|
const notEligible = (detail: string): Response =>
|
|
HttpResponse.json(
|
|
errorBody(detail, 400, ALERTS_SLACK_CHANNEL_NOT_ELIGIBLE_CODE),
|
|
{ status: 400 },
|
|
);
|
|
|
|
/**
|
|
* An added channel needs an enabled and connected integration, the channel
|
|
* configured on it, and a non-null `confirmation_sent_at`. Only NEWLY added
|
|
* ids are validated (section 6.3) — retained ids never block an edit — and
|
|
* the refusal is answered before any write lands.
|
|
*/
|
|
const refuseInvalidChannels = (
|
|
channelIds: string[] | undefined,
|
|
retainedIds: readonly string[] = [],
|
|
): Response | null => {
|
|
const added = (channelIds ?? []).filter(
|
|
(channelId) => !retainedIds.includes(channelId),
|
|
);
|
|
if (added.length === 0) return null;
|
|
|
|
if (!isConnected) return notEligible(ALERTS_SLACK_NOT_CONNECTED_DETAIL);
|
|
|
|
for (const channelId of added) {
|
|
const channel = configuredChannel(channelId);
|
|
if (!channel) {
|
|
return notEligible(alertsChannelNotAuthorizedDetail(channelId));
|
|
}
|
|
if (channel.confirmationSentAt === null) {
|
|
return notEligible(alertsChannelNotConfirmedDetail(channelId));
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
/** Deduplicated, as the contract requires of every Slack id list. */
|
|
const storedIds = (written: { id: string }[]): string[] =>
|
|
Array.from(new Set(written.map((channel) => channel.id)));
|
|
|
|
/**
|
|
* The mapping table holds ids only, so the read enriches them from the
|
|
* integration — a channel the workspace no longer carries is simply absent.
|
|
*/
|
|
const ruleResource = (rule: AlertRuleFixture) => ({
|
|
id: rule.id,
|
|
type: "alert-rules",
|
|
attributes: {
|
|
name: rule.name,
|
|
description: rule.description,
|
|
enabled: rule.enabled,
|
|
trigger: rule.trigger,
|
|
condition: rule.condition,
|
|
schema_version: 1,
|
|
recipient_emails: rule.recipientEmails,
|
|
slack_channels: rule.slackChannelIds.flatMap((channelId) => {
|
|
const channel = configuredChannel(channelId);
|
|
return channel ? [storedChannelAttribute(channel)] : [];
|
|
}),
|
|
inserted_at: TS,
|
|
updated_at: TS,
|
|
},
|
|
});
|
|
|
|
const integrationResource = (
|
|
integration: NonNullable<AlertsFixture["slackIntegration"]>,
|
|
) => ({
|
|
id: integration.id,
|
|
type: "integrations",
|
|
attributes: {
|
|
inserted_at: TS,
|
|
updated_at: TS,
|
|
enabled: true,
|
|
connected: integration.connected,
|
|
connection_last_checked_at: integration.connected === null ? null : TS,
|
|
integration_type: "slack",
|
|
configuration: {
|
|
team_id: "T01PROWLER",
|
|
team_name: integration.workspaceName,
|
|
bot_user_id: "U01PROWLERBOT",
|
|
channels: integration.channels.map((channel) => ({
|
|
...storedChannelAttribute(channel),
|
|
confirmation_sent_at: channel.confirmationSentAt,
|
|
})),
|
|
verification: {
|
|
task_id: null,
|
|
started_at: null,
|
|
finished_at: integration.connected === null ? null : TS,
|
|
},
|
|
},
|
|
},
|
|
links: { self: `${API}/integrations/${integration.id}` },
|
|
});
|
|
|
|
return [
|
|
http.get(`${API}/alerts/rules`, () => {
|
|
if (fx.listServerError) {
|
|
return HttpResponse.json(
|
|
errorBody(ALERTS_LIST_SERVER_ERROR_DETAIL, 500),
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
return HttpResponse.json(collection(rules.map(ruleResource)));
|
|
}),
|
|
|
|
/** Registered before the `:id` routes so the literal paths win. */
|
|
http.post(`${API}/alerts/rules/seed`, async ({ request }) => {
|
|
const body = (await request.json().catch(() => null)) as {
|
|
data?: { attributes?: { filter_bag?: Record<string, unknown> } };
|
|
} | null;
|
|
const bag = body?.data?.attributes?.filter_bag ?? {};
|
|
const severity = bag["filter[severity__in]"];
|
|
const severityValues = Array.isArray(severity)
|
|
? severity
|
|
: typeof severity === "string"
|
|
? severity.split(",")
|
|
: ["critical"];
|
|
|
|
return HttpResponse.json({
|
|
data: {
|
|
id: "seeded-rule",
|
|
type: "alert-rule-seedings",
|
|
attributes: {
|
|
condition: {
|
|
op: "count_gte",
|
|
filter: { severity: severityValues },
|
|
value: 1,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
}),
|
|
|
|
http.post(`${API}/alerts/rules/preview`, () =>
|
|
HttpResponse.json({
|
|
data: {
|
|
id: "preview",
|
|
type: "alert-rule-previews",
|
|
attributes: {
|
|
summary: { finding_count_total: 3, top_severity: "critical" },
|
|
evaluation_failed: false,
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
|
|
http.post(`${API}/alerts/rules`, async ({ request }) => {
|
|
const attributes = await parseRuleAttributes(request);
|
|
const written = storedIds(attributes.slack_channels ?? []);
|
|
// A create retains nothing: everything supplied is newly added.
|
|
const refusal = refuseInvalidChannels(written);
|
|
if (refusal) return refusal;
|
|
|
|
createdCount += 1;
|
|
const created: AlertRuleFixture = {
|
|
id: `created-rule-${createdCount}`,
|
|
name: attributes.name ?? "",
|
|
description: attributes.description ?? "",
|
|
enabled: attributes.enabled ?? true,
|
|
trigger: attributes.trigger ?? "after_scan",
|
|
condition: attributes.condition ?? {},
|
|
recipientEmails: attributes.recipient_emails ?? [],
|
|
slackChannelIds: written,
|
|
};
|
|
rules.push(created);
|
|
|
|
return HttpResponse.json(
|
|
{ data: ruleResource(created) },
|
|
{ status: 201 },
|
|
);
|
|
}),
|
|
|
|
http.get<{ id: string }>(`${API}/alerts/rules/:id`, ({ params }) => {
|
|
const rule = rules.find((candidate) => candidate.id === params.id);
|
|
if (!rule) {
|
|
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
|
|
}
|
|
return HttpResponse.json({ data: ruleResource(rule) });
|
|
}),
|
|
|
|
http.patch<{ id: string }>(
|
|
`${API}/alerts/rules/:id`,
|
|
async ({ params, request }) => {
|
|
const rule = rules.find((candidate) => candidate.id === params.id);
|
|
if (!rule) {
|
|
return HttpResponse.json(errorBody("Not found.", 404), {
|
|
status: 404,
|
|
});
|
|
}
|
|
|
|
const attributes = await parseRuleAttributes(request);
|
|
const written = attributes.slack_channels
|
|
? storedIds(attributes.slack_channels)
|
|
: undefined;
|
|
const refusal = refuseInvalidChannels(written, rule.slackChannelIds);
|
|
if (refusal) return refusal;
|
|
|
|
if (attributes.name !== undefined) rule.name = attributes.name;
|
|
if (attributes.description !== undefined) {
|
|
rule.description = attributes.description;
|
|
}
|
|
if (attributes.enabled !== undefined) rule.enabled = attributes.enabled;
|
|
if (attributes.trigger !== undefined) rule.trigger = attributes.trigger;
|
|
if (attributes.condition !== undefined) {
|
|
rule.condition = attributes.condition;
|
|
}
|
|
if (attributes.recipient_emails !== undefined) {
|
|
rule.recipientEmails = attributes.recipient_emails;
|
|
}
|
|
// A supplied list replaces the whole selection; omitting the key
|
|
// leaves it untouched.
|
|
if (written !== undefined) {
|
|
rule.slackChannelIds = written;
|
|
}
|
|
|
|
return HttpResponse.json({ data: ruleResource(rule) });
|
|
},
|
|
),
|
|
|
|
http.delete<{ id: string }>(`${API}/alerts/rules/:id`, ({ params }) => {
|
|
const index = rules.findIndex((candidate) => candidate.id === params.id);
|
|
if (index === -1) {
|
|
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
|
|
}
|
|
rules.splice(index, 1);
|
|
return new HttpResponse(null, { status: 204 });
|
|
}),
|
|
|
|
http.get(`${API}/alerts/slack-channels`, () => {
|
|
if (fx.channelsReadError) return readFailure(fx.channelsReadError);
|
|
return HttpResponse.json({
|
|
data: eligibleChannels().map((channel) => ({
|
|
type: "slack-channels",
|
|
id: channel.id,
|
|
attributes: { name: channel.name, is_private: channel.isPrivate },
|
|
})),
|
|
});
|
|
}),
|
|
|
|
http.get(`${API}/alerts/recipients`, () =>
|
|
HttpResponse.json(
|
|
collection(
|
|
fx.recipients.map((recipient, index) => ({
|
|
id: `recipient-${index + 1}`,
|
|
type: "alert-recipients",
|
|
attributes: {
|
|
email: recipient.email,
|
|
status: recipient.status,
|
|
inserted_at: TS,
|
|
updated_at: TS,
|
|
},
|
|
})),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Read so the form can tell "no workspace" from "no eligible channels".
|
|
http.get(`${API}/integrations`, ({ request }) => {
|
|
if (fx.integrationsNetworkError) return HttpResponse.error();
|
|
if (fx.integrationsReadError)
|
|
return readFailure(fx.integrationsReadError);
|
|
const type = new URL(request.url).searchParams.get(
|
|
"filter[integration_type]",
|
|
);
|
|
// An unfiltered read would pull every type into the alert form.
|
|
const install =
|
|
type === "slack" && fx.slackIntegration ? fx.slackIntegration : null;
|
|
return HttpResponse.json(
|
|
collection(install ? [integrationResource(install)] : []),
|
|
);
|
|
}),
|
|
|
|
// Sibling reads the alerts page issues on mount.
|
|
http.get(`${API}/providers`, () => HttpResponse.json(collection([]))),
|
|
http.get(`${API}/scans`, () => HttpResponse.json(collection([]))),
|
|
http.get(`${API}/findings/metadata/latest`, () =>
|
|
HttpResponse.json({
|
|
data: {
|
|
id: "latest",
|
|
type: "findings-metadata",
|
|
attributes: {
|
|
regions: [],
|
|
services: [],
|
|
resource_types: [],
|
|
categories: [],
|
|
groups: [],
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
];
|
|
};
|