diff --git a/ui/__tests__/msw/handlers/slack.fixtures.ts b/ui/__tests__/msw/handlers/slack.fixtures.ts new file mode 100644 index 0000000000..0be80786bb --- /dev/null +++ b/ui/__tests__/msw/handlers/slack.fixtures.ts @@ -0,0 +1,227 @@ +/** + * Fixture data for the Slack handlers. Shapes follow the API contract in + * `openspec/changes/add-slack-integration/design.md`. + */ + +export interface SlackWorkspaceFixture { + teamId: string; + teamName: string; + botUserId: string; + /** + * Absent from the serialized configuration until a channel is chosen: the API + * omits the keys rather than sending nulls. + */ + channelId?: string; + channelName?: string; +} + +export interface SlackInstallFixture { + id: string; + /** `null` until the first connection check runs. */ + connected: boolean | null; + connectionLastCheckedAt: string | null; + workspace: SlackWorkspaceFixture; +} + +export const SLACK_EXCHANGE_OUTCOME = { + CREATED: "created", + /** Same workspace re-installed: the existing row keeps its id. */ + REINSTALLED: "reinstalled", + REFUSED_STATE: "refused-state", + SLACK_REFUSED: "slack-refused", + /** A `409` named by its `code`: one workspace per tenant. */ + DIFFERENT_WORKSPACE: "different-workspace", + /** + * The three below are `2xx`: the install happened, but the answer is + * unreadable, so nothing on the failure path sees them. + */ + UNREADABLE_NO_CONTENT: "unreadable-no-content", + UNREADABLE_HTML: "unreadable-html", + UNREADABLE_NO_DATA: "unreadable-no-data", +} as const; + +export type SlackExchangeOutcome = + (typeof SLACK_EXCHANGE_OUTCOME)[keyof typeof SLACK_EXCHANGE_OUTCOME]; + +export interface SlackConnectionFixture { + connected: boolean; + error: string | null; +} + +export interface SlackFixture { + /** + * The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` / + * `SLACK_REDIRECT_URI`. Without them every Slack OAuth call answers `503`. + */ + appConfigured: boolean; + install: SlackInstallFixture | null; + exchangeWorkspace: SlackWorkspaceFixture; + exchangeOutcome: SlackExchangeOutcome; + connection: SlackConnectionFixture; + /** The Slack OAuth calls answer `429` with a `Retry-After`. */ + rateLimited: boolean; + /** + * The shared `GET /integrations` read answers `500`, which the UI's own + * helper turns into a thrown error rather than a result. + */ + listServerError: boolean; + /** The consent-URL call answers `200` with a proxy's HTML page, not JSON. */ + authorizeUrlUnreadable: boolean; + /** + * Both Slack OAuth calls answer `502`, the contract's status for upstream and + * transport failures. Distinct from `appConfigured: false`, which is a `503`. + */ + oauthUpstreamError: boolean; +} + +export const SLACK_INTEGRATION_ID = "slack-integration-1"; + +/** The scopes the channel picker and the posting need (design D2). */ +export const SLACK_BOT_SCOPES = [ + "chat:write", + "chat:write.public", + "channels:read", + "groups:read", +] as const; + +export const SLACK_REDIRECT_URI = + "https://cloud.prowler.com/integrations/slack/callback"; + +/** Server-minted, single-use, bound to the tenant and user (design D5). */ +export const SLACK_OAUTH_STATE = "st-2f1c9d7a"; +export const SLACK_OAUTH_CODE = "slack-code-1f4a"; + +export const SLACK_AUTHORIZE_URL = + "https://slack.com/oauth/v2/authorize" + + "?client_id=1234567890.0987654321" + + `&scope=${encodeURIComponent(SLACK_BOT_SCOPES.join(","))}` + + `&state=${SLACK_OAUTH_STATE}` + + `&redirect_uri=${encodeURIComponent(SLACK_REDIRECT_URI)}`; + +/** + * The `detail` strings the implementation sends. Human copy; the + * machine-readable reason travels in `code`, which is what the UI maps. + */ +export const SLACK_UNCONFIGURED_DETAIL = + "Slack integration is not configured or temporarily unavailable."; +export const SLACK_REFUSED_STATE_DETAIL = + "OAuth state is invalid, expired, or already consumed."; +export const SLACK_INVALID_CODE_DETAIL = "The Slack OAuth code is invalid."; +export const SLACK_DIFFERENT_WORKSPACE_DETAIL = + "This tenant is already connected to a different Slack workspace."; +export const SLACK_UPSTREAM_DETAIL = "Slack is temporarily unavailable."; +/** + * The `code` on the contract's `502`. The UI maps no copy of its own to it, so + * the `detail` is what reaches the user. + */ +export const SLACK_UPSTREAM_ERROR_CODE = "service_unavailable"; +/** + * Raised as a `ValidationError({"channel_id": ...})` that still points at + * `/data` rather than at the attribute. + */ +export const SLACK_NO_CHANNEL_DETAIL = + "This Slack integration has no channel configured."; +export const SLACK_RATE_LIMITED_DETAIL = + "Slack is rate limiting requests from Prowler."; +/** + * What a `500` from the shared `GET /integrations` read carries. Nothing here + * 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."; + +/** + * A `200` challenge page from a proxy or WAF that took the call instead of the + * API. V8 truncates the parser message for this body before the word `html`, so + * the UI's own detection (`HTML_ERROR_PATTERN`) cannot recognise it either. + */ +export const PROXY_CHALLENGE_PAGE = [ + "", + "Attention Required", + "

Checking your browser before you proceed.

", +].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. + */ +export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict"; + +export const SLACK_RETRY_AFTER_SECONDS = 30; + +export const SLACK_DEFAULT_CHANNEL = { + id: "C0123AB", + name: "security", +} as const; + +const PROWLER_HQ: SlackWorkspaceFixture = { + teamId: "T01PROWLER", + teamName: "Prowler HQ", + botUserId: "U01PROWLERBOT", +}; + +export const slackFixture = ( + overrides: Partial = {}, +): SlackFixture => ({ + appConfigured: true, + install: null, + exchangeWorkspace: { ...PROWLER_HQ }, + exchangeOutcome: SLACK_EXCHANGE_OUTCOME.CREATED, + connection: { connected: true, error: null }, + rateLimited: false, + listServerError: false, + authorizeUrlUnreadable: false, + oauthUpstreamError: false, + ...overrides, +}); + +/** + * A workspace approved with no destination channel yet. `connected` is `null`, + * not `true`: the check runs against the channel, so it has never run + * (design.md, "Connection state, in order"). + */ +export const connectedSlackFixture = ( + overrides: Partial = {}, +): SlackFixture => + slackFixture({ + install: { + id: SLACK_INTEGRATION_ID, + connected: null, + connectionLastCheckedAt: null, + workspace: { ...PROWLER_HQ }, + }, + exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED, + ...overrides, + }); + +const configuredInstall = (): 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, + }, +}); + +/** + * 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 => + connectedSlackFixture({ install: configuredInstall(), ...overrides }); + +/** + * The same finished setup, with a check time no parser can read: a zero date + * from a bad write or a serializer change. The contract types the attribute as + * a string and rules nothing else out. + */ +export const unreadableCheckTimeSlackFixture = (): SlackFixture => + connectedSlackFixture({ + install: { + ...configuredInstall(), + connectionLastCheckedAt: "0000-00-00T00:00:00Z", + }, + }); diff --git a/ui/__tests__/msw/handlers/slack.ts b/ui/__tests__/msw/handlers/slack.ts new file mode 100644 index 0000000000..722331bc92 --- /dev/null +++ b/ui/__tests__/msw/handlers/slack.ts @@ -0,0 +1,254 @@ +/** + * MSW handlers for the Slack integration, derived from the API contract in + * `openspec/changes/add-slack-integration/design.md` (the API itself lives in + * the cloud repository). State is per-call: an exchange creates the install the + * subsequent `GET /integrations` returns. + * + * Wire them per test via `worker.use(...handlersForSlack(fx))`. + */ + +import { http, HttpResponse } from "msw"; + +import { + INTEGRATIONS_SERVER_ERROR_DETAIL, + PROXY_CHALLENGE_PAGE, + SLACK_AUTHORIZE_URL, + SLACK_DIFFERENT_WORKSPACE_DETAIL, + SLACK_EXCHANGE_OUTCOME, + SLACK_INTEGRATION_ID, + SLACK_INVALID_CODE_DETAIL, + SLACK_NO_CHANNEL_DETAIL, + SLACK_RATE_LIMITED_DETAIL, + SLACK_REFUSED_STATE_DETAIL, + SLACK_RETRY_AFTER_SECONDS, + SLACK_UNCONFIGURED_DETAIL, + SLACK_UPSTREAM_DETAIL, + SLACK_UPSTREAM_ERROR_CODE, + SLACK_WORKSPACE_CONFLICT_CODE, +} from "./slack.fixtures"; +import type { + SlackExchangeOutcome, + SlackFixture, + SlackInstallFixture, +} from "./slack.fixtures"; + +const API = process.env.UI_API_BASE_URL; +const TS = "2026-08-10T09:00:00Z"; + +const CONNECTION_TASK_PREFIX = "slack-conn-task-"; + +/** + * `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. + */ +const errorBody = (detail: string, status: number, code?: string) => ({ + errors: [ + { + status: String(status), + ...(code ? { code } : {}), + detail, + source: { pointer: "/data" }, + }, + ], +}); + +const configuration = (workspace: SlackInstallFixture["workspace"]) => ({ + team_id: workspace.teamId, + team_name: workspace.teamName, + bot_user_id: workspace.botUserId, + // The API omits these keys until a channel is chosen, never sending nulls. + ...(workspace.channelId ? { channel_id: workspace.channelId } : {}), + ...(workspace.channelName ? { channel_name: workspace.channelName } : {}), +}); + +const integrationResource = (install: SlackInstallFixture) => ({ + id: install.id, + type: "integrations", + attributes: { + inserted_at: TS, + updated_at: TS, + enabled: true, + connected: install.connected, + connection_last_checked_at: install.connectionLastCheckedAt, + integration_type: "slack", + // No credentials: the bot token is encrypted at rest and never serialized. + configuration: configuration(install.workspace), + }, + links: { self: `${API}/integrations/${install.id}` }, +}); + +const collection = (install: SlackInstallFixture | null) => ({ + data: install ? [integrationResource(install)] : [], + meta: { + version: "v1", + pagination: { + page: 1, + pages: 1, + count: install ? 1 : 0, + }, + }, +}); + +const taskResource = (id: string, state: string, result: unknown) => ({ + data: { id, type: "tasks", attributes: { state, result } }, +}); + +/** + * All three are `2xx`: the first two make `response.json()` throw, the third + * parses into a body that names no resource. + */ +const unreadableExchange = (outcome: SlackExchangeOutcome): Response => { + switch (outcome) { + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT: + return new HttpResponse(null, { status: 204 }); + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML: + return HttpResponse.html(PROXY_CHALLENGE_PAGE); + default: + return HttpResponse.json({ meta: { version: "v1" } }); + } +}; + +export const handlersForSlack = (fx: SlackFixture) => { + // Mutable copy: the exchange must not write through to the caller's fixture. + let install: SlackInstallFixture | null = fx.install + ? { ...fx.install, workspace: { ...fx.install.workspace } } + : null; + + const unconfigured = () => + HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), { + status: 503, + }); + + const rateLimited = () => + HttpResponse.json(errorBody(SLACK_RATE_LIMITED_DETAIL, 429), { + status: 429, + headers: { "Retry-After": String(SLACK_RETRY_AFTER_SECONDS) }, + }); + + /** A `502` per the contract's taxonomy: a server fault, not a Slack state. */ + const upstreamError = () => + HttpResponse.json( + errorBody(SLACK_UPSTREAM_DETAIL, 502, SLACK_UPSTREAM_ERROR_CODE), + { status: 502, statusText: "Bad Gateway" }, + ); + + return [ + // --- OAuth ------------------------------------------------------------ + http.post(`${API}/integrations/slack/oauth/authorize-url`, () => { + if (!fx.appConfigured) return unconfigured(); + if (fx.rateLimited) return rateLimited(); + if (fx.oauthUpstreamError) return upstreamError(); + if (fx.authorizeUrlUnreadable) { + return HttpResponse.html(PROXY_CHALLENGE_PAGE); + } + // The URL travels in `meta`; the call creates nothing. + return HttpResponse.json({ + meta: { authorize_url: SLACK_AUTHORIZE_URL }, + }); + }), + + http.post(`${API}/integrations/slack/oauth/exchange`, () => { + if (!fx.appConfigured) return unconfigured(); + if (fx.rateLimited) return rateLimited(); + if (fx.oauthUpstreamError) return upstreamError(); + + switch (fx.exchangeOutcome) { + case SLACK_EXCHANGE_OUTCOME.REFUSED_STATE: + return HttpResponse.json(errorBody(SLACK_REFUSED_STATE_DETAIL, 400), { + status: 400, + }); + case SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED: + return HttpResponse.json(errorBody(SLACK_INVALID_CODE_DETAIL, 400), { + status: 400, + }); + case SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE: + return HttpResponse.json( + errorBody( + SLACK_DIFFERENT_WORKSPACE_DETAIL, + 409, + SLACK_WORKSPACE_CONFLICT_CODE, + ), + { status: 409 }, + ); + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT: + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML: + case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA: + // The install still happened: the API upserts before it answers. + install = { + id: SLACK_INTEGRATION_ID, + connected: null, + connectionLastCheckedAt: null, + workspace: { ...fx.exchangeWorkspace }, + }; + return unreadableExchange(fx.exchangeOutcome); + case SLACK_EXCHANGE_OUTCOME.REINSTALLED: + install = { + id: install?.id ?? SLACK_INTEGRATION_ID, + connected: null, + connectionLastCheckedAt: null, + workspace: { ...fx.exchangeWorkspace }, + }; + return HttpResponse.json({ data: integrationResource(install) }); + default: + install = { + id: SLACK_INTEGRATION_ID, + connected: null, + connectionLastCheckedAt: null, + workspace: { ...fx.exchangeWorkspace }, + }; + return HttpResponse.json( + { data: integrationResource(install) }, + { status: 201 }, + ); + } + }), + + // --- Generic integration endpoints the Slack UI reuses ----------------- + http.get(`${API}/integrations`, ({ request }) => { + if (fx.listServerError) { + return HttpResponse.json( + errorBody(INTEGRATIONS_SERVER_ERROR_DETAIL, 500), + { status: 500 }, + ); + } + + const type = new URL(request.url).searchParams.get( + "filter[integration_type]", + ); + // An unfiltered read would pull every type into the Slack page. + return HttpResponse.json(collection(type === "slack" ? install : null)); + }), + + http.post<{ id: string }>( + `${API}/integrations/:id/connection`, + ({ params }) => { + // The check posts to the channel, so the API refuses until one exists. + if (!install?.workspace.channelId) { + return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), { + status: 400, + }); + } + + return HttpResponse.json( + taskResource( + `${CONNECTION_TASK_PREFIX}${params.id}`, + "executing", + null, + ), + { status: 202 }, + ); + }, + ), + + http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => { + const { connected, error } = fx.connection; + if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) { + install.connected = connected; + install.connectionLastCheckedAt = TS; + } + return HttpResponse.json( + taskResource(params.taskId, "completed", { connected, error }), + ); + }), + ]; +}; diff --git a/ui/actions/integrations/integrations.ts b/ui/actions/integrations/integrations.ts index 8945842fe4..a9284a9fa7 100644 --- a/ui/actions/integrations/integrations.ts +++ b/ui/actions/integrations/integrations.ts @@ -341,6 +341,7 @@ export const testIntegrationConnection = 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 new file mode 100644 index 0000000000..743ea2f5ea --- /dev/null +++ b/ui/actions/integrations/slack.test.ts @@ -0,0 +1,286 @@ +/** + * 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`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { SLACK_UNREADABLE_RESULT_MESSAGE } from "@/lib/integrations/slack-errors"; +import { SentryErrorSource, SentryErrorType } from "@/sentry"; + +const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted( + () => ({ + /** + * The real SDK marks the exception `__sentry_captured__`, and + * `handleApiError` reads that mark to avoid reporting the same throw twice. + */ + captureExceptionMock: vi.fn((exception: unknown, _options?: unknown) => { + if (exception !== null && typeof exception === "object") { + Object.defineProperty(exception, "__sentry_captured__", { + configurable: true, + value: true, + }); + } + }), + captureMessageMock: vi.fn(), + fetchMock: vi.fn(), + }), +); + +vi.mock("@sentry/nextjs", () => ({ + captureException: captureExceptionMock, + captureMessage: captureMessageMock, +})); + +vi.mock("next/cache", () => ({ + revalidatePath: vi.fn(), +})); + +// The real `handleApiResponse` reads its copy from `lib/helper`, which reaches +// next-auth through `@/auth.config`; stubbing the session lets that copy load. +vi.mock("@/auth.config", () => ({ + auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })), +})); + +vi.mock("@/lib", () => ({ + apiBaseUrl: "https://api.test/api/v1", + getAuthHeaders: vi.fn(() => + Promise.resolve({ Authorization: "Bearer test-token" }), + ), + parseStringify: (value: unknown) => value, +})); + +import { exchangeSlackOAuthCode, getSlackAuthorizeUrl } from "./slack"; + +/** The status the contract reserves for an upstream Slack failure. */ +const UPSTREAM_STATUS = 502; +const UPSTREAM_DETAIL = "Slack is temporarily unavailable."; +const GENERIC_SERVER_ERROR_MESSAGE = + "Server is temporarily unavailable. Please try again in a few minutes."; + +const errorResponse = (status: number, detail: string, code?: string) => + new Response( + JSON.stringify({ + errors: [ + { + status: String(status), + ...(code ? { code } : {}), + detail, + source: { pointer: "/data" }, + }, + ], + }), + { status, headers: { "content-type": "application/vnd.api+json" } }, + ); + +const exchange = () => + exchangeSlackOAuthCode({ code: "slack-code-1f4a", state: "st-2f1c9d7a" }); + +beforeEach(() => { + vi.stubGlobal("fetch", fetchMock); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe.each([ + { action: getSlackAuthorizeUrl, name: "getSlackAuthorizeUrl" }, + { action: exchange, name: "exchangeSlackOAuthCode" }, +])("$name", ({ action }) => { + it("reports an upstream Slack failure instead of only turning it into copy", async () => { + // 502 covers `internal_error`, `fatal_error`, `service_unavailable` and + // transport failures. + fetchMock.mockResolvedValue( + errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL, "service_unavailable"), + ); + + const result = await action(); + + // 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(); + + // The throw lands in the action's catch, so the page gets a result to + // render rather than a rejection that strands the callback on its spinner. + expect(result).toEqual({ error: UPSTREAM_DETAIL }); + }); + + it("answers a 5xx the API described in HTML in Prowler's own words", async () => { + fetchMock.mockResolvedValue( + new Response("

502 Bad Gateway

", { + status: UPSTREAM_STATUS, + statusText: "Bad Gateway", + headers: { "content-type": "text/html" }, + }), + ); + + const result = await action(); + + expect(result).toEqual({ error: GENERIC_SERVER_ERROR_MESSAGE }); + expect(captureExceptionMock).toHaveBeenCalledTimes(1); + }); + + it.each([503, 404])( + "reports nothing for a %s: that is the feature being dark, not a fault", + async (status) => { + // 503 means `SLACK_CLIENT_*` is unset; 404 means no Slack API is served + // in this deployment at all. + fetchMock.mockResolvedValue( + errorResponse(status, "Slack integration is not configured."), + ); + + const result = await action(); + + // Capturing this would report the deliberate ship-dark state from every + // tenant on every page load. + expect(result).toEqual({ unavailable: true }); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }, + ); + + it("reports nothing when Slack is rate limiting: it is a wait, not a fault", async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + errors: [{ status: "429", detail: "Slack is rate limiting." }], + }), + { + status: 429, + headers: { + "content-type": "application/vnd.api+json", + "Retry-After": "30", + }, + }, + ), + ); + + const result = await action(); + + expect(result).toMatchObject({ rateLimited: true, retryAfterSeconds: 30 }); + expect(captureExceptionMock).not.toHaveBeenCalled(); + }); +}); + +/** + * The URL is rendered as the `Add to Slack` link's `href`, so a value the API + * got wrong must not become a redirect to somewhere that is not Slack. + */ +describe("getSlackAuthorizeUrl authorize URL", () => { + const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL."; + const CONSENT_SCREEN_URL = + "https://slack.com/oauth/v2/authorize" + + "?client_id=1234567890.0987654321&state=st-2f1c9d7a"; + + const authorizeUrlResponse = (authorizeUrl: unknown) => + new Response(JSON.stringify({ meta: { authorize_url: authorizeUrl } }), { + status: 200, + headers: { "content-type": "application/vnd.api+json" }, + }); + + it.each([ + ["a hostile scheme", "javascript:alert(document.domain)"], + ["plain HTTP", "http://slack.com/oauth/v2/authorize?client_id=1"], + ["another origin", "https://evil.test/oauth/v2/authorize?client_id=1"], + ["a lookalike hostname", "https://slack.com.evil.test/oauth/v2/authorize"], + [ + "another Slack path", + "https://slack.com/redirect?to=https%3A%2F%2Fevil.test", + ], + ["a value that is not a URL", "oauth/v2/authorize"], + ])( + "refuses %s instead of offering it as the install link", + async (_label, authorizeUrl) => { + // Given — a 2xx whose `meta.authorize_url` is not Slack's consent screen. + fetchMock.mockResolvedValue(authorizeUrlResponse(authorizeUrl)); + + // When + const result = await getSlackAuthorizeUrl(); + + // Then — the answer for no URL at all: nothing here is safe to link to. + expect(result).toEqual({ error: NO_AUTHORIZE_URL_MESSAGE }); + }, + ); + + it("hands over Slack's consent screen with its query untouched", async () => { + // Given + fetchMock.mockResolvedValue(authorizeUrlResponse(CONSENT_SCREEN_URL)); + + // When / Then + expect(await getSlackAuthorizeUrl()).toEqual({ + authorizeUrl: CONSENT_SCREEN_URL, + }); + }); +}); + +/** + * The callback names the workspace and redirects on `integration` alone, so a + * `2xx` body it cannot read back as an integration must not reach it. + */ +describe("exchangeSlackOAuthCode result shape", () => { + const INTEGRATION = { + id: "9b1f4c22-5e7a-4c2e-8f0d-6a3b1c9d7e42", + type: "integrations", + attributes: { + integration_type: "slack", + configuration: { team_name: "Prowler HQ" }, + }, + }; + + const exchangeResponse = (data: unknown) => + new Response(JSON.stringify({ data }), { + status: 200, + headers: { "content-type": "application/vnd.api+json" }, + }); + + it.each<[string, unknown]>([ + ["an empty object", {}], + ["an array", []], + ["a bare string", "invalid"], + ["a resource with no id", { type: "integrations", attributes: {} }], + ["a resource with an empty id", { ...INTEGRATION, id: "" }], + ["a resource of another type", { ...INTEGRATION, type: "tasks" }], + [ + "a resource with no attributes", + { id: INTEGRATION.id, type: "integrations" }, + ], + [ + "another kind of integration", + { + ...INTEGRATION, + attributes: { ...INTEGRATION.attributes, integration_type: "jira" }, + }, + ], + ])("cannot confirm the install from %s", async (_label, data) => { + // Given — a 2xx whose `data` is truthy but is not an integration resource. + fetchMock.mockResolvedValue(exchangeResponse(data)); + + // When + const result = await exchange(); + + // Then — the answer for a body with no `data`: the install happened, only + // its result is unknown. + expect(result).toEqual({ + unconfirmed: true, + message: SLACK_UNREADABLE_RESULT_MESSAGE, + }); + }); + + it("hands over the workspace the API upserted", async () => { + // Given + fetchMock.mockResolvedValue(exchangeResponse(INTEGRATION)); + + // When / Then + expect(await exchange()).toEqual({ integration: INTEGRATION }); + }); +}); diff --git a/ui/actions/integrations/slack.ts b/ui/actions/integrations/slack.ts new file mode 100644 index 0000000000..fc7a17a850 --- /dev/null +++ b/ui/actions/integrations/slack.ts @@ -0,0 +1,249 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib"; +import { + readSlackFailure, + SLACK_GENERIC_ERROR_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"; + +interface SlackUnavailable { + unavailable: true; +} + +interface SlackRateLimited { + rateLimited: true; + retryAfterSeconds: number | null; + message: string; +} + +/** + * The API accepted the exchange (`2xx`) and the UI could not read the workspace + * back: the install happened, only its result is unknown. + */ +interface SlackUnconfirmed { + unconfirmed: true; + message: string; +} + +interface SlackActionError { + error: string; +} + +interface SlackAuthorizeUrl { + authorizeUrl: string; +} + +export type SlackAuthorizeUrlResult = + | SlackAuthorizeUrl + | SlackUnavailable + | SlackRateLimited + | SlackActionError; + +interface SlackExchangeInput { + code: string; + state: string; +} + +const slackExchangeInputSchema = z.object({ + code: z.string().min(1), + state: z.string().min(1), +}); + +interface SlackExchangeSuccess { + integration: IntegrationProps; +} + +export type SlackExchangeResult = + | SlackExchangeSuccess + | SlackUnavailable + | SlackRateLimited + | SlackUnconfirmed + | SlackActionError; + +/** + * `503`: no Slack app configured in this deployment. `404`: no Slack API at + * all. Both mean "not available here", unlike `429`/`502` which mean "not now". + */ +const isUnavailableStatus = (status: number): boolean => + status === 503 || status === 404; + +const RATE_LIMITED_STATUS = 429; + +const SLACK_AUTHORIZE_HOSTNAME = "slack.com"; +const SLACK_AUTHORIZE_PATHNAME = "/oauth/v2/authorize"; +const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL."; + +/** + * The URL is rendered as the `Add to Slack` link's `href`, so anything that is + * not Slack's consent screen is a redirect to an origin the user did not choose. + */ +const isSlackAuthorizeUrl = (value: string): boolean => { + try { + const url = new URL(value); + return ( + url.protocol === "https:" && + url.hostname === SLACK_AUTHORIZE_HOSTNAME && + url.pathname === SLACK_AUTHORIZE_PATHNAME + ); + } catch { + return false; + } +}; + +const INTEGRATIONS_RESOURCE_TYPE = "integrations"; + +/** + * The callback names the workspace and redirects on this value alone, so a `2xx` + * payload that is not a JSON:API resource (`{}`, `[]`, `"invalid"`) must read as + * unreadable rather than as a connected workspace. Identity too: a resource + * that is not a linkable Slack integration would be shown as the workspace + * just installed. + */ +const isIntegrationResource = (value: unknown): boolean => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const { id, type, attributes } = value as Record; + + if ( + typeof id !== "string" || + id === "" || + type !== INTEGRATIONS_RESOURCE_TYPE || + typeof attributes !== "object" || + attributes === null || + Array.isArray(attributes) + ) { + return false; + } + + return ( + (attributes as Record).integration_type === + INTEGRATION_TYPE.SLACK + ); +}; + +const failureFrom = async ( + response: Response, + fallback: string, +): Promise => { + if (isUnavailableStatus(response.status)) return { unavailable: true }; + + // A 5xx (including the `502` the contract reserves for "Slack upstream + // broke") goes through the repo's 5xx handling, which reports to Sentry and + // throws, so the caller's catch answers the user. Must run before + // `readSlackFailure`: a body can only be read once. + if (response.status >= 500) await handleApiResponse(response); + + const failure = await readSlackFailure(response); + + if (failure.status === RATE_LIMITED_STATUS) { + return { + rateLimited: true, + retryAfterSeconds: failure.retryAfterSeconds, + message: slackRateLimitMessage(failure.retryAfterSeconds), + }; + } + + return { error: slackErrorMessage(failure, fallback) }; +}; + +/** Mint an OAuth state and get the consent URL. Creates no integration. */ +export const getSlackAuthorizeUrl = + async (): Promise => { + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/authorize-url`); + + try { + const response = await fetch(url.toString(), { method: "POST", headers }); + + if (!response.ok) { + // Awaited inside the `try`: a returned promise's rejection would skip + // this `catch`, and a 5xx rejects. + return await failureFrom( + response, + `Unable to start the Slack install: ${response.statusText}`, + ); + } + + // The URL travels in JSON:API `meta`: the call creates no resource. A + // non-JSON `2xx` reads as "no URL" instead of throwing a parser message + // the user would be shown verbatim. + const body = await response.json().catch(() => null); + const authorizeUrl = body?.meta?.authorize_url; + + // A URL that is not Slack's own is no more usable than a missing one. + if ( + typeof authorizeUrl !== "string" || + !isSlackAuthorizeUrl(authorizeUrl) + ) { + return { error: NO_AUTHORIZE_URL_MESSAGE }; + } + + return { authorizeUrl }; + } catch (error) { + return handleApiError(error); + } + }; + +/** + * Complete the install with what Slack put in the callback URL. The API + * consumes the `state`, exchanges the single-use `code`, and upserts the + * tenant's Slack integration. + */ +export const exchangeSlackOAuthCode = async ( + input: SlackExchangeInput, +): Promise => { + const parsed = slackExchangeInputSchema.safeParse(input); + if (!parsed.success) return { error: SLACK_GENERIC_ERROR_MESSAGE }; + + const { code, state } = parsed.data; + const headers = await getAuthHeaders({ contentType: true }); + const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/exchange`); + + try { + const response = await fetch(url.toString(), { + method: "POST", + headers, + body: JSON.stringify({ + data: { + type: "slack-oauth-exchanges", + attributes: { code, state }, + }, + }), + }); + + if (!response.ok) { + // Awaited inside the `try`: unawaited, a 5xx's rejection would skip this + // `catch` and leave the callback on its spinner. + return await failureFrom( + response, + `Unable to connect the Slack workspace: ${response.statusText}`, + ); + } + + const body = await response.json().catch(() => null); + + // Before the guard and on both paths: the API upserted the integration + // before answering, so a cache filled when there was none would list the + // connected workspace as missing. + revalidatePath("/integrations"); + revalidatePath("/integrations/slack"); + + if (!isIntegrationResource(body?.data)) { + return { unconfirmed: true, message: SLACK_UNREADABLE_RESULT_MESSAGE }; + } + + return { integration: parseStringify(body.data) as IntegrationProps }; + } catch (error) { + return handleApiError(error); + } +}; diff --git a/ui/app/(prowler)/integrations/integrations-content.tsx b/ui/app/(prowler)/integrations/integrations-content.tsx new file mode 100644 index 0000000000..04726a8b1e --- /dev/null +++ b/ui/app/(prowler)/integrations/integrations-content.tsx @@ -0,0 +1,44 @@ +import { ApiKeyLinkCard } from "@/components/integrations/api-key/api-key-link-card"; +import { JiraIntegrationCard } from "@/components/integrations/jira/jira-integration-card"; +import { S3IntegrationCard } from "@/components/integrations/s3/s3-integration-card"; +import { SecurityHubIntegrationCard } from "@/components/integrations/security-hub/security-hub-integration-card"; +import { SlackIntegrationCard } from "@/components/integrations/slack/slack-integration-card"; +import { SsoLinkCard } from "@/components/integrations/sso/sso-link-card"; +import { isCloud } from "@/lib/shared/env"; + +/** + * Split out of `page.tsx` for the browser-mode tests: `ContentLayout`'s navbar + * streams async server children a client renderer can't resolve. + */ +export function IntegrationsContent() { + return ( +
+
+

+ Connect external services to enhance your security workflow and + automatically export your scan results. +

+
+ +
+ {/* Amazon S3 Integration */} + + + {/* AWS Security Hub Integration */} + + + {/* Jira Integration */} + + + {/* Slack Integration - cloud-only API, nothing to manage self-hosted */} + {isCloud() && } + + {/* SSO Configuration - redirects to Profile */} + + + {/* API Keys - redirects to Profile */} + +
+
+ ); +} diff --git a/ui/app/(prowler)/integrations/integrations-page.integration.test.tsx b/ui/app/(prowler)/integrations/integrations-page.integration.test.tsx new file mode 100644 index 0000000000..a4ce0832d5 --- /dev/null +++ b/ui/app/(prowler)/integrations/integrations-page.integration.test.tsx @@ -0,0 +1,40 @@ +/** + * Browser-mode tests for the Slack entry in the integrations catalogue + * (`/integrations`), which is offered in Prowler Cloud only. Driven through + * `SlackIntegrationHarness` against the MSW handlers. + */ + +import { describe, expect } from "vitest"; + +import { it } from "@/__tests__/fixtures"; +import { slackFixture } from "@/__tests__/msw/handlers/slack.fixtures"; + +import { SlackIntegrationHarness } from "./slack/slack-integration.harness"; + +describe("the integrations catalogue", () => { + it("offers Slack in Prowler Cloud, with a way to manage it", async () => { + // Given — a Prowler Cloud deployment (the fixtures' default island). + const harness = new SlackIntegrationHarness(slackFixture()); + + harness.mountCatalogue(); + + expect(await harness.listedIntegrations()).toContain("Slack"); + expect(harness.offersSlackManagement()).toBe(true); + }, 30000); + + it("omits Slack in a deployment that is not Prowler Cloud", async ({ + seedRuntimeConfig, + }) => { + seedRuntimeConfig({ cloudEnabled: false }); + const harness = new SlackIntegrationHarness(slackFixture()); + + harness.mountCatalogue(); + + const listed = await harness.listedIntegrations(); + expect(listed).not.toContain("Slack"); + expect(harness.offersSlackManagement()).toBe(false); + // Tripwire: the catalogue rendered, so the assertions above are Slack's + // absence rather than the page failing to load. + expect(listed).toContain("Jira"); + }, 30000); +}); diff --git a/ui/app/(prowler)/integrations/page.tsx b/ui/app/(prowler)/integrations/page.tsx index c8b120391e..cd94ff493c 100644 --- a/ui/app/(prowler)/integrations/page.tsx +++ b/ui/app/(prowler)/integrations/page.tsx @@ -1,40 +1,11 @@ -import { - ApiKeyLinkCard, - JiraIntegrationCard, - S3IntegrationCard, - SecurityHubIntegrationCard, - SsoLinkCard, -} from "@/components/integrations"; import { ContentLayout } from "@/components/shadcn/content-layout"; +import { IntegrationsContent } from "./integrations-content"; + export default async function Integrations() { return ( -
-
-

- Connect external services to enhance your security workflow and - automatically export your scan results. -

-
- -
- {/* Amazon S3 Integration */} - - - {/* AWS Security Hub Integration */} - - - {/* Jira Integration */} - - - {/* SSO Configuration - redirects to Profile */} - - - {/* API Keys - redirects to Profile */} - -
-
+
); } diff --git a/ui/app/(prowler)/integrations/slack/callback/page.tsx b/ui/app/(prowler)/integrations/slack/callback/page.tsx new file mode 100644 index 0000000000..ba7e168c20 --- /dev/null +++ b/ui/app/(prowler)/integrations/slack/callback/page.tsx @@ -0,0 +1,21 @@ +import { redirect } from "next/navigation"; +import { Suspense } from "react"; + +import { SlackCallback } from "@/components/integrations/slack/slack-callback"; +import { ContentLayout } from "@/components/shadcn/content-layout"; +import { isCloud } from "@/lib/shared/env"; + +export default async function SlackCallbackPage() { + if (!isCloud()) { + redirect("/"); + } + + return ( + + {/* `SlackCallback` reads the query string, so it needs a boundary. */} + + + + + ); +} diff --git a/ui/app/(prowler)/integrations/slack/page.tsx b/ui/app/(prowler)/integrations/slack/page.tsx new file mode 100644 index 0000000000..7f1aea2b65 --- /dev/null +++ b/ui/app/(prowler)/integrations/slack/page.tsx @@ -0,0 +1,26 @@ +import { redirect } from "next/navigation"; + +import { ContentLayout } from "@/components/shadcn/content-layout"; +import { isCloud } from "@/lib/shared/env"; + +import { SlackIntegrationContent } from "./slack-integration-content"; + +export default async function SlackIntegrationPage() { + // The Slack API is cloud-only, so self-hosted has nothing behind this page. + // Mirrors `/alerts`. + if (!isCloud()) { + redirect("/"); + } + + return ( + +
+

+ Connect a Slack workspace so Prowler can post to one of its channels. +

+ + +
+
+ ); +} diff --git a/ui/app/(prowler)/integrations/slack/slack-callback-page.integration.test.tsx b/ui/app/(prowler)/integrations/slack/slack-callback-page.integration.test.tsx new file mode 100644 index 0000000000..b3e9295abb --- /dev/null +++ b/ui/app/(prowler)/integrations/slack/slack-callback-page.integration.test.tsx @@ -0,0 +1,246 @@ +/** + * Browser-mode tests for the Slack OAuth callback + * (`/integrations/slack/callback`), driven through `SlackIntegrationHarness`. + * MSW answers from handlers derived from the API contract in `design.md`. + */ + +import { describe, expect } from "vitest"; + +import { it } from "@/__tests__/fixtures"; +import { + SLACK_EXCHANGE_OUTCOME, + SLACK_OAUTH_CODE, + SLACK_OAUTH_STATE, + slackFixture, +} from "@/__tests__/msw/handlers/slack.fixtures"; + +import { SlackIntegrationHarness } from "./slack-integration.harness"; + +/** The workspace the fixtures connect. */ +const WORKSPACE_NAME = "Prowler HQ"; + +/** + * Callback headlines, spelled out rather than imported so a rename fails here. + * `FAILURE_TITLE` is for installs that connected nothing; `UNCONFIRMED_TITLE` + * for answers that arrive after the API already upserted the integration. + */ +const FAILURE_TITLE = "Slack workspace not connected"; +const UNCONFIRMED_TITLE = "Slack install not confirmed"; + +describe("returning from Slack", () => { + it("completes the install and shows the connected workspace", async () => { + const harness = new SlackIntegrationHarness(slackFixture()); + + await harness.mountCallback({ + code: SLACK_OAUTH_CODE, + state: SLACK_OAUTH_STATE, + }); + + expect(await harness.completedInstall()).toBe(true); + expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME); + // The code is single-use and the exchange runs from a render (design D4): + // without the once-guard, a second call burns it and reports a failure. + expect(harness.exchangeCallCount).toBe(1); + // A completed install invalidates the cached "none connected". + expect(harness.revalidatedPaths).toEqual( + expect.arrayContaining(["/integrations", "/integrations/slack"]), + ); + }, 30000); + + it("does not report an install the API completed as failed when it answers no content", async () => { + // Given — a `204`: the API consumed the code and upserted the integration, + // then answered with no body. `response.ok` is true, so this is no refusal. + const harness = new SlackIntegrationHarness( + slackFixture({ + exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT, + }), + ); + + await harness.mountCallback({ + code: SLACK_OAUTH_CODE, + state: SLACK_OAUTH_STATE, + }); + + const reason = await harness.installFailureReason(); + expect(reason).toMatch(/could not read the result of the install/); + expect(reason).toMatch(/Slack integration page/); + expect(reason).not.toMatch(/JSON/i); + expect(harness.offersRetry()).toBe(true); + // The `204` says the workspace is connected; the headline cannot deny it. + expect(await harness.installFailureTitle()).toBe(UNCONFIRMED_TITLE); + // The install exists, so the cached "none connected" has to go with it. + expect(harness.revalidatedPaths).toEqual( + expect.arrayContaining(["/integrations", "/integrations/slack"]), + ); + }, 30000); + + it("shows Prowler's own wording when a proxy answers the completion with an HTML page", async () => { + // Given — a proxy answering `200` with a challenge page instead of JSON. + const harness = new SlackIntegrationHarness( + slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML }), + ); + + await harness.mountCallback({ + code: SLACK_OAUTH_CODE, + state: SLACK_OAUTH_STATE, + }); + + // V8's parse message truncates before the word `html`, so the shared + // HTML-shaped-error filter cannot catch this one. + const reason = await harness.installFailureReason(); + expect(reason).toMatch(/could not read the result of the install/); + expect(reason).not.toMatch(/DOCTYPE/i); + expect(reason).not.toMatch(/not valid JSON/i); + }, 30000); + + it("says the result is unreadable, not that the workspace is unknown, when the answer names no resource", async () => { + // Given — a `200` carrying well-formed JSON:API with no `data` member. + const harness = new SlackIntegrationHarness( + slackFixture({ + exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA, + }), + ); + + await harness.mountCallback({ + code: SLACK_OAUTH_CODE, + state: SLACK_OAUTH_STATE, + }); + + const reason = await harness.installFailureReason(); + expect(reason).toMatch(/could not read the result of the install/); + expect(reason).not.toMatch(/undefined/i); + expect(await harness.completedInstall()).toBe(false); + }, 30000); + + it("connects nothing when the user declines in Slack, and offers to retry", async () => { + const harness = new SlackIntegrationHarness(slackFixture()); + + await harness.mountCallback({ error: "access_denied" }); + + expect(await harness.installFailureReason()).toMatch( + /not approved in Slack/, + ); + expect(harness.offersRetry()).toBe(true); + // A declined consent carries no code, so there was nothing to exchange. + expect(harness.exchangeCallCount).toBe(0); + }, 30000); + + it("surfaces the reason when Slack refuses to complete the install", async () => { + // Given — Slack rejects the code, and the API's own wording explains it. + const harness = new SlackIntegrationHarness( + slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED }), + ); + + await harness.mountCallback({ + code: SLACK_OAUTH_CODE, + state: SLACK_OAUTH_STATE, + }); + + // A refusal Prowler has no wording of its own for falls back to the API's + // `detail`, not to a generic failure. + expect(await harness.installFailureReason()).toMatch( + /OAuth code is invalid/, + ); + expect(harness.offersRetry()).toBe(true); + }, 30000); + + it("surfaces a completion the API refuses, and connects nothing", async () => { + // Given — the state was minted for another session, or already consumed. + const harness = new SlackIntegrationHarness( + slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REFUSED_STATE }), + ); + + await harness.mountCallback({ + code: SLACK_OAUTH_CODE, + state: "state-from-another-session", + }); + + expect(await harness.installFailureReason()).toMatch( + /state is invalid, expired, or already consumed/, + ); + // The API refused before consuming anything, so nothing was created and the + // headline states that plainly. + expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE); + expect(await harness.completedInstall()).toBe(false); + expect(harness.offersRetry()).toBe(true); + // Refused once, not retried into a second burnt code. + expect(harness.exchangeCallCount).toBe(1); + }, 30000); + + it("says how to resolve a workspace conflict, in Prowler's own words", async () => { + // Given — this tenant already has a different workspace connected, which + // the API refuses as a 409 naming the conflict in `code`. + const harness = new SlackIntegrationHarness( + slackFixture({ + exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE, + }), + ); + + await harness.mountCallback({ + code: SLACK_OAUTH_CODE, + state: SLACK_OAUTH_STATE, + }); + + // The copy comes from the error `code`: the API's `detail` states the + // conflict but not the way out of it. + const reason = await harness.installFailureReason(); + expect(reason).toMatch(/already connected to a different Slack workspace/); + expect(reason).toMatch(/Disconnect it before connecting another/); + expect(reason).not.toMatch(/tenant/); + expect(await harness.completedInstall()).toBe(false); + expect(harness.offersRetry()).toBe(true); + }, 30000); + + it("tells the user when to come back if Slack is rate limiting the install", async () => { + // Given — Slack answers 429 with a Retry-After. + const harness = new SlackIntegrationHarness( + slackFixture({ rateLimited: true }), + ); + + await harness.mountCallback({ + code: SLACK_OAUTH_CODE, + state: SLACK_OAUTH_STATE, + }); + + const reason = await harness.installFailureReason(); + expect(reason).toMatch(/rate limiting/); + expect(reason).toMatch(/about 30 seconds/); + expect(reason).not.toMatch(/not available in this environment/); + // A 429 refuses the exchange outright, so nothing was connected: the plain + // headline, unlike the unreadable `2xx` that arrives after the upsert. + expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE); + expect(harness.offersRetry()).toBe(true); + }, 30000); + + it("reports Slack being broken upstream, rather than leaving the callback spinning", async () => { + // Given — the completion answers `502`, the contract's status for a Slack + // upstream failure. The shared 5xx handling throws, so the callback only + // renders this if the action answers that rejection itself. + const harness = new SlackIntegrationHarness( + slackFixture({ oauthUpstreamError: true }), + ); + + await harness.mountCallback({ + code: SLACK_OAUTH_CODE, + state: SLACK_OAUTH_STATE, + }); + + // The API refused, so nothing was created: not the "could not confirm" the + // page falls back to when the action never answers at all. + const reason = await harness.installFailureReason(); + expect(reason).toMatch(/temporarily unavailable/); + expect(reason).not.toMatch(/could not confirm/); + expect(await harness.completedInstall()).toBe(false); + expect(harness.offersRetry()).toBe(true); + }, 30000); + + it("does not attempt an exchange when the completion carries no state", async () => { + const harness = new SlackIntegrationHarness(slackFixture()); + + await harness.mountCallback({ code: SLACK_OAUTH_CODE }); + + // Refused before the API is ever asked, so no code is spent. + expect(await harness.installFailureReason()).toMatch(/incomplete response/); + expect(harness.exchangeCallCount).toBe(0); + }, 30000); +}); diff --git a/ui/app/(prowler)/integrations/slack/slack-integration-content.tsx b/ui/app/(prowler)/integrations/slack/slack-integration-content.tsx new file mode 100644 index 0000000000..1d30a270a9 --- /dev/null +++ b/ui/app/(prowler)/integrations/slack/slack-integration-content.tsx @@ -0,0 +1,61 @@ +import { getIntegrations } from "@/actions/integrations/integrations"; +import { getSlackAuthorizeUrl } from "@/actions/integrations/slack"; +import { SlackIntegrationManager } from "@/components/integrations/slack/slack-integration-manager"; +import { GENERIC_SERVER_ERROR_MESSAGE } from "@/lib/helper"; +import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations"; + +/** + * `getIntegrations` throws a `>= 500` answer past its own catch, which covers + * only transport. Uncaught it trips the route's error boundary and replaces a + * page that could still offer the install, so report it as `{ error }` and take + * the page's one error path. + */ +const readSlackIntegrations = async (searchParams: URLSearchParams) => { + try { + return await getIntegrations(searchParams); + } catch { + // The thrown message can carry the server's own wording; `handleApiResponse` + // already reported it to Sentry. + return { error: GENERIC_SERVER_ERROR_MESSAGE }; + } +}; + +/** + * Split out of `page.tsx` so the browser-mode tests can render it without the + * surrounding `ContentLayout`. + */ +export async function SlackIntegrationContent() { + const searchParams = new URLSearchParams(); + searchParams.set("filter[integration_type]", INTEGRATION_TYPE.SLACK); + // One workspace per tenant, so one row is the whole result set. + searchParams.set("page[size]", "1"); + + const integrations = await readSlackIntegrations(searchParams); + const loadError = + integrations && "error" in integrations + ? (integrations.error as string) + : null; + const integration: IntegrationProps | null = + (integrations?.data?.[0] as IntegrationProps | undefined) ?? null; + + const authorize = integration ? null : await getSlackAuthorizeUrl(); + + return ( + + ); +} diff --git a/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts new file mode 100644 index 0000000000..2cb9cd7ee9 --- /dev/null +++ b/ui/app/(prowler)/integrations/slack/slack-integration.harness.ts @@ -0,0 +1,291 @@ +/** + * Page-level test harness for the Slack integration (Vitest Browser Mode). + * + * A client renderer cannot render an async server component, so the component is + * called and the element it returns is what gets rendered. + */ + +import { revalidatePath } from "next/cache"; +import { createElement } from "react"; +import { vi } from "vitest"; + +import { BrowserHarness } from "@/__tests__/browser-harness"; +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 { SlackCallback } from "@/components/integrations/slack/slack-callback"; + +import { IntegrationsContent } from "../integrations-content"; + +import { SlackIntegrationContent } from "./slack-integration-content"; + +interface CallbackParams { + code?: string; + state?: string; + /** Slack's own refusal code, e.g. `access_denied`. */ + error?: string; +} + +export class SlackIntegrationHarness extends BrowserHarness { + get exchangeCallCount(): number { + return this.countRequests("POST", "/slack/oauth/exchange"); + } + + get authorizeUrlCallCount(): number { + return this.countRequests("POST", "/slack/oauth/authorize-url"); + } + + /** Paths the actions asked Next to refresh (`next/cache` is stubbed in this lane). */ + get revalidatedPaths(): string[] { + return vi.mocked(revalidatePath).mock.calls.map(([path]) => path); + } + + // --- Mounting ----------------------------------------------------------- + + private wireHandlers(): void { + // The stub is module-level and shared, so clearing it here is what makes + // `revalidatedPaths` mean "since this mount". + vi.mocked(revalidatePath).mockClear(); + worker.use(...handlersForSlack(this.fixture)); + this.trackRequests(worker); + } + + async mount(): Promise { + window.history.replaceState(null, "", "/integrations/slack"); + this.wireHandlers(); + + render(await SlackIntegrationContent()); + } + + async mountCallback({ code, state, error }: CallbackParams): Promise { + const params = new URLSearchParams(); + if (code) params.set("code", code); + if (state) params.set("state", state); + if (error) params.set("error", error); + window.history.replaceState( + null, + "", + `/integrations/slack/callback?${params.toString()}`, + ); + this.wireHandlers(); + + render(createElement(SlackCallback)); + } + + /** Mount the integrations catalogue. No handlers: every card there is static. */ + mountCatalogue(): void { + window.history.replaceState(null, "", "/integrations"); + + render(createElement(IntegrationsContent)); + } + + // --- The integrations catalogue ------------------------------------------ + + async listedIntegrations(): Promise { + const headings = await this.waitFor( + () => { + const found = Array.from( + this.container.querySelectorAll("h4"), + ); + return found.length > 0 ? found : null; + }, + 5000, + "the integrations catalogue", + ); + return headings.map((heading) => (heading.textContent ?? "").trim()); + } + + offersSlackManagement(): boolean { + return this.q('a[href="/integrations/slack"]') !== null; + } + + // --- Starting the install ----------------------------------------------- + + private connectLink(): HTMLAnchorElement | null { + return ( + Array.from(this.container.querySelectorAll("a")).find((anchor) => + /Add to Slack/.test(anchor.textContent ?? ""), + ) ?? null + ); + } + + async authorizeUrl(): Promise { + const link = await this.waitFor( + () => this.connectLink(), + 5000, + "the Add to Slack link", + ); + return link.href; + } + + /** + * Clicks the install affordance and reports where it points. The default + * action is cancelled: following the link navigates the test frame off the app. + */ + async connect(): Promise { + const link = await this.waitFor( + () => this.connectLink(), + 5000, + "the Add to Slack link", + ); + + let destination = ""; + const intercept = (event: MouseEvent) => { + event.preventDefault(); + destination = link.href; + }; + link.addEventListener("click", intercept); + try { + await this.clickElement(link, { fallbackToDomClick: true }); + } finally { + link.removeEventListener("click", intercept); + } + + return destination; + } + + offersInstall(): boolean { + return this.connectLink() !== null; + } + + async waitForUnavailable(): Promise { + await this.waitForText(/Slack is not available in this environment yet/); + } + + saysUnavailable(): boolean { + return this.containsText(/Slack is not available in this environment yet/); + } + + saysLoadFailed(): boolean { + return this.containsText(/Could not load your Slack integration/); + } + + async rateLimitNotice(): Promise { + await this.waitForText(/Slack is busy right now/, 10000); + const description = await this.waitFor( + () => this.q('[data-slot="alert-description"]'), + 5000, + "the rate limit notice", + ); + return (description.textContent ?? "").trim(); + } + + async loadErrorNotice(): Promise { + await this.waitForText(/Could not load your Slack integration/, 10000); + const description = await this.waitFor( + () => this.q('[data-slot="alert-description"]'), + 5000, + "the load error notice", + ); + return (description.textContent ?? "").trim(); + } + + // --- Connected state ---------------------------------------------------- + + /** + * Read from the heading element, not the page text: in `textContent` + * "Connected to " runs straight into the copy that follows it. + */ + async connectedWorkspaceName(): Promise { + const heading = await this.waitFor( + () => this.deepestElementMatching(/^Connected to \S/), + 5000, + "the connected workspace name", + ); + return (heading.textContent ?? "").trim().replace(/^Connected to /, ""); + } + + /** Last match in document order: every ancestor of a match matches too. */ + private deepestElementMatching(pattern: RegExp): HTMLElement | null { + return ( + Array.from(this.container.querySelectorAll("*")) + .reverse() + .find((element) => pattern.test((element.textContent ?? "").trim())) ?? + null + ); + } + + /** + * Keyed on the badge's state attribute, not its copy: the heading beside it + * also starts "Connected to …". + */ + async connectionBadge(): Promise { + const badge = await this.waitFor( + () => this.q("[data-connection-status]"), + 5000, + "the connection badge", + ); + 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; + } + + saysChannelIsNextStep(): boolean { + return this.containsText(/Choosing a destination channel is the next step/); + } + + /** + * The "last checked" line as rendered, or null when the page shows none — + * which is what a workspace whose connection was never checked shows. + */ + lastCheckedLine(): string | null { + const line = Array.from( + this.container.querySelectorAll("p"), + ).find((p) => /^Last checked:/.test((p.textContent ?? "").trim())); + return line ? (line.textContent ?? "").trim() : null; + } + + // --- Returning from Slack ----------------------------------------------- + + /** + * The one element every non-success outcome renders. Keyed on it rather than + * the alert title, which is not the same claim on every outcome. + */ + private backLink(): HTMLAnchorElement | null { + return ( + Array.from(this.container.querySelectorAll("a")).find( + (anchor) => + anchor.getAttribute("href") === "/integrations/slack" && + /Back to Slack integration/.test(anchor.textContent ?? ""), + ) ?? null + ); + } + + async completedInstall(): Promise { + const outcome = await this.waitFor( + () => this.containsText(/Connected to /) || this.backLink() !== null, + 10000, + "the callback outcome", + ); + return outcome && this.containsText(/Connected to /); + } + + async installFailureReason(): Promise { + await this.waitFor(() => this.backLink(), 10000, "the failed callback"); + const description = await this.waitFor( + () => this.q('[data-slot="alert-description"]'), + 5000, + "the failure reason", + ); + return (description.textContent ?? "").trim(); + } + + async installFailureTitle(): Promise { + await this.waitFor(() => this.backLink(), 10000, "the failed callback"); + const title = await this.waitFor( + () => this.q('[data-slot="alert-title"]'), + 5000, + "the failure title", + ); + return (title.textContent ?? "").trim(); + } + + offersRetry(): boolean { + return this.backLink() !== null || this.offersInstall(); + } +} diff --git a/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx new file mode 100644 index 0000000000..4e00bc4941 --- /dev/null +++ b/ui/app/(prowler)/integrations/slack/slack-page.integration.test.tsx @@ -0,0 +1,217 @@ +/** + * Browser-mode tests for the Slack integration page (`/integrations/slack`), + * driven through `SlackIntegrationHarness`. MSW answers from handlers derived + * from the API contract in `design.md`. The OAuth callback is its own route, + * covered in `slack-callback-page.integration.test.tsx`. + */ + +import { describe, expect } from "vitest"; + +import { it } from "@/__tests__/fixtures"; +import { + configuredSlackFixture, + connectedSlackFixture, + INTEGRATIONS_SERVER_ERROR_DETAIL, + slackFixture, + unreadableCheckTimeSlackFixture, +} from "@/__tests__/msw/handlers/slack.fixtures"; + +import { SlackIntegrationHarness } from "./slack-integration.harness"; + +/** The workspace the fixtures connect. */ +const WORKSPACE_NAME = "Prowler HQ"; + +/** The only scopes Prowler asks a workspace for (design D2). */ +const REQUIRED_SCOPES = [ + "chat:write", + "chat:write.public", + "channels:read", + "groups:read", +]; + +describe("starting the install", () => { + it("sends the user to Slack's consent screen for the access Prowler needs", async () => { + // Given — a tenant with no workspace connected yet. + const harness = new SlackIntegrationHarness(slackFixture()); + await harness.mount(); + + const consentScreen = new URL(await harness.connect()); + + expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe( + "https://slack.com/oauth/v2/authorize", + ); + const scopes = (consentScreen.searchParams.get("scope") ?? "").split(","); + expect(scopes).toHaveLength(REQUIRED_SCOPES.length); + expect(scopes).toEqual(expect.arrayContaining(REQUIRED_SCOPES)); + // The state is server-minted, binding this install to the session + // (design D5). + expect(consentScreen.searchParams.get("state")).toBeTruthy(); + }, 30000); + + it("says so when the deployment has no Slack app, instead of offering an install", async () => { + // Given — no SLACK_CLIENT_ID/SECRET/REDIRECT_URI, which the API answers + // with a 503. + const harness = new SlackIntegrationHarness( + slackFixture({ appConfigured: false }), + ); + + await harness.mount(); + + // The read itself succeeded: an empty collection is what a deployment with + // no Slack app has, so nothing claims it failed. + await harness.waitForUnavailable(); + expect(harness.offersInstall()).toBe(false); + expect(harness.saysLoadFailed()).toBe(false); + }, 30000); + + it("still says the read failed when the deployment also has no Slack app", async () => { + // Given — both states, which coincide during rollout and rollback + // (design.md, Migration Plan §2-3 and §5). + const harness = new SlackIntegrationHarness( + slackFixture({ appConfigured: false, listServerError: true }), + ); + + await harness.mount(); + + // Both notices: the read's is the actionable half (a retry may still show a + // workspace this tenant has connected). + const notice = await harness.loadErrorNotice(); + expect(notice).toMatch(/temporarily unavailable/); + expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL); + expect(harness.saysUnavailable()).toBe(true); + // The install is still not on offer: there is no Slack app to install into. + expect(harness.offersInstall()).toBe(false); + }, 30000); + + it("says Slack is busy, not that the deployment has no Slack app, when it is rate limiting", async () => { + // Given — the app is configured; Slack rate limits (429) the call that + // mints the consent URL. + const harness = new SlackIntegrationHarness( + slackFixture({ rateLimited: true }), + ); + + await harness.mount(); + + expect(await harness.rateLimitNotice()).toMatch(/about 30 seconds/); + expect(harness.saysUnavailable()).toBe(false); + }, 30000); + + it("keeps the page usable when reading the install fails on the server", async () => { + // Given — the shared `GET /integrations` read answers 500. The action + // throws instead of returning a result, so the page has to catch it: + // uncaught, the route's error boundary replaces the Slack page. + const harness = new SlackIntegrationHarness( + slackFixture({ listServerError: true }), + ); + + await harness.mount(); + + const notice = await harness.loadErrorNotice(); + expect(notice).toMatch(/temporarily unavailable/); + expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL); + // The install stays on offer: one read failed, the Slack app is fine. + expect(harness.offersInstall()).toBe(true); + }, 30000); + + it("keeps the page usable when Slack's own side is broken upstream", async () => { + // Given — the `502` the contract reserves for a Slack upstream failure. + // The UI's shared 5xx handling throws, so this is the page's other + // rejection path. + const harness = new SlackIntegrationHarness( + slackFixture({ oauthUpstreamError: true }), + ); + + await harness.mount(); + + const notice = await harness.loadErrorNotice(); + expect(notice).toMatch(/temporarily unavailable/); + // 502 is not 503: the app is configured, Slack is down. + expect(harness.saysUnavailable()).toBe(false); + }, 30000); + + it("names the missing consent URL when a proxy answers that call with an HTML page", async () => { + // Given — a 200 carrying a challenge page instead of JSON. Nothing refused + // the call, so the action reaches its success path with no URL. + const harness = new SlackIntegrationHarness( + slackFixture({ authorizeUrlUnreadable: true }), + ); + + await harness.mount(); + + // V8 truncates the parse message to `" { + it("identifies the workspace and reports the connection as healthy", async () => { + // Given — a finished setup: workspace approved and a destination channel + // recorded, which the API requires before it will check a connection. + const harness = new SlackIntegrationHarness(configuredSlackFixture()); + await harness.mount(); + + expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME); + expect(await harness.connectionBadge()).toBe("Connected"); + // 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); + expect(harness.authorizeUrlCallCount).toBe(0); + }, 30000); + + it("still identifies the workspace before a destination channel is chosen", async () => { + // Given — the state the OAuth exchange leaves behind. + const harness = new SlackIntegrationHarness(connectedSlackFixture()); + + await harness.mount(); + + // The configuration carries no channel keys at all, which is "nothing + // chosen yet", not a broken install. + expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME); + expect(harness.offersInstall()).toBe(false); + }, 30000); + + it("reports the connection as never checked, not as broken, before the first check", async () => { + // Given — the state the OAuth exchange leaves behind: `connected` is null, + // neither true nor false (design.md, "Connection state, in order"). + const harness = new SlackIntegrationHarness(connectedSlackFixture()); + + await harness.mount(); + + const badge = await harness.connectionBadge(); + expect(badge).toBe("Not checked yet"); + expect(badge).not.toMatch(/Disconnected/); + }, 30000); + + it("keeps the page usable when the recorded check time is one no parser can read", async () => { + // Given — a finished setup whose `connection_last_checked_at` is a zero + // date. `date-fns` throws a RangeError on it, which would replace the whole + // page with the route's error boundary. + const harness = new SlackIntegrationHarness( + unreadableCheckTimeSlackFixture(), + ); + + await harness.mount(); + + expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME); + expect(await harness.connectionBadge()).toBe("Connected"); + // Nothing to show, so nothing is shown: the same line a workspace that was + // never checked renders. + expect(harness.lastCheckedLine()).toBeNull(); + }, 30000); + + it("does not offer a connection check the API is bound to refuse", async () => { + // Given — a workspace connected and no destination channel recorded. + const harness = new SlackIntegrationHarness(connectedSlackFixture()); + + 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); + }, 30000); +}); diff --git a/ui/app/(prowler)/providers/providers-tab-content.tsx b/ui/app/(prowler)/providers/providers-tab-content.tsx index 22bcd47eb9..c55f8730c6 100644 --- a/ui/app/(prowler)/providers/providers-tab-content.tsx +++ b/ui/app/(prowler)/providers/providers-tab-content.tsx @@ -32,13 +32,6 @@ export const ProvidersTabContent = async ({ }: { searchParams: SearchParamsProps; }) => { - // The React Compiler (`reactCompiler: true`) otherwise instruments this as a - // client component and injects `useMemoCache`, which needs a React dispatcher. - // An async server component renders once per request, so there is nothing to - // memoize — and the injected hook makes it uncallable outside a render, which - // is exactly how the browser-mode tests mount it. - "use no memo"; - const isCloudEnvironment = isCloud(); const [providersView, scanConfigsState] = await Promise.all([ loadProvidersAccountsViewData({ diff --git a/ui/changelog.d/slack-integration-connect-workspace.added.md b/ui/changelog.d/slack-integration-connect-workspace.added.md new file mode 100644 index 0000000000..8352e887ef --- /dev/null +++ b/ui/changelog.d/slack-integration-connect-workspace.added.md @@ -0,0 +1 @@ +Slack integration: connect a Slack workspace from the Integrations page (Prowler Cloud only) diff --git a/ui/components/icons/services/IconServices.tsx b/ui/components/icons/services/IconServices.tsx index 77158d8b07..7b84973038 100644 --- a/ui/components/icons/services/IconServices.tsx +++ b/ui/components/icons/services/IconServices.tsx @@ -752,6 +752,51 @@ export const JiraIcon: React.FC = ({ ); +export const SlackIcon: React.FC = ({ + size = 32, + width, + height, + className = "rounded-md", + ...props +}) => ( + +); + export const AWSSecurityHubIcon: React.FC = ({ size = 32, width, diff --git a/ui/components/integrations/shared/integration-card-header.tsx b/ui/components/integrations/shared/integration-card-header.tsx index 6b14ee482e..e78f12ed0d 100644 --- a/ui/components/integrations/shared/integration-card-header.tsx +++ b/ui/components/integrations/shared/integration-card-header.tsx @@ -6,18 +6,47 @@ import { ReactNode } from "react"; import { Badge } from "@/components/shadcn"; import { cn } from "@/lib/utils"; +// `null` means never checked, not disconnected: it must not get the fail tokens. +const CONNECTION_BADGE = { + connected: { + label: "Connected", + className: + "bg-bg-pass-secondary text-text-success-primary border-transparent", + }, + disconnected: { + label: "Disconnected", + className: + "bg-bg-fail-secondary text-text-error-primary border-transparent", + }, + unchecked: { + label: "Not checked yet", + className: "border-border-tag bg-bg-tag text-text-neutral-secondary", + }, +} as const; + +type ConnectionBadgeState = keyof typeof CONNECTION_BADGE; + +const connectionBadgeState = ( + connected: boolean | null, +): ConnectionBadgeState => + connected === null ? "unchecked" : connected ? "connected" : "disconnected"; + +interface IntegrationCardChip { + label: string; + className?: string; +} + +interface IntegrationConnectionStatus { + connected: boolean | null; + label?: string; +} + interface IntegrationCardHeaderProps { icon: ReactNode; title: string; subtitle?: string; - chips?: Array<{ - label: string; - className?: string; - }>; - connectionStatus?: { - connected: boolean; - label?: string; - }; + chips?: IntegrationCardChip[]; + connectionStatus?: IntegrationConnectionStatus; navigationUrl?: string; } @@ -29,6 +58,11 @@ export const IntegrationCardHeader = ({ connectionStatus, navigationUrl, }: IntegrationCardHeaderProps) => { + const badgeState = connectionStatus + ? connectionBadgeState(connectionStatus.connected) + : null; + const badge = badgeState ? CONNECTION_BADGE[badgeState] : null; + return (
@@ -55,7 +89,7 @@ export const IntegrationCardHeader = ({ )}
- {(chips.length > 0 || connectionStatus) && ( + {(chips.length > 0 || badge) && (
{chips.map((chip, index) => ( ))} - {connectionStatus && ( + {badge && badgeState && ( - {connectionStatus.label || - (connectionStatus.connected ? "Connected" : "Disconnected")} + {connectionStatus?.label || badge.label} )}
diff --git a/ui/components/integrations/slack/slack-callback.test.tsx b/ui/components/integrations/slack/slack-callback.test.tsx new file mode 100644 index 0000000000..4212a001c6 --- /dev/null +++ b/ui/components/integrations/slack/slack-callback.test.tsx @@ -0,0 +1,156 @@ +/** + * The cases `slack-page.integration.test.tsx` cannot express: it runs the Server + * Action as a plain function, so there is no client→server transport to reject, + * and its handler only answers the contract's shapes. React error boundaries + * cannot see a rejection awaited in an effect, so an uncaught one leaves the + * user on the spinner with no error and no way out. + */ + +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { IntegrationProps } from "@/types/integrations"; + +import { SlackCallback } from "./slack-callback"; + +const COMPLETED_QUERY = "code=slack-code-1f4a&state=st-2f1c9d7a"; + +const { exchangeSlackOAuthCode, callbackQuery, routerReplace } = vi.hoisted( + () => ({ + exchangeSlackOAuthCode: vi.fn(), + callbackQuery: { value: "" }, + routerReplace: vi.fn(), + }), +); + +vi.mock("@/actions/integrations/slack", () => ({ exchangeSlackOAuthCode })); + +// One router across renders, so the redirect off the spent code is assertable. +const router = { replace: routerReplace }; + +vi.mock("next/navigation", () => ({ + useRouter: () => router, + useSearchParams: () => new URLSearchParams(callbackQuery.value), +})); + +beforeEach(() => { + callbackQuery.value = COMPLETED_QUERY; + routerReplace.mockClear(); +}); + +const SPINNER_COPY = /Connecting your Slack workspace/; + +/** + * Literals, not imports: a rename on the component's side has to fail here. + * `FAILURE_TITLE` claims nothing was connected, which only holds for outcomes + * that happen before the API consumed the code. + */ +const FAILURE_TITLE = "Slack workspace not connected"; +const UNCONFIRMED_TITLE = "Slack install not confirmed"; + +describe("returning from Slack when the completion answers unexpectedly", () => { + it("reports an unconfirmed result instead of spinning forever when the exchange call never comes back", async () => { + // The client→server POST itself fails (dropped connection, action id + // invalidated by a deploy), so the action's own error handling never runs. + exchangeSlackOAuthCode.mockRejectedValue(new TypeError("Failed to fetch")); + + render(); + + // The API consumes the single-use code before answering, so the workspace + // may well be connected: unknown, not failed. + expect( + await screen.findByText(/could not confirm whether the workspace/i), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: /Back to Slack integration/ }), + ).toHaveAttribute("href", "/integrations/slack"); + expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument(); + + expect(screen.getByText(UNCONFIRMED_TITLE)).toBeInTheDocument(); + expect(screen.queryByText(FAILURE_TITLE)).not.toBeInTheDocument(); + }); + + it("still reports the workspace as connected when the created integration carries no configuration", async () => { + // The install already succeeded; `configuration` only goes missing on the + // client, where the callback reads the workspace name off it. + exchangeSlackOAuthCode.mockResolvedValue({ + integration: { + type: "integrations", + id: "slack-integration-1", + attributes: { + inserted_at: "2026-08-10T09:00:00Z", + updated_at: "2026-08-10T09:00:00Z", + enabled: true, + connected: null, + connection_last_checked_at: null, + integration_type: "slack", + }, + links: { self: "/api/v1/integrations/slack-integration-1" }, + // Cast: the shape is the one the contract rules out. + } as unknown as IntegrationProps, + }); + + render(); + + expect( + await screen.findByText(/Connected to your Slack workspace/), + ).toBeInTheDocument(); + expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument(); + // Keyed on the escape link, the only element unique to the failure branch, + // so this holds whichever headline that branch would have carried. + expect( + screen.queryByRole("link", { name: /Back to Slack integration/ }), + ).not.toBeInTheDocument(); + // `replace`, not `push`: a back navigation must not remount onto the code. + expect(routerReplace).toHaveBeenCalledWith("/integrations/slack"); + }); +}); + +describe("returning from Slack with an error on the callback URL", () => { + it("says the install was declined when Slack reports the approval was refused", async () => { + // The one code Slack reliably sends to this redirect. + callbackQuery.value = "error=access_denied&state=st-2f1c9d7a"; + + render(); + + expect( + await screen.findByText(/was not approved in Slack/), + ).toBeInTheDocument(); + expect(exchangeSlackOAuthCode).not.toHaveBeenCalled(); + + // Slack refused before issuing a code, so the flat "not connected" is a + // fact here, unlike in the outcomes that follow an exchange. + expect(screen.getByText(FAILURE_TITLE)).toBeInTheDocument(); + expect(screen.queryByText(UNCONFIRMED_TITLE)).not.toBeInTheDocument(); + }); + + it("names a Slack code it does not recognise, so a new failure reason is still diagnosable", async () => { + // Slack publishes no closed set of codes for this redirect, so the guard is + // on the shape of the value rather than on an allowlist. + callbackQuery.value = "error=invalid_scope&state=st-2f1c9d7a"; + + render(); + + expect( + await screen.findByText( + "Slack could not complete the install (invalid_scope).", + ), + ).toBeInTheDocument(); + }); + + it("drops a sentence smuggled into the error parameter instead of rendering it as Prowler's own copy", async () => { + // The balancing punctuation is the point: it closes Prowler's parenthetical + // and reopens it, so the payload would read as Prowler's own sentence. + const payload = + "). Slack has flagged this workspace. Contact Prowler support at +1-555-0100 to restore alerting ("; + callbackQuery.value = `error=${encodeURIComponent(payload)}&state=st-2f1c9d7a`; + + render(); + + expect( + await screen.findByText("Slack could not complete the install."), + ).toBeInTheDocument(); + expect(document.body.textContent).not.toContain("+1-555-0100"); + expect(document.body.textContent).not.toContain("flagged this workspace"); + }); +}); diff --git a/ui/components/integrations/slack/slack-callback.tsx b/ui/components/integrations/slack/slack-callback.tsx new file mode 100644 index 0000000000..7ece84226f --- /dev/null +++ b/ui/components/integrations/slack/slack-callback.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { AlertCircle, CircleCheck, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +import { exchangeSlackOAuthCode } from "@/actions/integrations/slack"; +import { + Alert, + AlertDescription, + AlertTitle, + Button, +} from "@/components/shadcn"; + +const SLACK_INTEGRATION_PATH = "/integrations/slack"; + +const STATUS = { + CONNECTING: "connecting", + CONNECTED: "connected", + FAILED: "failed", +} as const; + +type Status = (typeof STATUS)[keyof typeof STATUS]; + +const UNCONFIRMED_COMPLETION_MESSAGE = + "Prowler could not confirm whether the workspace was connected. Open the Slack integration page to check — if none is listed there, start the install again."; + +const FAILURE_TITLE = "Slack workspace not connected"; + +/** + * The API consumes the code and upserts the integration before it answers, so an + * unreadable or missing answer can still mean a connected workspace. Kept short: + * `AlertTitle` clamps to one line. + */ +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) + ? `Slack could not complete the install (${reason}).` + : "Slack could not complete the install."; +}; + +/** + * Slack's `code` is single-use: `hasStarted` holds the exchange to one run per + * mount, and `router.replace` (not `push`) keeps a back navigation from + * remounting onto a spent code. + */ +export const SlackCallback = () => { + const router = useRouter(); + const searchParams = useSearchParams(); + const [status, setStatus] = useState(STATUS.CONNECTING); + const [workspaceName, setWorkspaceName] = useState(null); + const [failure, setFailure] = useState(""); + const [failureTitle, setFailureTitle] = useState(FAILURE_TITLE); + const hasStarted = useRef(false); + + useEffect(() => { + if (hasStarted.current) return; + hasStarted.current = true; + + const slackError = searchParams.get("error"); + const code = searchParams.get("code"); + const state = searchParams.get("state"); + + // Slack answers a declined install with `error` and no code, so there is + // nothing to exchange. + if (slackError) { + setFailure(describeSlackError(slackError)); + setStatus(STATUS.FAILED); + return; + } + + if (!code || !state) { + setFailure( + "Slack sent an incomplete response back, so the install could not be completed.", + ); + setStatus(STATUS.FAILED); + return; + } + + const complete = async () => { + const result = await exchangeSlackOAuthCode({ code, state }); + + if ("integration" in result) { + setWorkspaceName( + result.integration.attributes?.configuration?.team_name ?? null, + ); + setStatus(STATUS.CONNECTED); + router.replace(SLACK_INTEGRATION_PATH); + return; + } + + if ("unavailable" in result) { + setFailure("Slack is not available in this environment yet."); + } else if ("rateLimited" in result) { + setFailure(result.message); + } else if ("unconfirmed" in result) { + setFailure(result.message); + setFailureTitle(UNCONFIRMED_TITLE); + } else { + setFailure(result.error); + } + setStatus(STATUS.FAILED); + }; + + // A rejection here means the call never came back (stale action id after a + // deploy, HTML 502): error boundaries cannot see a rejection awaited inside + // an effect, and the once-guard blocks a retry, so the page would spin. + void complete().catch(() => { + setFailure(UNCONFIRMED_COMPLETION_MESSAGE); + setFailureTitle(UNCONFIRMED_TITLE); + setStatus(STATUS.FAILED); + }); + }, [router, searchParams]); + + if (status === STATUS.CONNECTING) { + return ( +
+ + Connecting your Slack workspace... +
+ ); + } + + if (status === STATUS.CONNECTED) { + return ( + + + + Connected to {workspaceName ?? "your Slack workspace"} + + + Taking you back to the Slack integration, where you can choose the + channel Prowler posts to. + + + ); + } + + return ( +
+ + + {failureTitle} + {failure} + + +
+ ); +}; diff --git a/ui/components/integrations/slack/slack-integration-card.tsx b/ui/components/integrations/slack/slack-integration-card.tsx new file mode 100644 index 0000000000..005790e071 --- /dev/null +++ b/ui/components/integrations/slack/slack-integration-card.tsx @@ -0,0 +1,55 @@ +import { SettingsIcon } from "lucide-react"; +import Link from "next/link"; + +import { SlackIcon } from "@/components/icons/services/IconServices"; +import { Button, Card, CardContent, CardHeader } from "@/components/shadcn"; +import { CustomLink } from "@/components/shadcn/custom/custom-link"; + +// Placeholder slug: the docs slice writes the page and confirms it. +const SLACK_DOCS_URL = + "https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-slack-integration/"; + +export const SlackIntegrationCard = () => { + return ( + + +
+
+ +
+

+ Slack +

+
+

+ Send Prowler messages to your Slack workspace. +

+ + Learn more + +
+
+
+
+ +
+
+
+ +

+ Connect a Slack workspace and pick the channel Prowler posts to, so + your team gets security updates where it already works. +

+
+
+ ); +}; diff --git a/ui/components/integrations/slack/slack-integration-manager.tsx b/ui/components/integrations/slack/slack-integration-manager.tsx new file mode 100644 index 0000000000..4580f680b7 --- /dev/null +++ b/ui/components/integrations/slack/slack-integration-manager.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { format, isValid, parseISO } from "date-fns"; + +import { SlackIcon } from "@/components/icons/services/IconServices"; +import { IntegrationCardHeader } from "@/components/integrations/shared"; +import { + Alert, + AlertDescription, + AlertTitle, + Button, + Card, + CardContent, + CardHeader, +} from "@/components/shadcn"; +import type { IntegrationProps } from "@/types/integrations"; + +interface SlackIntegrationManagerProps { + /** At most one exists per tenant (one workspace). */ + integration: IntegrationProps | null; + authorizeUrl: string | null; + /** This deployment has no Prowler Slack app, so no install can be started. */ + unavailable: boolean; + rateLimitMessage: string | null; + loadError: string | null; +} + +export const SlackIntegrationManager = ({ + integration, + authorizeUrl, + unavailable, + 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 checkedAt = integration?.attributes.connection_last_checked_at; + const checkedOn = checkedAt ? parseISO(checkedAt) : null; + // `format` throws a RangeError on an unreadable value, which would replace + // the page with the route's error boundary: show nothing instead, as for a + // connection that was never checked. + const lastCheckedOn = + checkedOn && isValid(checkedOn) ? format(checkedOn, "yyyy/MM/dd") : null; + + return ( +
+ {rateLimitMessage && ( + + Slack is busy right now + {rateLimitMessage} + + )} + + {loadError && ( + + Could not load your Slack integration + {loadError} + + )} + + {/* Replaces the cards, not the whole page: an early return here would + swallow the rate-limit and load-error notices above. */} + {unavailable ? ( + + + Slack is not available in this environment yet + + + The Prowler Slack app is not configured here, so no workspace can be + connected. Nothing to do on your side — this page starts working as + soon as it is. + + + ) : integration ? ( + + + } + title={`Connected to ${workspaceName ?? "your Slack workspace"}`} + subtitle="Prowler posts to this workspace only." + connectionStatus={{ + connected: integration.attributes.connected, + }} + /> + + + + {/* No check here: it needs a destination channel, and nothing on + this page records one yet. */} +
+ {lastCheckedOn && ( +

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

+ )} + {!channelId && ( +

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

+ )} +
+
+
+ ) : ( + + + } + title="No workspace connected" + subtitle="Approve Prowler in Slack to connect a workspace. No tokens to copy." + /> + + + +
+

+ Prowler asks for permission to post messages and to read the + workspace's channel list. +

+ {authorizeUrl ? ( + + ) : ( + + )} +
+
+
+ )} +
+ ); +}; diff --git a/ui/lib/integrations/slack-errors.test.ts b/ui/lib/integrations/slack-errors.test.ts new file mode 100644 index 0000000000..234fb17168 --- /dev/null +++ b/ui/lib/integrations/slack-errors.test.ts @@ -0,0 +1,152 @@ +/** + * 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. + */ + +import { describe, expect, it } from "vitest"; + +import { + isSlackTokenErrorCode, + SLACK_ERROR_CODE, + SLACK_ERROR_MESSAGES, + SLACK_GENERIC_ERROR_MESSAGE, + SLACK_RATE_LIMITED_MESSAGE, + SLACK_TOKEN_ERROR_CODES, + readSlackFailure, + slackErrorMessage, + slackRateLimitMessage, +} from "./slack-errors"; + +describe("slackErrorMessage", () => { + it("prefers the code's own copy over the API's wording", () => { + // `detail` states the condition; the code's copy states the fix. + const failure = { + code: SLACK_ERROR_CODE.WORKSPACE_CONFLICT, + detail: + "This tenant is already connected to a different Slack workspace.", + }; + + expect(slackErrorMessage(failure)).toBe( + SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.WORKSPACE_CONFLICT], + ); + expect(slackErrorMessage(failure)).toMatch(/Disconnect it/); + }); + + it("tells the user how to grant a scope Prowler is missing", () => { + // A missing scope is fixable by the reader, so the copy names the fix. + const message = slackErrorMessage({ + code: SLACK_ERROR_CODE.MISSING_SCOPE, + detail: "missing_scope", + }); + + expect(message).toMatch(/Connect the workspace again/); + expect(message).not.toMatch(/missing_scope/); + }); + + it("says what to do about each channel refusal", () => { + expect( + slackErrorMessage({ code: SLACK_ERROR_CODE.CHANNEL_NOT_FOUND }), + ).toMatch(/Choose another one/); + expect( + slackErrorMessage({ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL }), + ).toMatch(/Invite @Prowler/); + expect(slackErrorMessage({ code: SLACK_ERROR_CODE.NO_PERMISSION })).toMatch( + /Choose another channel/, + ); + }); + + it("points every dead-credential code at reconnecting, not at retrying", () => { + for (const code of SLACK_TOKEN_ERROR_CODES) { + // Revoked, invalid, inactive or expired: no retry helps for any of them. + expect(slackErrorMessage({ code })).toMatch( + /Connect the workspace again to restore access/, + ); + expect(isSlackTokenErrorCode(code)).toBe(true); + } + }); + + it("does not treat an actionable refusal as a dead credential", () => { + expect(isSlackTokenErrorCode(SLACK_ERROR_CODE.MISSING_SCOPE)).toBe(false); + expect(isSlackTokenErrorCode(null)).toBe(false); + expect(isSlackTokenErrorCode(undefined)).toBe(false); + }); + + it("falls back to the API's detail for a code it does not know", () => { + expect( + slackErrorMessage({ + code: "some_future_slack_reason", + detail: "Slack said no.", + }), + ).toBe("Slack said no."); + }); + + it("falls back to the generic line when there is neither", () => { + expect(slackErrorMessage({ code: null, detail: null })).toBe( + SLACK_GENERIC_ERROR_MESSAGE, + ); + expect(slackErrorMessage(null)).toBe(SLACK_GENERIC_ERROR_MESSAGE); + expect( + slackErrorMessage({ detail: " " }, "Could not read channels."), + ).toBe("Could not read channels."); + }); +}); + +describe("slackRateLimitMessage", () => { + it("names the wait Slack asked for", () => { + expect(slackRateLimitMessage(30)).toMatch(/about 30 seconds/); + expect(slackRateLimitMessage(1)).toMatch(/about 1 second\b/); + expect(slackRateLimitMessage(90)).toMatch(/about 2 minutes/); + }); + + it("still says to come back when Slack named no wait", () => { + expect(slackRateLimitMessage(null)).toBe(SLACK_RATE_LIMITED_MESSAGE); + expect(slackRateLimitMessage(0)).toBe(SLACK_RATE_LIMITED_MESSAGE); + }); +}); + +describe("readSlackFailure", () => { + it("reads the code, the detail and the wait off a JSON:API refusal", async () => { + const response = new Response( + JSON.stringify({ + errors: [ + { + status: "429", + detail: "Slack is rate limiting requests from Prowler.", + source: { pointer: "/data" }, + }, + ], + }), + { status: 429, headers: { "Retry-After": "30" } }, + ); + + const failure = await readSlackFailure(response); + + expect(failure).toEqual({ + status: 429, + code: null, + detail: "Slack is rate limiting requests from Prowler.", + retryAfterSeconds: 30, + }); + }); + + it("survives a body that is not JSON:API at all", async () => { + const failure = await readSlackFailure( + new Response("Bad gateway", { status: 502 }), + ); + + expect(failure).toEqual({ + status: 502, + code: null, + detail: null, + retryAfterSeconds: null, + }); + }); + + it("ignores a Retry-After it cannot use", async () => { + const failure = await readSlackFailure( + new Response("{}", { status: 429, headers: { "Retry-After": "soon" } }), + ); + + expect(failure.retryAfterSeconds).toBeNull(); + }); +}); diff --git a/ui/lib/integrations/slack-errors.ts b/ui/lib/integrations/slack-errors.ts new file mode 100644 index 0000000000..8799907ae2 --- /dev/null +++ b/ui/lib/integrations/slack-errors.ts @@ -0,0 +1,135 @@ +export const SLACK_ERROR_CODE = { + MISSING_SCOPE: "missing_scope", + CHANNEL_NOT_FOUND: "channel_not_found", + NOT_IN_CHANNEL: "not_in_channel", + NO_PERMISSION: "no_permission", + TOKEN_REVOKED: "token_revoked", + INVALID_AUTH: "invalid_auth", + ACCOUNT_INACTIVE: "account_inactive", + TOKEN_EXPIRED: "token_expired", + /** One workspace per tenant. */ + WORKSPACE_CONFLICT: "slack_workspace_conflict", +} as const; + +export type SlackErrorCode = + (typeof SLACK_ERROR_CODE)[keyof typeof SLACK_ERROR_CODE]; + +/** + * The grant itself is dead: reconnecting is the only way out, not retrying. The + * API answers these with `400`, not `401`, so they are not mistaken for an + * expired Prowler session. + */ +export const SLACK_TOKEN_ERROR_CODES = [ + SLACK_ERROR_CODE.TOKEN_REVOKED, + SLACK_ERROR_CODE.INVALID_AUTH, + SLACK_ERROR_CODE.ACCOUNT_INACTIVE, + SLACK_ERROR_CODE.TOKEN_EXPIRED, +] as const; + +export type SlackTokenErrorCode = (typeof SLACK_TOKEN_ERROR_CODES)[number]; + +export const isSlackTokenErrorCode = ( + code: string | null | undefined, +): code is SlackTokenErrorCode => + SLACK_TOKEN_ERROR_CODES.includes(code as SlackTokenErrorCode); + +export const SLACK_GENERIC_ERROR_MESSAGE = + "Slack could not complete that request. Try again in a moment."; + +export const SLACK_RATE_LIMITED_MESSAGE = + "Slack is rate limiting Prowler right now. Try again in a few moments."; + +/** + * For a `2xx` the UI could not read. Not phrased as a failure: the install + * happened, only the workspace cannot be named. + */ +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."; + +const RECONNECT = "Connect the workspace again to restore access."; + +export const SLACK_ERROR_MESSAGES = { + [SLACK_ERROR_CODE.MISSING_SCOPE]: + "Prowler is missing a permission it needs in Slack. Connect the workspace again and approve the access Prowler asks for.", + [SLACK_ERROR_CODE.CHANNEL_NOT_FOUND]: + "That channel no longer exists in the workspace. Choose another one.", + [SLACK_ERROR_CODE.NOT_IN_CHANNEL]: + "Prowler is not in that channel. Invite @Prowler to it in Slack, or choose a channel it can already post to.", + [SLACK_ERROR_CODE.NO_PERMISSION]: + "Slack did not allow Prowler to post there. Choose another channel, or ask a workspace admin to allow it.", + [SLACK_ERROR_CODE.TOKEN_REVOKED]: `Prowler's access to Slack was revoked. ${RECONNECT}`, + [SLACK_ERROR_CODE.INVALID_AUTH]: `Slack no longer accepts Prowler's credential. ${RECONNECT}`, + [SLACK_ERROR_CODE.ACCOUNT_INACTIVE]: `The Slack account Prowler was installed with is no longer active. ${RECONNECT}`, + [SLACK_ERROR_CODE.TOKEN_EXPIRED]: `Prowler's Slack credential has expired. ${RECONNECT}`, + [SLACK_ERROR_CODE.WORKSPACE_CONFLICT]: + "Prowler is already connected to a different Slack workspace. Disconnect it before connecting another one.", +} as const satisfies Record; + +/** The parts of a JSON:API error this mapping reads. */ +export interface SlackErrorSource { + code?: string | null; + detail?: string | null; +} + +export interface SlackApiFailure extends SlackErrorSource { + status: number; + retryAfterSeconds: number | null; +} + +const isKnownCode = (code: string | null | undefined): code is SlackErrorCode => + typeof code === "string" && + Object.prototype.hasOwnProperty.call(SLACK_ERROR_MESSAGES, code); + +/** + * Copy for a refusal: Prowler's wording for a known `code`, else the API's + * `detail`, else `fallback`. + */ +export const slackErrorMessage = ( + error: SlackErrorSource | null | undefined, + fallback: string = SLACK_GENERIC_ERROR_MESSAGE, +): string => { + if (isKnownCode(error?.code)) return SLACK_ERROR_MESSAGES[error.code]; + return error?.detail?.trim() || fallback; +}; + +const describeWait = (seconds: number): string => { + if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`; + const minutes = Math.ceil(seconds / 60); + return `${minutes} minute${minutes === 1 ? "" : "s"}`; +}; + +export const slackRateLimitMessage = ( + retryAfterSeconds: number | null, +): string => { + if (retryAfterSeconds === null || retryAfterSeconds <= 0) { + return SLACK_RATE_LIMITED_MESSAGE; + } + return `Slack is rate limiting Prowler right now. Try again in about ${describeWait( + Math.ceil(retryAfterSeconds), + )}.`; +}; + +const retryAfterFrom = (response: Response): number | null => { + const header = response.headers.get("retry-after"); + if (!header) return null; + const seconds = Number(header.trim()); + return Number.isFinite(seconds) && seconds > 0 ? seconds : null; +}; + +/** + * Read a non-OK Slack response into the failure it describes. Never throws: a + * body that is not JSON:API still yields a failure carrying the status. + */ +export const readSlackFailure = async ( + response: Response, +): Promise => { + const body = await response.json().catch(() => null); + const error = Array.isArray(body?.errors) ? body.errors[0] : null; + + return { + status: response.status, + code: typeof error?.code === "string" ? error.code : null, + detail: typeof error?.detail === "string" ? error.detail : null, + retryAfterSeconds: retryAfterFrom(response), + }; +}; diff --git a/ui/types/integrations.ts b/ui/types/integrations.ts index 77ee623ad3..dc2e4b5c2e 100644 --- a/ui/types/integrations.ts +++ b/ui/types/integrations.ts @@ -2,7 +2,15 @@ import { z } from "zod"; import type { TaskState } from "@/types/tasks"; -export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira"; +export const INTEGRATION_TYPE = { + AMAZON_S3: "amazon_s3", + AWS_SECURITY_HUB: "aws_security_hub", + JIRA: "jira", + SLACK: "slack", +} as const; + +export type IntegrationType = + (typeof INTEGRATION_TYPE)[keyof typeof INTEGRATION_TYPE]; export const JIRA_DISPATCH_MODE = { INDIVIDUAL: "individual", @@ -68,7 +76,10 @@ export interface IntegrationProps { inserted_at: string; updated_at: string; enabled: boolean; - connected: boolean; + // `null` until a connection check has run: never verified, neither working + // nor broken. A Slack install starts here, and returns here on a channel + // change. + connected: boolean | null; connection_last_checked_at: string | null; integration_type: IntegrationType; configuration: { @@ -87,6 +98,13 @@ export interface IntegrationProps { domain?: string; projects?: { [key: string]: string }; issue_types?: { [key: string]: string[] }; + // Slack specific configuration, server-owned. The channel keys are absent + // until one is chosen, not present and null: read them with `?? null`. + team_id?: string; + team_name?: string; + bot_user_id?: string; + channel_id?: string; + channel_name?: string; [key: string]: unknown; }; url?: string; diff --git a/ui/vitest.config.ts b/ui/vitest.config.ts index dac9e8a762..81b21dbd70 100644 --- a/ui/vitest.config.ts +++ b/ui/vitest.config.ts @@ -1,9 +1,34 @@ -import react from "@vitejs/plugin-react"; +import react, { type BabelOptions } from "@vitejs/plugin-react"; import { playwright } from "@vitest/browser-playwright"; +import fs from "fs"; import path from "path"; import type { TestProjectConfiguration } from "vitest/config"; import { defineConfig } from "vitest/config"; +/** + * Next runs the React Compiler on the client compilation only — its + * `getReactCompilerPlugins` returns nothing when `isServer` — so a Server + * Component ships uncompiled. Mirror that: compiled, it calls `useMemoCache` + * on the active dispatcher, which a harness invoking the component as a + * function has none of, and `react/compiler-runtime` reads the client + * internals the `react-server` build does not export anyway. + */ +const isServerModule = (id: string): boolean => { + const file = id.split("?")[0]; + if (!file.includes("/app/")) return false; + try { + return !/^\s*["']use client["']/.test(fs.readFileSync(file, "utf8")); + } catch { + return false; + } +}; + +const reactCompilerBabel = (id: string): BabelOptions => ({ + plugins: isServerModule(id) + ? [] + : [["babel-plugin-react-compiler", { target: "19" }]], +}); + export default defineConfig(() => { const apiBaseUrl = process.env.UI_API_BASE_URL ?? "http://localhost/api/v1"; @@ -57,13 +82,7 @@ export default defineConfig(() => { }, { extends: true, - plugins: [ - react({ - babel: { - plugins: [["babel-plugin-react-compiler", { target: "19" }]], - }, - }), - ], + plugins: [react({ babel: reactCompilerBabel })], test: { name: "integration", setupFiles: ["./vitest.integration.setup.ts"], @@ -109,6 +128,9 @@ export default defineConfig(() => { // React runtime (pre-bundle so a cold run doesn't re-optimize and // reload mid-test — see the on-demand-reload note above). "react-dom/client", + // What the compiler's output imports. `@vitejs/plugin-react` adds it + // itself only when `babel` is a plain object, and ours is a function. + "react/compiler-runtime", // Next runtime "next/headers",