Compare commits

...
Author SHA1 Message Date
Pablo F.G 7ccf89fa29 fix(ui): feed the destinations column from the reworked alerts fixtures
- Seed a rule's channels by id, as the mapping table now holds them
- Anchor the list reader on the Destinations column it really renders
2026-08-21 14:40:04 +02:00
Pablo F.G e666d9b725 feat(ui): show a rule's destinations in the alerts list
- Retitle the Recipients column to Destinations per design D6
- Summarize emails and Slack channels side by side, at a glance
- Cover list-altitude destination summaries in the integration tests
2026-08-21 14:40:04 +02:00
Pablo F.G 67e1ec3f51 feat(ui): align alert Slack destinations with the signed API contract
- Offer the channels the eligible-channels endpoint returns
- Write slack_channels as objects carrying only the channel id
- Drop the destination states the server-side cascade made impossible
- Point the empty-pool notice at the connection check
2026-08-21 14:40:04 +02:00
Pablo F.G a87cbd2434 feat(ui): let alert rules target authorized Slack channels
- Add the channels field to the alert modal below Recipients
- Keep stored channels visible when de-authorized or disconnected
- Cover the alerts page spec with browser-mode integration tests
- Repair the unit tests the form rework touched
2026-08-21 14:40:04 +02:00
Pablo F.G 1fc021b157 feat(ui): scaffold Slack channel destinations for alerts
- Remove the alert form's method literal; destinations are the truth
- Add slackChannels to form values, schema, adapter and rule payload
- Add MSW alerts handlers and fixtures with the D3 channel contract
- Add the alerts page browser harness with channel vocabulary
2026-08-21 14:40:04 +02:00
19 changed files with 1750 additions and 32 deletions
@@ -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,
});
+428
View File
@@ -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,
});
});
+11 -6
View File
@@ -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(),
});
+2 -8
View File
@@ -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;
}
+7
View File
@@ -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