feat(ui): pick a Slack channel and verify it (#12436)

This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-08-21 11:45:24 +02:00
committed by GitHub
parent 75d7fa5006
commit 823efc5ab1
14 changed files with 2331 additions and 61 deletions
+1
View File
@@ -388,6 +388,7 @@ export const pollConnectionTestStatus = async (
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
revalidatePath("/integrations/jira");
revalidatePath("/integrations/slack");
if ("error" in pollResult) {
return { success: false, error: pollResult.error };
+407 -4
View File
@@ -1,11 +1,17 @@
/**
* Sentry reporting for the Slack OAuth actions. A capture leaves no mark on the
* DOM, so it cannot be covered from `slack-page.integration.test.tsx`.
* What the Slack actions do off the DOM, which
* `slack-page.integration.test.tsx` cannot cover: which failures reach Sentry,
* and the URLs the channel listing's cursor pagination follows.
*/
import { revalidatePath } from "next/cache";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SLACK_UNREADABLE_RESULT_MESSAGE } from "@/lib/integrations/slack-errors";
import {
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
SLACK_UNREADABLE_RESULT_MESSAGE,
} from "@/lib/integrations/slack-errors";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
@@ -30,6 +36,8 @@ const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
captureMessage: captureMessageMock,
// The task poll leaves breadcrumbs on every read it makes.
addBreadcrumb: vi.fn(),
}));
vi.mock("next/cache", () => ({
@@ -50,7 +58,12 @@ vi.mock("@/lib", () => ({
parseStringify: (value: unknown) => value,
}));
import { exchangeSlackOAuthCode, getSlackAuthorizeUrl } from "./slack";
import {
exchangeSlackOAuthCode,
getSlackAuthorizeUrl,
getSlackChannels,
setSlackDefaultChannel,
} from "./slack";
/** The status the contract reserves for an upstream Slack failure. */
const UPSTREAM_STATUS = 502;
@@ -284,3 +297,393 @@ describe("exchangeSlackOAuthCode result shape", () => {
expect(await exchange()).toEqual({ integration: INTEGRATION });
});
});
/** The shape the API's integration ids have, which is the only shape accepted. */
const SLACK_INTEGRATION_ID = "b2c7fd0a-3e51-4d8f-9a6c-1f0e2d3c4b5a";
const CHANNELS_URL =
`https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}` +
"/slack/channels";
const FIRST_CHANNEL = { id: "C0123AB", name: "security" };
const SECOND_CHANNEL = { id: "C0789EF", name: "platform" };
const channelPage = (
channel: { id: string; name: string },
next: string | null,
) =>
new Response(
JSON.stringify({
data: [
{
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: false },
},
],
links: { next },
}),
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
);
const channelOption = (channel: { id: string; name: string }) => ({
id: channel.id,
name: channel.name,
is_private: false,
});
const requestedUrls = (): string[] =>
fetchMock.mock.calls.map(([url]) => String(url));
const sentBody = (callIndex = 0): unknown =>
JSON.parse(String(fetchMock.mock.calls[callIndex]?.[1]?.body));
/**
* `MAX_CHANNEL_PAGES` in the action, which a `"use server"` module cannot
* export: only async functions may leave one.
*/
const MAX_CHANNEL_PAGES = 20;
const channelOptions = (count: number) =>
Array.from({ length: count }, () => channelOption(FIRST_CHANNEL));
/** What a `429` carrying `Retry-After: 30` is turned into. */
const RATE_LIMITED_MESSAGE =
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.";
describe("getSlackChannels", () => {
it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => {
// The link is opaque (design D6), so the API may answer with the cursor
// alone; resolved against the API root it loses the listing's own path.
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(channelPage(SECOND_CHANNEL, null));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(requestedUrls()).toEqual([
CHANNELS_URL,
`${CHANNELS_URL}?page[cursor]=2`,
]);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL), channelOption(SECOND_CHANNEL)],
});
});
it.each([
{
shape: "an absolute",
next: "https://evil.test/api/v1/integrations/x/slack/channels?cursor=2",
},
{ shape: "a protocol-relative", next: "//evil.test/api/v1/channels?c=2" },
])(
"stops at $shape off-origin `next` rather than sending the tenant's token to it",
async ({ next }) => {
// `fetch` strips the tenant's `Authorization` on a redirect that leaves
// the origin, but not on a hop the UI makes itself.
fetchMock.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, next));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(requestedUrls()).toEqual([CHANNELS_URL]);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
});
},
);
it("answers an unreadable page as no channels rather than parser prose", async () => {
fetchMock.mockResolvedValueOnce(unreadableOk(HTML_INTERSTITIAL));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ channels: [] });
expectNoParserProse(result);
});
it("says the list is short of the workspace when the page budget runs out", async () => {
// The budget exists because `conversations.list` is tier 2 and a workspace
// can outgrow it (design.md, Risks). A fresh `Response` per call: one
// instance is already consumed on its second read.
fetchMock.mockImplementation(() =>
Promise.resolve(channelPage(FIRST_CHANNEL, "?page[cursor]=next")),
);
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
expect(result).toEqual({
channels: channelOptions(MAX_CHANNEL_PAGES),
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
});
});
it("says nothing about a short list for a workspace that just fits the budget", async () => {
let page = 0;
fetchMock.mockImplementation(() => {
page += 1;
return Promise.resolve(
channelPage(
FIRST_CHANNEL,
page < MAX_CHANNEL_PAGES ? `?page[cursor]=${page}` : null,
),
);
});
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
expect(result).toEqual({ channels: channelOptions(MAX_CHANNEL_PAGES) });
expect(result).not.toHaveProperty("incomplete");
});
it("keeps the pages it read when a later one is refused, saying why the list stops", async () => {
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(rateLimitedResponse());
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: RATE_LIMITED_MESSAGE,
});
});
it("answers a refusal on the first page as a failure, having nothing to show", async () => {
fetchMock.mockResolvedValueOnce(rateLimitedResponse());
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE });
});
});
/**
* A `2xx` whose body is not JSON:API: an empty answer, or the HTML a proxy or
* WAF puts in front of one. The raw `SyntaxError` survives
* `sanitizeErrorMessage` (V8 truncates the snippet to ten characters, so its
* `<!doctype html>` branch never matches) and would be shown verbatim.
*/
const HTML_INTERSTITIAL =
"<!DOCTYPE html><html><body><h1>Checking your browser</h1></body></html>";
const unreadableOk = (body: string) =>
new Response(body, {
status: 200,
headers: { "content-type": body ? "text/html" : "application/json" },
});
/** V8's parser wording, which no user should ever be shown. */
const PARSER_PROSE = /unexpected (token|end of json)|not valid json/i;
const expectNoParserProse = (result: unknown) => {
const message = (result as { error?: string }).error ?? "";
expect(message).not.toMatch(PARSER_PROSE);
};
const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`;
const saveChannel = () =>
setSlackDefaultChannel(SLACK_INTEGRATION_ID, FIRST_CHANNEL.id);
/** The save as the API answers it: the channel's name derived server-side. */
const savedIntegration = () =>
new Response(
JSON.stringify({
data: {
type: "integrations",
id: SLACK_INTEGRATION_ID,
attributes: {
integration_type: "slack",
configuration: {
channel_id: FIRST_CHANNEL.id,
channel_name: FIRST_CHANNEL.name,
},
},
},
}),
{
status: 200,
headers: { "content-type": "application/vnd.api+json" },
},
);
const expectIntegrationsRevalidated = () => {
expect(vi.mocked(revalidatePath).mock.calls).toEqual([
["/integrations"],
["/integrations/slack"],
]);
};
describe("setSlackDefaultChannel", () => {
it("returns the saved integration and revalidates the pages listing it", async () => {
fetchMock.mockResolvedValueOnce(savedIntegration());
const result = await saveChannel();
expect(requestedUrls()).toEqual([INTEGRATION_URL]);
expect(result).toMatchObject({
integration: {
attributes: { configuration: { channel_name: FIRST_CHANNEL.name } },
},
});
expectIntegrationsRevalidated();
});
// 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 () => {
fetchMock.mockResolvedValueOnce(savedIntegration());
await saveChannel();
expect(sentBody()).toEqual({
data: {
type: "integrations",
id: SLACK_INTEGRATION_ID,
attributes: { configuration: { channel_id: FIRST_CHANNEL.id } },
},
});
});
it.each([
{ shape: "empty", body: "" },
{ shape: "an HTML interstitial", body: HTML_INTERSTITIAL },
])(
"answers a $shape `200` as an unread result, not as a failed save",
async ({ body }) => {
fetchMock.mockResolvedValueOnce(unreadableOk(body));
const result = await saveChannel();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
// The API recorded the channel before answering, so both pages refresh.
expectIntegrationsRevalidated();
},
);
// The caller reads `integration.attributes.configuration`, so a shallower
// guard lets the miss surface later as the manager's generic catch.
it.each([
{ shape: "no `data`", body: {} },
{ shape: "a null `data`", body: { data: null } },
{ shape: "a `data` with no configuration", body: { data: {} } },
])(
"answers a `200` carrying $shape as an unread result",
async ({ body }) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
}),
);
const result = await saveChannel();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
expectIntegrationsRevalidated();
},
);
});
/** The calls whose only failure path is one line of copy. */
const COPY_ONLY_ACTIONS = [
{
name: "getSlackChannels",
call: (id: string) => getSlackChannels(id),
},
{
name: "setSlackDefaultChannel",
call: (id: string) => setSlackDefaultChannel(id, FIRST_CHANNEL.id),
},
];
const rateLimitedResponse = () =>
new Response(
JSON.stringify({
errors: [{ status: "429", detail: "Slack is rate limiting." }],
}),
{
status: 429,
headers: {
"content-type": "application/vnd.api+json",
"Retry-After": "30",
},
},
);
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
it("reports an upstream Slack failure and still answers in the same words", async () => {
fetchMock.mockResolvedValue(
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL),
);
const result = await call(SLACK_INTEGRATION_ID);
// Once, not twice: `handleApiResponse` reports and throws, and the action's
// catch sees the mark.
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
tags: {
api_error: true,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
error_type: SentryErrorType.SERVER_ERROR,
status_code: String(UPSTREAM_STATUS),
},
});
expect(captureMessageMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: UPSTREAM_DETAIL });
});
it.each([
{
status: 503,
why: "Slack being unavailable, not a fault",
response: () => errorResponse(503, "Slack is unavailable."),
expected: "Slack is unavailable.",
},
{
status: 429,
why: "a wait, not a fault",
response: rateLimitedResponse,
expected:
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.",
},
{
status: 400,
why: "a refusal the API meant to give",
response: () => errorResponse(400, "No default channel is set."),
expected: "No default channel is set.",
},
])("reports nothing for a $status: that is $why", async (refusal) => {
fetchMock.mockResolvedValue(refusal.response());
const result = await call(SLACK_INTEGRATION_ID);
expect(captureExceptionMock).not.toHaveBeenCalled();
expect(captureMessageMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: refusal.expected });
});
});
/**
* The integration id is interpolated into every one of these URLs, so a
* malformed one is refused before the request is built.
*/
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
it.each(["../../users", "not-a-uuid", ""])(
"asks the API nothing when the integration id is %o",
async (id) => {
const result = await call(id);
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: SLACK_GENERIC_ERROR_MESSAGE });
},
);
});
+227 -1
View File
@@ -7,12 +7,17 @@ import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import {
readSlackFailure,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
SLACK_UNREADABLE_RESULT_MESSAGE,
slackErrorMessage,
slackRateLimitMessage,
} from "@/lib/integrations/slack-errors";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
import {
INTEGRATION_TYPE,
type IntegrationProps,
type SlackChannelOption,
} from "@/types/integrations";
interface SlackUnavailable {
unavailable: true;
@@ -57,6 +62,17 @@ const slackExchangeInputSchema = z.object({
state: z.string().min(1),
});
/**
* SSRF guard: the integration id is interpolated into the request URL, so only
* the shape the API's ids have reaches it.
*/
const integrationIdSchema = z.uuid();
const parseIntegrationId = (integrationId: string): string | null => {
const parsed = integrationIdSchema.safeParse(integrationId);
return parsed.success ? parsed.data : null;
};
interface SlackExchangeSuccess {
integration: IntegrationProps;
}
@@ -156,6 +172,30 @@ const failureFrom = async (
return { error: slackErrorMessage(failure, fallback) };
};
/**
* `failureFrom` flattened to one line of copy, for the calls whose only
* outcome is "it did not work". Rate limiting keeps its own wording:
* `conversations.list` is Slack tier 2, so a `429` shows up here (contract,
* Errors) and the wait it names is the useful part.
*/
const errorMessageFrom = async (
response: Response,
fallback: string,
): Promise<string> => {
// Same 5xx handling as `failureFrom`, `503` excepted: here too it means Slack
// is unavailable. Must run before `readSlackFailure`: a body can only be read
// once.
if (response.status >= 500 && response.status !== 503) {
await handleApiResponse(response);
}
const failure = await readSlackFailure(response);
return failure.status === RATE_LIMITED_STATUS
? slackRateLimitMessage(failure.retryAfterSeconds)
: slackErrorMessage(failure, fallback);
};
/** Mint an OAuth state and get the consent URL. Creates no integration. */
export const getSlackAuthorizeUrl =
async (): Promise<SlackAuthorizeUrlResult> => {
@@ -247,3 +287,189 @@ export const exchangeSlackOAuthCode = async (
return handleApiError(error);
}
};
interface SlackChannelsSuccess {
channels: SlackChannelOption[];
/**
* Present when these channels are only part of the workspace's, carrying the
* sentence that says why: a partial read is a success, so the caller renders
* the picker *and* the reason.
*/
incomplete?: string;
}
export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError;
/**
* Cursor pages followed before giving up: `conversations.list` is a tier-2,
* rate-limited Slack call (design.md, Risks), so the aggregation is bounded
* rather than open-ended.
*/
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.
*/
export const getSlackChannels = async (
integrationId: string,
): Promise<SlackChannelsResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: false });
const channels: SlackChannelOption[] = [];
const listing = new URL(`${apiBaseUrl}/integrations/${id}/slack/channels`);
let next: string | null = listing.toString();
let incomplete: string | null = null;
try {
for (let page = 0; next && page < MAX_CHANNEL_PAGES; page += 1) {
const current: string = next;
const response: Response = await fetch(current, {
method: "GET",
headers,
});
if (!response.ok) {
let message: string;
try {
message = await errorMessageFrom(
response,
`Unable to read the workspace's channels: ${response.statusText}`,
);
} catch (error) {
// `handleApiResponse` reported the 5xx and threw; a first-page
// failure stays a failure, but later pages keep what was read.
if (channels.length === 0) throw error;
return { channels, incomplete: SLACK_GENERIC_ERROR_MESSAGE };
}
return channels.length > 0
? { channels, incomplete: message }
: { error: message };
}
// A page that is not JSON reads as no channels, rather than throwing a
// parser message the user would be shown verbatim.
const body = await response.json().catch(() => null);
for (const resource of body?.data ?? []) {
// Radix `Select.Item` refuses an empty value; one malformed resource
// would break the whole picker.
const channelId = resource?.id;
if (typeof channelId !== "string" || channelId.length === 0) continue;
channels.push({
id: channelId,
name: resource?.attributes?.name ?? "",
is_private: Boolean(resource?.attributes?.is_private),
});
}
const rawNext = body?.links?.next;
const candidate =
typeof rawNext === "string" && rawNext.length > 0
? new URL(rawNext, current)
: null;
// Resolved against the page it arrived on, so a cursor-only `next` keeps
// this listing's path. Followed only while it stays on the listing's
// origin: every page is fetched with the tenant's token, and an
// off-origin hop made here would carry it along.
if (candidate === null) {
next = null;
} else if (candidate.origin === listing.origin) {
next = candidate.toString();
} else {
next = null;
incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
}
}
// A link still waiting when the budget ran out. Checked rather than assumed
// from the page count: a workspace of exactly `MAX_CHANNEL_PAGES` pages was
// read to the end.
if (next) incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
return incomplete === null ? { channels } : { channels, incomplete };
} catch (error) {
return handleApiError(error);
}
};
interface SlackDefaultChannelSuccess {
integration: IntegrationProps;
}
export type SlackDefaultChannelResult =
| SlackDefaultChannelSuccess
| SlackActionError;
/**
* Record the channel Prowler posts to, on the generic integration endpoint.
*
* 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).
*/
export const setSlackDefaultChannel = async (
integrationId: string,
channelId: string,
): Promise<SlackDefaultChannelResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify({
data: {
type: "integrations",
id,
// `configuration` is the only attribute the save may carry: the write
// 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 } },
},
}),
});
if (!response.ok) {
return {
error: await errorMessageFrom(
response,
`Unable to save the destination channel: ${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.
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
// Guarded as deep as the caller reads: it names the saved channel from
// `attributes.configuration`.
if (!body?.data?.attributes?.configuration) {
return { error: SLACK_UNREADABLE_RESULT_MESSAGE };
}
return { integration: parseStringify(body.data) as IntegrationProps };
} catch (error) {
return handleApiError(error);
}
};