mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-21 05:13:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1003491f1 | ||
|
|
7feaf213e6 | ||
|
|
82e286c37d |
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Fixture data for the Slack handlers. Shapes follow the API contract in
|
||||
* `openspec/changes/add-slack-integration/design.md`.
|
||||
* Fixture data for the Slack handlers. Shapes follow the signed API contract in
|
||||
* `openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`.
|
||||
*/
|
||||
|
||||
export interface SlackWorkspaceFixture {
|
||||
@@ -8,19 +8,35 @@ export interface SlackWorkspaceFixture {
|
||||
teamName: string;
|
||||
botUserId: string;
|
||||
/**
|
||||
* Absent from the serialized configuration until a channel is chosen: the API
|
||||
* omits the keys rather than sending nulls.
|
||||
* The authorized set. A new install has none, which the API serializes as an
|
||||
* empty `channels` array rather than by omitting the key.
|
||||
*/
|
||||
channelId?: string;
|
||||
channelName?: string;
|
||||
authorizedChannels?: SlackAuthorizedChannelFixture[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection check the API last recorded. Every field is null until one is
|
||||
* queued; a same-workspace reinstall puts them back that way.
|
||||
*/
|
||||
export interface SlackVerificationFixture {
|
||||
taskId: string | null;
|
||||
startedAt: string | null;
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
export const NO_VERIFICATION: SlackVerificationFixture = {
|
||||
taskId: null,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
};
|
||||
|
||||
export interface SlackInstallFixture {
|
||||
id: string;
|
||||
/** `null` until the first connection check runs. */
|
||||
connected: boolean | null;
|
||||
connectionLastCheckedAt: string | null;
|
||||
workspace: SlackWorkspaceFixture;
|
||||
verification?: SlackVerificationFixture;
|
||||
}
|
||||
|
||||
export const SLACK_EXCHANGE_OUTCOME = {
|
||||
@@ -46,6 +62,13 @@ export type SlackExchangeOutcome =
|
||||
export interface SlackConnectionFixture {
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
/**
|
||||
* The channel a channel-level failure is about, named by the task result so
|
||||
* the user hears which one Slack refused (contract, Connection). Absent for
|
||||
* credential-level failures, which are about the workspace as a whole.
|
||||
* TODO(Josema): the key the result names it under.
|
||||
*/
|
||||
failedChannelName?: string | null;
|
||||
}
|
||||
|
||||
/** A channel the listing endpoint offers for the picker. */
|
||||
@@ -56,6 +79,15 @@ export interface SlackChannelFixture {
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A channel authorized on the integration: the listing's fields plus when the
|
||||
* one-time confirmation landed in it. `null` means the next connection check
|
||||
* posts one there; a check never posts to a channel that already has one.
|
||||
*/
|
||||
export interface SlackAuthorizedChannelFixture extends SlackChannelFixture {
|
||||
confirmationSentAt: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal as the API sends one: the machine-readable reason in `code`, human
|
||||
* copy in `detail`, and — for a `429` — the wait in `Retry-After`.
|
||||
@@ -329,12 +361,6 @@ export const SLACK_CHANNELS: SlackChannelFixture[] = [
|
||||
/** Two channels per page, so `SLACK_CHANNELS` spans exactly two pages. */
|
||||
export const SLACK_CHANNELS_PAGE_SIZE = 2;
|
||||
|
||||
/**
|
||||
* The first channel the picker offers, so an install seeded with it always
|
||||
* points at a channel the listing really has.
|
||||
*/
|
||||
export const SLACK_DEFAULT_CHANNEL = SLACK_PUBLIC_CHANNEL;
|
||||
|
||||
const PROWLER_HQ: SlackWorkspaceFixture = {
|
||||
teamId: "T01PROWLER",
|
||||
teamName: "Prowler HQ",
|
||||
@@ -374,34 +400,79 @@ export const connectedSlackFixture = (
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...PROWLER_HQ },
|
||||
workspace: { ...PROWLER_HQ, authorizedChannels: [] },
|
||||
verification: { ...NO_VERIFICATION },
|
||||
},
|
||||
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** When the check that left this install connected posted its confirmations. */
|
||||
export const SLACK_CONFIRMED_AT = "2026-08-10T09:30:00Z";
|
||||
|
||||
/** The verification of that same check, as the configuration carries it. */
|
||||
const SETTLED_VERIFICATION: SlackVerificationFixture = {
|
||||
taskId: "5d408881-3e9e-4195-a281-a8d1be849472",
|
||||
startedAt: "2026-08-10T09:29:58Z",
|
||||
finishedAt: SLACK_CONFIRMED_AT,
|
||||
};
|
||||
|
||||
/**
|
||||
* A channel as the integration stores it, confirmed by default: an install
|
||||
* reporting itself connected has had a check post to every channel on it.
|
||||
*/
|
||||
export const authorizedChannel = (
|
||||
channel: SlackChannelFixture,
|
||||
confirmationSentAt: string | null = SLACK_CONFIRMED_AT,
|
||||
): SlackAuthorizedChannelFixture => ({ ...channel, confirmationSentAt });
|
||||
|
||||
const configuredInstall = (
|
||||
channel: SlackChannelFixture = SLACK_DEFAULT_CHANNEL,
|
||||
channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL],
|
||||
): SlackInstallFixture => ({
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-08-10T09:30:00Z",
|
||||
connectionLastCheckedAt: SLACK_CONFIRMED_AT,
|
||||
workspace: {
|
||||
...PROWLER_HQ,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
authorizedChannels: channels.map((channel) => authorizedChannel(channel)),
|
||||
},
|
||||
verification: { ...SETTLED_VERIFICATION },
|
||||
});
|
||||
|
||||
/**
|
||||
* The same tenant with a destination channel already on record: the state a
|
||||
* second visit starts from.
|
||||
* The same tenant with destination channels already authorized and confirmed:
|
||||
* the state a second visit starts from.
|
||||
*/
|
||||
export const slackFixtureWithDefaultChannel = (
|
||||
channel: SlackChannelFixture = SLACK_PUBLIC_CHANNEL,
|
||||
export const slackFixtureWithAuthorizedChannels = (
|
||||
channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL],
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({ install: configuredInstall(channel), ...overrides });
|
||||
connectedSlackFixture({ install: configuredInstall(channels), ...overrides });
|
||||
|
||||
/**
|
||||
* Channels authorized and none of them confirmed: what a same-workspace
|
||||
* reinstall leaves behind, which keeps the set but resets every confirmation
|
||||
* along with the connection and verification state (contract, OAuth and reads).
|
||||
*/
|
||||
export const unconfirmedChannelsSlackFixture = (
|
||||
channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL],
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
install: {
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: {
|
||||
...PROWLER_HQ,
|
||||
authorizedChannels: channels.map((channel) =>
|
||||
authorizedChannel(channel, null),
|
||||
),
|
||||
},
|
||||
verification: { ...NO_VERIFICATION },
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* The same finished setup, with a check time no parser can read: a zero date
|
||||
@@ -423,20 +494,20 @@ export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
|
||||
export const partiallyReadSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], {
|
||||
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
|
||||
channelsRefusalFromCursor: SLACK_CHANNELS_PAGE_SIZE,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A workspace connected *and* a channel on record. Anything the API refuses
|
||||
* until a channel exists (the connection check) needs this fixture.
|
||||
* A workspace connected *and* channels authorized. Anything the API refuses
|
||||
* while the set is empty (the connection check) needs this fixture.
|
||||
*/
|
||||
export const configuredSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
|
||||
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], overrides);
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect removes the row but cannot revoke at
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* MSW handlers for the Slack integration, derived from the API contract in
|
||||
* `openspec/changes/add-slack-integration/design.md` (the API itself lives in
|
||||
* the cloud repository). State is per-call: an exchange creates the install the
|
||||
* subsequent `GET /integrations` returns.
|
||||
* MSW handlers for the Slack integration, derived from the signed API contract
|
||||
* in `openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`
|
||||
* (the API itself lives in the cloud repository). State is per-call: an
|
||||
* exchange creates the install the subsequent `GET /integrations` returns, and
|
||||
* a save or a connection check writes to it.
|
||||
*
|
||||
* Wire them per test via `worker.use(...handlersForSlack(fx))`.
|
||||
*/
|
||||
@@ -11,6 +12,7 @@ import { http, HttpResponse } from "msw";
|
||||
|
||||
import {
|
||||
INTEGRATIONS_SERVER_ERROR_DETAIL,
|
||||
NO_VERIFICATION,
|
||||
PROXY_CHALLENGE_PAGE,
|
||||
SLACK_AUTHORIZE_URL,
|
||||
SLACK_DIFFERENT_WORKSPACE_DETAIL,
|
||||
@@ -27,6 +29,7 @@ import {
|
||||
SLACK_WORKSPACE_CONFLICT_CODE,
|
||||
} from "./slack.fixtures";
|
||||
import type {
|
||||
SlackAuthorizedChannelFixture,
|
||||
SlackExchangeOutcome,
|
||||
SlackFixture,
|
||||
SlackInstallFixture,
|
||||
@@ -35,9 +38,15 @@ import type {
|
||||
|
||||
const API = process.env.UI_API_BASE_URL;
|
||||
const TS = "2026-08-10T09:00:00Z";
|
||||
/** When a check run by these handlers lands, and stamps its confirmations. */
|
||||
const CHECK_TS = "2026-08-10T10:15:00Z";
|
||||
|
||||
const CONNECTION_TASK_PREFIX = "slack-conn-task-";
|
||||
|
||||
/** Order-insensitive: a reorder is not a changed set (contract, PATCH). */
|
||||
const sameChannelIds = (a: string[], b: string[]) =>
|
||||
a.length === b.length && new Set([...a, ...b]).size === a.length;
|
||||
|
||||
/** Opaque to the UI, which only ever follows `links.next` (design D6). */
|
||||
const CHANNEL_CURSOR_PARAM = "page[cursor]";
|
||||
|
||||
@@ -71,14 +80,27 @@ const refuse = (refusal: SlackRefusalFixture) =>
|
||||
},
|
||||
);
|
||||
|
||||
const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
|
||||
team_id: workspace.teamId,
|
||||
team_name: workspace.teamName,
|
||||
bot_user_id: workspace.botUserId,
|
||||
// The API omits these keys until a channel is chosen, never sending nulls.
|
||||
...(workspace.channelId ? { channel_id: workspace.channelId } : {}),
|
||||
...(workspace.channelName ? { channel_name: workspace.channelName } : {}),
|
||||
});
|
||||
const configuration = (install: SlackInstallFixture) => {
|
||||
const verification = install.verification ?? NO_VERIFICATION;
|
||||
return {
|
||||
team_id: install.workspace.teamId,
|
||||
team_name: install.workspace.teamName,
|
||||
bot_user_id: install.workspace.botUserId,
|
||||
// Always an array: a new install carries an empty one rather than omitting
|
||||
// the key (contract, OAuth and reads).
|
||||
channels: (install.workspace.authorizedChannels ?? []).map((channel) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
is_private: channel.isPrivate,
|
||||
confirmation_sent_at: channel.confirmationSentAt,
|
||||
})),
|
||||
verification: {
|
||||
task_id: verification.taskId,
|
||||
started_at: verification.startedAt,
|
||||
finished_at: verification.finishedAt,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const integrationResource = (install: SlackInstallFixture) => ({
|
||||
id: install.id,
|
||||
@@ -91,7 +113,7 @@ const integrationResource = (install: SlackInstallFixture) => ({
|
||||
connection_last_checked_at: install.connectionLastCheckedAt,
|
||||
integration_type: "slack",
|
||||
// No credentials: the bot token is encrypted at rest and never serialized.
|
||||
configuration: configuration(install.workspace),
|
||||
configuration: configuration(install),
|
||||
},
|
||||
links: { self: `${API}/integrations/${install.id}` },
|
||||
});
|
||||
@@ -112,6 +134,18 @@ const taskResource = (id: string, state: string, result: unknown) => ({
|
||||
data: { id, type: "tasks", attributes: { state, result } },
|
||||
});
|
||||
|
||||
/**
|
||||
* The confirmation a check posts, applied to one channel: only where none has
|
||||
* landed yet, and stamped only once Slack accepted the post — so the channel a
|
||||
* failure names keeps none, and a retry has it left to do.
|
||||
*/
|
||||
const confirmedByThisRun =
|
||||
(failedChannelName: string | null) =>
|
||||
(channel: SlackAuthorizedChannelFixture): SlackAuthorizedChannelFixture =>
|
||||
channel.confirmationSentAt === null && channel.name !== failedChannelName
|
||||
? { ...channel, confirmationSentAt: CHECK_TS }
|
||||
: channel;
|
||||
|
||||
/**
|
||||
* All three are `2xx`: the first two make `response.json()` throw, the third
|
||||
* parses into a body that names no resource.
|
||||
@@ -133,6 +167,15 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
? { ...fx.install, workspace: { ...fx.install.workspace } }
|
||||
: null;
|
||||
|
||||
/** What the exchange leaves behind: no channels, nothing verified yet. */
|
||||
const freshInstall = (id?: string): SlackInstallFixture => ({
|
||||
id: id ?? SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace, authorizedChannels: [] },
|
||||
verification: { ...NO_VERIFICATION },
|
||||
});
|
||||
|
||||
const unconfigured = () =>
|
||||
HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), {
|
||||
status: 503,
|
||||
@@ -189,28 +232,24 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
|
||||
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA:
|
||||
// The install still happened: the API upserts before it answers.
|
||||
install = {
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace },
|
||||
};
|
||||
install = freshInstall();
|
||||
return unreadableExchange(fx.exchangeOutcome);
|
||||
case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
|
||||
install = {
|
||||
id: install?.id ?? SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace },
|
||||
...freshInstall(install?.id),
|
||||
workspace: {
|
||||
...fx.exchangeWorkspace,
|
||||
// A same-workspace reinstall keeps the authorized channels and
|
||||
// resets every confirmation, along with the connection and
|
||||
// verification state (contract, OAuth and reads).
|
||||
authorizedChannels: (
|
||||
install?.workspace.authorizedChannels ?? []
|
||||
).map((channel) => ({ ...channel, confirmationSentAt: null })),
|
||||
},
|
||||
};
|
||||
return HttpResponse.json({ data: integrationResource(install) });
|
||||
default:
|
||||
install = {
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: null,
|
||||
connectionLastCheckedAt: null,
|
||||
workspace: { ...fx.exchangeWorkspace },
|
||||
};
|
||||
install = freshInstall();
|
||||
return HttpResponse.json(
|
||||
{ data: integrationResource(install) },
|
||||
{ status: 201 },
|
||||
@@ -237,32 +276,56 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
http.post<{ id: string }>(
|
||||
`${API}/integrations/:id/connection`,
|
||||
({ params }) => {
|
||||
// The check posts to the channel, so the API refuses until one exists.
|
||||
if (!install?.workspace.channelId) {
|
||||
// The check reaches every configured channel, so the API requires at
|
||||
// least one (contract, Connection).
|
||||
if (!install?.workspace.authorizedChannels?.length) {
|
||||
return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(
|
||||
taskResource(
|
||||
`${CONNECTION_TASK_PREFIX}${params.id}`,
|
||||
"executing",
|
||||
null,
|
||||
),
|
||||
{ status: 202 },
|
||||
);
|
||||
// The task id is pre-generated and stored before the task is
|
||||
// published; the worker is what stamps `started_at`.
|
||||
const taskId = `${CONNECTION_TASK_PREFIX}${params.id}`;
|
||||
install.verification = {
|
||||
taskId,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
};
|
||||
|
||||
return HttpResponse.json(taskResource(taskId, "executing", null), {
|
||||
status: 202,
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
|
||||
const { connected, error } = fx.connection;
|
||||
if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) {
|
||||
const { connected, error, failedChannelName } = fx.connection;
|
||||
// Only a task whose id still matches may write: a late one must not
|
||||
// overwrite a newer check (contract, Connection).
|
||||
if (
|
||||
install &&
|
||||
params.taskId.startsWith(CONNECTION_TASK_PREFIX) &&
|
||||
install.verification?.taskId === params.taskId
|
||||
) {
|
||||
install.connected = connected;
|
||||
install.connectionLastCheckedAt = TS;
|
||||
install.connectionLastCheckedAt = CHECK_TS;
|
||||
install.verification = {
|
||||
taskId: params.taskId,
|
||||
startedAt: CHECK_TS,
|
||||
finishedAt: CHECK_TS,
|
||||
};
|
||||
install.workspace.authorizedChannels = (
|
||||
install.workspace.authorizedChannels ?? []
|
||||
).map(confirmedByThisRun(failedChannelName ?? null));
|
||||
}
|
||||
return HttpResponse.json(
|
||||
taskResource(params.taskId, "completed", { connected, error }),
|
||||
// TODO(Josema): the key the result names a failing channel under.
|
||||
taskResource(params.taskId, "completed", {
|
||||
connected,
|
||||
error,
|
||||
channel: failedChannelName ?? null,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -307,8 +370,9 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
),
|
||||
|
||||
/**
|
||||
* The generic PATCH. The UI submits only `channel_id`; the name is derived
|
||||
* from it here, as the API derives it from Slack (design D6).
|
||||
* The generic PATCH. The write carries objects that name only `id`; the
|
||||
* names and privacy are derived from them here, as the API derives them
|
||||
* from Slack (contract, PATCH).
|
||||
*/
|
||||
http.patch(`${API}/integrations/:id`, async ({ request }) => {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
@@ -316,10 +380,9 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
} | null;
|
||||
const attributes = body?.data?.attributes ?? {};
|
||||
const configurationPatch = attributes.configuration as
|
||||
| { channel_id?: string }
|
||||
| { channels?: { id: string }[] }
|
||||
| undefined;
|
||||
const channelId = configurationPatch?.channel_id;
|
||||
const channel = fx.channels.find((c) => c.id === channelId);
|
||||
const requested = configurationPatch?.channels;
|
||||
|
||||
if (!install) {
|
||||
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
|
||||
@@ -338,17 +401,56 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
// Checked before the id lookup: the picker did offer this channel, and
|
||||
// Slack refused it anyway when the API validated it.
|
||||
// An omitted list leaves the set alone; an empty one clears it. Nothing
|
||||
// to validate against Slack either, so no refusal is reachable here.
|
||||
if (requested === undefined) {
|
||||
return HttpResponse.json({ data: integrationResource(install) });
|
||||
}
|
||||
|
||||
// Deduplicated before anything is validated or saved.
|
||||
const channelIds = Array.from(
|
||||
new Set(requested.map((channel) => channel.id)),
|
||||
);
|
||||
const matched = channelIds
|
||||
.map((channelId) =>
|
||||
fx.channels.find((channel) => channel.id === channelId),
|
||||
)
|
||||
.filter((channel) => channel !== undefined);
|
||||
|
||||
// Checked before the id lookup: the picker did offer these channels, and
|
||||
// Slack refused one anyway when the API validated the set.
|
||||
if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal);
|
||||
if (!channel) {
|
||||
// One unknown id refuses the whole save: the set is validated together.
|
||||
if (matched.length !== channelIds.length) {
|
||||
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
install.workspace.channelId = channel.id;
|
||||
install.workspace.channelName = channel.name;
|
||||
const previous = install.workspace.authorizedChannels ?? [];
|
||||
const confirmedAt = new Map(
|
||||
previous.map((channel) => [channel.id, channel.confirmationSentAt]),
|
||||
);
|
||||
install.workspace.authorizedChannels = matched.map((channel) => ({
|
||||
...channel,
|
||||
// Retained ids keep their confirmation; new ones start without one.
|
||||
confirmationSentAt: confirmedAt.get(channel.id) ?? null,
|
||||
}));
|
||||
|
||||
// A changed id set resets the connection and verification state, so the
|
||||
// record never claims a check covered a channel it never saw. Reordering
|
||||
// the same ids changes nothing.
|
||||
if (
|
||||
!sameChannelIds(
|
||||
previous.map((channel) => channel.id),
|
||||
channelIds,
|
||||
)
|
||||
) {
|
||||
install.connected = null;
|
||||
install.connectionLastCheckedAt = null;
|
||||
install.verification = { ...NO_VERIFICATION };
|
||||
}
|
||||
|
||||
return HttpResponse.json({ data: integrationResource(install) });
|
||||
}),
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ type TestConnectionResponse = {
|
||||
taskId?: string;
|
||||
data?: TaskStartResponse;
|
||||
error?: string;
|
||||
/** The channel a channel-level failure named, when the task named one. */
|
||||
failedChannel?: string | null;
|
||||
};
|
||||
|
||||
export const getIntegrations = async (searchParams?: URLSearchParams) => {
|
||||
@@ -265,7 +267,14 @@ export const deleteIntegration = async (
|
||||
}
|
||||
};
|
||||
|
||||
type ConnectionTaskResult = { connected?: boolean; error?: string | null };
|
||||
type ConnectionTaskResult = {
|
||||
connected?: boolean;
|
||||
error?: string | null;
|
||||
// The signed contract keeps the result at `{connected, error}` and has it
|
||||
// name the failing channel, without spelling out the key.
|
||||
// TODO(Josema): the key a channel-level failure names the channel under.
|
||||
channel?: string | null;
|
||||
};
|
||||
|
||||
type PollConnectionResult =
|
||||
| {
|
||||
@@ -358,6 +367,7 @@ export const testIntegrationConnection = async (
|
||||
return {
|
||||
success: false,
|
||||
error: pollResult.message || "Connection test failed.",
|
||||
failedChannel: pollResult.result?.channel ?? null,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
exchangeSlackOAuthCode,
|
||||
getSlackAuthorizeUrl,
|
||||
getSlackChannels,
|
||||
setSlackDefaultChannel,
|
||||
setSlackAuthorizedChannels,
|
||||
} from "./slack";
|
||||
|
||||
/** The status the contract reserves for an upstream Slack failure. */
|
||||
@@ -510,10 +510,10 @@ const expectNoParserProse = (result: unknown) => {
|
||||
|
||||
const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`;
|
||||
|
||||
const saveChannel = () =>
|
||||
setSlackDefaultChannel(SLACK_INTEGRATION_ID, FIRST_CHANNEL.id);
|
||||
const saveChannels = () =>
|
||||
setSlackAuthorizedChannels(SLACK_INTEGRATION_ID, [FIRST_CHANNEL.id]);
|
||||
|
||||
/** The save as the API answers it: the channel's name derived server-side. */
|
||||
/** The save as the API answers it: the channels' names derived server-side. */
|
||||
const savedIntegration = () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
@@ -523,8 +523,14 @@ const savedIntegration = () =>
|
||||
attributes: {
|
||||
integration_type: "slack",
|
||||
configuration: {
|
||||
channel_id: FIRST_CHANNEL.id,
|
||||
channel_name: FIRST_CHANNEL.name,
|
||||
channels: [
|
||||
{
|
||||
id: FIRST_CHANNEL.id,
|
||||
name: FIRST_CHANNEL.name,
|
||||
is_private: false,
|
||||
confirmation_sent_at: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -542,16 +548,20 @@ const expectIntegrationsRevalidated = () => {
|
||||
]);
|
||||
};
|
||||
|
||||
describe("setSlackDefaultChannel", () => {
|
||||
describe("setSlackAuthorizedChannels", () => {
|
||||
it("returns the saved integration and revalidates the pages listing it", async () => {
|
||||
fetchMock.mockResolvedValueOnce(savedIntegration());
|
||||
|
||||
const result = await saveChannel();
|
||||
const result = await saveChannels();
|
||||
|
||||
expect(requestedUrls()).toEqual([INTEGRATION_URL]);
|
||||
expect(result).toMatchObject({
|
||||
integration: {
|
||||
attributes: { configuration: { channel_name: FIRST_CHANNEL.name } },
|
||||
attributes: {
|
||||
configuration: {
|
||||
channels: [expect.objectContaining({ name: FIRST_CHANNEL.name })],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expectIntegrationsRevalidated();
|
||||
@@ -560,16 +570,38 @@ describe("setSlackDefaultChannel", () => {
|
||||
// The write serializer names whatever it will not take and refuses the whole
|
||||
// save, so a body that also carried the integration's own (immutable) type
|
||||
// came back as `Invalid fields: {'integration_type'}` and recorded nothing.
|
||||
it("submits the channel as the save's only attribute", async () => {
|
||||
it("submits the channels as the save's only attribute, naming nothing but their ids", async () => {
|
||||
fetchMock.mockResolvedValueOnce(savedIntegration());
|
||||
|
||||
await saveChannel();
|
||||
await saveChannels();
|
||||
|
||||
expect(sentBody()).toEqual({
|
||||
data: {
|
||||
type: "integrations",
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
attributes: { configuration: { channel_id: FIRST_CHANNEL.id } },
|
||||
attributes: {
|
||||
configuration: { channels: [{ id: FIRST_CHANNEL.id }] },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// The API deduplicates too; a caller that named a channel twice never meant
|
||||
// to authorize it twice, and the write is what the whole set is validated
|
||||
// from.
|
||||
it("submits a channel once, however many times the caller named it", async () => {
|
||||
fetchMock.mockResolvedValueOnce(savedIntegration());
|
||||
|
||||
await setSlackAuthorizedChannels(SLACK_INTEGRATION_ID, [
|
||||
FIRST_CHANNEL.id,
|
||||
FIRST_CHANNEL.id,
|
||||
]);
|
||||
|
||||
expect(sentBody()).toMatchObject({
|
||||
data: {
|
||||
attributes: {
|
||||
configuration: { channels: [{ id: FIRST_CHANNEL.id }] },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -582,11 +614,11 @@ describe("setSlackDefaultChannel", () => {
|
||||
async ({ body }) => {
|
||||
fetchMock.mockResolvedValueOnce(unreadableOk(body));
|
||||
|
||||
const result = await saveChannel();
|
||||
const result = await saveChannels();
|
||||
|
||||
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
|
||||
expectNoParserProse(result);
|
||||
// The API recorded the channel before answering, so both pages refresh.
|
||||
// The API recorded the channels before answering, so both pages refresh.
|
||||
expectIntegrationsRevalidated();
|
||||
},
|
||||
);
|
||||
@@ -607,7 +639,7 @@ describe("setSlackDefaultChannel", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await saveChannel();
|
||||
const result = await saveChannels();
|
||||
|
||||
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
|
||||
expectNoParserProse(result);
|
||||
@@ -623,8 +655,8 @@ const COPY_ONLY_ACTIONS = [
|
||||
call: (id: string) => getSlackChannels(id),
|
||||
},
|
||||
{
|
||||
name: "setSlackDefaultChannel",
|
||||
call: (id: string) => setSlackDefaultChannel(id, FIRST_CHANNEL.id),
|
||||
name: "setSlackAuthorizedChannels",
|
||||
call: (id: string) => setSlackAuthorizedChannels(id, [FIRST_CHANNEL.id]),
|
||||
},
|
||||
{
|
||||
name: "disconnectSlackIntegration",
|
||||
@@ -687,8 +719,8 @@ describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
|
||||
{
|
||||
status: 400,
|
||||
why: "a refusal the API meant to give",
|
||||
response: () => errorResponse(400, "No default channel is set."),
|
||||
expected: "No default channel is set.",
|
||||
response: () => errorResponse(400, "The integration is not connected."),
|
||||
expected: "The integration is not connected.",
|
||||
},
|
||||
])("reports nothing for a $status: that is $why", async (refusal) => {
|
||||
fetchMock.mockResolvedValue(refusal.response());
|
||||
|
||||
@@ -330,11 +330,11 @@ const MAX_CHANNEL_PAGES = 20;
|
||||
* Every channel Prowler can post to in the connected workspace — the picker's
|
||||
* options.
|
||||
*
|
||||
* The durable primitive, not the channel stored on the integration (design D6):
|
||||
* a consumer needing a per-rule channel reads the same endpoint. `links.next`
|
||||
* is followed opaquely — the contract does not pin the cursor parameter naming,
|
||||
* so the UI never builds one of its own. An early stop that still read
|
||||
* something reports through `incomplete`, not as a failure.
|
||||
* The durable primitive, not the channels recorded on the integration
|
||||
* (design D6): a consumer needing a per-rule channel reads the same endpoint.
|
||||
* `links.next` is followed opaquely — the contract does not pin the cursor
|
||||
* parameter naming, so the UI never builds one of its own. An early stop that
|
||||
* still read something reports through `incomplete`, not as a failure.
|
||||
*/
|
||||
export const getSlackChannels = async (
|
||||
integrationId: string,
|
||||
@@ -422,26 +422,29 @@ export const getSlackChannels = async (
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackDefaultChannelSuccess {
|
||||
interface SlackAuthorizedChannelsSuccess {
|
||||
integration: IntegrationProps;
|
||||
}
|
||||
|
||||
export type SlackDefaultChannelResult =
|
||||
| SlackDefaultChannelSuccess
|
||||
export type SlackAuthorizedChannelsResult =
|
||||
| SlackAuthorizedChannelsSuccess
|
||||
| SlackActionError;
|
||||
|
||||
/**
|
||||
* Record the channel Prowler posts to, on the generic integration endpoint.
|
||||
* Record the set of channels Prowler is authorized to post to, on the generic
|
||||
* integration endpoint. The list replaces the whole set: an empty one clears
|
||||
* it, and every id in it stays authorized, keeping the confirmation it already
|
||||
* has.
|
||||
*
|
||||
* A Slack action despite the generic `PATCH`: `channel_not_found` and
|
||||
* `not_in_channel` carry the same `detail`, so only `code` tells them apart,
|
||||
* and the generic action reads `detail` alone. Only `channel_id` travels — the
|
||||
* API derives `channel_name` server-side (design D6).
|
||||
* and the generic action reads `detail` alone. Only ids travel — the API
|
||||
* derives each name and its privacy server-side.
|
||||
*/
|
||||
export const setSlackDefaultChannel = async (
|
||||
export const setSlackAuthorizedChannels = async (
|
||||
integrationId: string,
|
||||
channelId: string,
|
||||
): Promise<SlackDefaultChannelResult> => {
|
||||
channelIds: string[],
|
||||
): Promise<SlackAuthorizedChannelsResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
@@ -460,7 +463,16 @@ export const setSlackDefaultChannel = async (
|
||||
// serializer refuses whatever it does not accept, so naming the
|
||||
// integration's own (immutable) type is answered with a 400,
|
||||
// "Invalid fields: {'integration_type'}".
|
||||
attributes: { configuration: { channel_id: channelId } },
|
||||
attributes: {
|
||||
configuration: {
|
||||
// Objects carrying only `id`, per the signed contract. The API
|
||||
// deduplicates too; doing it here keeps a caller from asking for
|
||||
// a set it did not mean.
|
||||
channels: Array.from(new Set(channelIds), (channelId) => ({
|
||||
id: channelId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -470,18 +482,18 @@ export const setSlackDefaultChannel = async (
|
||||
// this `catch`.
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to save the destination channel: ${response.statusText}`,
|
||||
`Unable to save the destination channels: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
// Before the guard and on both paths: the save happened, so a cache still
|
||||
// holding the previous channel would keep showing it.
|
||||
// holding the previous channels would keep showing them.
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
// Guarded as deep as the caller reads: it names the saved channel from
|
||||
// Guarded as deep as the caller reads: it names the saved channels from
|
||||
// `attributes.configuration`.
|
||||
if (!body?.data?.attributes?.configuration) {
|
||||
return { error: SLACK_UNREADABLE_RESULT_MESSAGE };
|
||||
|
||||
@@ -16,7 +16,8 @@ export default async function SlackIntegrationPage() {
|
||||
<ContentLayout title="Slack">
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect a Slack workspace so Prowler can post to one of its channels.
|
||||
Connect a Slack workspace so Prowler can post to the channels you
|
||||
authorize.
|
||||
</p>
|
||||
|
||||
<SlackIntegrationContent />
|
||||
|
||||
@@ -14,7 +14,7 @@ import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
|
||||
import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { setSlackDefaultChannel } from "@/actions/integrations/slack";
|
||||
import { setSlackAuthorizedChannels } from "@/actions/integrations/slack";
|
||||
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
|
||||
|
||||
import { IntegrationsContent } from "../integrations-content";
|
||||
@@ -29,8 +29,15 @@ export const CONNECTION_OUTCOME = {
|
||||
export type ConnectionOutcome =
|
||||
(typeof CONNECTION_OUTCOME)[keyof typeof CONNECTION_OUTCOME];
|
||||
|
||||
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
|
||||
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
|
||||
/** Sentinel: the page settled on "no channels authorized", rather than not yet. */
|
||||
const NO_AUTHORIZED_CHANNELS = "<no channels authorized>";
|
||||
|
||||
/** A chip the closed picker shows for an authorized channel. */
|
||||
interface ChannelChip {
|
||||
name: string;
|
||||
/** Whether the chip itself identifies the channel as private. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
export const REVOCATION_OUTCOME = {
|
||||
REVOKED: "revoked",
|
||||
@@ -119,6 +126,9 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
}
|
||||
|
||||
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
|
||||
// A different route: whatever this harness had mounted goes first, or two
|
||||
// copies of the page would answer every query.
|
||||
(await this.mounted)?.unmount();
|
||||
const params = new URLSearchParams();
|
||||
if (code) params.set("code", code);
|
||||
if (state) params.set("state", state);
|
||||
@@ -130,7 +140,9 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
);
|
||||
this.wireHandlers();
|
||||
|
||||
render(createElement(SlackCallback));
|
||||
// Held like any other mount: a reinstall is followed by a `revisit()`,
|
||||
// which has to take this render down before the management page goes up.
|
||||
this.mounted = render(createElement(SlackCallback));
|
||||
}
|
||||
|
||||
/** Mount the integrations catalogue. No handlers: every card there is static. */
|
||||
@@ -289,10 +301,26 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Why the check cannot run, read from the copy the button points at: a reason
|
||||
* found anywhere else on the page never reaches whoever sees the control.
|
||||
* What the check does — or why it cannot run — read from the copy the button
|
||||
* points at: a reason found anywhere else on the page never reaches whoever
|
||||
* sees the control.
|
||||
*/
|
||||
connectionCheckBlockedReason(): string | null {
|
||||
/**
|
||||
* The same copy, waited for: what the check will do follows the page's data,
|
||||
* which lands a beat after an action that changed it.
|
||||
*/
|
||||
async connectionCheckHintMatching(pattern: RegExp): Promise<string> {
|
||||
return this.waitFor(
|
||||
() => {
|
||||
const hint = this.connectionCheckHint();
|
||||
return hint && pattern.test(hint) ? hint : null;
|
||||
},
|
||||
10000,
|
||||
`the connection check hint to match ${pattern}`,
|
||||
);
|
||||
}
|
||||
|
||||
connectionCheckHint(): string | null {
|
||||
const button = this.buttonByText(/Test connection/);
|
||||
const describedBy = button?.getAttribute("aria-describedby");
|
||||
if (!describedBy) return null;
|
||||
@@ -301,6 +329,15 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
return reason ? (reason.textContent ?? "").trim() : null;
|
||||
}
|
||||
|
||||
/** What the failure toast of a connection test told the user. */
|
||||
async connectionFailureToast(): Promise<string> {
|
||||
return this.waitFor(
|
||||
() => this.toastText(/Connection test failed/),
|
||||
15000,
|
||||
"the connection failure toast",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The "last checked" line as rendered, or null when the page shows none —
|
||||
* which is what a workspace whose connection was never checked shows.
|
||||
@@ -415,24 +452,31 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The options of the open picker. Scoped to the popover: the multi-select
|
||||
* also renders a hidden mirror of its items for the chips to read labels
|
||||
* from, and that mirror exists with the listing closed.
|
||||
*/
|
||||
private openPickerOptions(): HTMLElement[] | null {
|
||||
const options = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
'[data-slot="multiselect-content"] [role="option"]',
|
||||
),
|
||||
);
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the picker and hand back its options. A re-render landing mid-gesture
|
||||
* makes Radix drop the open state, so re-open from the keyboard when nothing
|
||||
* mounted at all.
|
||||
*/
|
||||
private async openChannelPicker(): Promise<HTMLElement[]> {
|
||||
const mounted = (): HTMLElement[] | null => {
|
||||
const options = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
);
|
||||
return options.length > 0 ? options : null;
|
||||
};
|
||||
|
||||
const alreadyOpen = mounted();
|
||||
const alreadyOpen = this.openPickerOptions();
|
||||
if (alreadyOpen) return alreadyOpen;
|
||||
|
||||
const trigger = await this.waitFor<HTMLElement>(
|
||||
() => this.q("#slack-channel"),
|
||||
() => this.q("#slack-channels"),
|
||||
10000,
|
||||
"the channel picker",
|
||||
);
|
||||
@@ -440,13 +484,17 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
await this.clickElement(trigger, { fallbackToDomClick: true });
|
||||
|
||||
let options = await this.waitForOrNull(
|
||||
mounted,
|
||||
() => this.openPickerOptions(),
|
||||
2000,
|
||||
"the channel options",
|
||||
);
|
||||
if (!options) {
|
||||
await this.user.keyboard("{Enter}");
|
||||
options = await this.waitForOrNull(mounted, 8000, "the channel options");
|
||||
options = await this.waitForOrNull(
|
||||
() => this.openPickerOptions(),
|
||||
8000,
|
||||
"the channel options",
|
||||
);
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
@@ -529,97 +577,170 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
// would never settle — the tests only narrow.
|
||||
await this.waitFor(
|
||||
() =>
|
||||
document.querySelectorAll('[role="option"]').length !== all.length ||
|
||||
document.querySelector("[cmdk-empty]") !== null ||
|
||||
(this.openPickerOptions() ?? []).length !== all.length ||
|
||||
document.querySelector(
|
||||
'[data-slot="multiselect-content"] [cmdk-empty]',
|
||||
) !== null ||
|
||||
null,
|
||||
5000,
|
||||
"the search to narrow the channels",
|
||||
);
|
||||
|
||||
const offered = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
).map((option) => option.getAttribute("data-channel") ?? "");
|
||||
const offered = (this.openPickerOptions() ?? []).map(
|
||||
(option) => option.getAttribute("data-channel") ?? "",
|
||||
);
|
||||
const emptyNote =
|
||||
document.querySelector<HTMLElement>("[cmdk-empty]")?.textContent ?? null;
|
||||
document.querySelector<HTMLElement>(
|
||||
'[data-slot="multiselect-content"] [cmdk-empty]',
|
||||
)?.textContent ?? null;
|
||||
|
||||
await this.closeChannelPicker();
|
||||
|
||||
return { offered, emptyNote };
|
||||
}
|
||||
|
||||
private async pickAndSave(name: string): Promise<void> {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(element) => element.getAttribute("data-channel") === name,
|
||||
);
|
||||
/** Toggle the named channels in the open picker, then close it. */
|
||||
private async pickChannels(names: string[]): Promise<void> {
|
||||
for (const name of names) {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(element) => element.getAttribute("data-channel") === name,
|
||||
);
|
||||
|
||||
if (!option) {
|
||||
throw new Error(`pickAndSave: no channel named "${name}" is offered`);
|
||||
if (!option) {
|
||||
throw new Error(`pickChannels: no channel named "${name}" is offered`);
|
||||
}
|
||||
|
||||
await this.user.click(option);
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
await this.user.click(option);
|
||||
await this.waitForTransition();
|
||||
await this.clickButton(/Save channel/);
|
||||
await this.closeChannelPicker();
|
||||
}
|
||||
|
||||
/** Pick a channel, save it, and wait for it to be recorded as the destination. */
|
||||
async chooseChannel(name: string): Promise<void> {
|
||||
await this.pickAndSave(name);
|
||||
/**
|
||||
* Toggle the named channels without saving: the picks stay buffered, which is
|
||||
* the state the de-authorization warning is about.
|
||||
*/
|
||||
async chooseChannels(names: string[]): Promise<void> {
|
||||
await this.pickChannels(names);
|
||||
}
|
||||
|
||||
/** Save whatever channels are picked right now. */
|
||||
async saveChannels(): Promise<void> {
|
||||
await this.clickButton(/Save channels/);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the page says before a save that drops channels — the cascade into the
|
||||
* alert rules (design D11), read from the warning the pending selection
|
||||
* raises rather than from the page at large.
|
||||
*/
|
||||
deauthorizationWarning(): string | null {
|
||||
const warning = this.q("[data-deauthorize-warning]");
|
||||
return warning
|
||||
? (warning.textContent ?? "").replace(/\s+/g, " ").trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the named channels from the authorized set and save, waiting for each
|
||||
* to be gone from the record.
|
||||
*/
|
||||
async deauthorizeChannels(names: string[]): Promise<void> {
|
||||
await this.pickChannels(names);
|
||||
await this.saveChannels();
|
||||
await this.waitFor(
|
||||
() => this.defaultChannelName() === name,
|
||||
() => {
|
||||
const authorized = this.authorizedChannelNames();
|
||||
const settled =
|
||||
authorized ??
|
||||
(this.containsText(/No destination channels authorized yet/)
|
||||
? []
|
||||
: null);
|
||||
return settled && names.every((name) => !settled.includes(name))
|
||||
? true
|
||||
: null;
|
||||
},
|
||||
15000,
|
||||
`#${name} to be recorded as the destination`,
|
||||
`${names.map((name) => `#${name}`).join(", ")} to be de-authorized`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a different destination away from this page — a second tab, or someone
|
||||
* Toggle the named channels and save, waiting for each to be recorded among
|
||||
* the authorized destinations.
|
||||
*/
|
||||
async authorizeChannels(names: string[]): Promise<void> {
|
||||
await this.pickChannels(names);
|
||||
await this.saveChannels();
|
||||
await this.waitFor(
|
||||
() => {
|
||||
const authorized = this.authorizedChannelNames();
|
||||
return authorized && names.every((name) => authorized.includes(name))
|
||||
? true
|
||||
: null;
|
||||
},
|
||||
15000,
|
||||
`${names.map((name) => `#${name}`).join(", ")} to be authorized`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize a different set away from this page — a second tab, or someone
|
||||
* else in the tenant. Goes through the same call the page makes, leaving this
|
||||
* page's own copy of it untouched.
|
||||
*/
|
||||
async channelRecordedElsewhere(name: string): Promise<void> {
|
||||
const channel = this.fixture.channels.find((c) => c.name === name);
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`channelRecordedElsewhere: no channel named "${name}" is offered`,
|
||||
);
|
||||
}
|
||||
async channelsRecordedElsewhere(names: string[]): Promise<void> {
|
||||
const channels = names.map((name) => {
|
||||
const channel = this.fixture.channels.find((c) => c.name === name);
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`channelsRecordedElsewhere: no channel named "${name}" is offered`,
|
||||
);
|
||||
}
|
||||
return channel;
|
||||
});
|
||||
|
||||
const integrationId = this.fixture.install?.id;
|
||||
if (!integrationId) {
|
||||
throw new Error("channelRecordedElsewhere: no workspace is connected");
|
||||
throw new Error("channelsRecordedElsewhere: no workspace is connected");
|
||||
}
|
||||
|
||||
const result = await setSlackDefaultChannel(integrationId, channel.id);
|
||||
const result = await setSlackAuthorizedChannels(
|
||||
integrationId,
|
||||
channels.map((channel) => channel.id),
|
||||
);
|
||||
if ("error" in result) {
|
||||
throw new Error(`channelRecordedElsewhere: ${result.error}`);
|
||||
throw new Error(`channelsRecordedElsewhere: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the picked channel can be saved — false when there is nothing new to save. */
|
||||
offersChannelSave(): boolean {
|
||||
const button = this.buttonByText(/Save channel/);
|
||||
/** Whether the picked channels can be saved — false when there is nothing new to save. */
|
||||
offersChannelsSave(): boolean {
|
||||
const button = this.buttonByText(/Save channels/);
|
||||
return button !== null && !button.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to save a channel the API refuses and hand back what the user is told. A
|
||||
* save that succeeds fails the test rather than timing out.
|
||||
* Try to save channels the API refuses and hand back what the user is told.
|
||||
* A save that succeeds fails the test rather than timing out.
|
||||
*/
|
||||
async refusedChannelSave(name: string): Promise<string> {
|
||||
await this.pickAndSave(name);
|
||||
async refusedChannelsSave(names: string[]): Promise<string> {
|
||||
await this.pickChannels(names);
|
||||
await this.saveChannels();
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.defaultChannelName() === name) {
|
||||
const authorized = this.authorizedChannelNames() ?? [];
|
||||
if (names.every((name) => authorized.includes(name))) {
|
||||
throw new Error(
|
||||
`refusedChannelSave: #${name} was recorded, not refused`,
|
||||
`refusedChannelsSave: ${names.join(", ")} were recorded, not refused`,
|
||||
);
|
||||
}
|
||||
return this.toastText(/Could not save the destination channel/);
|
||||
return this.toastText(/Could not save the destination channels/);
|
||||
},
|
||||
15000,
|
||||
"the refused channel save",
|
||||
"the refused channels save",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -634,26 +755,59 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
return toast ? (toast.textContent ?? "").replace(/\s+/g, " ").trim() : null;
|
||||
}
|
||||
|
||||
private defaultChannelName(): string | null {
|
||||
return (
|
||||
/Prowler posts to #(\S+?)\./.exec(
|
||||
this.container.textContent ?? "",
|
||||
)?.[1] ?? null
|
||||
);
|
||||
/** The names the "Prowler posts to …" summary carries, or null while unsettled. */
|
||||
private authorizedChannelNames(): string[] | null {
|
||||
// The keyed element, not a text search: the card's subtitle also starts
|
||||
// with "Prowler posts to".
|
||||
const summary = this.q("[data-authorized-channels]");
|
||||
const text = (summary?.textContent ?? "").trim();
|
||||
if (!/^Prowler posts to /.test(text)) return null;
|
||||
|
||||
return Array.from(text.matchAll(/#([^\s,.]+)/g), (match) => match[1]);
|
||||
}
|
||||
|
||||
/** The channel recorded as the integration's destination, if any. */
|
||||
async defaultChannel(): Promise<string | null> {
|
||||
const settled = await this.waitFor(
|
||||
/** The channels recorded as the integration's authorized destinations. */
|
||||
async authorizedChannels(): Promise<string[]> {
|
||||
const settled = await this.waitFor<
|
||||
string[] | typeof NO_AUTHORIZED_CHANNELS
|
||||
>(
|
||||
() =>
|
||||
this.defaultChannelName() ??
|
||||
(this.containsText(/No destination channel recorded yet/)
|
||||
? NO_DEFAULT_CHANNEL
|
||||
this.authorizedChannelNames() ??
|
||||
(this.containsText(/No destination channels authorized yet/)
|
||||
? NO_AUTHORIZED_CHANNELS
|
||||
: null),
|
||||
10000,
|
||||
"the recorded destination channel",
|
||||
"the authorized destination channels",
|
||||
);
|
||||
return settled === NO_DEFAULT_CHANNEL ? null : settled;
|
||||
return settled === NO_AUTHORIZED_CHANNELS ? [] : settled;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chips the closed picker shows for the current selection, each with
|
||||
* whether it identifies its channel as private — the spec's "identified with
|
||||
* the listing closed" is read from here.
|
||||
*/
|
||||
async authorizedChannelChips(): Promise<ChannelChip[]> {
|
||||
const chips = await this.waitFor(
|
||||
() => {
|
||||
const found = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>(
|
||||
'[data-slot="multiselect-value"] [data-selected-item]',
|
||||
),
|
||||
);
|
||||
return found.length > 0 ? found : null;
|
||||
},
|
||||
10000,
|
||||
"the authorized channel chips",
|
||||
);
|
||||
|
||||
return chips.map((chip) => {
|
||||
const text = (chip.textContent ?? "").trim();
|
||||
return {
|
||||
name: text.replace(/^Private/, "").replace(/^#/, ""),
|
||||
isPrivate: /^Private/.test(text),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** What the user is told when the workspace exposes no channel at all. */
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
SLACK_MISSING_SCOPE_REFUSAL,
|
||||
SLACK_NOT_IN_CHANNEL_CODE,
|
||||
SLACK_NOT_IN_CHANNEL_REFUSAL,
|
||||
SLACK_OAUTH_CODE,
|
||||
SLACK_OAUTH_STATE,
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_RATE_LIMITED_REFUSAL,
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UPSTREAM_REFUSAL,
|
||||
slackFixture,
|
||||
slackFixtureWithDefaultChannel,
|
||||
slackFixtureWithAuthorizedChannels,
|
||||
unreadableCheckTimeSlackFixture,
|
||||
unreportedRevocationSlackFixture,
|
||||
} from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
@@ -41,7 +43,7 @@ import {
|
||||
SlackIntegrationHarness,
|
||||
} from "./slack-integration.harness";
|
||||
|
||||
/** The shape the channel save is asserted against — only the id travels. */
|
||||
/** The shape the channels save is asserted against — only ids travel. */
|
||||
interface PatchIntegrationBody {
|
||||
data: PatchIntegrationData;
|
||||
}
|
||||
@@ -55,12 +57,18 @@ interface PatchIntegrationAttributes {
|
||||
}
|
||||
|
||||
interface PatchChannelConfiguration {
|
||||
channel_id: string;
|
||||
channels: { id: string }[];
|
||||
}
|
||||
|
||||
/** The workspace the fixtures connect. */
|
||||
const WORKSPACE_NAME = "Prowler HQ";
|
||||
|
||||
/**
|
||||
* One channel named in copy. The tail guard is what keeps `#security` from
|
||||
* matching `#security-alerts`, which is exactly the pair the fixtures use.
|
||||
*/
|
||||
const channelMention = (name: string) => new RegExp(`#${name}(?![\\w-])`);
|
||||
|
||||
/** The only scopes Prowler asks a workspace for (design D2). */
|
||||
const REQUIRED_SCOPES = [
|
||||
"chat:write",
|
||||
@@ -246,30 +254,28 @@ describe("a connected workspace", () => {
|
||||
}, 30000);
|
||||
|
||||
it("does not offer a connection check the API is bound to refuse", async () => {
|
||||
// Given — a workspace connected and no destination channel recorded.
|
||||
// Given — a workspace connected and no channels authorized yet.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
await harness.mount();
|
||||
|
||||
// The check posts to the destination channel, so with none recorded the API
|
||||
// answers 400 rather than `connected: false`.
|
||||
// The check posts to the authorized channels, so with none the API answers
|
||||
// 400 rather than `connected: false`.
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
// And — the control itself says what unblocks it.
|
||||
expect(harness.connectionCheckBlockedReason()).toMatch(
|
||||
/destination channel/i,
|
||||
);
|
||||
expect(harness.connectionCheckHint()).toMatch(/destination channel/i);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("choosing a destination channel", () => {
|
||||
it("offers the workspace's channels and remembers the one chosen", async () => {
|
||||
describe("authorizing destination channels", () => {
|
||||
it("offers the workspace's channels and remembers the several authorized", async () => {
|
||||
// Given — a connected tenant whose channels span two cursor pages.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// Then — every channel is offered, so the picker followed `links.next`
|
||||
// rather than stopping at the first page (design D6). Alphabetically: the
|
||||
// picker sorts, so the API's page order is not the offered order.
|
||||
// rather than stopping at the first page. Alphabetically: the picker
|
||||
// sorts, so the API's page order is not the offered order.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
@@ -277,23 +283,63 @@ describe("choosing a destination channel", () => {
|
||||
]);
|
||||
expect(harness.channelListCallCount).toBe(2);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
// When — more than one channel is authorized in a single save.
|
||||
await harness.authorizeChannels([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
]);
|
||||
|
||||
// Then — only the id is submitted: the API derives the name from it.
|
||||
// Then — objects naming nothing but the id: the API derives each name and
|
||||
// its privacy from Slack itself.
|
||||
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
|
||||
"PATCH",
|
||||
"/integrations/",
|
||||
);
|
||||
expect(saved?.data.attributes.configuration).toEqual({
|
||||
channel_id: SLACK_PUBLIC_CHANNEL.id,
|
||||
});
|
||||
const written = saved?.data.attributes.configuration.channels ?? [];
|
||||
expect(written).toHaveLength(2);
|
||||
expect(written).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ id: SLACK_PUBLIC_CHANNEL.id },
|
||||
{ id: SLACK_PRIVATE_CHANNEL.id },
|
||||
]),
|
||||
);
|
||||
|
||||
// And — a later visit shows it, under the name the API derived from the id.
|
||||
// And — a later visit shows the set, under the names the API derived.
|
||||
await harness.revisit();
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(await harness.authorizedChannels()).toEqual(
|
||||
expect.arrayContaining([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
]),
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it("keeps a private channel identified on its chip with the listing closed", async () => {
|
||||
// Given — an authorized set holding a private channel next to a public
|
||||
// one, as a later visit reads it back.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithAuthorizedChannels([
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
]),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page: the listing stays closed.
|
||||
await harness.mount();
|
||||
|
||||
// Then — the chips themselves carry the identification, not only the rows
|
||||
// inside the open listing (spec: identified while a selected value).
|
||||
const chips = await harness.authorizedChannelChips();
|
||||
expect(chips).toContainEqual({
|
||||
name: SLACK_PRIVATE_CHANNEL.name,
|
||||
isPrivate: true,
|
||||
});
|
||||
expect(chips).toContainEqual({
|
||||
name: SLACK_PUBLIC_CHANNEL.name,
|
||||
isPrivate: false,
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
it("narrows the offered channels as the user types", async () => {
|
||||
// Given — a connected workspace whose channels were read.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
@@ -311,7 +357,7 @@ describe("choosing a destination channel", () => {
|
||||
expect(none.emptyNote).toMatch(/No channel matches/);
|
||||
}, 60000);
|
||||
|
||||
it("offers a private channel the app was invited to, marked as private, and saves it", async () => {
|
||||
it("offers a private channel the app was invited to, marked as private, and authorizes it", async () => {
|
||||
// Given — `@Prowler` was invited to one private channel; `groups:read` is
|
||||
// membership-gated (D2).
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
@@ -329,10 +375,12 @@ describe("choosing a destination channel", () => {
|
||||
).toBe(false);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PRIVATE_CHANNEL.name);
|
||||
await harness.authorizeChannels([SLACK_PRIVATE_CHANNEL.name]);
|
||||
|
||||
// Then
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PRIVATE_CHANNEL.name);
|
||||
expect(await harness.authorizedChannels()).toEqual([
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
]);
|
||||
}, 60000);
|
||||
|
||||
it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => {
|
||||
@@ -378,79 +426,108 @@ describe("choosing a destination channel", () => {
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/No channels available yet/);
|
||||
expect(message).toMatch(/invite @Prowler/);
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(await harness.authorizedChannels()).toEqual([]);
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("checks the connection itself as soon as the destination is saved", async () => {
|
||||
// Given — connected with nothing recorded: the check posts to the
|
||||
// destination, so it is not offered yet.
|
||||
it("checks the connection itself as soon as the channels are saved", async () => {
|
||||
// Given — connected with nothing authorized: the check posts to the set,
|
||||
// so it is not offered yet.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
expect(harness.connectionCheckBlockedReason()).toMatch(
|
||||
/destination channel/i,
|
||||
);
|
||||
expect(harness.connectionCheckHint()).toMatch(/destination channel/i);
|
||||
expect(harness.connectionCheckCallCount).toBe(0);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
await harness.authorizeChannels([SLACK_PUBLIC_CHANNEL.name]);
|
||||
|
||||
// Then
|
||||
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
||||
expect(harness.connectionCheckCallCount).toBe(1);
|
||||
// And — everything waiting on a destination moves with the save, in the
|
||||
// same paint: no reload to find the check on offer for later.
|
||||
// same paint: no reload to find the check on offer for later. The copy now
|
||||
// says what the check does over the set, before the user clicks.
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
expect(harness.connectionCheckBlockedReason()).toBeNull();
|
||||
expect(harness.connectionCheckHint()).toMatch(/every authorized channel/);
|
||||
// And — a check is never a message to everyone: it confirms each channel
|
||||
// once (design D7), so the copy promises exactly one post per channel.
|
||||
expect(harness.connectionCheckHint()).not.toMatch(/test message/i);
|
||||
}, 60000);
|
||||
|
||||
it("reports a saved destination the check cannot reach, without losing the save", async () => {
|
||||
// Given — a channel the API records, then refuses to reach: the bot is not
|
||||
// in it, which only the check finds out.
|
||||
it("reports a saved destination the check cannot reach, naming the channel, without losing the save", async () => {
|
||||
// Given — channels the API records, then refuses to reach one of: the bot
|
||||
// is not in it, which only the check finds out, and only the check can say
|
||||
// which channel of the set it was (design D7).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
connection: { connected: false, error: SLACK_NOT_IN_CHANNEL_CODE },
|
||||
connection: {
|
||||
connected: false,
|
||||
error: SLACK_NOT_IN_CHANNEL_CODE,
|
||||
failedChannelName: SLACK_PRIVATE_CHANNEL.name,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
await harness.authorizeChannels([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
]);
|
||||
|
||||
// Then — only the check failed, so the destination stays on record.
|
||||
// Then — only the check failed, so the destinations stay on record, and
|
||||
// the failure names the one channel Slack refused.
|
||||
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(await harness.connectionFailureToast()).toMatch(
|
||||
new RegExp(`Slack refused #${SLACK_PRIVATE_CHANNEL.name}`),
|
||||
);
|
||||
expect(await harness.authorizedChannels()).toEqual(
|
||||
expect.arrayContaining([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
]),
|
||||
);
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
|
||||
// And — the confirmation is stamped only where Slack accepted it, so the
|
||||
// next check has the refused channel alone left to confirm.
|
||||
await harness.refreshPageData();
|
||||
const hint = await harness.connectionCheckHintMatching(
|
||||
channelMention(SLACK_PRIVATE_CHANNEL.name),
|
||||
);
|
||||
expect(hint).not.toMatch(channelMention(SLACK_PUBLIC_CHANNEL.name));
|
||||
}, 60000);
|
||||
|
||||
it("follows the destination recorded elsewhere when the page's data refreshes under it", async () => {
|
||||
it("follows the set recorded elsewhere when the page's data refreshes under it", async () => {
|
||||
// Given — a finished setup, open on screen.
|
||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||
await harness.mount();
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(await harness.authorizedChannels()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
|
||||
// When — the destination changes elsewhere (a second tab, another user) and
|
||||
// this page's server data refreshes under the open card, as
|
||||
// `revalidatePath` does after an action.
|
||||
await harness.channelRecordedElsewhere(SLACK_SECOND_PUBLIC_CHANNEL.name);
|
||||
// When — the set changes elsewhere (a second tab, another user) and this
|
||||
// page's server data refreshes under the open card, as `revalidatePath`
|
||||
// does after an action.
|
||||
await harness.channelsRecordedElsewhere([SLACK_SECOND_PUBLIC_CHANNEL.name]);
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — the card reports what is on record, not the copy it took at mount.
|
||||
expect(await harness.defaultChannel()).toBe(
|
||||
expect(await harness.authorizedChannels()).toEqual([
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
);
|
||||
]);
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
// And — the picker followed too: the superseded destination is not left one
|
||||
// click from being saved back.
|
||||
expect(harness.offersChannelSave()).toBe(false);
|
||||
// And — the picker followed too: the superseded set is not left one click
|
||||
// from being saved back.
|
||||
expect(harness.offersChannelsSave()).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
it("says which permission is missing when Slack refuses the channel listing, leaving the recorded channel alone", async () => {
|
||||
// Given — a recorded destination, and an install missing a scope the listing
|
||||
it("says which permission is missing when Slack refuses the channel listing, leaving the authorized set alone", async () => {
|
||||
// Given — an authorized set, and an install missing a scope the listing
|
||||
// needs. The API names it in `code` (contract, Errors), not in `detail`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], {
|
||||
channelsRefusal: SLACK_MISSING_SCOPE_REFUSAL,
|
||||
}),
|
||||
);
|
||||
@@ -468,9 +545,11 @@ describe("choosing a destination channel", () => {
|
||||
expect(message).not.toMatch(SLACK_MISSING_SCOPE_CODE);
|
||||
expect(harness.channelInviteHint()).toMatch(/invites @Prowler/);
|
||||
|
||||
// And — a listing Prowler could not read says nothing about the channel
|
||||
// already recorded.
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
// And — a listing Prowler could not read says nothing about the channels
|
||||
// already authorized.
|
||||
expect(await harness.authorizedChannels()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
@@ -478,7 +557,7 @@ describe("choosing a destination channel", () => {
|
||||
// Given — `conversations.list` is Slack tier 2 and paginated (contract,
|
||||
// Errors); the `429` carries the wait in `Retry-After`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], {
|
||||
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
|
||||
}),
|
||||
);
|
||||
@@ -494,7 +573,9 @@ describe("choosing a destination channel", () => {
|
||||
|
||||
// And — waiting is the fix, so nothing is said about permissions.
|
||||
expect(message).not.toMatch(/permission/);
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(await harness.authorizedChannels()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the channels it did read on offer when Slack refuses a later page", async () => {
|
||||
@@ -519,8 +600,10 @@ describe("choosing a destination channel", () => {
|
||||
expect(notice).toMatch(/rate limiting/);
|
||||
expect(notice).toMatch(/about 30 seconds/);
|
||||
|
||||
// And — a partial read says nothing about the destination already recorded.
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
// And — a partial read says nothing about the channels already authorized.
|
||||
expect(await harness.authorizedChannels()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
@@ -539,7 +622,7 @@ describe("choosing a destination channel", () => {
|
||||
// Given — a `502`, which names no `code` because there is nothing to act on
|
||||
// (contract, Errors).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], {
|
||||
channelsRefusal: SLACK_UPSTREAM_REFUSAL,
|
||||
}),
|
||||
);
|
||||
@@ -551,12 +634,14 @@ describe("choosing a destination channel", () => {
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/Slack is temporarily unavailable/);
|
||||
expect(message).not.toMatch(/rate limiting/);
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(await harness.authorizedChannels()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
}, 30000);
|
||||
|
||||
it("says to invite @Prowler when Slack refuses the channel because the app is not in it", async () => {
|
||||
it("says to invite @Prowler when Slack refuses a channel because the app is not in it", async () => {
|
||||
// Given — a private channel the app was removed from. The API validates the
|
||||
// channel against Slack on the way in and refuses with `not_in_channel`.
|
||||
// set against Slack on the way in and refuses with `not_in_channel`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channelSaveRefusal: SLACK_NOT_IN_CHANNEL_REFUSAL,
|
||||
@@ -565,9 +650,9 @@ describe("choosing a destination channel", () => {
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
const refusal = await harness.refusedChannelSave(
|
||||
const refusal = await harness.refusedChannelsSave([
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
]);
|
||||
|
||||
// Then — the one fix the user can carry out themselves, in Slack.
|
||||
expect(refusal).toMatch(/Prowler is not in that channel/);
|
||||
@@ -575,7 +660,7 @@ describe("choosing a destination channel", () => {
|
||||
expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
|
||||
|
||||
// And — nothing was recorded, so there is still nothing to check against.
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(await harness.authorizedChannels()).toEqual([]);
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
@@ -591,7 +676,9 @@ describe("choosing a destination channel", () => {
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
const refusal = await harness.refusedChannelSave(SLACK_PUBLIC_CHANNEL.name);
|
||||
const refusal = await harness.refusedChannelsSave([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
|
||||
// Then — a different problem, so different copy: nothing to invite to a
|
||||
// channel that no longer exists.
|
||||
@@ -599,7 +686,162 @@ describe("choosing a destination channel", () => {
|
||||
expect(refusal).toMatch(/Choose another one/);
|
||||
expect(refusal).not.toMatch(/Invite @Prowler/);
|
||||
expect(refusal).not.toMatch(SLACK_UNKNOWN_CHANNEL_DETAIL);
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(await harness.authorizedChannels()).toEqual([]);
|
||||
}, 60000);
|
||||
|
||||
it("promises the confirmation only to the channels that have not had one", async () => {
|
||||
// Given — one channel authorized, confirmed by the check that left the
|
||||
// install connected.
|
||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// Then — checking again posts nothing: the confirmation is one-time
|
||||
// (design D7), not a fresh message every run.
|
||||
expect(harness.connectionCheckHint()).toMatch(/nothing is posted/);
|
||||
expect(harness.connectionCheckHint()).not.toMatch(/test message/i);
|
||||
|
||||
// When — a second channel is authorized.
|
||||
await harness.authorizeChannels([SLACK_SECOND_PUBLIC_CHANNEL.name]);
|
||||
|
||||
// Then — the copy names the newly authorized channel alone, and says what
|
||||
// will land in it.
|
||||
const hint = harness.connectionCheckHint() ?? "";
|
||||
expect(hint).toMatch(channelMention(SLACK_SECOND_PUBLIC_CHANNEL.name));
|
||||
expect(hint).not.toMatch(channelMention(SLACK_PUBLIC_CHANNEL.name));
|
||||
expect(hint).toMatch(
|
||||
/✅ Prowler connection verified\. Notifications will be delivered to this channel\./,
|
||||
);
|
||||
|
||||
// And — once the check the save ran has landed, there is nothing left to
|
||||
// confirm at all.
|
||||
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
||||
await harness.refreshPageData();
|
||||
expect(
|
||||
await harness.connectionCheckHintMatching(/nothing is posted/),
|
||||
).toMatch(/every authorized channel/);
|
||||
}, 60000);
|
||||
|
||||
it("warns that dropping a channel drops it from the alert rules too, before saving", async () => {
|
||||
// Given — two channels authorized, either of which an alert rule may
|
||||
// target.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithAuthorizedChannels([
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
]),
|
||||
);
|
||||
await harness.mount();
|
||||
// Nothing pending, nothing to warn about.
|
||||
expect(harness.deauthorizationWarning()).toBeNull();
|
||||
|
||||
// When — one is deselected and nothing is saved yet.
|
||||
await harness.chooseChannels([SLACK_PRIVATE_CHANNEL.name]);
|
||||
|
||||
// Then — what the save would do server-side, said where it is decided
|
||||
// (design D11): the rules lose the channel, delivery stops, history stays.
|
||||
const warning = harness.deauthorizationWarning() ?? "";
|
||||
expect(warning).toMatch(channelMention(SLACK_PRIVATE_CHANNEL.name));
|
||||
expect(warning).not.toMatch(channelMention(SLACK_PUBLIC_CHANNEL.name));
|
||||
expect(warning).toMatch(/alert rule/i);
|
||||
expect(warning).toMatch(/stops delivering/);
|
||||
expect(warning).toMatch(/already delivered stay in Slack/);
|
||||
|
||||
// And — it belongs to the pending selection, not to the record: putting the
|
||||
// channel back takes it away.
|
||||
await harness.chooseChannels([SLACK_PRIVATE_CHANNEL.name]);
|
||||
expect(harness.deauthorizationWarning()).toBeNull();
|
||||
|
||||
// When — the removal is saved after all.
|
||||
await harness.deauthorizeChannels([SLACK_PRIVATE_CHANNEL.name]);
|
||||
|
||||
// Then — the record follows and the warning has nothing left to say.
|
||||
expect(await harness.authorizedChannels()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
expect(harness.deauthorizationWarning()).toBeNull();
|
||||
}, 60000);
|
||||
|
||||
it("clears the authorized set when the last channel is dropped", async () => {
|
||||
// Given — a single authorized channel.
|
||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
await harness.deauthorizeChannels([SLACK_PUBLIC_CHANNEL.name]);
|
||||
|
||||
// Then — an empty list clears the set, where an omitted one would leave it
|
||||
// untouched (contract, PATCH).
|
||||
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
|
||||
"PATCH",
|
||||
"/integrations/",
|
||||
);
|
||||
expect(saved?.data.attributes.configuration.channels).toEqual([]);
|
||||
expect(await harness.authorizedChannels()).toEqual([]);
|
||||
|
||||
// And — with nothing to post to, the check is not offered and says why.
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
expect(harness.connectionCheckHint()).toMatch(
|
||||
/Authorize at least one destination channel/,
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it("keeps the connection when the same channels are recorded in another order", async () => {
|
||||
// Given — two channels authorized and a connection checked against them.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithAuthorizedChannels([
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL,
|
||||
]),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
|
||||
// When — the same ids are written back in the other order.
|
||||
await harness.channelsRecordedElsewhere([
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — a reorder is not a changed set, so the check that ran still
|
||||
// covers it (contract, PATCH).
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
|
||||
// When — the set itself changes.
|
||||
await harness.channelsRecordedElsewhere([SLACK_PUBLIC_CHANNEL.name]);
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — no check has covered this set, and the card says exactly that
|
||||
// rather than claiming a connection it cannot vouch for.
|
||||
expect(await harness.connectionBadge()).toBe("Not checked yet");
|
||||
}, 60000);
|
||||
|
||||
it("keeps the authorized channels through a reinstall, with their confirmations to run again", async () => {
|
||||
// Given — a finished setup whose channel an earlier check confirmed.
|
||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||
await harness.mount();
|
||||
expect(harness.connectionCheckHint()).toMatch(/nothing is posted/);
|
||||
|
||||
// When — the same workspace is approved again.
|
||||
await harness.mountCallback({
|
||||
code: SLACK_OAUTH_CODE,
|
||||
state: SLACK_OAUTH_STATE,
|
||||
});
|
||||
expect(await harness.completedInstall()).toBe(true);
|
||||
await harness.revisit();
|
||||
|
||||
// Then — a same-workspace reinstall keeps the channels and resets every
|
||||
// confirmation along with the connection state (contract, OAuth and
|
||||
// reads), so the check has each channel to confirm again.
|
||||
expect(await harness.authorizedChannels()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
expect(await harness.connectionBadge()).toBe("Not checked yet");
|
||||
expect(
|
||||
await harness.connectionCheckHintMatching(
|
||||
channelMention(SLACK_PUBLIC_CHANNEL.name),
|
||||
),
|
||||
).toMatch(/Prowler connection verified/);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -776,10 +1018,10 @@ describe("a credential Slack no longer accepts", () => {
|
||||
expect(harness.showsRevokedCredentialNotice()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
|
||||
// When — the access is approved again in Slack and the user saves a
|
||||
// destination: both the save and the check it runs answer for the grant.
|
||||
// When — the access is approved again in Slack and the user saves a wider
|
||||
// set: both the save and the check it runs answer for the grant.
|
||||
harness.fixture.connection = { connected: true, error: null };
|
||||
await harness.chooseChannel(SLACK_SECOND_PUBLIC_CHANNEL.name);
|
||||
await harness.authorizeChannels([SLACK_SECOND_PUBLIC_CHANNEL.name]);
|
||||
|
||||
// Then — Slack answered, so the notice about a credential it no longer
|
||||
// accepts goes, and the card is back to what it reported on arrival.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Slack integration: authorize several destination channels at once — the connection check confirms each authorized channel with a one-time message and names the one Slack refuses
|
||||
@@ -137,8 +137,8 @@ export const SlackCallback = () => {
|
||||
Connected to {workspaceName ?? "your Slack workspace"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Taking you back to the Slack integration, where you can choose the
|
||||
channel Prowler posts to.
|
||||
Taking you back to the Slack integration, where you can authorize the
|
||||
channels Prowler posts to.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { Lock, RefreshCw } from "lucide-react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Badge,
|
||||
Button,
|
||||
Label,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
MultiSelect,
|
||||
MultiSelectContent,
|
||||
MultiSelectItem,
|
||||
MultiSelectTrigger,
|
||||
MultiSelectValue,
|
||||
} from "@/components/shadcn/select/multiselect";
|
||||
import type { SlackChannelOption } from "@/types/integrations";
|
||||
|
||||
const INVITE_HINT =
|
||||
"A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh.";
|
||||
|
||||
interface SlackChannelMultiSelectProps {
|
||||
options: SlackChannelOption[];
|
||||
values: string[];
|
||||
onChange: (channelIds: string[]) => void;
|
||||
isLoading?: boolean;
|
||||
/** Why the channels could not be read — Slack's own reason, when it gave one. */
|
||||
error?: string | null;
|
||||
/** Why the list is partial. Shown with the picker, not instead of it. */
|
||||
incompleteNotice?: string | null;
|
||||
onRefresh?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chip a selected channel renders with the listing closed: a private one
|
||||
* keeps its identification there, not only inside the open listing.
|
||||
*/
|
||||
const chipLabel = (option: SlackChannelOption) => (
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
{option.is_private && (
|
||||
<>
|
||||
<Lock size={12} aria-hidden="true" />
|
||||
<span className="sr-only">Private</span>
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">#{option.name}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
/** Driven entirely by props (design D1) so any consumer can reuse it. */
|
||||
export const SlackChannelMultiSelect = ({
|
||||
options,
|
||||
values,
|
||||
onChange,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
incompleteNotice = null,
|
||||
onRefresh,
|
||||
disabled = false,
|
||||
}: SlackChannelMultiSelectProps) => {
|
||||
const isEmpty = !isLoading && !error && options.length === 0;
|
||||
// `htmlFor` may only name an element that exists, and the trigger is only
|
||||
// rendered in the picker branch.
|
||||
const hasPicker = !error && !isEmpty;
|
||||
|
||||
// A copy: the list belongs to the caller. Sorted here rather than upstream so
|
||||
// every consumer of the picker offers the same order.
|
||||
const listed = [...options].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, undefined, {
|
||||
sensitivity: "base",
|
||||
numeric: true,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor={hasPicker ? "slack-channels" : undefined}>
|
||||
Destination channels
|
||||
</Label>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{isLoading ? "Refreshing..." : "Refresh channels"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not read the workspace's channels</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : isEmpty ? (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>No channels available yet</AlertTitle>
|
||||
<AlertDescription>
|
||||
Prowler cannot see a single channel in this workspace. Create a
|
||||
public channel, or invite @Prowler to a private one in Slack with
|
||||
<span className="font-medium"> /invite @Prowler</span>, then
|
||||
refresh.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{incompleteNotice && (
|
||||
<Alert variant="warning" data-channels-notice>
|
||||
<AlertTitle>Not every channel is listed</AlertTitle>
|
||||
<AlertDescription>{incompleteNotice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<MultiSelect values={values} onValuesChange={onChange}>
|
||||
<MultiSelectTrigger
|
||||
id="slack-channels"
|
||||
aria-label="Destination channels"
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
<MultiSelectValue
|
||||
placeholder={
|
||||
isLoading ? "Reading channels..." : "Choose channels"
|
||||
}
|
||||
/>
|
||||
</MultiSelectTrigger>
|
||||
<MultiSelectContent
|
||||
search={{
|
||||
placeholder: "Search channels",
|
||||
emptyMessage: "No channel matches that search.",
|
||||
}}
|
||||
>
|
||||
{listed.map((option) => (
|
||||
<MultiSelectItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
badgeLabel={chipLabel(option)}
|
||||
// The search matches on `value`, which is the id here, so the
|
||||
// name the user types has to be searchable on its own.
|
||||
keywords={[option.name]}
|
||||
// Name hook: the rendered label mixes it with a "Private"
|
||||
// badge.
|
||||
data-channel={option.name}
|
||||
>
|
||||
<span className="min-w-0 truncate">#{option.name}</span>
|
||||
{option.is_private && (
|
||||
<Badge variant="tag" size="sm">
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</MultiSelectItem>
|
||||
))}
|
||||
</MultiSelectContent>
|
||||
</MultiSelect>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-text-neutral-secondary text-xs">{INVITE_HINT}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Badge,
|
||||
Button,
|
||||
Label,
|
||||
} from "@/components/shadcn";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/shadcn/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/shadcn/popover";
|
||||
import type { SlackChannelOption } from "@/types/integrations";
|
||||
|
||||
const INVITE_HINT =
|
||||
"A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh.";
|
||||
|
||||
interface SlackChannelSelectorProps {
|
||||
options: SlackChannelOption[];
|
||||
value: string | null;
|
||||
onChange: (channelId: string) => void;
|
||||
isLoading?: boolean;
|
||||
/** Why the channels could not be read — Slack's own reason, when it gave one. */
|
||||
error?: string | null;
|
||||
/** Why the list is partial. Shown with the picker, not instead of it. */
|
||||
incompleteNotice?: string | null;
|
||||
onRefresh?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Driven entirely by props (design D13) so the alert-rule form can reuse it. */
|
||||
export const SlackChannelSelector = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
incompleteNotice = null,
|
||||
onRefresh,
|
||||
disabled = false,
|
||||
}: SlackChannelSelectorProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const isEmpty = !isLoading && !error && options.length === 0;
|
||||
// `htmlFor` may only name an element that exists, and the trigger is only
|
||||
// rendered in the picker branch.
|
||||
const hasPicker = !error && !isEmpty;
|
||||
|
||||
// A copy: the list belongs to the caller. Sorted here rather than upstream so
|
||||
// every consumer of the picker offers the same order.
|
||||
const listed = [...options].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, undefined, {
|
||||
sensitivity: "base",
|
||||
numeric: true,
|
||||
}),
|
||||
);
|
||||
const selected = options.find((option) => option.id === value) ?? null;
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open);
|
||||
// Drop the search with the popover, so re-opening it never starts filtered.
|
||||
if (!open) setQuery("");
|
||||
};
|
||||
|
||||
const handleSelect = (channelId: string) => {
|
||||
onChange(channelId);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor={hasPicker ? "slack-channel" : undefined}>
|
||||
Destination channel
|
||||
</Label>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{isLoading ? "Refreshing..." : "Refresh channels"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not read the workspace's channels</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : isEmpty ? (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>No channels available yet</AlertTitle>
|
||||
<AlertDescription>
|
||||
Prowler cannot see a single channel in this workspace. Create a
|
||||
public channel, or invite @Prowler to a private one in Slack with
|
||||
<span className="font-medium"> /invite @Prowler</span>, then
|
||||
refresh.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{incompleteNotice && (
|
||||
<Alert variant="warning" data-channels-notice>
|
||||
<AlertTitle>Not every channel is listed</AlertTitle>
|
||||
<AlertDescription>{incompleteNotice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id="slack-channel"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
disabled={disabled || isLoading}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{selected
|
||||
? `#${selected.name}`
|
||||
: isLoading
|
||||
? "Reading channels..."
|
||||
: "Choose a channel"}
|
||||
</span>
|
||||
<ChevronDown size={16} aria-hidden="true" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search channels"
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
aria-label="Search channels"
|
||||
/>
|
||||
<CommandList>
|
||||
{/* A workspace with no channels at all is a different
|
||||
situation, answered by the alert above. */}
|
||||
<CommandEmpty>No channel matches that search.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{listed.map((option) => (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
// The search matches on this value, so it carries the
|
||||
// name the user types; the id travels to `onChange`
|
||||
// through the closure. A name can be empty on the wire.
|
||||
value={option.name || option.id}
|
||||
onSelect={() => handleSelect(option.id)}
|
||||
// Name hook: the rendered label mixes it with a
|
||||
// "Private" badge.
|
||||
data-channel={option.name}
|
||||
>
|
||||
<span className="min-w-0 truncate">#{option.name}</span>
|
||||
{option.is_private && (
|
||||
<Badge variant="tag" size="sm">
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-text-neutral-secondary text-xs">{INVITE_HINT}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -45,8 +45,8 @@ export const SlackIntegrationCard = () => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Connect a Slack workspace and pick the channel Prowler posts to, so
|
||||
your team gets security updates where it already works.
|
||||
Connect a Slack workspace and authorize the channels Prowler posts to,
|
||||
so your team gets security updates where it already works.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { SlackIntegrationManager } from "./slack-integration-manager";
|
||||
|
||||
vi.mock("@/actions/integrations/slack", () => ({
|
||||
getSlackChannels: vi.fn(),
|
||||
setSlackDefaultChannel: vi.fn(),
|
||||
setSlackAuthorizedChannels: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/integrations/integrations", () => ({
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
disconnectSlackIntegration,
|
||||
getSlackAuthorizeUrl,
|
||||
getSlackChannels,
|
||||
setSlackDefaultChannel,
|
||||
setSlackAuthorizedChannels,
|
||||
} from "@/actions/integrations/slack";
|
||||
import { SlackIcon } from "@/components/icons/services/IconServices";
|
||||
import { IntegrationCardHeader } from "@/components/integrations/shared";
|
||||
import { SlackChannelSelector } from "@/components/integrations/slack/slack-channel-selector";
|
||||
import { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
|
||||
import type {
|
||||
IntegrationProps,
|
||||
SlackAuthorizedChannel,
|
||||
SlackChannelOption,
|
||||
} from "@/types/integrations";
|
||||
|
||||
@@ -60,7 +61,15 @@ interface ChannelsLoaded {
|
||||
|
||||
type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
|
||||
|
||||
const CHECK_BLOCKED_REASON_ID = "slack-connection-check-blocked";
|
||||
const CHECK_HINT_ID = "slack-connection-check-hint";
|
||||
|
||||
/**
|
||||
* What the connection check posts, word for word (contract, Connection). Named
|
||||
* here because the copy promises it: a user about to run the check sees exactly
|
||||
* what will land in their channels.
|
||||
*/
|
||||
const CONFIRMATION_MESSAGE =
|
||||
"✅ Prowler connection verified. Notifications will be delivered to this channel.";
|
||||
|
||||
/**
|
||||
* A disconnect that removed the row without Slack confirming the revocation.
|
||||
@@ -71,16 +80,71 @@ interface UnconfirmedRevocation {
|
||||
workspace: string | null;
|
||||
}
|
||||
|
||||
// The name may be missing: the id decides what the UI can do with it.
|
||||
interface SlackChannelRef {
|
||||
id: string;
|
||||
name: string | null;
|
||||
}
|
||||
/** Order-insensitive: the mirror must not re-seed on a mere reordering. */
|
||||
const sameChannelIds = (a: string[], b: string[]) =>
|
||||
a.length === b.length && new Set([...a, ...b]).size === a.length;
|
||||
|
||||
const channelRefEquals = (
|
||||
a: SlackChannelRef | null,
|
||||
b: SlackChannelRef | null,
|
||||
) => a?.id === b?.id && a?.name === b?.name;
|
||||
/**
|
||||
* Confirmation counts as part of the record: a check that confirms a channel
|
||||
* changes nothing else, and the mirror has to follow it or the card would keep
|
||||
* offering to confirm what is already confirmed.
|
||||
*/
|
||||
const sameChannelSets = (
|
||||
a: SlackAuthorizedChannel[],
|
||||
b: SlackAuthorizedChannel[],
|
||||
) => {
|
||||
if (a.length !== b.length) return false;
|
||||
const byId = new Map(a.map((channel) => [channel.id, channel]));
|
||||
return b.every((channel) => {
|
||||
const other = byId.get(channel.id);
|
||||
return (
|
||||
other?.name === channel.name &&
|
||||
other?.confirmation_sent_at === channel.confirmation_sent_at
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The listing's copy of a channel wins over the stored one (a rename in Slack
|
||||
* shows up there first), and a stored channel the listing no longer carries
|
||||
* stays offered: dropping it silently would deselect it behind the user's back.
|
||||
*/
|
||||
const mergeChannelOptions = (
|
||||
listed: SlackChannelOption[],
|
||||
stored: SlackAuthorizedChannel[],
|
||||
): SlackChannelOption[] => {
|
||||
const merged = new Map(listed.map((channel) => [channel.id, channel]));
|
||||
stored.forEach(({ id, name, is_private }) => {
|
||||
// Narrowed on the way in: the picker offers channels, and whether Prowler
|
||||
// has confirmed one is no part of choosing it.
|
||||
if (!merged.has(id)) merged.set(id, { id, name, is_private });
|
||||
});
|
||||
return Array.from(merged.values());
|
||||
};
|
||||
|
||||
/**
|
||||
* What the save recorded, for an answer that carried no channels back. Retained
|
||||
* ids keep the confirmation they had and new ones start without one, which is
|
||||
* the rule the API applies.
|
||||
*/
|
||||
const recordedFromSelection = (
|
||||
channelIds: string[],
|
||||
options: SlackChannelOption[],
|
||||
previous: SlackAuthorizedChannel[],
|
||||
): SlackAuthorizedChannel[] =>
|
||||
channelIds.flatMap((channelId) => {
|
||||
const option = options.find((channel) => channel.id === channelId);
|
||||
if (!option) return [];
|
||||
const stored = previous.find((channel) => channel.id === channelId);
|
||||
return [
|
||||
{ ...option, confirmation_sent_at: stored?.confirmation_sent_at ?? null },
|
||||
];
|
||||
});
|
||||
|
||||
const channelList = (names: string[]): string =>
|
||||
new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format(
|
||||
names.map((name) => `#${name}`),
|
||||
);
|
||||
|
||||
/**
|
||||
* Slack's own reason, when the string is one: the connection check reports a
|
||||
@@ -129,14 +193,8 @@ export const SlackIntegrationManager = ({
|
||||
const { toast } = useToast();
|
||||
|
||||
const integrationId = integration?.id ?? null;
|
||||
const recordedChannelId =
|
||||
integration?.attributes.configuration.channel_id ?? null;
|
||||
const recordedChannelName =
|
||||
integration?.attributes.configuration.channel_name ?? null;
|
||||
|
||||
const recordedChannel: SlackChannelRef | null = recordedChannelId
|
||||
? { id: recordedChannelId, name: recordedChannelName }
|
||||
: null;
|
||||
const recordedChannels: SlackAuthorizedChannel[] =
|
||||
integration?.attributes.configuration.channels ?? [];
|
||||
|
||||
// Seeded `loading`, not by the effect: the effect never runs on the server,
|
||||
// so anything else would server-render a "no channels" picker until
|
||||
@@ -148,26 +206,27 @@ export const SlackIntegrationManager = ({
|
||||
);
|
||||
// Bumped by refresh: a channel invited after load only shows on a re-read.
|
||||
const [channelReloads, setChannelReloads] = useState(0);
|
||||
// Local state needed: the pick is buffered until the user saves it.
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
|
||||
recordedChannelId,
|
||||
// Local state needed: the picks are buffered until the user saves them.
|
||||
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>(
|
||||
recordedChannels.map((channel) => channel.id),
|
||||
);
|
||||
// Mirrored in state, not read from the prop, so channel-gated affordances
|
||||
// move on save instead of waiting for the revalidation.
|
||||
const [defaultChannel, setDefaultChannel] = useState(recordedChannel);
|
||||
const [authorizedChannels, setAuthorizedChannels] =
|
||||
useState(recordedChannels);
|
||||
// The prop the mirror was last taken from: the card never unmounts, so a
|
||||
// mirror seeded only at mount would go stale when the record changes.
|
||||
const [syncedChannel, setSyncedChannel] = useState(recordedChannel);
|
||||
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
||||
const [syncedChannels, setSyncedChannels] = useState(recordedChannels);
|
||||
const [isSavingChannels, setIsSavingChannels] = useState(false);
|
||||
|
||||
if (!channelRefEquals(recordedChannel, syncedChannel)) {
|
||||
const previousSyncedId = syncedChannel?.id ?? null;
|
||||
setSyncedChannel(recordedChannel);
|
||||
setDefaultChannel(recordedChannel);
|
||||
// Follow the record only while the buffered pick still matches it: an
|
||||
if (!sameChannelSets(recordedChannels, syncedChannels)) {
|
||||
const previousSyncedIds = syncedChannels.map((channel) => channel.id);
|
||||
setSyncedChannels(recordedChannels);
|
||||
setAuthorizedChannels(recordedChannels);
|
||||
// Follow the record only while the buffered picks still match it: an
|
||||
// unsaved pick is the user's, not ours to overwrite mid-edit.
|
||||
if (selectedChannelId === previousSyncedId) {
|
||||
setSelectedChannelId(recordedChannel?.id ?? null);
|
||||
if (sameChannelIds(selectedChannelIds, previousSyncedIds)) {
|
||||
setSelectedChannelIds(recordedChannels.map((channel) => channel.id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,65 +309,101 @@ export const SlackIntegrationManager = ({
|
||||
};
|
||||
}, [integrationId, channelReloads]);
|
||||
|
||||
const channels =
|
||||
const listedChannels =
|
||||
channelsState.status === CHANNELS_STATUS.LOADED
|
||||
? channelsState.channels
|
||||
: [];
|
||||
const channelOptions = mergeChannelOptions(
|
||||
listedChannels,
|
||||
authorizedChannels,
|
||||
);
|
||||
|
||||
const handleSaveChannel = async () => {
|
||||
if (!integrationId || !selectedChannelId) return;
|
||||
// The check posts its confirmation only where none has landed yet, so these
|
||||
// are the channels the next one would post to (contract, Connection).
|
||||
const unconfirmedChannels = authorizedChannels.filter(
|
||||
(channel) => channel.confirmation_sent_at === null,
|
||||
);
|
||||
// Channels the buffered selection would drop. Removing one cascades into the
|
||||
// alert rules that target it, in the same transaction (design D11), so the
|
||||
// warning belongs to the pending save rather than to what is on record.
|
||||
const droppedChannels = authorizedChannels.filter(
|
||||
(channel) => !selectedChannelIds.includes(channel.id),
|
||||
);
|
||||
|
||||
let saved = false;
|
||||
setIsSavingChannel(true);
|
||||
const checkHint = (): string => {
|
||||
if (authorizedChannels.length === 0) {
|
||||
return "Authorize at least one destination channel below to enable this check.";
|
||||
}
|
||||
return unconfirmedChannels.length > 0
|
||||
? `Checks every authorized channel and posts “${CONFIRMATION_MESSAGE}” once to ${channelList(
|
||||
unconfirmedChannels.map((channel) => channel.name),
|
||||
)}.`
|
||||
: "Checks every authorized channel. Each was confirmed once already, so nothing is posted.";
|
||||
};
|
||||
|
||||
const handleSaveChannels = async () => {
|
||||
if (!integrationId) return;
|
||||
|
||||
let savedCount = 0;
|
||||
setIsSavingChannels(true);
|
||||
try {
|
||||
// Only the id travels — the API validates it and derives the name
|
||||
// (design D6).
|
||||
const result = await setSlackDefaultChannel(
|
||||
// Only ids travel — the API validates them and derives the names.
|
||||
const result = await setSlackAuthorizedChannels(
|
||||
integrationId,
|
||||
selectedChannelId,
|
||||
selectedChannelIds,
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
// The API validates the channel against Slack, so the save can
|
||||
// The API validates the channels against Slack, so the save can
|
||||
// discover the credential is gone.
|
||||
recordRefusal(result.code);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not save the destination channel",
|
||||
title: "Could not save the destination channels",
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the API's derived name: a channel renamed in Slack since the
|
||||
// list was read would otherwise show its old name.
|
||||
const savedName =
|
||||
result.integration.attributes.configuration.channel_name ??
|
||||
channels.find((channel) => channel.id === selectedChannelId)?.name ??
|
||||
null;
|
||||
// Prefer the API's own record: it derives the names (a channel renamed
|
||||
// in Slack since the list was read would otherwise show its old name)
|
||||
// and it is what says which channels are confirmed.
|
||||
const savedChannels =
|
||||
result.integration.attributes.configuration.channels ??
|
||||
recordedFromSelection(
|
||||
selectedChannelIds,
|
||||
channelOptions,
|
||||
authorizedChannels,
|
||||
);
|
||||
|
||||
provedCredentialAlive();
|
||||
setDefaultChannel({ id: selectedChannelId, name: savedName });
|
||||
saved = true;
|
||||
setAuthorizedChannels(savedChannels);
|
||||
setSelectedChannelIds(savedChannels.map((channel) => channel.id));
|
||||
savedCount = savedChannels.length;
|
||||
toast({
|
||||
title: "Destination channel saved",
|
||||
description: savedName
|
||||
? `Prowler will post to #${savedName}.`
|
||||
: "Prowler will post to the channel you chose.",
|
||||
title: "Destination channels saved",
|
||||
description:
|
||||
savedChannels.length > 0
|
||||
? `Prowler will post to ${channelList(
|
||||
savedChannels.map((channel) => channel.name),
|
||||
)}.`
|
||||
: "No destination channels are authorized any more.",
|
||||
});
|
||||
} catch (_error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not save the destination channel",
|
||||
title: "Could not save the destination channels",
|
||||
description: "Something went wrong. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsSavingChannel(false);
|
||||
setIsSavingChannels(false);
|
||||
}
|
||||
|
||||
// Recording a destination is what makes a check possible (design D7), and
|
||||
// the save alone only proves the API took the id.
|
||||
if (saved) await handleTestConnection(integrationId);
|
||||
// Recording destinations is what makes a check possible (design D7), and
|
||||
// the save alone only proves the API took the ids: the check is what
|
||||
// reaches each channel and confirms it. A save that cleared the set has
|
||||
// nothing to post to.
|
||||
if (savedCount > 0) await handleTestConnection(integrationId);
|
||||
};
|
||||
|
||||
const handleTestConnection = async (id: string) => {
|
||||
@@ -321,7 +416,8 @@ export const SlackIntegrationManager = ({
|
||||
toast({
|
||||
title: "Connection test successful!",
|
||||
description:
|
||||
result.message || "Prowler can reach your Slack workspace.",
|
||||
result.message ||
|
||||
"Prowler can reach your Slack workspace and every authorized channel.",
|
||||
});
|
||||
} else {
|
||||
// A dead credential named here is not a failure checking again can
|
||||
@@ -330,12 +426,18 @@ export const SlackIntegrationManager = ({
|
||||
|
||||
recordRefusal(asReasonCode(reason));
|
||||
|
||||
const explanation = reason
|
||||
? slackErrorMessage({ code: reason, detail: reason })
|
||||
: "Failed to reach your Slack workspace.";
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Connection test failed",
|
||||
description: reason
|
||||
? slackErrorMessage({ code: reason, detail: reason })
|
||||
: "Failed to reach your Slack workspace.",
|
||||
// The failure names the channel it is about (design D7): the fix is
|
||||
// in Slack, on that channel, not on the integration as a whole.
|
||||
description: result.failedChannel
|
||||
? `Slack refused #${result.failedChannel}: ${explanation}`
|
||||
: explanation,
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
@@ -540,17 +642,15 @@ export const SlackIntegrationManager = ({
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* The check posts to the destination channel: the API answers
|
||||
400 when none is recorded yet. */}
|
||||
{/* The check reaches the authorized channels: the API
|
||||
answers 400 while the set is empty. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting || !defaultChannel}
|
||||
// The reason travels with the control: a disabled button
|
||||
// whose explanation sits across the row reads as broken.
|
||||
aria-describedby={
|
||||
defaultChannel ? undefined : CHECK_BLOCKED_REASON_ID
|
||||
}
|
||||
disabled={isTesting || authorizedChannels.length === 0}
|
||||
// What the control does — or why it cannot — travels with
|
||||
// it: an explanation across the row reads as unrelated.
|
||||
aria-describedby={CHECK_HINT_ID}
|
||||
onClick={() => handleTestConnection(integration.id)}
|
||||
>
|
||||
<TestTube size={14} />
|
||||
@@ -566,22 +666,20 @@ export const SlackIntegrationManager = ({
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
{!defaultChannel && (
|
||||
<p
|
||||
id={CHECK_BLOCKED_REASON_ID}
|
||||
className="text-xs text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
Choose a destination channel below to enable this check.
|
||||
</p>
|
||||
)}
|
||||
<p
|
||||
id={CHECK_HINT_ID}
|
||||
className="max-w-prose text-xs text-gray-500 sm:text-right dark:text-gray-300"
|
||||
>
|
||||
{checkHint()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border-neutral-secondary mt-6 flex flex-col gap-4 border-t pt-6">
|
||||
<SlackChannelSelector
|
||||
options={channels}
|
||||
value={selectedChannelId}
|
||||
onChange={setSelectedChannelId}
|
||||
<SlackChannelMultiSelect
|
||||
options={channelOptions}
|
||||
values={selectedChannelIds}
|
||||
onChange={setSelectedChannelIds}
|
||||
isLoading={channelsState.status === CHANNELS_STATUS.LOADING}
|
||||
error={
|
||||
channelsState.status === CHANNELS_STATUS.ERROR
|
||||
@@ -594,30 +692,52 @@ export const SlackIntegrationManager = ({
|
||||
: null
|
||||
}
|
||||
onRefresh={() => setChannelReloads((reloads) => reloads + 1)}
|
||||
disabled={isSavingChannel}
|
||||
disabled={isSavingChannels}
|
||||
/>
|
||||
|
||||
{droppedChannels.length > 0 && (
|
||||
<Alert variant="warning" data-deauthorize-warning>
|
||||
<AlertTitle>
|
||||
Dropped channels leave your alert rules too
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
Saving this selection drops{" "}
|
||||
{channelList(
|
||||
droppedChannels.map((channel) => channel.name),
|
||||
)}
|
||||
. Every alert rule targeting a dropped channel stops
|
||||
targeting it, and Prowler stops delivering there.
|
||||
Notifications already delivered stay in Slack.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
{/* The id decides, not the name: a missing name would deny
|
||||
a destination the check runs against. */}
|
||||
{defaultChannel
|
||||
? defaultChannel.name
|
||||
? `Prowler posts to #${defaultChannel.name}.`
|
||||
: "Prowler posts to the channel you saved."
|
||||
: "No destination channel recorded yet."}
|
||||
{/* The card's subtitle also starts "Prowler posts to", so the
|
||||
recorded set is keyed for what reads it. */}
|
||||
<p
|
||||
className="text-text-neutral-secondary text-xs"
|
||||
data-authorized-channels
|
||||
>
|
||||
{authorizedChannels.length > 0
|
||||
? `Prowler posts to ${channelList(
|
||||
authorizedChannels.map((channel) => channel.name),
|
||||
)}.`
|
||||
: "No destination channels authorized yet."}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!selectedChannelId ||
|
||||
selectedChannelId === (defaultChannel?.id ?? null) ||
|
||||
isSavingChannel ||
|
||||
sameChannelIds(
|
||||
selectedChannelIds,
|
||||
authorizedChannels.map((channel) => channel.id),
|
||||
) ||
|
||||
isSavingChannels ||
|
||||
isTesting
|
||||
}
|
||||
onClick={handleSaveChannel}
|
||||
onClick={handleSaveChannels}
|
||||
>
|
||||
{isSavingChannel ? "Saving..." : "Save channel"}
|
||||
{isSavingChannels ? "Saving..." : "Save channels"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,13 +98,15 @@ export interface IntegrationProps {
|
||||
domain?: string;
|
||||
projects?: { [key: string]: string };
|
||||
issue_types?: { [key: string]: string[] };
|
||||
// Slack specific configuration, server-owned. The channel keys are absent
|
||||
// until one is chosen, not present and null: read them with `?? null`.
|
||||
// Slack specific configuration, server-owned. A new install carries an
|
||||
// empty `channels` array and a `verification` whose fields are all null;
|
||||
// the keys are optional here because this shape is shared with the other
|
||||
// integration types, so read them with `?? []`.
|
||||
team_id?: string;
|
||||
team_name?: string;
|
||||
bot_user_id?: string;
|
||||
channel_id?: string;
|
||||
channel_name?: string;
|
||||
channels?: SlackAuthorizedChannel[];
|
||||
verification?: SlackVerification;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
url?: string;
|
||||
@@ -116,6 +118,10 @@ export interface IntegrationProps {
|
||||
/**
|
||||
* A channel Prowler can post to: every active public channel, plus the private
|
||||
* ones `@Prowler` was invited to. `is_private` keeps the API's own naming.
|
||||
*
|
||||
* The picker's option type, deliberately without the integration's stored
|
||||
* fields: whoever renders a choice has no business knowing whether Prowler has
|
||||
* already confirmed the channel.
|
||||
*/
|
||||
export interface SlackChannelOption {
|
||||
id: string;
|
||||
@@ -123,6 +129,28 @@ export interface SlackChannelOption {
|
||||
is_private: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A channel authorized on the integration. `confirmation_sent_at` is when the
|
||||
* one-time confirmation the connection check posts landed in it: null until a
|
||||
* check posts one, and null again after a same-workspace reinstall, which keeps
|
||||
* the channels but resets every confirmation.
|
||||
*/
|
||||
export interface SlackAuthorizedChannel extends SlackChannelOption {
|
||||
confirmation_sent_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection check the API last recorded. `task_id` is pre-generated when
|
||||
* the check is queued, so it exists before the worker starts: `started_at` is
|
||||
* what says execution began, and only a task whose id still matches may write
|
||||
* here — which is what keeps a late check from overwriting a newer one.
|
||||
*/
|
||||
export interface SlackVerification {
|
||||
task_id: string | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
// Jira dispatch types
|
||||
export interface JiraDispatchRequest {
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user