diff --git a/ui/__tests__/msw/handlers/slack.fixtures.ts b/ui/__tests__/msw/handlers/slack.fixtures.ts index 0be80786bb..3d063c5ee8 100644 --- a/ui/__tests__/msw/handlers/slack.fixtures.ts +++ b/ui/__tests__/msw/handlers/slack.fixtures.ts @@ -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 => - 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 => + 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 => + slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides); diff --git a/ui/__tests__/msw/handlers/slack.ts b/ui/__tests__/msw/handlers/slack.ts index 722331bc92..60d0908fbf 100644 --- a/ui/__tests__/msw/handlers/slack.ts +++ b/ui/__tests__/msw/handlers/slack.ts @@ -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 }; + } | 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) }); + }), ]; }; diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index a9284a9fa7..f93ef93baa 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -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 }; diff --git a/ui/actions/integrations/slack.test.ts b/ui/actions/integrations/slack.test.ts index 743ea2f5ea..c790a161eb 100644 --- a/ui/actions/integrations/slack.test.ts +++ b/ui/actions/integrations/slack.test.ts @@ -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 + * `` branch never matches) and would be shown verbatim. + */ +const HTML_INTERSTITIAL = + "

Checking your browser

"; + +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 }); + }, + ); +}); diff --git a/ui/actions/integrations/slack.ts b/ui/actions/integrations/slack.ts index fc7a17a850..e29031b99b 100644 --- a/ui/actions/integrations/slack.ts +++ b/ui/actions/integrations/slack.ts @@ -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 => { + // 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 => { @@ -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 => { + 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 => { + 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); + } +}; diff --git a/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts index 2cb9cd7ee9..095d9a7660 100644 --- a/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts +++ b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts @@ -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 = ""; + 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 { get exchangeCallCount(): number { return this.countRequests("POST", "/slack/oauth/exchange"); @@ -55,7 +75,37 @@ export class SlackIntegrationHarness extends BrowserHarness { 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 | 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 { + (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 { + 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 { @@ -219,14 +269,26 @@ export class SlackIntegrationHarness extends BrowserHarness { 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 { + 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(`#${describedBy}`); + return reason ? (reason.textContent ?? "").trim() : null; } /** @@ -240,6 +302,33 @@ export class SlackIntegrationHarness extends BrowserHarness { 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 { + 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 { + await this.clickButton(/Test connection/); + + return this.connectionOutcome(); + } + // --- Returning from Slack ----------------------------------------------- /** @@ -288,4 +377,313 @@ export class SlackIntegrationHarness extends BrowserHarness { 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 { + 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 { + const mounted = (): HTMLElement[] | null => { + const options = Array.from( + document.querySelectorAll('[role="option"]'), + ); + return options.length > 0 ? options : null; + }; + + const alreadyOpen = mounted(); + if (alreadyOpen) return alreadyOpen; + + const trigger = await this.waitFor( + () => 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 { + 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 { + 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 { + 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 { + 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 { + const all = await this.openChannelPicker(); + + const input = document.querySelector("[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('[role="option"]'), + ).map((option) => option.getAttribute("data-channel") ?? ""); + const emptyNote = + document.querySelector("[cmdk-empty]")?.textContent ?? null; + + await this.closeChannelPicker(); + + return { offered, emptyNote }; + } + + private async pickAndSave(name: string): Promise { + 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 { + 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 { + 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 { + 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 `
  • `, outside the page's markup. + */ + private toastText(pattern: RegExp): string | null { + const toast = Array.from( + document.querySelectorAll("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 { + 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 { + const alert = await this.waitFor( + () => + Array.from( + this.container.querySelectorAll('[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("p"), + ).find((element) => /invites? @Prowler/.test(element.textContent ?? "")); + return hint ? (hint.textContent ?? "").trim() : null; + } } diff --git a/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx index 4e00bc4941..0ca1a28eea 100644 --- a/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx +++ b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx @@ -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( + "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); +}); diff --git a/ui/components/integrations/slack/slack-callback.tsx b/ui/components/integrations/slack/slack-callback.tsx index 7ece84226f..fa3ed36a0d 100644 --- a/ui/components/integrations/slack/slack-callback.tsx +++ b/ui/components/integrations/slack/slack-callback.tsx @@ -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."; }; diff --git a/ui/components/integrations/slack/slack-channel-selector.tsx b/ui/components/integrations/slack/slack-channel-selector.tsx new file mode 100644 index 0000000000..cfa326cc79 --- /dev/null +++ b/ui/components/integrations/slack/slack-channel-selector.tsx @@ -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 ( +
    +
    + + {onRefresh && ( + + )} +
    + + {error ? ( + + Could not read the workspace's channels + {error} + + ) : isEmpty ? ( + + No channels available yet + + Prowler cannot see a single channel in this workspace. Create a + public channel, or invite @Prowler to a private one in Slack with + /invite @Prowler, then + refresh. + + + ) : ( + <> + {incompleteNotice && ( + + Not every channel is listed + {incompleteNotice} + + )} + + + + + + + + + {/* A workspace with no channels at all is a different + situation, answered by the alert above. */} + No channel matches that search. + + {listed.map((option) => ( + handleSelect(option.id)} + // Name hook: the rendered label mixes it with a + // "Private" badge. + data-channel={option.name} + > + #{option.name} + {option.is_private && ( + + Private + + )} + + ))} + + + + + + + )} + +

    {INVITE_HINT}

    +
    + ); +}; diff --git a/ui/components/integrations/slack/slack-integration-manager.test.tsx b/ui/components/integrations/slack/slack-integration-manager.test.tsx new file mode 100644 index 0000000000..b5f011ae75 --- /dev/null +++ b/ui/components/integrations/slack/slack-integration-manager.test.tsx @@ -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( + , + ); + + // Then + expect(serverHtml).toContain("Reading channels..."); + expect(serverHtml).not.toContain("No channels available yet"); + }); +}); diff --git a/ui/components/integrations/slack/slack-integration-manager.tsx b/ui/components/integrations/slack/slack-integration-manager.tsx index 4580f680b7..fa43d1c7c2 100644 --- a/ui/components/integrations/slack/slack-integration-manager.tsx +++ b/ui/components/integrations/slack/slack-integration-manager.tsx @@ -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( + 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( + 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 = ({ - {/* No check here: it needs a destination channel, and nothing on - this page records one yet. */} -
    - {lastCheckedOn && ( -

    - Last checked:{" "} - {lastCheckedOn} +

    +
    + {lastCheckedOn && ( +

    + Last checked:{" "} + {lastCheckedOn} +

    + )} +
    +
    + {/* The check posts to the destination channel: the API answers + 400 when none is recorded yet. */} + + {!defaultChannel && ( +

    + Choose a destination channel below to enable this check. +

    + )} +
    +
    + +
    + setChannelReloads((reloads) => reloads + 1)} + disabled={isSavingChannel} + /> + +
    +

    + {/* 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."}

    - )} - {!channelId && ( -

    - Choosing a destination channel is the next step — the - connection is checked against it. -

    - )} + +
    diff --git a/ui/lib/integrations/slack-errors.test.ts b/ui/lib/integrations/slack-errors.test.ts index 234fb17168..078bf53c03 100644 --- a/ui/lib/integrations/slack-errors.test.ts +++ b/ui/lib/integrations/slack-errors.test.ts @@ -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", () => { diff --git a/ui/lib/integrations/slack-errors.ts b/ui/lib/integrations/slack-errors.ts index 8799907ae2..296cd99dcf 100644 --- a/ui/lib/integrations/slack-errors.ts +++ b/ui/lib/integrations/slack-errors.ts @@ -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 = { diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index dc2e4b5c2e..a1efe220cb 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -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: {