mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-21 13:20:57 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ccf89fa29 | ||
|
|
e666d9b725 | ||
|
|
67e1ec3f51 | ||
|
|
a87cbd2434 | ||
|
|
1fc021b157 |
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Fixture data for the alerts handlers. The alert-rule shapes mirror the API
|
||||
* the alerts UI already consumes; the Slack-channel shapes follow the signed
|
||||
* contract (`openspec/changes/add-slack-alert-channels/contract/`), and what
|
||||
* the contract still leaves open carries a `TODO(Josema)`.
|
||||
*
|
||||
* The disabled/empty channel states are driven by what the fixture OMITS
|
||||
* (no integration, no configured channels, no confirmations), never by
|
||||
* handing the UI a pre-disabled state (design D9).
|
||||
*/
|
||||
|
||||
/** A Slack channel configured on the integration. */
|
||||
export interface AlertsSlackChannelFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
/**
|
||||
* Null until the connection check posts its one-time confirmation. Only a
|
||||
* confirmed channel is eligible as an alert destination, and a
|
||||
* same-workspace reinstall resets every timestamp.
|
||||
*/
|
||||
confirmationSentAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tenant's Slack integration. `slackIntegration: null` is the tenant with
|
||||
* no workspace connected at all.
|
||||
*/
|
||||
export interface AlertsSlackIntegrationFixture {
|
||||
id: string;
|
||||
workspaceName: string;
|
||||
/** Null until a connection check has run; a reinstall resets it. */
|
||||
connected: boolean | null;
|
||||
/** The channels authorized on the integration. */
|
||||
channels: AlertsSlackChannelFixture[];
|
||||
}
|
||||
|
||||
export const ALERT_RULE_TRIGGERS = {
|
||||
AFTER_SCAN: "after_scan",
|
||||
DAILY: "daily",
|
||||
BOTH: "both",
|
||||
} as const;
|
||||
|
||||
export type AlertRuleTriggerFixture =
|
||||
(typeof ALERT_RULE_TRIGGERS)[keyof typeof ALERT_RULE_TRIGGERS];
|
||||
|
||||
export interface AlertRuleFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
trigger: AlertRuleTriggerFixture;
|
||||
/** The condition DSL travels through the UI opaquely. */
|
||||
condition: Record<string, unknown>;
|
||||
recipientEmails: string[];
|
||||
/**
|
||||
* Stored destinations, by channel id — all the mapping table holds. The
|
||||
* read enriches them from the integration, so an id its workspace no longer
|
||||
* carries simply disappears, exactly as the server-side cascade leaves it.
|
||||
*/
|
||||
slackChannelIds: string[];
|
||||
}
|
||||
|
||||
export const ALERT_RECIPIENT_STATUSES = {
|
||||
PENDING: "pending",
|
||||
CONFIRMED: "confirmed",
|
||||
UNSUBSCRIBED: "unsubscribed",
|
||||
BOUNCED: "bounced",
|
||||
} as const;
|
||||
|
||||
export type AlertRecipientStatusFixture =
|
||||
(typeof ALERT_RECIPIENT_STATUSES)[keyof typeof ALERT_RECIPIENT_STATUSES];
|
||||
|
||||
export interface AlertRecipientFixture {
|
||||
email: string;
|
||||
status: AlertRecipientStatusFixture;
|
||||
}
|
||||
|
||||
export interface AlertsFixture {
|
||||
rules: AlertRuleFixture[];
|
||||
recipients: AlertRecipientFixture[];
|
||||
slackIntegration: AlertsSlackIntegrationFixture | null;
|
||||
/** The rules list read answers `500`. */
|
||||
listServerError: boolean;
|
||||
}
|
||||
|
||||
/** UUIDs, as the API's ids are. */
|
||||
export const ALERTS_SLACK_INTEGRATION_ID =
|
||||
"7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c";
|
||||
export const ALERT_RULE_ID = "1f6d3c2b-8a4e-4b7d-9c5f-0e1a2b3c4d5e";
|
||||
|
||||
const CONFIRMED_AT = "2026-08-18T10:15:00Z";
|
||||
|
||||
/**
|
||||
* The channel ids and names match the Slack fixtures' workspace so an
|
||||
* end-to-end reading of both pages tells one story, without importing from
|
||||
* `slack.fixtures.ts` (that file belongs to the integrations lane).
|
||||
*/
|
||||
export const ALERTS_PUBLIC_CHANNEL: AlertsSlackChannelFixture = {
|
||||
id: "C0123AB",
|
||||
name: "security",
|
||||
isPrivate: false,
|
||||
confirmationSentAt: CONFIRMED_AT,
|
||||
};
|
||||
|
||||
export const ALERTS_PRIVATE_CHANNEL: AlertsSlackChannelFixture = {
|
||||
id: "C0456CD",
|
||||
name: "security-alerts",
|
||||
isPrivate: true,
|
||||
confirmationSentAt: CONFIRMED_AT,
|
||||
};
|
||||
|
||||
export const ALERTS_CONFIGURED_CHANNELS: AlertsSlackChannelFixture[] = [
|
||||
ALERTS_PUBLIC_CHANNEL,
|
||||
ALERTS_PRIVATE_CHANNEL,
|
||||
];
|
||||
|
||||
/**
|
||||
* Refusal wire values for the rule-write validation, spelled out rather than
|
||||
* imported from any UI mapping: a rename on our side must fail these tests.
|
||||
* TODO(Josema): the refusal's HTTP status and error codes are the one thing
|
||||
* the signed contract leaves open (D3, Validation row); these stay the
|
||||
* working assumption until it answers.
|
||||
*/
|
||||
export const ALERTS_SLACK_NOT_CONNECTED_CODE = "slack_not_connected";
|
||||
export const ALERTS_CHANNEL_NOT_AUTHORIZED_CODE =
|
||||
"slack_channel_not_authorized";
|
||||
export const ALERTS_CHANNEL_NOT_CONFIRMED_CODE = "slack_channel_not_confirmed";
|
||||
|
||||
export const ALERTS_SLACK_NOT_CONNECTED_DETAIL =
|
||||
"Slack must be connected before an alert rule can name channel destinations.";
|
||||
|
||||
export const alertsChannelNotAuthorizedDetail = (channelId: string): string =>
|
||||
`Channel ${channelId} is not configured on the Slack integration.`;
|
||||
|
||||
export const alertsChannelNotConfirmedDetail = (channelId: string): string =>
|
||||
`Channel ${channelId} has not been confirmed yet. Run the Slack connection check first.`;
|
||||
|
||||
export const ALERTS_LIST_SERVER_ERROR_DETAIL = "A server error occurred.";
|
||||
|
||||
export const alertRuleFixture = (
|
||||
overrides: Partial<AlertRuleFixture> = {},
|
||||
): AlertRuleFixture => ({
|
||||
id: ALERT_RULE_ID,
|
||||
name: "Critical findings",
|
||||
description: "Notify security when critical findings land.",
|
||||
enabled: true,
|
||||
trigger: ALERT_RULE_TRIGGERS.AFTER_SCAN,
|
||||
condition: {
|
||||
op: "count_gte",
|
||||
filter: { severity: ["critical"] },
|
||||
value: 1,
|
||||
},
|
||||
recipientEmails: ["security@example.com"],
|
||||
slackChannelIds: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* The baseline tenant: a connected workspace with two confirmed channels (one
|
||||
* private) and one email-only rule to edit.
|
||||
*/
|
||||
export const alertsFixture = (
|
||||
overrides: Partial<AlertsFixture> = {},
|
||||
): AlertsFixture => ({
|
||||
rules: [alertRuleFixture()],
|
||||
recipients: [
|
||||
{
|
||||
email: "security@example.com",
|
||||
status: ALERT_RECIPIENT_STATUSES.CONFIRMED,
|
||||
},
|
||||
{ email: "ops@example.com", status: ALERT_RECIPIENT_STATUSES.CONFIRMED },
|
||||
],
|
||||
slackIntegration: {
|
||||
id: ALERTS_SLACK_INTEGRATION_ID,
|
||||
workspaceName: "Prowler HQ",
|
||||
connected: true,
|
||||
channels: ALERTS_CONFIGURED_CHANNELS.map((channel) => ({ ...channel })),
|
||||
},
|
||||
listServerError: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** No Slack workspace connected: the channel destination must say why (D9). */
|
||||
export const noSlackAlertsFixture = (
|
||||
overrides: Partial<AlertsFixture> = {},
|
||||
): AlertsFixture => alertsFixture({ slackIntegration: null, ...overrides });
|
||||
|
||||
/** Workspace connected, nothing authorized yet: the empty-pool state (D9). */
|
||||
export const emptyChannelPoolAlertsFixture = (
|
||||
overrides: Partial<AlertsFixture> = {},
|
||||
): AlertsFixture =>
|
||||
alertsFixture({
|
||||
slackIntegration: {
|
||||
id: ALERTS_SLACK_INTEGRATION_ID,
|
||||
workspaceName: "Prowler HQ",
|
||||
connected: true,
|
||||
channels: [],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A same-workspace reinstall: the channels and the rules' mappings survive,
|
||||
* every confirmation and the connection state are reset. The only way the API
|
||||
* can still hand the form a stored channel it does not offer.
|
||||
*/
|
||||
export const reinstalledWorkspaceAlertsFixture = (
|
||||
overrides: Partial<AlertsFixture> = {},
|
||||
): AlertsFixture =>
|
||||
alertsFixture({
|
||||
slackIntegration: {
|
||||
id: ALERTS_SLACK_INTEGRATION_ID,
|
||||
workspaceName: "Prowler HQ",
|
||||
connected: null,
|
||||
channels: ALERTS_CONFIGURED_CHANNELS.map((channel) => ({
|
||||
...channel,
|
||||
confirmationSentAt: null,
|
||||
})),
|
||||
},
|
||||
rules: [
|
||||
alertRuleFixture({
|
||||
slackChannelIds: [ALERTS_PUBLIC_CHANNEL.id, ALERTS_PRIVATE_CHANNEL.id],
|
||||
}),
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
/**
|
||||
* MSW handlers for the alerts pages: rules CRUD, recipients, the eligible
|
||||
* Slack channels the destination field offers, the integration read that
|
||||
* tells an empty pool from no workspace at all, and the sibling reads the
|
||||
* alerts page issues on mount (providers, scans, findings metadata).
|
||||
*
|
||||
* The Slack shapes follow the signed contract
|
||||
* (`openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`,
|
||||
* section 2); what it leaves open carries a `TODO(Josema)`.
|
||||
*
|
||||
* 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_CHANNEL_NOT_AUTHORIZED_CODE,
|
||||
ALERTS_CHANNEL_NOT_CONFIRMED_CODE,
|
||||
ALERTS_LIST_SERVER_ERROR_DETAIL,
|
||||
ALERTS_SLACK_NOT_CONNECTED_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 — same taxonomy the Slack
|
||||
* handlers answer with, since the validation is about Slack state.
|
||||
*/
|
||||
const errorBody = (detail: string, status: number, code?: string) => ({
|
||||
errors: [
|
||||
{
|
||||
status: String(status),
|
||||
...(code ? { code } : {}),
|
||||
detail,
|
||||
source: { pointer: "/data" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const collection = (data: unknown[]) => ({
|
||||
data,
|
||||
meta: {
|
||||
version: "v1",
|
||||
pagination: { page: 1, pages: 1, count: data.length },
|
||||
},
|
||||
});
|
||||
|
||||
/** The rule read's channel shape: resolved name and privacy, no Slack call. */
|
||||
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[];
|
||||
/** Objects carrying only `id`; 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,
|
||||
);
|
||||
|
||||
/**
|
||||
* What `GET /alerts/slack-channels` offers: the enabled and connected
|
||||
* integration's channels.
|
||||
* TODO(Josema): the contract does not literally say the listing filters to
|
||||
* *confirmed* channels (D3, Eligibility row). Assumed here — the rule write
|
||||
* refuses unconfirmed ones, so offering them would offer a refusal.
|
||||
*/
|
||||
const eligibleChannels = (): AlertsSlackChannelFixture[] =>
|
||||
isConnected
|
||||
? (fx.slackIntegration?.channels ?? []).filter(
|
||||
(channel) => channel.confirmationSentAt !== null,
|
||||
)
|
||||
: [];
|
||||
|
||||
/**
|
||||
* The rule-write validation the contract signs: an enabled and connected
|
||||
* integration, the channel configured on it, and a non-null
|
||||
* `confirmation_sent_at`. Answered before any write lands, so a refusal
|
||||
* leaves the stored rule unchanged.
|
||||
* TODO(Josema): whether PATCH re-validates channels a rule already stores is
|
||||
* open (tasks 7.14). The supplied list is validated on both writes here —
|
||||
* the literal reading — and the UI only surfaces what comes back.
|
||||
*/
|
||||
const refuseInvalidChannels = (
|
||||
channelIds: string[] | undefined,
|
||||
): Response | null => {
|
||||
if (!channelIds || channelIds.length === 0) return null;
|
||||
|
||||
if (!isConnected) {
|
||||
return HttpResponse.json(
|
||||
errorBody(
|
||||
ALERTS_SLACK_NOT_CONNECTED_DETAIL,
|
||||
400,
|
||||
ALERTS_SLACK_NOT_CONNECTED_CODE,
|
||||
),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
for (const channelId of channelIds) {
|
||||
const channel = configuredChannel(channelId);
|
||||
if (!channel) {
|
||||
return HttpResponse.json(
|
||||
errorBody(
|
||||
alertsChannelNotAuthorizedDetail(channelId),
|
||||
400,
|
||||
ALERTS_CHANNEL_NOT_AUTHORIZED_CODE,
|
||||
),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (channel.confirmationSentAt === null) {
|
||||
return HttpResponse.json(
|
||||
errorBody(
|
||||
alertsChannelNotConfirmedDetail(channelId),
|
||||
400,
|
||||
ALERTS_CHANNEL_NOT_CONFIRMED_CODE,
|
||||
),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 read enriches the mapping's ids from the integration — the mapping
|
||||
* table holds no metadata — so a channel the workspace no longer carries is
|
||||
* simply absent, exactly as the cascade leaves it.
|
||||
*/
|
||||
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 [
|
||||
// --- Rules -------------------------------------------------------------
|
||||
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. The seed
|
||||
* echoes the filter bag back as a leaf condition — the UI treats the DSL
|
||||
* opaquely, so the exact translation is this fixture's business alone.
|
||||
*/
|
||||
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 ?? []);
|
||||
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 ?? [],
|
||||
// Omission defaults both destination lists to empty.
|
||||
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);
|
||||
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 Slack selection atomically, `[]`
|
||||
// clears it, and 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 });
|
||||
}),
|
||||
|
||||
// --- The channels the alert form offers ---------------------------------
|
||||
http.get(`${API}/alerts/slack-channels`, () =>
|
||||
HttpResponse.json({
|
||||
data: eligibleChannels().map((channel) => ({
|
||||
type: "slack-channels",
|
||||
id: channel.id,
|
||||
attributes: { name: channel.name, is_private: channel.isPrivate },
|
||||
})),
|
||||
}),
|
||||
),
|
||||
|
||||
// --- Recipients ---------------------------------------------------------
|
||||
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,
|
||||
},
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// --- The integration read that tells the two empty states apart ---------
|
||||
http.get(`${API}/integrations`, ({ request }) => {
|
||||
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: [],
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
];
|
||||
};
|
||||
@@ -24,6 +24,12 @@ export interface AlertPayload {
|
||||
* emails. Recipient IDs are NOT used by the rule write path.
|
||||
*/
|
||||
recipientEmails?: string[];
|
||||
/**
|
||||
* Slack channel ids from the eligible set. Replace-not-additive like
|
||||
* `recipientEmails`: a supplied list replaces the whole Slack selection,
|
||||
* `[]` clears it, and omitting the key leaves it unchanged.
|
||||
*/
|
||||
slackChannels?: string[];
|
||||
}
|
||||
|
||||
const buildRuleEnvelope = (payload: AlertPayload, alertId?: string) => ({
|
||||
@@ -40,6 +46,10 @@ const buildRuleEnvelope = (payload: AlertPayload, alertId?: string) => ({
|
||||
...(payload.recipientEmails !== undefined
|
||||
? { recipient_emails: payload.recipientEmails }
|
||||
: {}),
|
||||
// Objects carrying only `id`; the API derives name and privacy.
|
||||
...(payload.slackChannels !== undefined
|
||||
? { slack_channels: payload.slackChannels.map((id) => ({ id })) }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const ALERT_SLACK_CHANNELS_PATH = "/alerts/slack-channels";
|
||||
|
||||
/**
|
||||
* The channels eligible as alert destinations, from the tenant's enabled and
|
||||
* connected Slack integration. Server-side only: no Slack round-trip, so the
|
||||
* alert form has neither pagination nor a listing-failure state.
|
||||
*/
|
||||
export const getAlertSlackChannels = async () => {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const url = new URL(`${apiBaseUrl}${ALERT_SLACK_CHANNELS_PATH}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { headers });
|
||||
return handleApiResponse(response);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
@@ -26,11 +26,29 @@ const alertsActionMocks = vi.hoisted(() => ({
|
||||
seedAlertRule: vi.fn(),
|
||||
}));
|
||||
|
||||
const integrationsActionMocks = vi.hoisted(() => ({
|
||||
getIntegrations: vi.fn(),
|
||||
}));
|
||||
|
||||
const slackChannelsActionMocks = vi.hoisted(() => ({
|
||||
getAlertSlackChannels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@/app/(prowler)/alerts/_actions/recipients",
|
||||
() => recipientsActionMocks,
|
||||
);
|
||||
|
||||
// The channels field reads the eligible channels and the Slack integration on
|
||||
// mount; its behavior is covered by the page integration tests (no-overlap
|
||||
// rule), so the unit lane only keeps the fetches from escaping jsdom.
|
||||
vi.mock("@/actions/integrations/integrations", () => integrationsActionMocks);
|
||||
|
||||
vi.mock(
|
||||
"@/app/(prowler)/alerts/_actions/slack-channels",
|
||||
() => slackChannelsActionMocks,
|
||||
);
|
||||
|
||||
vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks);
|
||||
|
||||
vi.mock(
|
||||
@@ -247,6 +265,16 @@ describe("AlertFormModal", () => {
|
||||
recipientsActionMocks.listAlertRecipients.mockReturnValue(
|
||||
new Promise(() => {}),
|
||||
);
|
||||
integrationsActionMocks.getIntegrations.mockReset();
|
||||
slackChannelsActionMocks.getAlertSlackChannels.mockReset();
|
||||
// Never resolve, like the recipients read above: the channels field's
|
||||
// settled states are integration-tested; the unit lane keeps it loading.
|
||||
integrationsActionMocks.getIntegrations.mockReturnValue(
|
||||
new Promise(() => {}),
|
||||
);
|
||||
slackChannelsActionMocks.getAlertSlackChannels.mockReturnValue(
|
||||
new Promise(() => {}),
|
||||
);
|
||||
alertsActionMocks.previewAlertCondition.mockReset();
|
||||
alertsActionMocks.seedAlertRule.mockReset();
|
||||
alertsActionMocks.seedAlertRule.mockResolvedValue({
|
||||
@@ -279,7 +307,8 @@ describe("AlertFormModal", () => {
|
||||
expect(screen.getByLabelText(/^description$/i)).toBeVisible();
|
||||
expect(screen.getByLabelText(/^frequency$/i)).toBeVisible();
|
||||
expect(screen.getByLabelText(/^recipients$/i)).toBeVisible();
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(2);
|
||||
// Frequency, Recipients, and the Slack destination channels trigger.
|
||||
expect(screen.getAllByRole("combobox")).toHaveLength(3);
|
||||
expect(screen.queryByText("Alert criteria")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/delivery settings/i)).not.toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
@@ -112,13 +112,13 @@ vi.mock("../alert-form-modal", () => ({
|
||||
const result = await onSubmit({
|
||||
name: "Updated alert",
|
||||
description: "",
|
||||
method: "email",
|
||||
frequency: ALERT_TRIGGER_KINDS.AFTER_SCAN,
|
||||
condition: {
|
||||
op: ALERT_AGGREGATE_OPS.ANY,
|
||||
filter: { severity: ["critical"] },
|
||||
},
|
||||
recipientEmails: [],
|
||||
slackChannels: [],
|
||||
enabled: true,
|
||||
});
|
||||
setError(result.ok ? null : (result.error ?? null));
|
||||
|
||||
@@ -79,13 +79,13 @@ vi.mock("@/app/(prowler)/alerts/_components/alert-form-modal", () => ({
|
||||
onSubmit({
|
||||
name: defaultName ?? "Findings filter alert",
|
||||
description: "",
|
||||
method: "email",
|
||||
frequency: "after_scan",
|
||||
condition: seededCondition ?? {
|
||||
op: "any",
|
||||
filter: { severity: ["critical"] },
|
||||
},
|
||||
recipientEmails: ["security@example.com"],
|
||||
slackChannels: [],
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,7 +60,8 @@ import type {
|
||||
AlertFormSubmitResult,
|
||||
AlertFormValues,
|
||||
} from "../_types/alert-form";
|
||||
import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form";
|
||||
|
||||
import { SlackChannelsField } from "./slack-channels-field";
|
||||
|
||||
interface AlertFormModalProps {
|
||||
open: boolean;
|
||||
@@ -369,6 +370,10 @@ const AlertFormModalContent = ({
|
||||
const [selectedRecipientEmails, setSelectedRecipientEmails] = useState(
|
||||
() => new Set(defaults.recipientEmails.map(normalizeEmail)),
|
||||
);
|
||||
// Local state needed: channel picks are buffered until the form submits.
|
||||
const [selectedSlackChannels, setSelectedSlackChannels] = useState<string[]>(
|
||||
defaults.slackChannels,
|
||||
);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
@@ -395,10 +400,10 @@ const AlertFormModalContent = ({
|
||||
const buildCurrentValues = (condition: AlertCondition): AlertFormValues => ({
|
||||
name,
|
||||
description,
|
||||
method: ALERT_NOTIFICATION_METHODS.EMAIL,
|
||||
frequency,
|
||||
condition,
|
||||
recipientEmails: getRecipientEmails(selectedRecipientEmails),
|
||||
slackChannels: selectedSlackChannels,
|
||||
enabled: defaults.enabled,
|
||||
});
|
||||
|
||||
@@ -554,6 +559,13 @@ const AlertFormModalContent = ({
|
||||
<FieldError>{errors.recipientEmails}</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<SlackChannelsField
|
||||
selectedChannelIds={selectedSlackChannels}
|
||||
storedChannels={editingAlert?.attributes.slack_channels ?? []}
|
||||
onValuesChange={setSelectedSlackChannels}
|
||||
/>
|
||||
</Field>
|
||||
{editingAlert && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Card variant="inner" padding="sm">
|
||||
|
||||
@@ -29,11 +29,26 @@ const TRIGGER_LABELS = {
|
||||
both: "After scan and daily",
|
||||
} as const satisfies Record<AlertRule["attributes"]["trigger"], string>;
|
||||
|
||||
const formatRecipients = (alert: AlertRule): string => {
|
||||
const summarize = (first: string, count: number): string | null => {
|
||||
if (count === 0) return null;
|
||||
if (count === 1) return first;
|
||||
return `${first} +${count - 1} more`;
|
||||
};
|
||||
|
||||
/**
|
||||
* One compact answer to "where does this go?" (design D6): the email summary
|
||||
* and the channel summary side by side, either omitted when empty.
|
||||
*/
|
||||
const formatDestinations = (alert: AlertRule): string => {
|
||||
const recipients = alert.attributes.recipient_emails ?? [];
|
||||
if (recipients.length === 0) return "No recipients";
|
||||
if (recipients.length === 1) return recipients[0];
|
||||
return `${recipients[0]} +${recipients.length - 1} more`;
|
||||
const channels = alert.attributes.slack_channels ?? [];
|
||||
|
||||
const summaries = [
|
||||
summarize(recipients[0], recipients.length),
|
||||
summarize(channels[0] ? `#${channels[0].name}` : "", channels.length),
|
||||
].filter((summary): summary is string => summary !== null);
|
||||
|
||||
return summaries.length > 0 ? summaries.join(" · ") : "No destinations";
|
||||
};
|
||||
|
||||
interface GetAlertsTableColumnsOptions {
|
||||
@@ -148,14 +163,14 @@ const getAlertsTableColumns = ({
|
||||
cell: ({ row }) => TRIGGER_LABELS[row.original.attributes.trigger],
|
||||
},
|
||||
{
|
||||
id: "recipients",
|
||||
id: "destinations",
|
||||
size: 220,
|
||||
minSize: 180,
|
||||
accessorFn: (alert) => formatRecipients(alert),
|
||||
accessorFn: (alert) => formatDestinations(alert),
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Recipients" />
|
||||
<DataTableColumnHeader column={column} title="Destinations" />
|
||||
),
|
||||
cell: ({ row }) => formatRecipients(row.original),
|
||||
cell: ({ row }) => formatDestinations(row.original),
|
||||
},
|
||||
{
|
||||
id: "inserted_at",
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
import { getIntegrations } from "@/actions/integrations/integrations";
|
||||
import { getAlertSlackChannels } from "@/app/(prowler)/alerts/_actions/slack-channels";
|
||||
import { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import { useMountEffect } from "@/hooks/use-mount-effect";
|
||||
import type {
|
||||
IntegrationProps,
|
||||
SlackChannelOption,
|
||||
} from "@/types/integrations";
|
||||
|
||||
const SLACK_INTEGRATION_HREF = "/integrations/slack";
|
||||
|
||||
const NO_INTEGRATION_COPY =
|
||||
"Posting alerts to Slack channels needs a connected Slack workspace.";
|
||||
const EMPTY_POOL_COPY =
|
||||
"No channels are ready yet. Authorize channels on the Slack integration and run its connection check to offer them here.";
|
||||
|
||||
/**
|
||||
* The three presentations the spec allows (design D2/D4). Read by the page
|
||||
* harness off `data-alert-channels-state`.
|
||||
*/
|
||||
const FIELD_STATE = {
|
||||
NO_INTEGRATION: "no-integration",
|
||||
EMPTY_POOL: "empty-pool",
|
||||
POPULATED: "populated",
|
||||
} as const;
|
||||
|
||||
type FieldState = (typeof FIELD_STATE)[keyof typeof FIELD_STATE];
|
||||
|
||||
/** `GET /alerts/slack-channels` — id is the channel id (contract section 2). */
|
||||
interface EligibleChannelResource {
|
||||
id: string;
|
||||
attributes: { name: string; is_private: boolean };
|
||||
}
|
||||
|
||||
interface SlackChannelsFieldProps {
|
||||
selectedChannelIds: string[];
|
||||
/**
|
||||
* The rule's stored channels from the read model (id, name, privacy). They
|
||||
* are merged into the options so a channel that is configured but not yet
|
||||
* confirmed — what a same-workspace reinstall leaves behind — still renders
|
||||
* by name and privacy instead of blanking the stored selection.
|
||||
*/
|
||||
storedChannels: SlackChannelOption[];
|
||||
onValuesChange: (channelIds: string[]) => void;
|
||||
}
|
||||
|
||||
const toChannelOptions = (data: unknown): SlackChannelOption[] =>
|
||||
Array.isArray(data)
|
||||
? (data as EligibleChannelResource[]).map((resource) => ({
|
||||
id: resource.id,
|
||||
name: resource.attributes.name,
|
||||
is_private: resource.attributes.is_private,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const mergeOptions = (
|
||||
eligible: SlackChannelOption[],
|
||||
stored: SlackChannelOption[],
|
||||
): SlackChannelOption[] => {
|
||||
const byId = new Map(eligible.map((channel) => [channel.id, channel]));
|
||||
stored.forEach((channel) => {
|
||||
if (!byId.has(channel.id)) byId.set(channel.id, channel);
|
||||
});
|
||||
return Array.from(byId.values());
|
||||
};
|
||||
|
||||
const ManageIntegrationLink = () => (
|
||||
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
|
||||
<Link href={SLACK_INTEGRATION_HREF}>Manage the Slack integration</Link>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const FieldNotice = ({ copy }: { copy: string }) => (
|
||||
<p
|
||||
data-alert-channels-notice
|
||||
className="text-text-neutral-secondary flex flex-wrap items-center gap-1 text-xs"
|
||||
>
|
||||
<span>{copy}</span>
|
||||
<ManageIntegrationLink />
|
||||
</p>
|
||||
);
|
||||
|
||||
/**
|
||||
* Slack channel destinations for an alert rule (design D2/D4): the options
|
||||
* come from the dedicated eligible-channels endpoint — never the workspace
|
||||
* listing, so there is no pagination and no listing-failure state — and the
|
||||
* integration is read only to tell an empty pool from no workspace at all,
|
||||
* which an empty collection cannot say on its own.
|
||||
*/
|
||||
export const SlackChannelsField = ({
|
||||
selectedChannelIds,
|
||||
storedChannels,
|
||||
onValuesChange,
|
||||
}: SlackChannelsFieldProps) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [eligibleChannels, setEligibleChannels] = useState<
|
||||
SlackChannelOption[]
|
||||
>([]);
|
||||
const [integrationUsable, setIntegrationUsable] = useState(false);
|
||||
|
||||
useMountEffect(() => {
|
||||
Promise.all([
|
||||
getAlertSlackChannels(),
|
||||
getIntegrations(
|
||||
new URLSearchParams({ "filter[integration_type]": "slack" }),
|
||||
),
|
||||
]).then(([channelsResult, integrationsResult]) => {
|
||||
setLoading(false);
|
||||
// A failed read collapses to the disabled presentation: the spec allows
|
||||
// exactly three states, and the integration page is where a read
|
||||
// problem gets diagnosed.
|
||||
if (!channelsResult?.error) {
|
||||
setEligibleChannels(toChannelOptions(channelsResult?.data));
|
||||
}
|
||||
if (integrationsResult?.error) return;
|
||||
const integration = (
|
||||
integrationsResult?.data as IntegrationProps[] | undefined
|
||||
)?.[0];
|
||||
setIntegrationUsable(
|
||||
Boolean(integration?.attributes.enabled) &&
|
||||
integration?.attributes.connected === true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const options = mergeOptions(eligibleChannels, storedChannels);
|
||||
// Eligibility decides the state; the merged stored channels only decide what
|
||||
// an already-saved rule renders.
|
||||
const state: FieldState =
|
||||
eligibleChannels.length > 0
|
||||
? FIELD_STATE.POPULATED
|
||||
: integrationUsable
|
||||
? FIELD_STATE.EMPTY_POOL
|
||||
: FIELD_STATE.NO_INTEGRATION;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SlackChannelMultiSelect
|
||||
options={options}
|
||||
values={selectedChannelIds}
|
||||
onChange={onValuesChange}
|
||||
isLoading
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === FIELD_STATE.NO_INTEGRATION) {
|
||||
return (
|
||||
<div data-alert-channels-state={state} className="flex flex-col gap-2">
|
||||
{options.length > 0 ? (
|
||||
// A rule keeps its channels while its workspace is unverified — a
|
||||
// reinstall resets the confirmations, not the mappings — so the
|
||||
// stored selection stays readable until the check runs again.
|
||||
<SlackChannelMultiSelect
|
||||
options={options}
|
||||
values={selectedChannelIds}
|
||||
onChange={onValuesChange}
|
||||
disabled
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Label>Destination channels</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex w-full" tabIndex={0}>
|
||||
<MultiSelect values={[]} onValuesChange={() => undefined}>
|
||||
<MultiSelectTrigger
|
||||
id="slack-channels"
|
||||
aria-label="Destination channels"
|
||||
disabled
|
||||
>
|
||||
<MultiSelectValue placeholder="Requires a connected Slack workspace" />
|
||||
</MultiSelectTrigger>
|
||||
</MultiSelect>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{NO_INTEGRATION_COPY}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<FieldNotice copy={NO_INTEGRATION_COPY} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === FIELD_STATE.EMPTY_POOL) {
|
||||
return (
|
||||
<div data-alert-channels-state={state} className="flex flex-col gap-2">
|
||||
<Label>Destination channels</Label>
|
||||
<FieldNotice copy={EMPTY_POOL_COPY} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-alert-channels-state={state} className="flex flex-col gap-2">
|
||||
<SlackChannelMultiSelect
|
||||
options={options}
|
||||
values={selectedChannelIds}
|
||||
onChange={onValuesChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,10 +23,10 @@ const condition: AlertCondition = {
|
||||
const baseValues = {
|
||||
name: " Critical findings ",
|
||||
description: " Notify security ",
|
||||
method: "email",
|
||||
frequency: ALERT_TRIGGER_KINDS.DAILY,
|
||||
condition,
|
||||
recipientEmails: [" Security@Example.COM ", "ops@example.com"],
|
||||
slackChannels: [" C0123AB ", "C0123AB", ""],
|
||||
enabled: true,
|
||||
} satisfies AlertFormValues;
|
||||
|
||||
@@ -65,6 +65,7 @@ describe("simple alert adapter", () => {
|
||||
trigger: ALERT_TRIGGER_KINDS.DAILY,
|
||||
condition,
|
||||
recipientEmails: ["security@example.com", "ops@example.com"],
|
||||
slackChannels: ["C0123AB"],
|
||||
});
|
||||
expect(payload.condition).toBe(condition);
|
||||
expect(payload).not.toHaveProperty("method");
|
||||
@@ -78,10 +79,10 @@ describe("simple alert adapter", () => {
|
||||
expect(defaults).toEqual({
|
||||
name: "Existing alert",
|
||||
description: "Existing description",
|
||||
method: "email",
|
||||
frequency: ALERT_TRIGGER_KINDS.BOTH,
|
||||
condition,
|
||||
recipientEmails: ["alerts@example.com"],
|
||||
slackChannels: [],
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,10 +8,7 @@ import {
|
||||
type AlertRule,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
import {
|
||||
ALERT_NOTIFICATION_METHODS,
|
||||
type AlertFormValues,
|
||||
} from "../_types/alert-form";
|
||||
import type { AlertFormValues } from "../_types/alert-form";
|
||||
|
||||
const DEFAULT_CONDITION: AlertCondition = {
|
||||
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
|
||||
@@ -24,6 +21,11 @@ const normalizeRecipientEmails = (emails: string[]): string[] =>
|
||||
.map((email) => email.trim().toLowerCase())
|
||||
.filter((email) => email.length > 0);
|
||||
|
||||
const normalizeSlackChannels = (channelIds: string[]): string[] =>
|
||||
Array.from(
|
||||
new Set(channelIds.map((id) => id.trim()).filter((id) => id.length > 0)),
|
||||
);
|
||||
|
||||
export const toAlertPayload = (values: AlertFormValues): AlertPayload => ({
|
||||
name: values.name.trim(),
|
||||
description: values.description.trim(),
|
||||
@@ -31,6 +33,7 @@ export const toAlertPayload = (values: AlertFormValues): AlertPayload => ({
|
||||
trigger: values.frequency,
|
||||
condition: values.condition,
|
||||
recipientEmails: normalizeRecipientEmails(values.recipientEmails),
|
||||
slackChannels: normalizeSlackChannels(values.slackChannels),
|
||||
});
|
||||
|
||||
export const getEmptyAlertFormDefaults = (
|
||||
@@ -39,20 +42,22 @@ export const getEmptyAlertFormDefaults = (
|
||||
): AlertFormValues => ({
|
||||
name: "",
|
||||
description: "",
|
||||
method: ALERT_NOTIFICATION_METHODS.EMAIL,
|
||||
frequency,
|
||||
condition,
|
||||
recipientEmails: [],
|
||||
slackChannels: [],
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
export const getAlertFormDefaults = (alert: AlertRule): AlertFormValues => ({
|
||||
name: alert.attributes.name,
|
||||
description: alert.attributes.description,
|
||||
method: ALERT_NOTIFICATION_METHODS.EMAIL,
|
||||
frequency: alert.attributes.trigger,
|
||||
condition: alert.attributes.condition,
|
||||
recipientEmails: alert.attributes.recipient_emails ?? [],
|
||||
slackChannels: (alert.attributes.slack_channels ?? []).map(
|
||||
(channel) => channel.id,
|
||||
),
|
||||
enabled: alert.attributes.enabled,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
type AlertCondition,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form";
|
||||
|
||||
const alertConditionSchema = z.custom<AlertCondition>(
|
||||
(value) => typeof value === "object" && value !== null,
|
||||
"Alert condition is required.",
|
||||
@@ -15,11 +13,11 @@ const alertConditionSchema = z.custom<AlertCondition>(
|
||||
export const alertFormSchema = z.object({
|
||||
name: z.string().trim().min(1, { error: "Name is required." }).max(120),
|
||||
description: z.string().trim().max(2000).default(""),
|
||||
method: z.literal(ALERT_NOTIFICATION_METHODS.EMAIL),
|
||||
frequency: z.enum(ALERT_TRIGGER_KIND_VALUES),
|
||||
condition: alertConditionSchema,
|
||||
recipientEmails: z
|
||||
.array(z.email({ error: "Enter a valid email address." }))
|
||||
.default([]),
|
||||
slackChannels: z.array(z.string().trim().min(1)).default([]),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
@@ -3,20 +3,14 @@ import type {
|
||||
AlertTriggerKind,
|
||||
} from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
export const ALERT_NOTIFICATION_METHODS = {
|
||||
EMAIL: "email",
|
||||
} as const;
|
||||
|
||||
export type AlertNotificationMethod =
|
||||
(typeof ALERT_NOTIFICATION_METHODS)[keyof typeof ALERT_NOTIFICATION_METHODS];
|
||||
|
||||
export interface AlertFormValues {
|
||||
name: string;
|
||||
description: string;
|
||||
method: AlertNotificationMethod;
|
||||
frequency: AlertTriggerKind;
|
||||
condition: AlertCondition;
|
||||
recipientEmails: string[];
|
||||
/** Slack channel ids drawn from the integration's authorized set. */
|
||||
slackChannels: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SlackChannelOption } from "@/types/integrations";
|
||||
import { SEVERITY_LEVELS } from "@/types/severities";
|
||||
|
||||
// Canonical DSL vocabulary and resource types for the Alerts UI.
|
||||
@@ -147,6 +148,12 @@ export interface AlertRuleAttributes {
|
||||
* `recipient_emails` attribute), not as a JSON:API relationships block.
|
||||
*/
|
||||
recipient_emails?: string[];
|
||||
/**
|
||||
* Slack channel destinations, resolved by the API to id + name + privacy so
|
||||
* the UI renders stored channels without a Slack round-trip. The write side
|
||||
* takes ids only.
|
||||
*/
|
||||
slack_channels?: SlackChannelOption[];
|
||||
created_by?: string | null;
|
||||
inserted_at: string;
|
||||
updated_at: string;
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Page-level test harness for the Alerts page (Vitest Browser Mode).
|
||||
*
|
||||
* A client renderer cannot render an async server component, so the page is
|
||||
* called and the element it returns is what gets rendered — the providers and
|
||||
* Slack harnesses' pattern.
|
||||
*
|
||||
* The channel-destination readers are the harness side of the S2 contract:
|
||||
* the alert form's channels field renders a wrapper carrying
|
||||
* `data-alert-channels-state` (`no-integration` | `empty-pool` | `populated`),
|
||||
* its explanatory copy under `data-alert-channels-notice`, and the shared
|
||||
* multi-select's trigger as `#slack-channels`.
|
||||
*/
|
||||
|
||||
import { createElement } from "react";
|
||||
|
||||
import { BrowserHarness } from "@/__tests__/browser-harness";
|
||||
import { handlersForAlerts } from "@/__tests__/msw/handlers/alerts";
|
||||
import type { AlertsFixture } from "@/__tests__/msw/handlers/alerts.fixtures";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import type { AlertsFilterBag } from "@/app/(prowler)/alerts/_types";
|
||||
|
||||
import { SeedFromFindingsButton } from "./_components/seed-from-findings-button";
|
||||
import AlertsPage from "./page";
|
||||
|
||||
export const CHANNEL_FIELD_STATE = {
|
||||
/** No enabled and connected Slack workspace: visible, disabled, explains itself. */
|
||||
NO_INTEGRATION: "no-integration",
|
||||
/** Workspace connected, no channel eligible yet. */
|
||||
EMPTY_POOL: "empty-pool",
|
||||
/** The eligible channels are on offer. */
|
||||
POPULATED: "populated",
|
||||
} as const;
|
||||
|
||||
export type ChannelFieldState =
|
||||
(typeof CHANNEL_FIELD_STATE)[keyof typeof CHANNEL_FIELD_STATE];
|
||||
|
||||
/** A selected channel as the user reads it off the closed field. */
|
||||
export interface SelectedChannelChip {
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CREATE_FILTER_BAG: AlertsFilterBag = {
|
||||
"filter[severity__in]": ["critical"],
|
||||
};
|
||||
|
||||
export class AlertsPageHarness extends BrowserHarness<AlertsFixture> {
|
||||
private wireHandlers(): void {
|
||||
worker.use(...handlersForAlerts(this.fixture));
|
||||
this.trackRequests(worker);
|
||||
}
|
||||
|
||||
// --- Mounting -----------------------------------------------------------
|
||||
|
||||
/** Open the alerts page, the way a visit does. */
|
||||
async mount(
|
||||
searchParams: Record<string, string | undefined> = {},
|
||||
): Promise<void> {
|
||||
window.history.replaceState(null, "", "/alerts");
|
||||
this.wireHandlers();
|
||||
|
||||
render(await AlertsPage({ searchParams: Promise.resolve(searchParams) }));
|
||||
await this.waitForText(/Get notified when findings match/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the creation entry as the findings page composes it — the alerts
|
||||
* page itself only edits; rules are created from Findings (seed flow).
|
||||
*/
|
||||
mountCreateEntry(
|
||||
filterBag: AlertsFilterBag = DEFAULT_CREATE_FILTER_BAG,
|
||||
): void {
|
||||
window.history.replaceState(null, "", "/findings");
|
||||
this.wireHandlers();
|
||||
|
||||
render(createElement(SeedFromFindingsButton, { filterBag }));
|
||||
}
|
||||
|
||||
// --- The alert modal ------------------------------------------------------
|
||||
|
||||
private dialog(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>('[role="dialog"]');
|
||||
}
|
||||
|
||||
/** Seed from the mounted create entry and wait for the modal. */
|
||||
async openCreateModal(): Promise<void> {
|
||||
await this.clickButton(/Create Alert/);
|
||||
await this.waitFor(() => this.dialog(), 10000, "the create alert modal");
|
||||
}
|
||||
|
||||
/** Open a listed rule for editing, by its name. */
|
||||
async openEditModal(ruleName: string): Promise<void> {
|
||||
const nameButton = await this.waitFor(
|
||||
() =>
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLButtonElement>("button"),
|
||||
).find((button) => (button.textContent ?? "").trim() === ruleName),
|
||||
10000,
|
||||
`the listed rule "${ruleName}"`,
|
||||
);
|
||||
await this.clickElement(nameButton, { fallbackToDomClick: true });
|
||||
await this.waitFor(() => this.dialog(), 10000, "the edit alert modal");
|
||||
}
|
||||
|
||||
/** Submit the open modal (Create or Save) and wait for it to close. */
|
||||
async saveRule(): Promise<void> {
|
||||
await this.submitModal();
|
||||
await this.waitFor(
|
||||
() => this.dialog() === null,
|
||||
10000,
|
||||
"the alert modal to close after saving",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the open modal expecting a refusal, and hand back what the user is
|
||||
* told. A save that closes the modal fails the test rather than timing out.
|
||||
*/
|
||||
async refusedRuleSave(): Promise<string> {
|
||||
await this.submitModal();
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.dialog() === null) {
|
||||
throw new Error("refusedRuleSave: the save landed, not refused");
|
||||
}
|
||||
return this.modalErrorText();
|
||||
},
|
||||
10000,
|
||||
"the refused rule save",
|
||||
);
|
||||
}
|
||||
|
||||
private async submitModal(): Promise<void> {
|
||||
const dialog = this.dialog();
|
||||
if (!dialog) throw new Error("submitModal: no alert modal is open");
|
||||
const submit = await this.waitFor(
|
||||
() => this.buttonByText(/^(Create|Save)$/, dialog),
|
||||
5000,
|
||||
"the modal submit button",
|
||||
);
|
||||
await this.clickElement(submit, { fallbackToDomClick: true });
|
||||
}
|
||||
|
||||
private modalErrorText(): string | null {
|
||||
const dialog = this.dialog();
|
||||
if (!dialog) return null;
|
||||
const error = dialog.querySelector<HTMLElement>(".text-text-error-primary");
|
||||
const text = (error?.textContent ?? "").trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The channel ids the last rule write actually submitted, read off the
|
||||
* contract's `[{id}, …]` write shape.
|
||||
*/
|
||||
async savedRuleChannels(): Promise<string[] | undefined> {
|
||||
const method =
|
||||
this.countRequests("POST", "/alerts/rules") >
|
||||
this.countRequests("PATCH", "/alerts/rules")
|
||||
? "POST"
|
||||
: "PATCH";
|
||||
const body = await this.lastRequestBody<{
|
||||
data?: { attributes?: { slack_channels?: { id: string }[] } };
|
||||
}>(method, "/alerts/rules");
|
||||
return body?.data?.attributes?.slack_channels?.map((channel) => channel.id);
|
||||
}
|
||||
|
||||
// --- The channel destination field ---------------------------------------
|
||||
|
||||
private channelField(): HTMLElement | null {
|
||||
return document.querySelector<HTMLElement>("[data-alert-channels-state]");
|
||||
}
|
||||
|
||||
/** Which of its three states the channel destination is presenting. */
|
||||
async channelFieldState(): Promise<ChannelFieldState> {
|
||||
const field = await this.waitFor(
|
||||
() => this.channelField(),
|
||||
10000,
|
||||
"the channel destination field",
|
||||
);
|
||||
return field.getAttribute("data-alert-channels-state") as ChannelFieldState;
|
||||
}
|
||||
|
||||
/** The copy explaining a degraded state, wherever the field renders it. */
|
||||
async channelFieldNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => document.querySelector<HTMLElement>("[data-alert-channels-notice]"),
|
||||
10000,
|
||||
"the channel destination notice",
|
||||
);
|
||||
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** The affordance a degraded state offers toward the integration page. */
|
||||
integrationAffordanceHref(): string | null {
|
||||
const scopes: (ParentNode | null)[] = [
|
||||
document.querySelector("[data-alert-channels-notice]"),
|
||||
this.channelField(),
|
||||
];
|
||||
for (const scope of scopes) {
|
||||
const link = scope?.querySelector<HTMLAnchorElement>(
|
||||
'a[href="/integrations/slack"]',
|
||||
);
|
||||
if (link) return link.getAttribute("href");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The options inside the popover THIS trigger controls, or null. Correlated
|
||||
* through `aria-controls`: two pickers share the page (channels,
|
||||
* recipients) and the MultiSelect also keeps a hidden mirror of its items,
|
||||
* so any unscoped `[role="option"]` query can answer for the wrong picker.
|
||||
*/
|
||||
private static optionsControlledBy(
|
||||
trigger: HTMLElement,
|
||||
): HTMLElement[] | null {
|
||||
const contentId = trigger.getAttribute("aria-controls");
|
||||
const content = contentId ? document.getElementById(contentId) : null;
|
||||
if (!content || content.getAttribute("data-state") !== "open") return null;
|
||||
const options = Array.from(
|
||||
content.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
);
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
private async openPicker(triggerSelector: string): Promise<HTMLElement[]> {
|
||||
const trigger = await this.waitFor<HTMLElement>(
|
||||
() => this.q(triggerSelector),
|
||||
10000,
|
||||
`the ${triggerSelector} picker`,
|
||||
);
|
||||
const mounted = () => AlertsPageHarness.optionsControlledBy(trigger);
|
||||
|
||||
const alreadyOpen = mounted();
|
||||
if (alreadyOpen) return alreadyOpen;
|
||||
|
||||
await this.clickElement(trigger, { fallbackToDomClick: true });
|
||||
|
||||
let options = await this.waitForOrNull(mounted, 2000, "the picker options");
|
||||
if (!options) {
|
||||
await this.user.keyboard("{Enter}");
|
||||
options = await this.waitForOrNull(mounted, 8000, "the picker options");
|
||||
}
|
||||
if (!options) {
|
||||
throw new Error(`openPicker: ${triggerSelector} offered nothing`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private async openChannelPicker(): Promise<HTMLElement[]> {
|
||||
return this.openPicker("#slack-channels");
|
||||
}
|
||||
|
||||
/** Whether the channel picker is rendered but refuses interaction. */
|
||||
async channelPickerDisabled(): Promise<boolean> {
|
||||
const trigger = await this.waitFor(
|
||||
() => this.q("#slack-channels"),
|
||||
10000,
|
||||
"the channel picker trigger",
|
||||
);
|
||||
return (
|
||||
(trigger as HTMLButtonElement).disabled ||
|
||||
trigger.getAttribute("aria-disabled") === "true" ||
|
||||
trigger.hasAttribute("disabled")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the named recipient emails in the recipients picker, verifying
|
||||
* each pick against its chip like `pickChannels` does.
|
||||
*/
|
||||
async pickRecipients(emails: string[]): Promise<void> {
|
||||
for (const email of emails) {
|
||||
let picked = false;
|
||||
for (let attempt = 0; attempt < 3 && !picked; attempt += 1) {
|
||||
const options = await this.openPicker("#alert-recipients");
|
||||
const option = options.find((candidate) =>
|
||||
(candidate.textContent ?? "").includes(email),
|
||||
);
|
||||
if (!option) {
|
||||
await this.closePicker("#alert-recipients");
|
||||
throw new Error(`pickRecipients: no recipient "${email}" is offered`);
|
||||
}
|
||||
await this.clickElement(option, { fallbackToDomClick: true });
|
||||
picked =
|
||||
(await this.waitForOrNull(
|
||||
() => this.chipContaining(email),
|
||||
2000,
|
||||
`the ${email} chip`,
|
||||
)) !== null;
|
||||
}
|
||||
if (!picked) {
|
||||
throw new Error(`pickRecipients: ${email} never showed as selected`);
|
||||
}
|
||||
}
|
||||
await this.closePicker("#alert-recipients");
|
||||
}
|
||||
|
||||
/**
|
||||
* Close an open picker by clicking a neutral spot inside the dialog (its
|
||||
* title). Not a bare Escape — with focus outside the popover it reaches the
|
||||
* dialog and closes the whole modal. Not the trigger either — its chips
|
||||
* remove-on-click, so a click landing on one silently drops a selection.
|
||||
*/
|
||||
private async closePicker(triggerSelector: string): Promise<void> {
|
||||
const trigger = this.q(triggerSelector);
|
||||
const isOpen = () => {
|
||||
const contentId = trigger?.getAttribute("aria-controls");
|
||||
const content = contentId ? document.getElementById(contentId) : null;
|
||||
return content?.getAttribute("data-state") === "open";
|
||||
};
|
||||
if (trigger && isOpen()) {
|
||||
const neutral =
|
||||
this.dialog()?.querySelector<HTMLElement>("h2") ?? trigger;
|
||||
await this.clickElement(neutral, { fallbackToDomClick: true });
|
||||
await this.waitForOrNull(() => !isOpen(), 2000, "the picker to close");
|
||||
}
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
private async closeChannelPicker(): Promise<void> {
|
||||
await this.closePicker("#slack-channels");
|
||||
}
|
||||
|
||||
private static optionChannelName(option: HTMLElement): string {
|
||||
return (
|
||||
option.getAttribute("data-channel") ??
|
||||
(option.textContent ?? "")
|
||||
.replace(/Private/g, "")
|
||||
.trim()
|
||||
.replace(/^#/, "")
|
||||
);
|
||||
}
|
||||
|
||||
/** The channels offered for the rule, in the order the picker lists them. */
|
||||
async offeredChannels(): Promise<string[]> {
|
||||
const options = await this.openChannelPicker();
|
||||
const names = options.map(AlertsPageHarness.optionChannelName);
|
||||
await this.closeChannelPicker();
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Whether the channel offered under `name` is presented as private. */
|
||||
async isChannelOfferedAsPrivate(name: string): Promise<boolean> {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(candidate) => AlertsPageHarness.optionChannelName(candidate) === name,
|
||||
);
|
||||
await this.closeChannelPicker();
|
||||
return /Private/.test(option?.textContent ?? "");
|
||||
}
|
||||
|
||||
/** A visible chip whose text contains `text`, anywhere in the open form. */
|
||||
private chipContaining(text: string): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>("[data-selected-item]"),
|
||||
).find((chip) => (chip.textContent ?? "").includes(text)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the named channels in the picker, then close it. Each pick is
|
||||
* verified against the chip the user sees and retried when the click
|
||||
* landed on a node the selection re-render had already replaced.
|
||||
*/
|
||||
async pickChannels(names: string[]): Promise<void> {
|
||||
for (const name of names) {
|
||||
let picked = false;
|
||||
for (let attempt = 0; attempt < 3 && !picked; attempt += 1) {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(candidate) =>
|
||||
AlertsPageHarness.optionChannelName(candidate) === name,
|
||||
);
|
||||
if (!option) {
|
||||
await this.closeChannelPicker();
|
||||
throw new Error(
|
||||
`pickChannels: no channel named "${name}" is offered`,
|
||||
);
|
||||
}
|
||||
await this.clickElement(option, { fallbackToDomClick: true });
|
||||
picked =
|
||||
(await this.waitForOrNull(
|
||||
() => this.chipContaining(`#${name}`),
|
||||
2000,
|
||||
`the #${name} chip`,
|
||||
)) !== null;
|
||||
}
|
||||
if (!picked) {
|
||||
throw new Error(`pickChannels: #${name} never showed as selected`);
|
||||
}
|
||||
}
|
||||
await this.closeChannelPicker();
|
||||
}
|
||||
|
||||
/**
|
||||
* The rule's selected channels as the closed field shows them — name and
|
||||
* privacy read from the chip the user sees.
|
||||
*/
|
||||
async selectedChannelChips(): Promise<SelectedChannelChip[]> {
|
||||
const field = await this.waitFor(
|
||||
() => this.channelField(),
|
||||
10000,
|
||||
"the channel destination field",
|
||||
);
|
||||
const chips = Array.from(
|
||||
field.querySelectorAll<HTMLElement>("[data-selected-item]"),
|
||||
);
|
||||
return chips.map((chip) => {
|
||||
const text = (chip.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
return {
|
||||
isPrivate: /Private/.test(text),
|
||||
// The chip renders `#name` with an sr-only "Private" marker.
|
||||
name: text
|
||||
.replace(/Private/g, "")
|
||||
.trim()
|
||||
.replace(/^#/, ""),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- The alerts list ------------------------------------------------------
|
||||
|
||||
/** The names of the listed rules, in table order. */
|
||||
async listedRules(): Promise<string[]> {
|
||||
const rows = await this.waitFor(
|
||||
() => {
|
||||
const found = Array.from(
|
||||
this.container.querySelectorAll<HTMLTableRowElement>("tbody tr"),
|
||||
);
|
||||
return found.length > 0 ? found : null;
|
||||
},
|
||||
10000,
|
||||
"the alerts list",
|
||||
);
|
||||
return rows.map((row) =>
|
||||
(row.querySelector("button")?.textContent ?? "").trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/** The destinations summary the list shows for a rule, without opening it. */
|
||||
async ruleDestinationsSummary(ruleName: string): Promise<string> {
|
||||
const headers = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>("thead th"),
|
||||
);
|
||||
const columnIndex = headers.findIndex((header) =>
|
||||
/Destinations/.test(header.textContent ?? ""),
|
||||
);
|
||||
if (columnIndex === -1) {
|
||||
throw new Error("ruleDestinationsSummary: no destinations column");
|
||||
}
|
||||
|
||||
const row = await this.waitFor(
|
||||
() =>
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLTableRowElement>("tbody tr"),
|
||||
).find((candidate) => (candidate.textContent ?? "").includes(ruleName)),
|
||||
10000,
|
||||
`the listed rule "${ruleName}"`,
|
||||
);
|
||||
const cell = row.querySelectorAll("td")[columnIndex];
|
||||
return (cell?.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Browser-mode tests for the Alerts page (`/alerts`) and the alert modal's
|
||||
* Slack channel destinations, driven through `AlertsPageHarness`. MSW answers
|
||||
* from handlers encoding the signed contract
|
||||
* (`openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`).
|
||||
*
|
||||
* The no-integration and empty-pool states are driven through fixtures that
|
||||
* OMIT the integration, its channels or their confirmations (design D9) —
|
||||
* never by handing the UI a pre-disabled state.
|
||||
*/
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { it } from "@/__tests__/fixtures";
|
||||
import {
|
||||
ALERTS_PRIVATE_CHANNEL,
|
||||
ALERTS_PUBLIC_CHANNEL,
|
||||
alertRuleFixture,
|
||||
alertsFixture,
|
||||
emptyChannelPoolAlertsFixture,
|
||||
noSlackAlertsFixture,
|
||||
reinstalledWorkspaceAlertsFixture,
|
||||
} from "@/__tests__/msw/handlers/alerts.fixtures";
|
||||
|
||||
import { AlertsPageHarness, CHANNEL_FIELD_STATE } from "./alerts-page.harness";
|
||||
|
||||
const RULE_NAME = "Critical findings";
|
||||
|
||||
interface RuleWriteBody {
|
||||
data: { attributes: { recipient_emails?: string[] } };
|
||||
}
|
||||
|
||||
describe("alert rules target Slack channels", () => {
|
||||
it("creates a rule with channels and an email, keeping both destination kinds", async () => {
|
||||
const harness = new AlertsPageHarness(alertsFixture());
|
||||
harness.mountCreateEntry();
|
||||
await harness.openCreateModal();
|
||||
|
||||
await harness.pickChannels([
|
||||
ALERTS_PUBLIC_CHANNEL.name,
|
||||
ALERTS_PRIVATE_CHANNEL.name,
|
||||
]);
|
||||
await harness.pickRecipients(["security@example.com"]);
|
||||
await harness.saveRule();
|
||||
|
||||
expect(await harness.savedRuleChannels()).toEqual([
|
||||
ALERTS_PUBLIC_CHANNEL.id,
|
||||
ALERTS_PRIVATE_CHANNEL.id,
|
||||
]);
|
||||
const body = await harness.lastRequestBody<RuleWriteBody>(
|
||||
"POST",
|
||||
"/alerts/rules",
|
||||
);
|
||||
expect(body?.data.attributes.recipient_emails).toContain(
|
||||
"security@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a rule whose only destinations are channels", async () => {
|
||||
const harness = new AlertsPageHarness(alertsFixture());
|
||||
harness.mountCreateEntry();
|
||||
await harness.openCreateModal();
|
||||
|
||||
await harness.pickChannels([ALERTS_PRIVATE_CHANNEL.name]);
|
||||
await harness.saveRule();
|
||||
|
||||
expect(await harness.savedRuleChannels()).toEqual([
|
||||
ALERTS_PRIVATE_CHANNEL.id,
|
||||
]);
|
||||
const body = await harness.lastRequestBody<RuleWriteBody>(
|
||||
"POST",
|
||||
"/alerts/rules",
|
||||
);
|
||||
expect(body?.data.attributes.recipient_emails).toEqual([]);
|
||||
});
|
||||
|
||||
it("offers exactly the eligible channels", async () => {
|
||||
const harness = new AlertsPageHarness(alertsFixture());
|
||||
harness.mountCreateEntry();
|
||||
await harness.openCreateModal();
|
||||
|
||||
const offered = await harness.offeredChannels();
|
||||
|
||||
expect(offered).toEqual(
|
||||
expect.arrayContaining([
|
||||
ALERTS_PUBLIC_CHANNEL.name,
|
||||
ALERTS_PRIVATE_CHANNEL.name,
|
||||
]),
|
||||
);
|
||||
expect(offered).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("identifies a private channel in the listing and on its chip", async () => {
|
||||
const harness = new AlertsPageHarness(alertsFixture());
|
||||
harness.mountCreateEntry();
|
||||
await harness.openCreateModal();
|
||||
|
||||
expect(
|
||||
await harness.isChannelOfferedAsPrivate(ALERTS_PRIVATE_CHANNEL.name),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await harness.isChannelOfferedAsPrivate(ALERTS_PUBLIC_CHANNEL.name),
|
||||
).toBe(false);
|
||||
|
||||
await harness.pickChannels([ALERTS_PRIVATE_CHANNEL.name]);
|
||||
|
||||
expect(await harness.selectedChannelChips()).toEqual([
|
||||
{ name: ALERTS_PRIVATE_CHANNEL.name, isPrivate: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("says channels must be authorized and checked when nothing is eligible, and still saves", async () => {
|
||||
const harness = new AlertsPageHarness(emptyChannelPoolAlertsFixture());
|
||||
await harness.mount();
|
||||
await harness.openEditModal(RULE_NAME);
|
||||
|
||||
expect(await harness.channelFieldState()).toBe(
|
||||
CHANNEL_FIELD_STATE.EMPTY_POOL,
|
||||
);
|
||||
const notice = await harness.channelFieldNotice();
|
||||
expect(notice).toMatch(/authorize/i);
|
||||
expect(notice).toMatch(/connection check/i);
|
||||
expect(harness.integrationAffordanceHref()).toBe("/integrations/slack");
|
||||
|
||||
// The rule's other fields and destinations still save.
|
||||
await harness.saveRule();
|
||||
expect(await harness.savedRuleChannels()).toEqual([]);
|
||||
});
|
||||
|
||||
it("explains the disabled destination when no workspace is connected", async () => {
|
||||
const harness = new AlertsPageHarness(noSlackAlertsFixture());
|
||||
await harness.mount();
|
||||
await harness.openEditModal(RULE_NAME);
|
||||
|
||||
expect(await harness.channelFieldState()).toBe(
|
||||
CHANNEL_FIELD_STATE.NO_INTEGRATION,
|
||||
);
|
||||
expect(await harness.channelPickerDisabled()).toBe(true);
|
||||
// Deleting the workspace deletes the rules' channel mappings with it.
|
||||
expect(await harness.selectedChannelChips()).toEqual([]);
|
||||
expect(await harness.channelFieldNotice()).toMatch(
|
||||
/connected Slack workspace/i,
|
||||
);
|
||||
expect(harness.integrationAffordanceHref()).toBe("/integrations/slack");
|
||||
});
|
||||
|
||||
it("shows the stored selection, by name and privacy, when editing", async () => {
|
||||
const harness = new AlertsPageHarness(
|
||||
alertsFixture({
|
||||
rules: [
|
||||
alertRuleFixture({
|
||||
slackChannelIds: [
|
||||
ALERTS_PUBLIC_CHANNEL.id,
|
||||
ALERTS_PRIVATE_CHANNEL.id,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.openEditModal(RULE_NAME);
|
||||
|
||||
expect(await harness.channelFieldState()).toBe(
|
||||
CHANNEL_FIELD_STATE.POPULATED,
|
||||
);
|
||||
expect(await harness.selectedChannelChips()).toEqual([
|
||||
{ name: ALERTS_PUBLIC_CHANNEL.name, isPrivate: false },
|
||||
{ name: ALERTS_PRIVATE_CHANNEL.name, isPrivate: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("submits the complete selection, not just the added channel", async () => {
|
||||
const harness = new AlertsPageHarness(
|
||||
alertsFixture({
|
||||
rules: [
|
||||
alertRuleFixture({ slackChannelIds: [ALERTS_PUBLIC_CHANNEL.id] }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.openEditModal(RULE_NAME);
|
||||
|
||||
await harness.pickChannels([ALERTS_PRIVATE_CHANNEL.name]);
|
||||
await harness.saveRule();
|
||||
|
||||
expect(await harness.savedRuleChannels()).toEqual([
|
||||
ALERTS_PUBLIC_CHANNEL.id,
|
||||
ALERTS_PRIVATE_CHANNEL.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a stored channel readable after a reinstall reset its confirmation", async () => {
|
||||
const harness = new AlertsPageHarness(reinstalledWorkspaceAlertsFixture());
|
||||
await harness.mount();
|
||||
await harness.openEditModal(RULE_NAME);
|
||||
|
||||
// The workspace still carries both channels, but an unverified install
|
||||
// offers none of them: the options come from the eligible-channels
|
||||
// endpoint, never from the integration's configuration.
|
||||
expect(await harness.channelFieldState()).toBe(
|
||||
CHANNEL_FIELD_STATE.NO_INTEGRATION,
|
||||
);
|
||||
expect(await harness.channelPickerDisabled()).toBe(true);
|
||||
expect(await harness.selectedChannelChips()).toEqual([
|
||||
{ name: ALERTS_PUBLIC_CHANNEL.name, isPrivate: false },
|
||||
{ name: ALERTS_PRIVATE_CHANNEL.name, isPrivate: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces the refusal when a stored channel is not confirmed and connected", async () => {
|
||||
const harness = new AlertsPageHarness(reinstalledWorkspaceAlertsFixture());
|
||||
await harness.mount();
|
||||
await harness.openEditModal(RULE_NAME);
|
||||
|
||||
const refusal = await harness.refusedRuleSave();
|
||||
|
||||
expect(refusal).toMatch(/Slack must be connected/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the alerts list shows destinations", () => {
|
||||
it("shows a rule's channels by name alongside its emails, without opening it", async () => {
|
||||
const harness = new AlertsPageHarness(
|
||||
alertsFixture({
|
||||
rules: [
|
||||
alertRuleFixture({
|
||||
slackChannelIds: [
|
||||
ALERTS_PUBLIC_CHANNEL.id,
|
||||
ALERTS_PRIVATE_CHANNEL.id,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
expect(await harness.ruleDestinationsSummary(RULE_NAME)).toBe(
|
||||
`security@example.com · #${ALERTS_PUBLIC_CHANNEL.name} +1 more`,
|
||||
);
|
||||
});
|
||||
|
||||
it("reads correctly for emails-only, channels-only and empty rules", async () => {
|
||||
const harness = new AlertsPageHarness(
|
||||
alertsFixture({
|
||||
rules: [
|
||||
alertRuleFixture({ id: "rule-emails", name: "Emails only" }),
|
||||
alertRuleFixture({
|
||||
id: "rule-channels",
|
||||
name: "Channels only",
|
||||
recipientEmails: [],
|
||||
slackChannelIds: [ALERTS_PRIVATE_CHANNEL.id],
|
||||
}),
|
||||
alertRuleFixture({
|
||||
id: "rule-none",
|
||||
name: "No destinations yet",
|
||||
recipientEmails: [],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
expect(await harness.ruleDestinationsSummary("Emails only")).toBe(
|
||||
"security@example.com",
|
||||
);
|
||||
expect(await harness.ruleDestinationsSummary("Channels only")).toBe(
|
||||
`#${ALERTS_PRIVATE_CHANNEL.name}`,
|
||||
);
|
||||
expect(await harness.ruleDestinationsSummary("No destinations yet")).toBe(
|
||||
"No destinations",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
Slack channels confirmed on the Slack integration as alert rule destinations, selectable in the alert modal alongside email recipients
|
||||
@@ -0,0 +1 @@
|
||||
Alerts list Recipients column becomes Destinations, summarizing a rule's email recipients and Slack channels at a glance
|
||||
Reference in New Issue
Block a user