feat(ui): authorize multiple Slack destination channels (#12491)

This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-08-26 08:52:56 +02:00
committed by GitHub
parent 301ca50541
commit 39c85ffb77
17 changed files with 1371 additions and 572 deletions
+10 -1
View File
@@ -18,6 +18,8 @@ type TestConnectionResponse = {
taskId?: string;
data?: TaskStartResponse;
error?: string;
/** The id of the channel a channel-level failure named, when it named one. */
failedChannelId?: string | null;
};
export const getIntegrations = async (searchParams?: URLSearchParams) => {
@@ -265,7 +267,12 @@ export const deleteIntegration = async (
}
};
type ConnectionTaskResult = { connected?: boolean; error?: string | null };
type ConnectionTaskResult = {
connected?: boolean;
error?: string | null;
// The failing channel's id, or null when the failure names no channel.
channel?: string | null;
};
type PollConnectionResult =
| {
@@ -358,6 +365,7 @@ export const testIntegrationConnection = async (
return {
success: false,
error: pollResult.message || "Connection test failed.",
failedChannelId: pollResult.result?.channel ?? null,
};
}
} else {
@@ -404,6 +412,7 @@ export const pollConnectionTestStatus = async (
return {
success: false,
error: pollResult.message || "Connection test failed.",
failedChannelId: pollResult.result?.channel ?? null,
};
}
} catch (_error) {
+48 -19
View File
@@ -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,35 @@ 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 }] },
},
},
});
});
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 +611,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 +636,7 @@ describe("setSlackDefaultChannel", () => {
}),
);
const result = await saveChannel();
const result = await saveChannels();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
@@ -623,8 +652,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 +716,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());
+26 -18
View File
@@ -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,27 @@ 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 set; an empty one clears it.
*
* 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 +461,14 @@ 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: {
// Ids wrapped as objects, never a bare array (contract, PATCH).
channels: Array.from(new Set(channelIds), (channelId) => ({
id: channelId,
})),
},
},
},
}),
});
@@ -470,18 +478,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 };