Files
prowler/ui/__tests__/msw/handlers/slack.ts

472 lines
17 KiB
TypeScript

/**
* MSW handlers for the Slack integration, derived from the signed API contract
* in `openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`
* (the API itself lives in the cloud repository). State is per-call: an
* exchange creates the install the subsequent `GET /integrations` returns, and
* a save or a connection check writes to it.
*
* Wire them per test via `worker.use(...handlersForSlack(fx))`.
*/
import { http, HttpResponse } from "msw";
import {
INTEGRATIONS_SERVER_ERROR_DETAIL,
NO_VERIFICATION,
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_REFUSAL,
SLACK_REFUSED_STATE_DETAIL,
SLACK_UNCONFIGURED_DETAIL,
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UPSTREAM_DETAIL,
SLACK_UPSTREAM_ERROR_CODE,
SLACK_WORKSPACE_CONFLICT_CODE,
} from "./slack.fixtures";
import type {
SlackAuthorizedChannelFixture,
SlackExchangeOutcome,
SlackFixture,
SlackInstallFixture,
SlackRefusalFixture,
} from "./slack.fixtures";
const API = process.env.UI_API_BASE_URL;
const TS = "2026-08-10T09:00:00Z";
/** The time a check records, distinct from `TS` so a test can tell one ran. */
const CHECK_TS = "2026-08-10T10:15:00Z";
const CONNECTION_TASK_PREFIX = "slack-conn-task-";
/** Order-insensitive: a reorder is not a changed set (contract, PATCH). */
const sameChannelIds = (a: string[], b: string[]) =>
a.length === b.length && new Set([...a, ...b]).size === a.length;
/** Opaque to the UI, which only ever follows `links.next` (design D6). */
const CHANNEL_CURSOR_PARAM = "page[cursor]";
/**
* `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even
* for a field-shaped `ValidationError`: the errors are about the request.
*/
const errorBody = (detail: string, status: number, code?: string) => ({
errors: [
{
status: String(status),
...(code ? { code } : {}),
detail,
source: { pointer: "/data" },
},
],
});
/**
* Answer a fixture's refusal as the API would: its own status, its `code`
* when it names one, and `Retry-After` only where the status carries a wait.
*/
const refuse = (refusal: SlackRefusalFixture) =>
HttpResponse.json(
errorBody(refusal.detail, refusal.status, refusal.code ?? undefined),
{
status: refusal.status,
...(refusal.retryAfterSeconds === null
? {}
: { headers: { "Retry-After": String(refusal.retryAfterSeconds) } }),
},
);
const configuration = (install: SlackInstallFixture) => {
const verification = install.verification ?? NO_VERIFICATION;
return {
team_id: install.workspace.teamId,
team_name: install.workspace.teamName,
bot_user_id: install.workspace.botUserId,
// Always an array, empty rather than omitted (contract, OAuth and reads).
channels: (install.workspace.authorizedChannels ?? []).map((channel) => ({
id: channel.id,
name: channel.name,
is_private: channel.isPrivate,
confirmation_sent_at: channel.confirmationSentAt,
})),
verification: {
task_id: verification.taskId,
started_at: verification.startedAt,
finished_at: verification.finishedAt,
},
};
};
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),
},
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 } },
});
/** Stamps only channels this run posted to, never one already confirmed. */
const confirmedByThisRun =
(connected: boolean, failedChannelId: string | null) =>
(channel: SlackAuthorizedChannelFixture): SlackAuthorizedChannelFixture =>
// A failure naming no channel posted nothing: `auth.test` runs once, before
// the per-channel loop (contract, Connection).
(connected || failedChannelId !== null) &&
channel.confirmationSentAt === null &&
channel.id !== failedChannelId
? { ...channel, confirmationSentAt: CHECK_TS }
: channel;
/**
* 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 freshInstall = (id?: string): SlackInstallFixture => ({
id: id ?? SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace, authorizedChannels: [] },
verification: { ...NO_VERIFICATION },
});
const unconfigured = () =>
HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), {
status: 503,
});
const rateLimited = () => refuse(SLACK_RATE_LIMITED_REFUSAL);
/** 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.REFUSED_STATE_CODED:
return HttpResponse.json(
errorBody(SLACK_REFUSED_STATE_DETAIL, 400, "invalid_oauth_state"),
{ 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 = freshInstall();
return unreadableExchange(fx.exchangeOutcome);
case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
install = {
...freshInstall(install?.id),
workspace: {
...fx.exchangeWorkspace,
// A reinstall keeps the set and resets every confirmation, with the
// connection and verification state (contract, OAuth and reads).
authorizedChannels: (
install?.workspace.authorizedChannels ?? []
).map((channel) => ({ ...channel, confirmationSentAt: null })),
},
};
return HttpResponse.json({ data: integrationResource(install) });
default:
install = freshInstall();
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 }) => {
// A check posts to every authorized channel, so the API requires at least
// one (contract, Connection).
if (!install?.workspace.authorizedChannels?.length) {
return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), {
status: 400,
});
}
// Id stored before the task is published; the worker stamps `started_at`.
const taskId = `${CONNECTION_TASK_PREFIX}${params.id}`;
install.verification = {
taskId,
startedAt: null,
finishedAt: null,
};
return HttpResponse.json(taskResource(taskId, "executing", null), {
status: 202,
});
},
),
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
const { connected, error, failedChannelId } = fx.connection;
// Only the task whose id still matches may write: a late poll must not
// overwrite a newer check (contract, Connection).
if (
install &&
params.taskId.startsWith(CONNECTION_TASK_PREFIX) &&
install.verification?.taskId === params.taskId
) {
install.connected = connected;
install.connectionLastCheckedAt = CHECK_TS;
install.verification = {
taskId: params.taskId,
startedAt: CHECK_TS,
finishedAt: CHECK_TS,
};
install.workspace.authorizedChannels = (
install.workspace.authorizedChannels ?? []
).map(confirmedByThisRun(connected, failedChannelId ?? null));
}
return HttpResponse.json(
// `channel` carries the failing channel's id, not an object.
taskResource(params.taskId, "completed", {
connected,
error,
channel: failedChannelId ?? null,
}),
);
}),
// --- Channels ----------------------------------------------------------
http.get<{ id: string }>(
`${API}/integrations/:id/slack/channels`,
({ params, request }) => {
// The UI follows `links.next` opaquely, so the cursor's shape is this
// fixture's business alone. Read first: the page decides the refusal.
const cursor = Number(
new URL(request.url).searchParams.get(CHANNEL_CURSOR_PARAM) ?? "0",
);
// An endpoint-specific refusal wins over the blanket rate limiting,
// and applies from the named cursor, so a partial read is expressible.
if (
fx.channelsRefusal &&
cursor >= (fx.channelsRefusalFromCursor ?? 0)
) {
return refuse(fx.channelsRefusal);
}
if (fx.rateLimited) return rateLimited();
const nextCursor = cursor + fx.channelsPageSize;
const page = fx.channels.slice(cursor, nextCursor);
const hasMore = nextCursor < fx.channels.length;
return HttpResponse.json({
data: page.map((channel) => ({
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: channel.isPrivate },
})),
links: {
next: hasMore
? `${API}/integrations/${params.id}/slack/channels` +
`?${CHANNEL_CURSOR_PARAM}=${nextCursor}`
: null,
},
});
},
),
/**
* The generic PATCH. The write names only `id`; name and privacy are derived
* here, as the API derives them from Slack (contract, PATCH).
*/
http.patch(`${API}/integrations/:id`, async ({ request }) => {
const body = (await request.json().catch(() => null)) as {
data?: { attributes?: Record<string, unknown> };
} | null;
const attributes = body?.data?.attributes ?? {};
const configurationPatch = attributes.configuration as
| { channels?: { id: string }[] }
| undefined;
const requested = configurationPatch?.channels;
if (!install) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
}
// The API's write serializer names the attributes it will not take and
// refuses the whole save, rather than quietly dropping the extra one:
// sending `integration_type` here refused every channel save.
const refusedAttributes = Object.keys(attributes).filter(
(attribute) => attribute !== "configuration",
);
if (refusedAttributes.length > 0) {
const named = refusedAttributes
.map((attribute) => `'${attribute}'`)
.join(", ");
return HttpResponse.json(errorBody(`Invalid fields: {${named}}`, 400), {
status: 400,
});
}
// Omitted leaves the set alone; empty clears it (contract, PATCH).
if (requested === undefined) {
return HttpResponse.json({ data: integrationResource(install) });
}
const channelIds = Array.from(
new Set(requested.map((channel) => channel.id)),
);
const matched = channelIds
.map((channelId) =>
fx.channels.find((channel) => channel.id === channelId),
)
.filter((channel) => channel !== undefined);
// Checked before the id lookup: the picker did offer these channels, and
// Slack refused one anyway when the API validated the set.
if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal);
// One unknown id refuses the whole save: the set is validated together.
if (matched.length !== channelIds.length) {
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
status: 400,
});
}
const previous = install.workspace.authorizedChannels ?? [];
const confirmedAt = new Map(
previous.map((channel) => [channel.id, channel.confirmationSentAt]),
);
install.workspace.authorizedChannels = matched.map((channel) => ({
...channel,
// Retained ids keep their confirmation; new ones start without one.
confirmationSentAt: confirmedAt.get(channel.id) ?? null,
}));
// A changed id set resets the connection and verification state, so the
// record never claims a check covered a channel it never saw.
if (
!sameChannelIds(
previous.map((channel) => channel.id),
channelIds,
)
) {
install.connected = null;
install.connectionLastCheckedAt = null;
install.verification = { ...NO_VERIFICATION };
}
return HttpResponse.json({ data: integrationResource(install) });
}),
// Disconnect. Revocation at Slack is best-effort: the row is removed either
// way and the outcome travels in `meta` — or nowhere at all, in the plain
// `204` a deployment with no `destroy` override sends.
http.delete<{ id: string }>(`${API}/integrations/:id`, ({ params }) => {
if (!install || install.id !== params.id) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
}
// Checked first: a refused disconnect removes nothing.
if (fx.disconnectRefusal) return refuse(fx.disconnectRefusal);
install = null;
if (fx.revocation.revoked === null) {
return new HttpResponse(null, { status: 204 });
}
return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } });
}),
];
};