mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): authorize multiple Slack destination channels (#12491)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user