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
+179 -12
View File
@@ -48,6 +48,27 @@ export interface SlackConnectionFixture {
error: string | null;
}
/** A channel the listing endpoint offers for the picker. */
export interface SlackChannelFixture {
id: string;
name: string;
/** Private channels are listed only where `@Prowler` has been invited. */
isPrivate: boolean;
}
/**
* 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`.
*/
export interface SlackRefusalFixture {
status: number;
/** Slack's stable reason. `null` for the failures classified by status. */
code: string | null;
detail: string;
/** Seconds `Retry-After` asked for; only a `429` carries one. */
retryAfterSeconds: number | null;
}
export interface SlackFixture {
/**
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
@@ -72,9 +93,31 @@ export interface SlackFixture {
* transport failures. Distinct from `appConfigured: false`, which is a `503`.
*/
oauthUpstreamError: boolean;
channels: SlackChannelFixture[];
/**
* Small on purpose: the default workspace spans two pages, so a UI that
* stopped at `data` instead of following `links.next` would lose channels.
*/
channelsPageSize: number;
/** Slack refused the listing outright, with the reason named in `code`. */
channelsRefusal: SlackRefusalFixture | null;
/**
* The cursor the refusal starts at. Absent, the whole read fails; a page
* size serves the first page and refuses the second — the partial read.
*/
channelsRefusalFromCursor?: number;
/**
* Slack refused the chosen channel when the `PATCH` validated it — the
* listing itself answered fine.
*/
channelSaveRefusal: SlackRefusalFixture | null;
}
export const SLACK_INTEGRATION_ID = "slack-integration-1";
/**
* A UUID, as the API's ids are: it travels in the URL of every Slack call and
* the actions accept no other shape.
*/
export const SLACK_INTEGRATION_ID = "7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c";
/** The scopes the channel picker and the posting need (design D2). */
export const SLACK_BOT_SCOPES = [
@@ -128,6 +171,14 @@ export const SLACK_RATE_LIMITED_DETAIL =
* is for the user to act on, so the UI answers a server error in its own words.
*/
export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred.";
export const SLACK_MISSING_SCOPE_DETAIL =
"Slack refused the request: missing_scope.";
/**
* The same sentence for "it is gone" and "the app was removed from it": only
* `code` separates them, which is why a client must read `code`.
*/
export const SLACK_UNKNOWN_CHANNEL_DETAIL =
"That channel is not one Prowler can post to.";
/**
* A `200` challenge page from a proxy or WAF that took the call instead of the
@@ -141,17 +192,104 @@ export const PROXY_CHALLENGE_PAGE = [
].join("\n");
/**
* A wire value, spelled out rather than imported from the UI's own mapping, so
* a rename on our side fails these tests instead of agreeing with itself.
* The `code` values the refusals below are named by. Wire values, spelled out
* rather than imported from the UI's own mapping: a rename on our side must
* fail these tests, not quietly agree with itself.
*/
export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict";
export const SLACK_MISSING_SCOPE_CODE = "missing_scope";
export const SLACK_CHANNEL_NOT_FOUND_CODE = "channel_not_found";
export const SLACK_NOT_IN_CHANNEL_CODE = "not_in_channel";
/**
* A reason Slack really sends that the UI's mapping does not cover — the set is
* open-ended, so having no copy for one is the ordinary case.
*/
export const SLACK_UNMAPPED_REASON_CODE = "is_archived";
export const SLACK_RETRY_AFTER_SECONDS = 30;
export const SLACK_DEFAULT_CHANNEL = {
/** The install never granted a scope the call needs: actionable, so a `400`. */
export const SLACK_MISSING_SCOPE_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_MISSING_SCOPE_CODE,
detail: SLACK_MISSING_SCOPE_DETAIL,
retryAfterSeconds: null,
};
/**
* Where this really happens is the channel listing: `conversations.list` is
* tier 2 and paginated.
*/
export const SLACK_RATE_LIMITED_REFUSAL: SlackRefusalFixture = {
status: 429,
code: null,
detail: SLACK_RATE_LIMITED_DETAIL,
retryAfterSeconds: SLACK_RETRY_AFTER_SECONDS,
};
/** Slack-side or transport failure — a `502` naming no reason at all. */
export const SLACK_UPSTREAM_REFUSAL: SlackRefusalFixture = {
status: 502,
code: null,
detail: SLACK_UPSTREAM_DETAIL,
retryAfterSeconds: null,
};
/** The chosen channel is archived, deleted, or was never in the workspace. */
export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_CHANNEL_NOT_FOUND_CODE,
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
retryAfterSeconds: null,
};
/**
* The channel is fine, the Prowler app is simply not in it — fixed with
* `/invite @Prowler`. Identical `detail` to the refusal above, deliberately.
*/
export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_NOT_IN_CHANNEL_CODE,
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
retryAfterSeconds: null,
};
/**
* Two public channels and one private the Prowler app was invited to, ordered
* so the private one lands on the second cursor page.
*/
export const SLACK_PUBLIC_CHANNEL: SlackChannelFixture = {
id: "C0123AB",
name: "security",
} as const;
isPrivate: false,
};
export const SLACK_SECOND_PUBLIC_CHANNEL: SlackChannelFixture = {
id: "C0789EF",
name: "platform",
isPrivate: false,
};
export const SLACK_PRIVATE_CHANNEL: SlackChannelFixture = {
id: "C0456CD",
name: "security-alerts",
isPrivate: true,
};
export const SLACK_CHANNELS: SlackChannelFixture[] = [
SLACK_PUBLIC_CHANNEL,
SLACK_SECOND_PUBLIC_CHANNEL,
SLACK_PRIVATE_CHANNEL,
];
/** 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",
@@ -171,6 +309,10 @@ export const slackFixture = (
listServerError: false,
authorizeUrlUnreadable: false,
oauthUpstreamError: false,
channels: SLACK_CHANNELS.map((channel) => ({ ...channel })),
channelsPageSize: SLACK_CHANNELS_PAGE_SIZE,
channelsRefusal: null,
channelSaveRefusal: null,
...overrides,
});
@@ -193,25 +335,28 @@ export const connectedSlackFixture = (
...overrides,
});
const configuredInstall = (): SlackInstallFixture => ({
const configuredInstall = (
channel: SlackChannelFixture = SLACK_DEFAULT_CHANNEL,
): SlackInstallFixture => ({
id: SLACK_INTEGRATION_ID,
connected: true,
connectionLastCheckedAt: "2026-08-10T09:30:00Z",
workspace: {
...PROWLER_HQ,
channelId: SLACK_DEFAULT_CHANNEL.id,
channelName: SLACK_DEFAULT_CHANNEL.name,
channelId: channel.id,
channelName: channel.name,
},
});
/**
* A workspace connected *and* a channel on record. Anything the API refuses
* until a channel exists (the connection check) needs this fixture.
* The same tenant with a destination channel already on record: the state a
* second visit starts from.
*/
export const configuredSlackFixture = (
export const slackFixtureWithDefaultChannel = (
channel: SlackChannelFixture = SLACK_PUBLIC_CHANNEL,
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({ install: configuredInstall(), ...overrides });
connectedSlackFixture({ install: configuredInstall(channel), ...overrides });
/**
* The same finished setup, with a check time no parser can read: a zero date
@@ -225,3 +370,25 @@ export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
connectionLastCheckedAt: "0000-00-00T00:00:00Z",
},
});
/**
* The first cursor page is served and Slack rate limits the second: what is
* already read stays usable, the refusal only says why the list is short.
*/
export const partiallyReadSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithDefaultChannel(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.
*/
export const configuredSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
+108 -7
View File
@@ -18,10 +18,10 @@ import {
SLACK_INTEGRATION_ID,
SLACK_INVALID_CODE_DETAIL,
SLACK_NO_CHANNEL_DETAIL,
SLACK_RATE_LIMITED_DETAIL,
SLACK_RATE_LIMITED_REFUSAL,
SLACK_REFUSED_STATE_DETAIL,
SLACK_RETRY_AFTER_SECONDS,
SLACK_UNCONFIGURED_DETAIL,
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UPSTREAM_DETAIL,
SLACK_UPSTREAM_ERROR_CODE,
SLACK_WORKSPACE_CONFLICT_CODE,
@@ -30,6 +30,7 @@ import type {
SlackExchangeOutcome,
SlackFixture,
SlackInstallFixture,
SlackRefusalFixture,
} from "./slack.fixtures";
const API = process.env.UI_API_BASE_URL;
@@ -37,6 +38,9 @@ const TS = "2026-08-10T09:00:00Z";
const CONNECTION_TASK_PREFIX = "slack-conn-task-";
/** Opaque to the UI, which only ever follows `links.next` (design D6). */
const CHANNEL_CURSOR_PARAM = "page[cursor]";
/**
* `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even
* for a field-shaped `ValidationError`: the errors are about the request.
@@ -52,6 +56,21 @@ const errorBody = (detail: string, status: number, code?: string) => ({
],
});
/**
* Answer a fixture's refusal as the API would: its own status, its `code`
* when it names one, and `Retry-After` only where the status carries a wait.
*/
const refuse = (refusal: SlackRefusalFixture) =>
HttpResponse.json(
errorBody(refusal.detail, refusal.status, refusal.code ?? undefined),
{
status: refusal.status,
...(refusal.retryAfterSeconds === null
? {}
: { headers: { "Retry-After": String(refusal.retryAfterSeconds) } }),
},
);
const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
team_id: workspace.teamId,
team_name: workspace.teamName,
@@ -119,11 +138,7 @@ export const handlersForSlack = (fx: SlackFixture) => {
status: 503,
});
const rateLimited = () =>
HttpResponse.json(errorBody(SLACK_RATE_LIMITED_DETAIL, 429), {
status: 429,
headers: { "Retry-After": String(SLACK_RETRY_AFTER_SECONDS) },
});
const rateLimited = () => refuse(SLACK_RATE_LIMITED_REFUSAL);
/** A `502` per the contract's taxonomy: a server fault, not a Slack state. */
const upstreamError = () =>
@@ -250,5 +265,91 @@ export const handlersForSlack = (fx: SlackFixture) => {
taskResource(params.taskId, "completed", { connected, error }),
);
}),
// --- Channels ----------------------------------------------------------
http.get<{ id: string }>(
`${API}/integrations/:id/slack/channels`,
({ params, request }) => {
// The UI follows `links.next` opaquely, so the cursor's shape is this
// fixture's business alone. Read first: the page decides the refusal.
const cursor = Number(
new URL(request.url).searchParams.get(CHANNEL_CURSOR_PARAM) ?? "0",
);
// An endpoint-specific refusal wins over the blanket rate limiting,
// and applies from the named cursor, so a partial read is expressible.
if (
fx.channelsRefusal &&
cursor >= (fx.channelsRefusalFromCursor ?? 0)
) {
return refuse(fx.channelsRefusal);
}
if (fx.rateLimited) return rateLimited();
const nextCursor = cursor + fx.channelsPageSize;
const page = fx.channels.slice(cursor, nextCursor);
const hasMore = nextCursor < fx.channels.length;
return HttpResponse.json({
data: page.map((channel) => ({
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: channel.isPrivate },
})),
links: {
next: hasMore
? `${API}/integrations/${params.id}/slack/channels` +
`?${CHANNEL_CURSOR_PARAM}=${nextCursor}`
: null,
},
});
},
),
/**
* 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).
*/
http.patch(`${API}/integrations/:id`, async ({ request }) => {
const body = (await request.json().catch(() => null)) as {
data?: { attributes?: Record<string, unknown> };
} | null;
const attributes = body?.data?.attributes ?? {};
const configurationPatch = attributes.configuration as
| { channel_id?: string }
| undefined;
const channelId = configurationPatch?.channel_id;
const channel = fx.channels.find((c) => c.id === channelId);
if (!install) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
}
// The API's write serializer names the attributes it will not take and
// refuses the whole save, rather than quietly dropping the extra one:
// sending `integration_type` here refused every channel save.
const refusedAttributes = Object.keys(attributes).filter(
(attribute) => attribute !== "configuration",
);
if (refusedAttributes.length > 0) {
const named = refusedAttributes
.map((attribute) => `'${attribute}'`)
.join(", ");
return HttpResponse.json(errorBody(`Invalid fields: {${named}}`, 400), {
status: 400,
});
}
// Checked before the id lookup: the picker did offer this channel, and
// Slack refused it anyway when the API validated it.
if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal);
if (!channel) {
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
status: 400,
});
}
install.workspace.channelId = channel.id;
install.workspace.channelName = channel.name;
return HttpResponse.json({ data: integrationResource(install) });
}),
];
};
+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);
}
};
@@ -14,12 +14,24 @@ 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 { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { IntegrationsContent } from "../integrations-content";
import { SlackIntegrationContent } from "./slack-integration-content";
export const CONNECTION_OUTCOME = {
SUCCESS: "success",
FAILURE: "failure",
} as const;
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>";
interface CallbackParams {
code?: string;
state?: string;
@@ -27,6 +39,14 @@ interface CallbackParams {
error?: string;
}
/** What a picker search leaves on offer. */
interface ChannelSearch {
/** Names still offered once the filter landed, in the order offered. */
offered: string[];
/** The picker's no-match note; null while any channel is still offered. */
emptyNote: string | null;
}
export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
get exchangeCallCount(): number {
return this.countRequests("POST", "/slack/oauth/exchange");
@@ -55,7 +75,37 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
window.history.replaceState(null, "", "/integrations/slack");
this.wireHandlers();
render(await SlackIntegrationContent());
const readsBefore = this.channelListCallCount;
this.mounted = render(await SlackIntegrationContent());
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
}
private mounted: ReturnType<typeof render> | null = null;
/**
* Open the management page again, the way a later visit does — the handlers in
* place keep serving what the previous visit left behind. Unmounts the previous
* render first: two live copies would make every assertion ambiguous.
*/
async revisit(): Promise<void> {
(await this.mounted)?.unmount();
const readsBefore = this.channelListCallCount;
this.mounted = render(await SlackIntegrationContent());
await this.mounted;
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
}
/**
* Refresh the page's server data under the open card, as `revalidatePath` does
* after an action: new props, no unmount, so React state survives — unlike
* `revisit()`, which re-seeds everything from scratch.
*/
async refreshPageData(): Promise<void> {
const rendered = await this.mounted;
if (!rendered) {
throw new Error("refreshPageData: the page is not mounted");
}
await rendered.rerender(await SlackIntegrationContent());
}
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
@@ -219,14 +269,26 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
return (badge.textContent ?? "").trim();
}
/** Absent, or there but disabled: both read as "not offered". */
offersConnectionTest(): boolean {
const button = this.buttonByText(/Test connection/);
return button !== null && !button.disabled;
async offersConnectionTest(): Promise<boolean> {
const button = await this.waitFor(
() => this.buttonByText(/Test connection/),
5000,
"the Test connection button",
);
return !button.disabled;
}
saysChannelIsNextStep(): boolean {
return this.containsText(/Choosing a destination channel is the next step/);
/**
* 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.
*/
connectionCheckBlockedReason(): string | null {
const button = this.buttonByText(/Test connection/);
const describedBy = button?.getAttribute("aria-describedby");
if (!describedBy) return null;
const reason = this.container.querySelector<HTMLElement>(`#${describedBy}`);
return reason ? (reason.textContent ?? "").trim() : null;
}
/**
@@ -240,6 +302,33 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
return line ? (line.textContent ?? "").trim() : null;
}
get connectionCheckCallCount(): number {
return this.countRequests("POST", "/connection");
}
/** The outcome of a check under way, started by the button or by a save. */
async connectionOutcome(): Promise<ConnectionOutcome> {
return this.waitFor(
() => {
if (this.containsText(/Connection test successful/)) {
return CONNECTION_OUTCOME.SUCCESS;
}
if (this.containsText(/Connection test failed/)) {
return CONNECTION_OUTCOME.FAILURE;
}
return null;
},
15000,
"the connection test outcome",
);
}
async testConnection(): Promise<ConnectionOutcome> {
await this.clickButton(/Test connection/);
return this.connectionOutcome();
}
// --- Returning from Slack -----------------------------------------------
/**
@@ -288,4 +377,313 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
offersRetry(): boolean {
return this.backLink() !== null || this.offersInstall();
}
// --- Choosing a destination channel --------------------------------------
/** Channel reads issued — one per cursor page the UI followed. */
get channelListCallCount(): number {
return this.countRequests("GET", "/slack/channels");
}
/**
* Wait for the channel read every connected mount starts, counting from the
* reads already issued: one still in flight when the test ends lands in the
* middle of the next, against a harness that never asked for it.
*/
private async waitForChannelsRead(readsBefore: number): Promise<void> {
await this.waitFor(
() => {
const refresh = this.buttonByText(/Refresh channels/);
return this.channelListCallCount > readsBefore &&
refresh !== null &&
!refresh.disabled
? true
: null;
},
15000,
"the workspace's channels to be read",
);
}
/**
* 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();
if (alreadyOpen) return alreadyOpen;
const trigger = await this.waitFor<HTMLElement>(
() => this.q("#slack-channel"),
10000,
"the channel picker",
);
await this.clickElement(trigger, { fallbackToDomClick: true });
let options = await this.waitForOrNull(
mounted,
2000,
"the channel options",
);
if (!options) {
await this.user.keyboard("{Enter}");
options = await this.waitForOrNull(mounted, 8000, "the channel options");
}
if (!options) {
throw new Error("openChannelPicker: the channel picker offered nothing");
}
return options;
}
private async closeChannelPicker(): Promise<void> {
await this.user.keyboard("{Escape}");
await this.waitForTransition();
}
/**
* Re-read the workspace's channels, the way a user does after inviting
* `@Prowler` to one in Slack. Waits for the read to have settled, not for the
* click alone.
*/
async refreshChannels(): Promise<void> {
const readsBefore = this.channelListCallCount;
await this.clickButton(/Refresh channels/);
await this.waitFor(
() => {
const button = this.buttonByText(/Refresh channels/);
return (
this.channelListCallCount > readsBefore &&
button !== null &&
!button.disabled
);
},
15000,
"the workspace's channels to be read again",
);
}
/** The channels the workspace offers, in the order the picker lists them. */
async channelOptions(): Promise<string[]> {
const options = await this.openChannelPicker();
const names = options.map(
(option) => option.getAttribute("data-channel") ?? "",
);
await this.closeChannelPicker();
return names;
}
/**
* Whether the channel offered under `name` is presented as private — read
* from the marker the user sees, not from how the option is wired up.
*/
async isChannelShownAsPrivate(name: string): Promise<boolean> {
const options = await this.openChannelPicker();
const option = options.find(
(element) => element.getAttribute("data-channel") === name,
);
await this.closeChannelPicker();
return /Private/.test(option?.textContent ?? "");
}
/**
* Open the picker, type `query` into its search, and hand back what the
* filter leaves on offer. The search dies with the popover, so each call
* starts from the full list.
*/
async searchChannels(query: string): Promise<ChannelSearch> {
const all = await this.openChannelPicker();
const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
if (!input) {
throw new Error("searchChannels: the open picker has no search field");
}
await this.user.fill(input, query);
// The filter lands a render after the last keystroke: the offered set
// shrinks, or the no-match note shows. A query that matches everything
// would never settle — the tests only narrow.
await this.waitFor(
() =>
document.querySelectorAll('[role="option"]').length !== all.length ||
document.querySelector("[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 emptyNote =
document.querySelector<HTMLElement>("[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,
);
if (!option) {
throw new Error(`pickAndSave: no channel named "${name}" is offered`);
}
await this.user.click(option);
await this.waitForTransition();
await this.clickButton(/Save channel/);
}
/** 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);
await this.waitFor(
() => this.defaultChannelName() === name,
15000,
`#${name} to be recorded as the destination`,
);
}
/**
* Record a different destination 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`,
);
}
const integrationId = this.fixture.install?.id;
if (!integrationId) {
throw new Error("channelRecordedElsewhere: no workspace is connected");
}
const result = await setSlackDefaultChannel(integrationId, channel.id);
if ("error" in result) {
throw new Error(`channelRecordedElsewhere: ${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/);
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.
*/
async refusedChannelSave(name: string): Promise<string> {
await this.pickAndSave(name);
return this.waitFor(
() => {
if (this.defaultChannelName() === name) {
throw new Error(
`refusedChannelSave: #${name} was recorded, not refused`,
);
}
return this.toastText(/Could not save the destination channel/);
},
15000,
"the refused channel save",
);
}
/**
* The text of the toast matching `pattern` — title and message together. Radix
* portals each toast into its viewport as an `<li>`, outside the page's markup.
*/
private toastText(pattern: RegExp): string | null {
const toast = Array.from(
document.querySelectorAll<HTMLElement>("ol li"),
).find((element) => pattern.test(element.textContent ?? ""));
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 channel recorded as the integration's destination, if any. */
async defaultChannel(): Promise<string | null> {
const settled = await this.waitFor(
() =>
this.defaultChannelName() ??
(this.containsText(/No destination channel recorded yet/)
? NO_DEFAULT_CHANNEL
: null),
10000,
"the recorded destination channel",
);
return settled === NO_DEFAULT_CHANNEL ? null : settled;
}
/** What the user is told when the workspace exposes no channel at all. */
async channelPickerMessage(): Promise<string> {
const alert = await this.waitFor(
() =>
Array.from(
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((element) =>
/No channels available yet|Could not read the workspace/.test(
element.textContent ?? "",
),
),
10000,
"the channel picker's message",
);
return (alert.textContent ?? "").replace(/\s+/g, " ").trim();
}
/**
* What the user is told about a list short of the workspace, shown beside a
* picker that still works — unlike `channelPickerMessage()`, which replaces it.
*/
partialListNotice(): string | null {
const notice = this.q("[data-channels-notice]");
return notice
? (notice.textContent ?? "").replace(/\s+/g, " ").trim()
: null;
}
/** Whether the picker was replaced by the "could not read them" alert. */
saysChannelsUnreadable(): boolean {
return this.containsText(/Could not read the workspace/);
}
/** The invite copy that says how to make a private channel appear. */
channelInviteHint(): string | null {
const hint = Array.from(
this.container.querySelectorAll<HTMLElement>("p"),
).find((element) => /invites? @Prowler/.test(element.textContent ?? ""));
return hint ? (hint.textContent ?? "").trim() : null;
}
}
@@ -12,11 +12,44 @@ import {
configuredSlackFixture,
connectedSlackFixture,
INTEGRATIONS_SERVER_ERROR_DETAIL,
partiallyReadSlackFixture,
SLACK_CHANNEL_NOT_FOUND_REFUSAL,
SLACK_MISSING_SCOPE_CODE,
SLACK_MISSING_SCOPE_REFUSAL,
SLACK_NOT_IN_CHANNEL_CODE,
SLACK_NOT_IN_CHANNEL_REFUSAL,
SLACK_PRIVATE_CHANNEL,
SLACK_PUBLIC_CHANNEL,
SLACK_RATE_LIMITED_REFUSAL,
SLACK_SECOND_PUBLIC_CHANNEL,
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UPSTREAM_REFUSAL,
slackFixture,
slackFixtureWithDefaultChannel,
unreadableCheckTimeSlackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import { SlackIntegrationHarness } from "./slack-integration.harness";
import {
CONNECTION_OUTCOME,
SlackIntegrationHarness,
} from "./slack-integration.harness";
/** The shape the channel save is asserted against — only the id travels. */
interface PatchIntegrationBody {
data: PatchIntegrationData;
}
interface PatchIntegrationData {
attributes: PatchIntegrationAttributes;
}
interface PatchIntegrationAttributes {
configuration: PatchChannelConfiguration;
}
interface PatchChannelConfiguration {
channel_id: string;
}
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
@@ -156,6 +189,8 @@ describe("a connected workspace", () => {
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(await harness.connectionBadge()).toBe("Connected");
expect(await harness.offersConnectionTest()).toBe(true);
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
// One workspace per tenant (design D10): no second install on offer, and no
// consent URL minted for a page that would never use it.
expect(harness.offersInstall()).toBe(false);
@@ -210,8 +245,353 @@ describe("a connected workspace", () => {
await harness.mount();
// The check posts to the destination channel, so with none recorded the API
// answers 400. Nothing here records one, so it is not offered at all.
expect(harness.offersConnectionTest()).toBe(false);
expect(harness.saysChannelIsNextStep()).toBe(true);
// 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,
);
}, 30000);
});
describe("choosing a destination channel", () => {
it("offers the workspace's channels and remembers the one chosen", 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.
expect(await harness.channelOptions()).toEqual([
SLACK_SECOND_PUBLIC_CHANNEL.name,
SLACK_PUBLIC_CHANNEL.name,
SLACK_PRIVATE_CHANNEL.name,
]);
expect(harness.channelListCallCount).toBe(2);
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// Then — only the id is submitted: the API derives the name from it.
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
"PATCH",
"/integrations/",
);
expect(saved?.data.attributes.configuration).toEqual({
channel_id: SLACK_PUBLIC_CHANNEL.id,
});
// And — a later visit shows it, under the name the API derived from the id.
await harness.revisit();
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
}, 60000);
it("narrows the offered channels as the user types", async () => {
// Given — a connected workspace whose channels were read.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// When — the user types part of a name. Then — only the match stays on
// offer, so a long workspace list stays navigable.
const narrowed = await harness.searchChannels("plat");
expect(narrowed.offered).toEqual([SLACK_SECOND_PUBLIC_CHANNEL.name]);
expect(narrowed.emptyNote).toBeNull();
// And — a search matching nothing says so instead of listing channels.
const none = await harness.searchChannels("no-such-channel");
expect(none.offered).toEqual([]);
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 () => {
// Given — `@Prowler` was invited to one private channel; `groups:read` is
// membership-gated (D2).
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// Then
expect(await harness.channelOptions()).toContain(
SLACK_PRIVATE_CHANNEL.name,
);
expect(
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
).toBe(true);
expect(
await harness.isChannelShownAsPrivate(SLACK_PUBLIC_CHANNEL.name),
).toBe(false);
// When
await harness.chooseChannel(SLACK_PRIVATE_CHANNEL.name);
// Then
expect(await harness.defaultChannel()).toBe(SLACK_PRIVATE_CHANNEL.name);
}, 60000);
it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => {
// Given — a workspace whose only channels are public: `groups:read` is
// membership-gated (design D2).
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channels: [
{ ...SLACK_PUBLIC_CHANNEL },
{ ...SLACK_SECOND_PUBLIC_CHANNEL },
],
}),
);
await harness.mount();
expect(await harness.channelOptions()).not.toContain(
SLACK_PRIVATE_CHANNEL.name,
);
// When — `@Prowler` is invited to a private channel, and the user refreshes
// instead of reconnecting the workspace.
harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL });
await harness.refreshChannels();
// Then
expect(await harness.channelOptions()).toContain(
SLACK_PRIVATE_CHANNEL.name,
);
expect(
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
).toBe(true);
}, 60000);
it("says what to do when the workspace exposes no channel Prowler can post to", async () => {
// Given — a connected workspace exposing no channels at all.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({ channels: [] }),
);
// When
await harness.mount();
// Then — the user is told what to do, not merely that the list is empty.
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.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.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
expect(await harness.offersConnectionTest()).toBe(false);
expect(harness.connectionCheckBlockedReason()).toMatch(
/destination channel/i,
);
expect(harness.connectionCheckCallCount).toBe(0);
// When
await harness.chooseChannel(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.
expect(await harness.offersConnectionTest()).toBe(true);
expect(harness.connectionCheckBlockedReason()).toBeNull();
}, 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.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
connection: { connected: false, error: SLACK_NOT_IN_CHANNEL_CODE },
}),
);
await harness.mount();
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// Then — only the check failed, so the destination stays on record.
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.FAILURE);
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
expect(await harness.offersConnectionTest()).toBe(true);
}, 60000);
it("follows the destination 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);
// 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);
await harness.refreshPageData();
// Then — the card reports what is on record, not the copy it took at mount.
expect(await harness.defaultChannel()).toBe(
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);
}, 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
// needs. The API names it in `code` (contract, Errors), not in `detail`.
const harness = new SlackIntegrationHarness(
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_MISSING_SCOPE_REFUSAL,
}),
);
// When
await harness.mount();
// Then — the reason, worded as a fix, with the invite copy still beside the
// picker.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/missing a permission it needs in Slack/);
expect(message).toMatch(/Connect the workspace again and approve/);
// Slack's reason is a protocol token: it travels in `code` and is never
// shown.
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);
expect(await harness.offersConnectionTest()).toBe(true);
}, 30000);
it("names the wait Slack asked for when it rate limits the channel listing", async () => {
// 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, {
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
}),
);
// When
await harness.mount();
// Then — when to come back, not just that it was refused: the wait is
// asserted, not only the wording.
const message = await harness.channelPickerMessage();
expect(message).toMatch(/rate limiting/);
expect(message).toMatch(/about 30 seconds/);
// 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);
}, 30000);
it("keeps the channels it did read on offer when Slack refuses a later page", async () => {
// Given — a two-page workspace whose second page is rate limited
// (`conversations.list` is tier 2, contract, Errors).
const harness = new SlackIntegrationHarness(partiallyReadSlackFixture());
// When
await harness.mount();
// Then — the picker offers what was read rather than being replaced by the
// refusal: every reload re-runs the same reads into the same limit.
// Alphabetically, as the picker sorts what it offers.
expect(await harness.channelOptions()).toEqual([
SLACK_SECOND_PUBLIC_CHANNEL.name,
SLACK_PUBLIC_CHANNEL.name,
]);
expect(harness.saysChannelsUnreadable()).toBe(false);
// And — the wait is still said, as the explanation for the short list.
const notice = harness.partialListNotice();
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);
expect(await harness.offersConnectionTest()).toBe(true);
}, 60000);
it("says nothing about a short list when the whole workspace was read", async () => {
// Given — the default workspace: two cursor pages, read to the end.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
// When
await harness.mount();
// Then
expect(harness.partialListNotice()).toBeNull();
}, 30000);
it("falls back to the API's wording when the listing fails upstream", async () => {
// Given — a `502`, which names no `code` because there is nothing to act on
// (contract, Errors).
const harness = new SlackIntegrationHarness(
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
channelsRefusal: SLACK_UPSTREAM_REFUSAL,
}),
);
// When
await harness.mount();
// Then — the API's own `detail`, and not a wait that was never promised.
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);
}, 30000);
it("says to invite @Prowler when Slack refuses the 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`.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channelSaveRefusal: SLACK_NOT_IN_CHANNEL_REFUSAL,
}),
);
await harness.mount();
// When
const refusal = await harness.refusedChannelSave(
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/);
expect(refusal).toMatch(/Invite @Prowler to it in Slack/);
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.offersConnectionTest()).toBe(false);
}, 60000);
it("says the channel is gone, not that @Prowler needs inviting, when Slack no longer has it", async () => {
// Given — a channel archived since the listing was read. The API's `detail`
// is word-for-word the one for `not_in_channel`, so only `code` tells them
// apart.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channelSaveRefusal: SLACK_CHANNEL_NOT_FOUND_REFUSAL,
}),
);
await harness.mount();
// When
const refusal = await harness.refusedChannelSave(SLACK_PUBLIC_CHANNEL.name);
// Then — a different problem, so different copy: nothing to invite to a
// channel that no longer exists.
expect(refusal).toMatch(/no longer exists in the workspace/);
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();
}, 60000);
});
@@ -12,6 +12,7 @@ import {
AlertTitle,
Button,
} from "@/components/shadcn";
import { SLACK_REASON_TOKEN } from "@/lib/integrations/slack-errors";
const SLACK_INTEGRATION_PATH = "/integrations/slack";
@@ -35,15 +36,13 @@ const FAILURE_TITLE = "Slack workspace not connected";
*/
const UNCONFIRMED_TITLE = "Slack install not confirmed";
// `error` comes straight off the URL and is interpolated into Prowler's own copy,
// so gate on the shape of a code: Slack publishes no closed set of values.
const REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
const describeSlackError = (reason: string): string => {
if (reason === "access_denied") {
return "The install was not approved in Slack, so no workspace was connected.";
}
return REASON_TOKEN.test(reason)
// `error` comes straight off the URL and is interpolated into Prowler's own
// copy, so gate on the shape of a code: Slack publishes no closed set.
return SLACK_REASON_TOKEN.test(reason)
? `Slack could not complete the install (${reason}).`
: "Slack could not complete the install.";
};
@@ -0,0 +1,195 @@
"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&apos;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>
);
};
@@ -0,0 +1,65 @@
/**
* The case `slack-page.integration.test.tsx` cannot express: it asserts against
* a hydrated, settled page, so it never sees the first frame the user is
* served. The effect that reads the channels only runs in the browser, so the
* channel state at render time is what the served HTML says until hydration.
*/
import { renderToString } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
import { SlackIntegrationManager } from "./slack-integration-manager";
vi.mock("@/actions/integrations/slack", () => ({
getSlackChannels: vi.fn(),
setSlackDefaultChannel: vi.fn(),
}));
vi.mock("@/actions/integrations/integrations", () => ({
testIntegrationConnection: vi.fn(),
}));
/**
* A connected workspace with no channel recorded, as the contract has it before
* a save: with one, the picker would show that channel instead of the
* placeholder this test reads.
*/
const CONNECTED_WORKSPACE: IntegrationProps = {
type: "integrations",
id: "slack-integration-1",
attributes: {
inserted_at: "2026-08-10T09:00:00Z",
updated_at: "2026-08-10T09:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: "2026-08-10T09:05:00Z",
integration_type: INTEGRATION_TYPE.SLACK,
configuration: {
team_id: "T024BE7LD",
team_name: "Prowler HQ",
bot_user_id: "U0KRQLJ9H",
},
},
links: { self: "/api/v1/integrations/slack-integration-1" },
};
describe("the first paint of a connected workspace", () => {
it("reads as still reading the channels rather than as a workspace with none", () => {
// When
const serverHtml = renderToString(
<SlackIntegrationManager
integration={CONNECTED_WORKSPACE}
authorizeUrl={null}
unavailable={false}
rateLimitMessage={null}
loadError={null}
/>,
);
// Then
expect(serverHtml).toContain("Reading channels...");
expect(serverHtml).not.toContain("No channels available yet");
});
});
@@ -1,9 +1,17 @@
"use client";
import { format, isValid, parseISO } from "date-fns";
import { TestTube } from "lucide-react";
import { useEffect, useState } from "react";
import { testIntegrationConnection } from "@/actions/integrations/integrations";
import {
getSlackChannels,
setSlackDefaultChannel,
} 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 {
Alert,
AlertDescription,
@@ -12,8 +20,49 @@ import {
Card,
CardContent,
CardHeader,
useToast,
} from "@/components/shadcn";
import type { IntegrationProps } from "@/types/integrations";
import type {
IntegrationProps,
SlackChannelOption,
} from "@/types/integrations";
const CHANNELS_STATUS = {
LOADING: "loading",
ERROR: "error",
LOADED: "loaded",
} as const;
interface ChannelsLoading {
status: typeof CHANNELS_STATUS.LOADING;
}
interface ChannelsFailed {
status: typeof CHANNELS_STATUS.ERROR;
message: string;
}
interface ChannelsLoaded {
status: typeof CHANNELS_STATUS.LOADED;
channels: SlackChannelOption[];
// Rides with the list it qualifies, so it can never outlive it.
notice: string | null;
}
type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
const CHECK_BLOCKED_REASON_ID = "slack-connection-check-blocked";
// The name may be missing: the id decides what the UI can do with it.
interface SlackChannelRef {
id: string;
name: string | null;
}
const channelRefEquals = (
a: SlackChannelRef | null,
b: SlackChannelRef | null,
) => a?.id === b?.id && a?.name === b?.name;
interface SlackIntegrationManagerProps {
/** At most one exists per tenant (one workspace). */
@@ -32,10 +81,171 @@ export const SlackIntegrationManager = ({
rateLimitMessage,
loadError,
}: SlackIntegrationManagerProps) => {
const configuration = integration?.attributes.configuration;
const workspaceName = configuration?.team_name;
// Absent until a channel is chosen, never present-and-null.
const channelId = configuration?.channel_id ?? null;
const [isTesting, setIsTesting] = useState(false);
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;
// Seeded `loading`, not by the effect: the effect never runs on the server,
// so anything else would server-render a "no channels" picker until
// hydration.
const [channelsState, setChannelsState] = useState<ChannelsState>(
integrationId
? { status: CHANNELS_STATUS.LOADING }
: { status: CHANNELS_STATUS.LOADED, channels: [], notice: null },
);
// 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,
);
// 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);
// 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);
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
// unsaved pick is the user's, not ours to overwrite mid-edit.
if (selectedChannelId === previousSyncedId) {
setSelectedChannelId(recordedChannel?.id ?? null);
}
}
useEffect(() => {
if (!integrationId) return;
let cancelled = false;
setChannelsState({ status: CHANNELS_STATUS.LOADING });
getSlackChannels(integrationId)
.then((result) => {
if (cancelled) return;
setChannelsState(
"error" in result
? { status: CHANNELS_STATUS.ERROR, message: result.error }
: {
status: CHANNELS_STATUS.LOADED,
channels: result.channels,
notice: result.incomplete ?? null,
},
);
})
.catch(() => {
if (cancelled) return;
setChannelsState({
status: CHANNELS_STATUS.ERROR,
message: "Could not reach Slack to read the channel list.",
});
});
return () => {
cancelled = true;
};
}, [integrationId, channelReloads]);
const channels =
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.channels
: [];
const handleSaveChannel = async () => {
if (!integrationId || !selectedChannelId) return;
let saved = false;
setIsSavingChannel(true);
try {
// Only the id travels — the API validates it and derives the name
// (design D6).
const result = await setSlackDefaultChannel(
integrationId,
selectedChannelId,
);
if ("error" in result) {
toast({
variant: "destructive",
title: "Could not save the destination channel",
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;
setDefaultChannel({ id: selectedChannelId, name: savedName });
saved = true;
toast({
title: "Destination channel saved",
description: savedName
? `Prowler will post to #${savedName}.`
: "Prowler will post to the channel you chose.",
});
} catch (_error) {
toast({
variant: "destructive",
title: "Could not save the destination channel",
description: "Something went wrong. Please try again.",
});
} finally {
setIsSavingChannel(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);
};
const handleTestConnection = async (id: string) => {
setIsTesting(true);
try {
const result = await testIntegrationConnection(id);
if (result.success) {
toast({
title: "Connection test successful!",
description:
result.message || "Prowler can reach your Slack workspace.",
});
} else {
toast({
variant: "destructive",
title: "Connection test failed",
description: result.error || "Failed to reach your Slack workspace.",
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to test connection. Please try again.",
});
} finally {
setIsTesting(false);
}
};
const workspaceName = integration?.attributes.configuration.team_name;
const checkedAt = integration?.attributes.connection_last_checked_at;
const checkedOn = checkedAt ? parseISO(checkedAt) : null;
@@ -88,21 +298,86 @@ export const SlackIntegrationManager = ({
</CardHeader>
<CardContent className="pt-0">
{/* No check here: it needs a destination channel, and nothing on
this page records one yet. */}
<div className="text-xs text-gray-500 dark:text-gray-300">
{lastCheckedOn && (
<p>
<span className="font-medium">Last checked:</span>{" "}
{lastCheckedOn}
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="text-xs text-gray-500 dark:text-gray-300">
{lastCheckedOn && (
<p>
<span className="font-medium">Last checked:</span>{" "}
{lastCheckedOn}
</p>
)}
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
{/* The check posts to the destination channel: the API answers
400 when none is recorded yet. */}
<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
}
onClick={() => handleTestConnection(integration.id)}
>
<TestTube size={14} />
{isTesting ? "Testing..." : "Test connection"}
</Button>
{!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>
)}
</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}
isLoading={channelsState.status === CHANNELS_STATUS.LOADING}
error={
channelsState.status === CHANNELS_STATUS.ERROR
? channelsState.message
: null
}
incompleteNotice={
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.notice
: null
}
onRefresh={() => setChannelReloads((reloads) => reloads + 1)}
disabled={isSavingChannel}
/>
<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."}
</p>
)}
{!channelId && (
<p>
Choosing a destination channel is the next step — the
connection is checked against it.
</p>
)}
<Button
size="sm"
disabled={
!selectedChannelId ||
selectedChannelId === (defaultChannel?.id ?? null) ||
isSavingChannel ||
isTesting
}
onClick={handleSaveChannel}
>
{isSavingChannel ? "Saving..." : "Save channel"}
</Button>
</div>
</div>
</CardContent>
</Card>
+39 -2
View File
@@ -1,6 +1,6 @@
/**
* Unit-tested because most of the codes in the mapping belong to flows this
* layer does not have yet: the channel picker, the test message, the disconnect.
* Unit-tested because the mapping carries copy for codes no page-level flow can
* be driven into — `no_permission`, `invalid_auth`, `account_inactive`.
*/
import { describe, expect, it } from "vitest";
@@ -11,6 +11,7 @@ import {
SLACK_ERROR_MESSAGES,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_RATE_LIMITED_MESSAGE,
SLACK_REASON_TOKEN,
SLACK_TOKEN_ERROR_CODES,
readSlackFailure,
slackErrorMessage,
@@ -89,6 +90,42 @@ describe("slackErrorMessage", () => {
slackErrorMessage({ detail: " " }, "Could not read channels."),
).toBe("Could not read channels.");
});
it("falls back only for a code the mapping does not cover", () => {
const FALLBACK = "Slack refused it (is_archived).";
expect(
slackErrorMessage({ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL }, FALLBACK),
).toBe(SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL]);
// No `detail`: one holding the same token would make the raw token the
// whole message again.
expect(slackErrorMessage({ code: "is_archived" }, FALLBACK)).toBe(FALLBACK);
});
});
describe("SLACK_REASON_TOKEN", () => {
it("recognises a reason code and refuses anything that reads as a sentence", () => {
// Slack publishes no closed set of reasons, so the guard is on shape rather
// than an allowlist.
for (const reason of [
"is_archived",
"restricted_action",
"team_access_not_granted",
"ekm_access_denied",
"messages_tab_disabled",
]) {
expect(SLACK_REASON_TOKEN.test(reason)).toBe(true);
}
for (const prose of [
"Slack rejected the message: the channel is archived.",
"). Contact support at +1-555-0100 (",
"",
"a".repeat(49),
]) {
expect(SLACK_REASON_TOKEN.test(prose)).toBe(false);
}
});
});
describe("slackRateLimitMessage", () => {
+13
View File
@@ -39,6 +39,13 @@ export const SLACK_GENERIC_ERROR_MESSAGE =
export const SLACK_RATE_LIMITED_MESSAGE =
"Slack is rate limiting Prowler right now. Try again in a few moments.";
/**
* For a channel list that stopped short of the workspace: the page budget ran
* out, or `links.next` left the API's origin.
*/
export const SLACK_PARTIAL_CHANNEL_LIST_MESSAGE =
"This workspace has more channels than Prowler reads in one go, so this list is not all of them. A channel missing from it is not necessarily one @Prowler has to be invited to.";
/**
* For a `2xx` the UI could not read. Not phrased as a failure: the install
* happened, only the workspace cannot be named.
@@ -46,6 +53,12 @@ export const SLACK_RATE_LIMITED_MESSAGE =
export const SLACK_UNREADABLE_RESULT_MESSAGE =
"Prowler could not read the result of the install. Open the Slack integration page to see the workspace — if none is listed there, start the install again.";
/**
* The shape of a Slack reason code, as opposed to a sentence: the set is
* open-ended, so a reason is gated on its shape before being interpolated.
*/
export const SLACK_REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
const RECONNECT = "Connect the workspace again to restore access.";
export const SLACK_ERROR_MESSAGES = {
+10
View File
@@ -113,6 +113,16 @@ export interface IntegrationProps {
links: { self: string };
}
/**
* 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.
*/
export interface SlackChannelOption {
id: string;
name: string;
is_private: boolean;
}
// Jira dispatch types
export interface JiraDispatchRequest {
data: {