mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
Compare commits
36
Commits
3cc2a30497
...
652c950a71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652c950a71 | ||
|
|
da166c04e6 | ||
|
|
ba64cf35b8 | ||
|
|
e78c128de4 | ||
|
|
0c296b3227 | ||
|
|
d9f957a892 | ||
|
|
4d32fa59b7 | ||
|
|
2403dbca82 | ||
|
|
ac3dbd1959 | ||
|
|
cbc6d23444 | ||
|
|
c02c28fc76 | ||
|
|
00d2aacf26 | ||
|
|
ff3e4a4d24 | ||
|
|
7da6d06f51 | ||
|
|
0c9aa84676 | ||
|
|
b388bb68f6 | ||
|
|
df0775d135 | ||
|
|
5af26a5f53 | ||
|
|
5690d1ad94 | ||
|
|
e178722f0b | ||
|
|
d28272b17b | ||
|
|
4ffe215a67 | ||
|
|
d60cb02f72 | ||
|
|
349d6d8dd4 | ||
|
|
6d1852b38a | ||
|
|
531b7fd311 | ||
|
|
d19c81bdbb | ||
|
|
b323ab139e | ||
|
|
57434ea16e | ||
|
|
c9c769c1df | ||
|
|
71be560bc2 | ||
|
|
608ce240d0 | ||
|
|
c3420151b5 | ||
|
|
6adbb75ca9 | ||
|
|
23d6c77a78 | ||
|
|
9d43aa0c33 |
@@ -48,6 +48,54 @@ export interface SlackConnectionFixture {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** A channel the listing endpoint offers for the picker. */
|
||||
export interface SlackChannelFixture {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Private channels are listed only where `@Prowler` has been invited. */
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
export interface SlackTestMessageFixture {
|
||||
accepted: boolean;
|
||||
/**
|
||||
* Why it did not: the reason `code` would carry, or prose — the contract
|
||||
* leaves the task result's shape open.
|
||||
*/
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A refusal as the API sends one: the machine-readable reason in `code`, human
|
||||
* copy in `detail`, and — for a `429` — the wait in `Retry-After`.
|
||||
*/
|
||||
export interface SlackRefusalFixture {
|
||||
status: number;
|
||||
/** Slack's stable reason. `null` for the failures classified by status. */
|
||||
code: string | null;
|
||||
detail: string;
|
||||
/** Seconds `Retry-After` asked for; only a `429` carries one. */
|
||||
retryAfterSeconds: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What `DELETE /integrations/{id}` reports about revoking the token at Slack.
|
||||
* Revocation is best-effort: the row goes either way, and the outcome travels in
|
||||
* JSON:API `meta`.
|
||||
*
|
||||
* One boolean is the whole of it: the API sends no reason for a revocation that
|
||||
* did not happen, so modelling one would let a test prove copy the real
|
||||
* deployment can never produce.
|
||||
*/
|
||||
export interface SlackRevocationFixture {
|
||||
/**
|
||||
* Slack confirmed the token no longer grants Prowler anything. `null` when the
|
||||
* answer reports nothing at all — the plain `204` a deployment without a
|
||||
* `destroy` override sends, which is what the UI meets today.
|
||||
*/
|
||||
revoked: boolean | null;
|
||||
}
|
||||
|
||||
export interface SlackFixture {
|
||||
/**
|
||||
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
|
||||
@@ -72,9 +120,33 @@ export interface SlackFixture {
|
||||
* transport failures. Distinct from `appConfigured: false`, which is a `503`.
|
||||
*/
|
||||
oauthUpstreamError: boolean;
|
||||
channels: SlackChannelFixture[];
|
||||
/**
|
||||
* Small on purpose: the default workspace spans two pages, so a UI that
|
||||
* stopped at `data` instead of following `links.next` would lose channels.
|
||||
*/
|
||||
channelsPageSize: number;
|
||||
/** Slack refused the listing outright, with the reason named in `code`. */
|
||||
channelsRefusal: SlackRefusalFixture | null;
|
||||
/**
|
||||
* The cursor the refusal starts at. Absent, the whole read fails; a page
|
||||
* size serves the first page and refuses the second — the partial read.
|
||||
*/
|
||||
channelsRefusalFromCursor?: number;
|
||||
/**
|
||||
* Slack refused the chosen channel when the `PATCH` validated it — the
|
||||
* listing itself answered fine.
|
||||
*/
|
||||
channelSaveRefusal: SlackRefusalFixture | null;
|
||||
testMessage: SlackTestMessageFixture;
|
||||
revocation: SlackRevocationFixture;
|
||||
}
|
||||
|
||||
export const SLACK_INTEGRATION_ID = "slack-integration-1";
|
||||
/**
|
||||
* A UUID, as the API's ids are: it travels in the URL of every Slack call and
|
||||
* the actions accept no other shape.
|
||||
*/
|
||||
export const SLACK_INTEGRATION_ID = "7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c";
|
||||
|
||||
/** The scopes the channel picker and the posting need (design D2). */
|
||||
export const SLACK_BOT_SCOPES = [
|
||||
@@ -128,6 +200,25 @@ export const SLACK_RATE_LIMITED_DETAIL =
|
||||
* is for the user to act on, so the UI answers a server error in its own words.
|
||||
*/
|
||||
export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred.";
|
||||
export const SLACK_MISSING_SCOPE_DETAIL =
|
||||
"Slack refused the request: missing_scope.";
|
||||
/**
|
||||
* Names the raw reason, as the missing-scope wording does: what lets a test tell
|
||||
* copy the UI mapped from `code` apart from an echoed `detail`.
|
||||
*/
|
||||
export const SLACK_TOKEN_EXPIRED_DETAIL =
|
||||
"Slack refused the request: token_expired.";
|
||||
/**
|
||||
* The same sentence for "it is gone" and "the app was removed from it": only
|
||||
* `code` separates them, which is why a client must read `code`.
|
||||
*/
|
||||
export const SLACK_UNKNOWN_CHANNEL_DETAIL =
|
||||
"That channel is not one Prowler can post to.";
|
||||
export const SLACK_NO_DEFAULT_CHANNEL_DETAIL =
|
||||
"No default channel is recorded on this integration.";
|
||||
/** A task result that reports the refusal as prose instead of as a reason. */
|
||||
export const SLACK_TEST_MESSAGE_REFUSED_DETAIL =
|
||||
"Slack rejected the message: the channel is archived.";
|
||||
|
||||
/**
|
||||
* A `200` challenge page from a proxy or WAF that took the call instead of the
|
||||
@@ -141,17 +232,123 @@ export const PROXY_CHALLENGE_PAGE = [
|
||||
].join("\n");
|
||||
|
||||
/**
|
||||
* A wire value, spelled out rather than imported from the UI's own mapping, so
|
||||
* a rename on our side fails these tests instead of agreeing with itself.
|
||||
* The `code` values the refusals below are named by. Wire values, spelled out
|
||||
* rather than imported from the UI's own mapping: a rename on our side must
|
||||
* fail these tests, not quietly agree with itself.
|
||||
*/
|
||||
export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict";
|
||||
export const SLACK_MISSING_SCOPE_CODE = "missing_scope";
|
||||
export const SLACK_CHANNEL_NOT_FOUND_CODE = "channel_not_found";
|
||||
export const SLACK_NOT_IN_CHANNEL_CODE = "not_in_channel";
|
||||
/**
|
||||
* A reason Slack really sends that the UI's mapping does not cover — the set is
|
||||
* open-ended, so having no copy for one is the ordinary case.
|
||||
*/
|
||||
export const SLACK_UNMAPPED_REASON_CODE = "is_archived";
|
||||
/**
|
||||
* Two of the four dead-grant codes the contract lists. Whichever call surfaces
|
||||
* one, the integration is disconnected and the only way out is connecting the
|
||||
* workspace again (contract, Cross-cutting).
|
||||
*/
|
||||
export const SLACK_TOKEN_REVOKED_CODE = "token_revoked";
|
||||
export const SLACK_TOKEN_EXPIRED_CODE = "token_expired";
|
||||
|
||||
export const SLACK_RETRY_AFTER_SECONDS = 30;
|
||||
|
||||
export const SLACK_DEFAULT_CHANNEL = {
|
||||
/** The install never granted a scope the call needs: actionable, so a `400`. */
|
||||
export const SLACK_MISSING_SCOPE_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_MISSING_SCOPE_CODE,
|
||||
detail: SLACK_MISSING_SCOPE_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Where this really happens is the channel listing: `conversations.list` is
|
||||
* tier 2 and paginated.
|
||||
*/
|
||||
export const SLACK_RATE_LIMITED_REFUSAL: SlackRefusalFixture = {
|
||||
status: 429,
|
||||
code: null,
|
||||
detail: SLACK_RATE_LIMITED_DETAIL,
|
||||
retryAfterSeconds: SLACK_RETRY_AFTER_SECONDS,
|
||||
};
|
||||
|
||||
/**
|
||||
* The stored grant is no longer usable: a `400` like any other actionable
|
||||
* refusal, deliberately not the `401` that would read as an expired Prowler
|
||||
* session (contract, Errors).
|
||||
*/
|
||||
export const SLACK_TOKEN_EXPIRED_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_TOKEN_EXPIRED_CODE,
|
||||
detail: SLACK_TOKEN_EXPIRED_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/** Slack-side or transport failure — a `502` naming no reason at all. */
|
||||
export const SLACK_UPSTREAM_REFUSAL: SlackRefusalFixture = {
|
||||
status: 502,
|
||||
code: null,
|
||||
detail: SLACK_UPSTREAM_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/** The chosen channel is archived, deleted, or was never in the workspace. */
|
||||
export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_CHANNEL_NOT_FOUND_CODE,
|
||||
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* The channel is fine, the Prowler app is simply not in it — fixed with
|
||||
* `/invite @Prowler`. Identical `detail` to the refusal above, deliberately.
|
||||
*/
|
||||
export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = {
|
||||
status: 400,
|
||||
code: SLACK_NOT_IN_CHANNEL_CODE,
|
||||
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
retryAfterSeconds: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Two public channels and one private the Prowler app was invited to, ordered
|
||||
* so the private one lands on the second cursor page.
|
||||
*/
|
||||
export const SLACK_PUBLIC_CHANNEL: SlackChannelFixture = {
|
||||
id: "C0123AB",
|
||||
name: "security",
|
||||
} as const;
|
||||
isPrivate: false,
|
||||
};
|
||||
|
||||
export const SLACK_SECOND_PUBLIC_CHANNEL: SlackChannelFixture = {
|
||||
id: "C0789EF",
|
||||
name: "platform",
|
||||
isPrivate: false,
|
||||
};
|
||||
|
||||
export const SLACK_PRIVATE_CHANNEL: SlackChannelFixture = {
|
||||
id: "C0456CD",
|
||||
name: "security-alerts",
|
||||
isPrivate: true,
|
||||
};
|
||||
|
||||
export const SLACK_CHANNELS: SlackChannelFixture[] = [
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL,
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
];
|
||||
|
||||
/** Two channels per page, so `SLACK_CHANNELS` spans exactly two pages. */
|
||||
export const SLACK_CHANNELS_PAGE_SIZE = 2;
|
||||
|
||||
/**
|
||||
* The first channel the picker offers, so an install seeded with it always
|
||||
* points at a channel the listing really has.
|
||||
*/
|
||||
export const SLACK_DEFAULT_CHANNEL = SLACK_PUBLIC_CHANNEL;
|
||||
|
||||
const PROWLER_HQ: SlackWorkspaceFixture = {
|
||||
teamId: "T01PROWLER",
|
||||
@@ -171,6 +368,12 @@ export const slackFixture = (
|
||||
listServerError: false,
|
||||
authorizeUrlUnreadable: false,
|
||||
oauthUpstreamError: false,
|
||||
channels: SLACK_CHANNELS.map((channel) => ({ ...channel })),
|
||||
channelsPageSize: SLACK_CHANNELS_PAGE_SIZE,
|
||||
channelsRefusal: null,
|
||||
channelSaveRefusal: null,
|
||||
testMessage: { accepted: true, error: null },
|
||||
revocation: { revoked: true },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -193,25 +396,28 @@ export const connectedSlackFixture = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const configuredInstall = (): SlackInstallFixture => ({
|
||||
const configuredInstall = (
|
||||
channel: SlackChannelFixture = SLACK_DEFAULT_CHANNEL,
|
||||
): SlackInstallFixture => ({
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
connected: true,
|
||||
connectionLastCheckedAt: "2026-08-10T09:30:00Z",
|
||||
workspace: {
|
||||
...PROWLER_HQ,
|
||||
channelId: SLACK_DEFAULT_CHANNEL.id,
|
||||
channelName: SLACK_DEFAULT_CHANNEL.name,
|
||||
channelId: channel.id,
|
||||
channelName: channel.name,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* A workspace connected *and* a channel on record. Anything the API refuses
|
||||
* until a channel exists (the connection check) needs this fixture.
|
||||
* The same tenant with a destination channel already on record: the state a
|
||||
* second visit starts from.
|
||||
*/
|
||||
export const configuredSlackFixture = (
|
||||
export const slackFixtureWithDefaultChannel = (
|
||||
channel: SlackChannelFixture = SLACK_PUBLIC_CHANNEL,
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({ install: configuredInstall(), ...overrides });
|
||||
connectedSlackFixture({ install: configuredInstall(channel), ...overrides });
|
||||
|
||||
/**
|
||||
* The same finished setup, with a check time no parser can read: a zero date
|
||||
@@ -225,3 +431,61 @@ export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
|
||||
connectionLastCheckedAt: "0000-00-00T00:00:00Z",
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The first cursor page is served and Slack rate limits the second: what is
|
||||
* already read stays usable, the refusal only says why the list is short.
|
||||
*/
|
||||
export const partiallyReadSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
|
||||
channelsRefusalFromCursor: SLACK_CHANNELS_PAGE_SIZE,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A workspace connected *and* a channel on record. Anything the API refuses
|
||||
* until a channel exists (the connection check) needs this fixture.
|
||||
*/
|
||||
export const configuredSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect removes the row but cannot revoke at
|
||||
* Slack — the outcome the user has to finish by hand in the workspace.
|
||||
*/
|
||||
export const revokeFailureSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
revocation: { revoked: false },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected tenant whose disconnect answers a plain `204` with no body: the
|
||||
* row is gone and the revocation is unreported.
|
||||
*/
|
||||
export const unreportedRevocationSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
connectedSlackFixture({
|
||||
revocation: { revoked: null },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/**
|
||||
* A connected tenant whose token has been revoked at Slack: the row still says
|
||||
* connected until a check runs, and the check is what surfaces it.
|
||||
*/
|
||||
export const revokedTokenSlackFixture = (
|
||||
overrides: Partial<SlackFixture> = {},
|
||||
): SlackFixture =>
|
||||
configuredSlackFixture({
|
||||
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -18,10 +18,11 @@ import {
|
||||
SLACK_INTEGRATION_ID,
|
||||
SLACK_INVALID_CODE_DETAIL,
|
||||
SLACK_NO_CHANNEL_DETAIL,
|
||||
SLACK_RATE_LIMITED_DETAIL,
|
||||
SLACK_NO_DEFAULT_CHANNEL_DETAIL,
|
||||
SLACK_RATE_LIMITED_REFUSAL,
|
||||
SLACK_REFUSED_STATE_DETAIL,
|
||||
SLACK_RETRY_AFTER_SECONDS,
|
||||
SLACK_UNCONFIGURED_DETAIL,
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UPSTREAM_DETAIL,
|
||||
SLACK_UPSTREAM_ERROR_CODE,
|
||||
SLACK_WORKSPACE_CONFLICT_CODE,
|
||||
@@ -30,12 +31,17 @@ import type {
|
||||
SlackExchangeOutcome,
|
||||
SlackFixture,
|
||||
SlackInstallFixture,
|
||||
SlackRefusalFixture,
|
||||
} 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-";
|
||||
const TEST_MESSAGE_TASK_PREFIX = "slack-test-message-task-";
|
||||
|
||||
/** Opaque to the UI, which only ever follows `links.next` (design D6). */
|
||||
const CHANNEL_CURSOR_PARAM = "page[cursor]";
|
||||
|
||||
/**
|
||||
* `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even
|
||||
@@ -52,6 +58,21 @@ const errorBody = (detail: string, status: number, code?: string) => ({
|
||||
],
|
||||
});
|
||||
|
||||
/**
|
||||
* Answer a fixture's refusal as the API would: its own status, its `code`
|
||||
* when it names one, and `Retry-After` only where the status carries a wait.
|
||||
*/
|
||||
const refuse = (refusal: SlackRefusalFixture) =>
|
||||
HttpResponse.json(
|
||||
errorBody(refusal.detail, refusal.status, refusal.code ?? undefined),
|
||||
{
|
||||
status: refusal.status,
|
||||
...(refusal.retryAfterSeconds === null
|
||||
? {}
|
||||
: { headers: { "Retry-After": String(refusal.retryAfterSeconds) } }),
|
||||
},
|
||||
);
|
||||
|
||||
const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
|
||||
team_id: workspace.teamId,
|
||||
team_name: workspace.teamName,
|
||||
@@ -119,11 +140,7 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
status: 503,
|
||||
});
|
||||
|
||||
const rateLimited = () =>
|
||||
HttpResponse.json(errorBody(SLACK_RATE_LIMITED_DETAIL, 429), {
|
||||
status: 429,
|
||||
headers: { "Retry-After": String(SLACK_RETRY_AFTER_SECONDS) },
|
||||
});
|
||||
const rateLimited = () => refuse(SLACK_RATE_LIMITED_REFUSAL);
|
||||
|
||||
/** A `502` per the contract's taxonomy: a server fault, not a Slack state. */
|
||||
const upstreamError = () =>
|
||||
@@ -241,6 +258,16 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
),
|
||||
|
||||
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
|
||||
// The test message settles as its own task (design D9).
|
||||
if (params.taskId.startsWith(TEST_MESSAGE_TASK_PREFIX)) {
|
||||
const { accepted, error } = fx.testMessage;
|
||||
return HttpResponse.json(
|
||||
taskResource(params.taskId, accepted ? "completed" : "failed", {
|
||||
error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const { connected, error } = fx.connection;
|
||||
if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) {
|
||||
install.connected = connected;
|
||||
@@ -250,5 +277,105 @@ export const handlersForSlack = (fx: SlackFixture) => {
|
||||
taskResource(params.taskId, "completed", { connected, error }),
|
||||
);
|
||||
}),
|
||||
|
||||
// --- Channels ----------------------------------------------------------
|
||||
http.get<{ id: string }>(
|
||||
`${API}/integrations/:id/slack/channels`,
|
||||
({ params, request }) => {
|
||||
// The UI follows `links.next` opaquely, so the cursor's shape is this
|
||||
// fixture's business alone. Read first: the page decides the refusal.
|
||||
const cursor = Number(
|
||||
new URL(request.url).searchParams.get(CHANNEL_CURSOR_PARAM) ?? "0",
|
||||
);
|
||||
|
||||
// An endpoint-specific refusal wins over the blanket rate limiting,
|
||||
// and applies from the named cursor, so a partial read is expressible.
|
||||
if (
|
||||
fx.channelsRefusal &&
|
||||
cursor >= (fx.channelsRefusalFromCursor ?? 0)
|
||||
) {
|
||||
return refuse(fx.channelsRefusal);
|
||||
}
|
||||
if (fx.rateLimited) return rateLimited();
|
||||
|
||||
const nextCursor = cursor + fx.channelsPageSize;
|
||||
const page = fx.channels.slice(cursor, nextCursor);
|
||||
const hasMore = nextCursor < fx.channels.length;
|
||||
|
||||
return HttpResponse.json({
|
||||
data: page.map((channel) => ({
|
||||
type: "slack-channels",
|
||||
id: channel.id,
|
||||
attributes: { name: channel.name, is_private: channel.isPrivate },
|
||||
})),
|
||||
links: {
|
||||
next: hasMore
|
||||
? `${API}/integrations/${params.id}/slack/channels` +
|
||||
`?${CHANNEL_CURSOR_PARAM}=${nextCursor}`
|
||||
: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
/**
|
||||
* The generic PATCH. The UI submits only `channel_id`; the name is derived
|
||||
* from it here, as the API derives it from Slack (design D6).
|
||||
*/
|
||||
http.patch(`${API}/integrations/:id`, async ({ request }) => {
|
||||
const body = (await request.json().catch(() => null)) as {
|
||||
data?: { attributes?: { configuration?: { channel_id?: string } } };
|
||||
} | null;
|
||||
const channelId = body?.data?.attributes?.configuration?.channel_id;
|
||||
const channel = fx.channels.find((c) => c.id === channelId);
|
||||
|
||||
if (!install) {
|
||||
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
|
||||
}
|
||||
// Checked before the id lookup: the picker did offer this channel, and
|
||||
// Slack refused it anyway when the API validated it.
|
||||
if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal);
|
||||
if (!channel) {
|
||||
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
install.workspace.channelId = channel.id;
|
||||
install.workspace.channelName = channel.name;
|
||||
return HttpResponse.json({ data: integrationResource(install) });
|
||||
}),
|
||||
|
||||
// --- Test message ------------------------------------------------------
|
||||
http.post<{ id: string }>(
|
||||
`${API}/integrations/:id/slack/test-message`,
|
||||
({ params }) => {
|
||||
if (!install?.workspace.channelId) {
|
||||
return HttpResponse.json(
|
||||
errorBody(SLACK_NO_DEFAULT_CHANNEL_DETAIL, 400),
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
return HttpResponse.json(
|
||||
taskResource(
|
||||
`${TEST_MESSAGE_TASK_PREFIX}${params.id}`,
|
||||
"available",
|
||||
null,
|
||||
),
|
||||
{ status: 202 },
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 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(`${API}/integrations/:id`, () => {
|
||||
install = null;
|
||||
if (fx.revocation.revoked === null) {
|
||||
return new HttpResponse(null, { status: 204 });
|
||||
}
|
||||
return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } });
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -388,6 +388,7 @@ export const pollConnectionTestStatus = async (
|
||||
revalidatePath("/integrations/amazon-s3");
|
||||
revalidatePath("/integrations/aws-security-hub");
|
||||
revalidatePath("/integrations/jira");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
if ("error" in pollResult) {
|
||||
return { success: false, error: pollResult.error };
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
/**
|
||||
* Sentry reporting for the Slack OAuth actions. A capture leaves no mark on the
|
||||
* DOM, so it cannot be covered from `slack-page.integration.test.tsx`.
|
||||
* What the Slack actions do off the DOM, which
|
||||
* `slack-page.integration.test.tsx` cannot cover: which failures reach Sentry,
|
||||
* and the URLs the channel listing's cursor pagination follows.
|
||||
*/
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SLACK_UNREADABLE_RESULT_MESSAGE } from "@/lib/integrations/slack-errors";
|
||||
import {
|
||||
SLACK_ERROR_CODE,
|
||||
SLACK_ERROR_MESSAGES,
|
||||
SLACK_GENERIC_ERROR_MESSAGE,
|
||||
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
SLACK_UNREADABLE_RESULT_MESSAGE,
|
||||
} from "@/lib/integrations/slack-errors";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
|
||||
@@ -30,6 +38,8 @@ const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: captureExceptionMock,
|
||||
captureMessage: captureMessageMock,
|
||||
// The task poll leaves breadcrumbs on every read it makes.
|
||||
addBreadcrumb: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
@@ -50,7 +60,14 @@ vi.mock("@/lib", () => ({
|
||||
parseStringify: (value: unknown) => value,
|
||||
}));
|
||||
|
||||
import { exchangeSlackOAuthCode, getSlackAuthorizeUrl } from "./slack";
|
||||
import {
|
||||
disconnectSlackIntegration,
|
||||
exchangeSlackOAuthCode,
|
||||
getSlackAuthorizeUrl,
|
||||
getSlackChannels,
|
||||
sendSlackTestMessage,
|
||||
setSlackDefaultChannel,
|
||||
} from "./slack";
|
||||
|
||||
/** The status the contract reserves for an upstream Slack failure. */
|
||||
const UPSTREAM_STATUS = 502;
|
||||
@@ -248,10 +265,19 @@ describe("exchangeSlackOAuthCode result shape", () => {
|
||||
["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));
|
||||
@@ -275,3 +301,482 @@ describe("exchangeSlackOAuthCode result shape", () => {
|
||||
expect(await exchange()).toEqual({ integration: INTEGRATION });
|
||||
});
|
||||
});
|
||||
|
||||
/** The shape the API's integration ids have, which is the only shape accepted. */
|
||||
const SLACK_INTEGRATION_ID = "b2c7fd0a-3e51-4d8f-9a6c-1f0e2d3c4b5a";
|
||||
|
||||
const CHANNELS_URL =
|
||||
`https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}` +
|
||||
"/slack/channels";
|
||||
|
||||
const FIRST_CHANNEL = { id: "C0123AB", name: "security" };
|
||||
const SECOND_CHANNEL = { id: "C0789EF", name: "platform" };
|
||||
|
||||
const channelPage = (
|
||||
channel: { id: string; name: string },
|
||||
next: string | null,
|
||||
) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{
|
||||
type: "slack-channels",
|
||||
id: channel.id,
|
||||
attributes: { name: channel.name, is_private: false },
|
||||
},
|
||||
],
|
||||
links: { next },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
|
||||
);
|
||||
|
||||
const channelOption = (channel: { id: string; name: string }) => ({
|
||||
id: channel.id,
|
||||
name: channel.name,
|
||||
is_private: false,
|
||||
});
|
||||
|
||||
const requestedUrls = (): string[] =>
|
||||
fetchMock.mock.calls.map(([url]) => String(url));
|
||||
|
||||
/**
|
||||
* `MAX_CHANNEL_PAGES` in the action, which a `"use server"` module cannot
|
||||
* export: only async functions may leave one.
|
||||
*/
|
||||
const MAX_CHANNEL_PAGES = 20;
|
||||
|
||||
const channelOptions = (count: number) =>
|
||||
Array.from({ length: count }, () => channelOption(FIRST_CHANNEL));
|
||||
|
||||
/** What a `429` carrying `Retry-After: 30` is turned into. */
|
||||
const RATE_LIMITED_MESSAGE =
|
||||
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.";
|
||||
|
||||
/** A dead grant as the API reports it: reason in `code`, prose in `detail`. */
|
||||
const TOKEN_EXPIRED_CODE = "token_expired";
|
||||
const TOKEN_EXPIRED_DETAIL = "Slack refused the request: token_expired.";
|
||||
const TOKEN_EXPIRED_MESSAGE =
|
||||
"Prowler's Slack credential has expired. Connect the workspace again to restore access.";
|
||||
|
||||
describe("getSlackChannels", () => {
|
||||
it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => {
|
||||
// The link is opaque (design D6), so the API may answer with the cursor
|
||||
// alone; resolved against the API root it loses the listing's own path.
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(channelPage(SECOND_CHANNEL, null));
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(requestedUrls()).toEqual([
|
||||
CHANNELS_URL,
|
||||
`${CHANNELS_URL}?page[cursor]=2`,
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL), channelOption(SECOND_CHANNEL)],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
shape: "an absolute",
|
||||
next: "https://evil.test/api/v1/integrations/x/slack/channels?cursor=2",
|
||||
},
|
||||
{ shape: "a protocol-relative", next: "//evil.test/api/v1/channels?c=2" },
|
||||
])(
|
||||
"stops at $shape off-origin `next` rather than sending the tenant's token to it",
|
||||
async ({ next }) => {
|
||||
// `fetch` strips the tenant's `Authorization` on a redirect that leaves
|
||||
// the origin, but not on a hop the UI makes itself.
|
||||
fetchMock.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, next));
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(requestedUrls()).toEqual([CHANNELS_URL]);
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("answers an unreadable page as no channels rather than parser prose", async () => {
|
||||
fetchMock.mockResolvedValueOnce(unreadableOk(HTML_INTERSTITIAL));
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ channels: [] });
|
||||
expectNoParserProse(result);
|
||||
});
|
||||
|
||||
it("says the list is short of the workspace when the page budget runs out", async () => {
|
||||
// The budget exists because `conversations.list` is tier 2 and a workspace
|
||||
// can outgrow it (design.md, Risks). A fresh `Response` per call: one
|
||||
// instance is already consumed on its second read.
|
||||
fetchMock.mockImplementation(() =>
|
||||
Promise.resolve(channelPage(FIRST_CHANNEL, "?page[cursor]=next")),
|
||||
);
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
|
||||
expect(result).toEqual({
|
||||
channels: channelOptions(MAX_CHANNEL_PAGES),
|
||||
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
it("says nothing about a short list for a workspace that just fits the budget", async () => {
|
||||
let page = 0;
|
||||
fetchMock.mockImplementation(() => {
|
||||
page += 1;
|
||||
return Promise.resolve(
|
||||
channelPage(
|
||||
FIRST_CHANNEL,
|
||||
page < MAX_CHANNEL_PAGES ? `?page[cursor]=${page}` : null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
|
||||
expect(result).toEqual({ channels: channelOptions(MAX_CHANNEL_PAGES) });
|
||||
expect(result).not.toHaveProperty("incomplete");
|
||||
});
|
||||
|
||||
it("keeps the pages it read when a later one is refused, saying why the list stops", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(rateLimitedResponse());
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
// A rate limit says nothing about the grant, so the truncation names none.
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: RATE_LIMITED_MESSAGE,
|
||||
code: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("names the reason a later page was refused, not only the wording", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
|
||||
.mockResolvedValueOnce(
|
||||
errorResponse(400, TOKEN_EXPIRED_DETAIL, TOKEN_EXPIRED_CODE),
|
||||
);
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
channels: [channelOption(FIRST_CHANNEL)],
|
||||
incomplete: TOKEN_EXPIRED_MESSAGE,
|
||||
code: TOKEN_EXPIRED_CODE,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a refusal on the first page as a failure, having nothing to show", async () => {
|
||||
fetchMock.mockResolvedValueOnce(rateLimitedResponse());
|
||||
|
||||
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE, code: null });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A `2xx` whose body is not JSON:API: an empty answer, or the HTML a proxy or
|
||||
* WAF puts in front of one. The raw `SyntaxError` survives
|
||||
* `sanitizeErrorMessage` (V8 truncates the snippet to ten characters, so its
|
||||
* `<!doctype html>` branch never matches) and would be shown verbatim.
|
||||
*/
|
||||
const HTML_INTERSTITIAL =
|
||||
"<!DOCTYPE html><html><body><h1>Checking your browser</h1></body></html>";
|
||||
|
||||
const unreadableOk = (body: string) =>
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": body ? "text/html" : "application/json" },
|
||||
});
|
||||
|
||||
/** V8's parser wording, which no user should ever be shown. */
|
||||
const PARSER_PROSE = /unexpected (token|end of json)|not valid json/i;
|
||||
|
||||
const expectNoParserProse = (result: unknown) => {
|
||||
const message = (result as { error?: string }).error ?? "";
|
||||
expect(message).not.toMatch(PARSER_PROSE);
|
||||
};
|
||||
|
||||
const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`;
|
||||
|
||||
const saveChannel = () =>
|
||||
setSlackDefaultChannel(SLACK_INTEGRATION_ID, FIRST_CHANNEL.id);
|
||||
|
||||
const expectIntegrationsRevalidated = () => {
|
||||
expect(vi.mocked(revalidatePath).mock.calls).toEqual([
|
||||
["/integrations"],
|
||||
["/integrations/slack"],
|
||||
]);
|
||||
};
|
||||
|
||||
describe("setSlackDefaultChannel", () => {
|
||||
it("returns the saved integration and revalidates the pages listing it", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
type: "integrations",
|
||||
id: SLACK_INTEGRATION_ID,
|
||||
attributes: {
|
||||
integration_type: "slack",
|
||||
configuration: {
|
||||
channel_id: FIRST_CHANNEL.id,
|
||||
channel_name: FIRST_CHANNEL.name,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await saveChannel();
|
||||
|
||||
expect(requestedUrls()).toEqual([INTEGRATION_URL]);
|
||||
expect(result).toMatchObject({
|
||||
integration: {
|
||||
attributes: { configuration: { channel_name: FIRST_CHANNEL.name } },
|
||||
},
|
||||
});
|
||||
expectIntegrationsRevalidated();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ shape: "empty", body: "" },
|
||||
{ shape: "an HTML interstitial", body: HTML_INTERSTITIAL },
|
||||
])(
|
||||
"answers a $shape `200` as an unread result, not as a failed save",
|
||||
async ({ body }) => {
|
||||
fetchMock.mockResolvedValueOnce(unreadableOk(body));
|
||||
|
||||
const result = await saveChannel();
|
||||
|
||||
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
|
||||
expectNoParserProse(result);
|
||||
// The API recorded the channel before answering, so both pages refresh.
|
||||
expectIntegrationsRevalidated();
|
||||
},
|
||||
);
|
||||
|
||||
// The caller reads `integration.attributes.configuration`, so a shallower
|
||||
// guard lets the miss surface later as the manager's generic catch.
|
||||
it.each([
|
||||
{ shape: "no `data`", body: {} },
|
||||
{ shape: "a null `data`", body: { data: null } },
|
||||
{ shape: "a `data` with no configuration", body: { data: {} } },
|
||||
])(
|
||||
"answers a `200` carrying $shape as an unread result",
|
||||
async ({ body }) => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await saveChannel();
|
||||
|
||||
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
|
||||
expectNoParserProse(result);
|
||||
expectIntegrationsRevalidated();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** The `202` that hands back the task the post is reported on (design D9). */
|
||||
const TEST_MESSAGE_TASK_ID = "5f8b1c2d-7e64-4a90-8c31-2b7d6e5f4a90";
|
||||
|
||||
const testMessageAccepted = () =>
|
||||
new Response(JSON.stringify({ data: { id: TEST_MESSAGE_TASK_ID } }), {
|
||||
status: 202,
|
||||
headers: { "content-type": "application/vnd.api+json" },
|
||||
});
|
||||
|
||||
/** The task read the poll makes, already settled on its first look. */
|
||||
const settledTask = (state: string, result: unknown) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
type: "tasks",
|
||||
id: TEST_MESSAGE_TASK_ID,
|
||||
attributes: { state, result },
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
|
||||
);
|
||||
|
||||
describe("sendSlackTestMessage", () => {
|
||||
it("answers an unreadable `202` as no task started, not as parser prose", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(HTML_INTERSTITIAL, {
|
||||
status: 202,
|
||||
headers: { "content-type": "text/html" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ error: "Slack did not start the test message." });
|
||||
expectNoParserProse(result);
|
||||
});
|
||||
|
||||
it("wraps a reason it has no copy for instead of answering with the bare token", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(testMessageAccepted())
|
||||
.mockResolvedValueOnce(settledTask("failed", { error: "is_archived" }));
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
const error = (result as { error?: string }).error ?? "";
|
||||
expect(error).toMatch(/Slack refused the message/);
|
||||
expect(error).toContain("is_archived");
|
||||
expect(error).not.toBe("is_archived");
|
||||
});
|
||||
|
||||
it("keeps Prowler's own wording for a reason the mapping covers", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(testMessageAccepted())
|
||||
.mockResolvedValueOnce(
|
||||
settledTask("failed", { error: "not_in_channel" }),
|
||||
);
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({
|
||||
error: SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL],
|
||||
code: SLACK_ERROR_CODE.NOT_IN_CHANNEL,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a reason the task worded itself as the prose it is", async () => {
|
||||
// Not token-shaped, so nothing is wrapped around it.
|
||||
const prose = "Slack rejected the message: the channel is archived.";
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(testMessageAccepted())
|
||||
.mockResolvedValueOnce(settledTask("failed", { error: prose }));
|
||||
|
||||
const result = await sendSlackTestMessage(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(result).toEqual({ error: prose });
|
||||
});
|
||||
});
|
||||
|
||||
/** The calls whose only failure path is one line of copy. */
|
||||
const COPY_ONLY_ACTIONS = [
|
||||
{
|
||||
name: "getSlackChannels",
|
||||
call: (id: string) => getSlackChannels(id),
|
||||
},
|
||||
{
|
||||
name: "setSlackDefaultChannel",
|
||||
call: (id: string) => setSlackDefaultChannel(id, FIRST_CHANNEL.id),
|
||||
},
|
||||
{
|
||||
name: "sendSlackTestMessage",
|
||||
call: (id: string) => sendSlackTestMessage(id),
|
||||
},
|
||||
{
|
||||
name: "disconnectSlackIntegration",
|
||||
call: (id: string) => disconnectSlackIntegration(id),
|
||||
},
|
||||
];
|
||||
|
||||
const rateLimitedResponse = () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
errors: [{ status: "429", detail: "Slack is rate limiting." }],
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"content-type": "application/vnd.api+json",
|
||||
"Retry-After": "30",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
|
||||
it("reports an upstream Slack failure and still answers in the same words", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL),
|
||||
);
|
||||
|
||||
const result = await call(SLACK_INTEGRATION_ID);
|
||||
|
||||
// Once, not twice: `handleApiResponse` reports and throws, and the action's
|
||||
// catch sees the mark.
|
||||
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
|
||||
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
tags: {
|
||||
api_error: true,
|
||||
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
|
||||
error_type: SentryErrorType.SERVER_ERROR,
|
||||
status_code: String(UPSTREAM_STATUS),
|
||||
},
|
||||
});
|
||||
expect(captureMessageMock).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({ error: UPSTREAM_DETAIL });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
status: 503,
|
||||
why: "Slack being unavailable, not a fault",
|
||||
response: () => errorResponse(503, "Slack is unavailable."),
|
||||
expected: "Slack is unavailable.",
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
why: "a wait, not a fault",
|
||||
response: rateLimitedResponse,
|
||||
expected:
|
||||
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.",
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
why: "a refusal the API meant to give",
|
||||
response: () => errorResponse(400, "No default channel is set."),
|
||||
expected: "No default channel is set.",
|
||||
},
|
||||
])("reports nothing for a $status: that is $why", async (refusal) => {
|
||||
fetchMock.mockResolvedValue(refusal.response());
|
||||
|
||||
const result = await call(SLACK_INTEGRATION_ID);
|
||||
|
||||
expect(captureExceptionMock).not.toHaveBeenCalled();
|
||||
expect(captureMessageMock).not.toHaveBeenCalled();
|
||||
// None of these refusals names a `code`.
|
||||
expect(result).toEqual({ error: refusal.expected, code: null });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The integration id is interpolated into every one of these URLs, so a
|
||||
* malformed one is refused before the request is built.
|
||||
*/
|
||||
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
|
||||
it.each(["../../users", "not-a-uuid", ""])(
|
||||
"asks the API nothing when the integration id is %o",
|
||||
async (id) => {
|
||||
const result = await call(id);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ error: SLACK_GENERIC_ERROR_MESSAGE });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,16 +3,24 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
|
||||
import {
|
||||
readSlackFailure,
|
||||
SLACK_GENERIC_ERROR_MESSAGE,
|
||||
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
|
||||
SLACK_REASON_TOKEN,
|
||||
SLACK_UNREADABLE_RESULT_MESSAGE,
|
||||
slackErrorMessage,
|
||||
slackRateLimitMessage,
|
||||
slackUnknownReasonMessage,
|
||||
} from "@/lib/integrations/slack-errors";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import type { IntegrationProps } from "@/types/integrations";
|
||||
import {
|
||||
INTEGRATION_TYPE,
|
||||
type IntegrationProps,
|
||||
type SlackChannelOption,
|
||||
} from "@/types/integrations";
|
||||
|
||||
interface SlackUnavailable {
|
||||
unavailable: true;
|
||||
@@ -35,6 +43,14 @@ interface SlackUnconfirmed {
|
||||
|
||||
interface SlackActionError {
|
||||
error: string;
|
||||
/**
|
||||
* The refusal's `code`, when it named one, alongside the copy. A caller reads
|
||||
* it to recognise a class of failure the wording cannot be pattern-matched
|
||||
* for — a Slack grant that has stopped working, which the contract allows
|
||||
* from any of these calls (Cross-cutting) and is recovered from by
|
||||
* reconnecting rather than by retrying.
|
||||
*/
|
||||
code?: string | null;
|
||||
}
|
||||
|
||||
interface SlackAuthorizeUrl {
|
||||
@@ -57,6 +73,17 @@ const slackExchangeInputSchema = z.object({
|
||||
state: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* SSRF guard: the integration id is interpolated into the request URL, so only
|
||||
* the shape the API's ids have reaches it.
|
||||
*/
|
||||
const integrationIdSchema = z.uuid();
|
||||
|
||||
const parseIntegrationId = (integrationId: string): string | null => {
|
||||
const parsed = integrationIdSchema.safeParse(integrationId);
|
||||
return parsed.success ? parsed.data : null;
|
||||
};
|
||||
|
||||
interface SlackExchangeSuccess {
|
||||
integration: IntegrationProps;
|
||||
}
|
||||
@@ -98,23 +125,36 @@ const isSlackAuthorizeUrl = (value: string): boolean => {
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
* 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, attributes } = value as Record<string, unknown>;
|
||||
const { id, type, attributes } = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof id !== "string" ||
|
||||
id === "" ||
|
||||
type !== INTEGRATIONS_RESOURCE_TYPE ||
|
||||
typeof attributes !== "object" ||
|
||||
attributes === null ||
|
||||
Array.isArray(attributes)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
typeof id === "string" &&
|
||||
typeof attributes === "object" &&
|
||||
attributes !== null &&
|
||||
!Array.isArray(attributes)
|
||||
(attributes as Record<string, unknown>).integration_type ===
|
||||
INTEGRATION_TYPE.SLACK
|
||||
);
|
||||
};
|
||||
|
||||
@@ -140,7 +180,35 @@ const failureFrom = async (
|
||||
};
|
||||
}
|
||||
|
||||
return { error: slackErrorMessage(failure, fallback) };
|
||||
return { error: slackErrorMessage(failure, fallback), code: failure.code };
|
||||
};
|
||||
|
||||
/**
|
||||
* `failureFrom` flattened to one refusal, for the calls whose only outcome is
|
||||
* "it did not work". Rate limiting keeps its own wording: `conversations.list`
|
||||
* is Slack tier 2, so a `429` shows up here (contract, Errors) and the wait it
|
||||
* names is the useful part.
|
||||
*/
|
||||
const refusalFrom = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<SlackActionError> => {
|
||||
// Same 5xx handling as `failureFrom`, `503` excepted: here too it means Slack
|
||||
// is unavailable. Must run before `readSlackFailure`: a body can only be read
|
||||
// once.
|
||||
if (response.status >= 500 && response.status !== 503) {
|
||||
await handleApiResponse(response);
|
||||
}
|
||||
|
||||
const failure = await readSlackFailure(response);
|
||||
|
||||
return {
|
||||
error:
|
||||
failure.status === RATE_LIMITED_STATUS
|
||||
? slackRateLimitMessage(failure.retryAfterSeconds)
|
||||
: slackErrorMessage(failure, fallback),
|
||||
code: failure.code,
|
||||
};
|
||||
};
|
||||
|
||||
/** Mint an OAuth state and get the consent URL. Creates no integration. */
|
||||
@@ -234,3 +302,339 @@ export const exchangeSlackOAuthCode = async (
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackChannelsSuccess {
|
||||
channels: SlackChannelOption[];
|
||||
/**
|
||||
* Present when these channels are only part of the workspace's, carrying the
|
||||
* sentence that says why: a partial read is a success, so the caller renders
|
||||
* the picker *and* the reason.
|
||||
*/
|
||||
incomplete?: string;
|
||||
/**
|
||||
* The `code` of the refusal that cut the read short, when it named one. A
|
||||
* grant that has stopped working refuses the second cursor page exactly as it
|
||||
* refuses the first, and a caller reading only the failure path would never
|
||||
* hear about it.
|
||||
*/
|
||||
code?: string | null;
|
||||
}
|
||||
|
||||
export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError;
|
||||
|
||||
/**
|
||||
* Cursor pages followed before giving up: `conversations.list` is a tier-2,
|
||||
* rate-limited Slack call (design.md, Risks), so the aggregation is bounded
|
||||
* rather than open-ended.
|
||||
*/
|
||||
const MAX_CHANNEL_PAGES = 20;
|
||||
|
||||
/**
|
||||
* Every channel Prowler can post to in the connected workspace — the picker's
|
||||
* options.
|
||||
*
|
||||
* The durable primitive, not the channel stored on the integration (design D6):
|
||||
* a consumer needing a per-rule channel reads the same endpoint. `links.next`
|
||||
* is followed opaquely — the contract does not pin the cursor parameter naming,
|
||||
* so the UI never builds one of its own. An early stop that still read
|
||||
* something reports through `incomplete`, not as a failure.
|
||||
*/
|
||||
export const getSlackChannels = async (
|
||||
integrationId: string,
|
||||
): Promise<SlackChannelsResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const channels: SlackChannelOption[] = [];
|
||||
|
||||
const listing = new URL(`${apiBaseUrl}/integrations/${id}/slack/channels`);
|
||||
let next: string | null = listing.toString();
|
||||
let incomplete: string | null = null;
|
||||
|
||||
try {
|
||||
for (let page = 0; next && page < MAX_CHANNEL_PAGES; page += 1) {
|
||||
const current: string = next;
|
||||
const response: Response = await fetch(current, {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const refusal = await refusalFrom(
|
||||
response,
|
||||
`Unable to read the workspace's channels: ${response.statusText}`,
|
||||
);
|
||||
|
||||
return channels.length > 0
|
||||
? { channels, incomplete: refusal.error, code: refusal.code }
|
||||
: refusal;
|
||||
}
|
||||
|
||||
// A page that is not JSON reads as no channels, rather than throwing a
|
||||
// parser message the user would be shown verbatim.
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
for (const resource of body?.data ?? []) {
|
||||
channels.push({
|
||||
id: resource?.id,
|
||||
name: resource?.attributes?.name ?? "",
|
||||
is_private: Boolean(resource?.attributes?.is_private),
|
||||
});
|
||||
}
|
||||
|
||||
const rawNext = body?.links?.next;
|
||||
const candidate =
|
||||
typeof rawNext === "string" && rawNext.length > 0
|
||||
? new URL(rawNext, current)
|
||||
: null;
|
||||
// Resolved against the page it arrived on, so a cursor-only `next` keeps
|
||||
// this listing's path. Followed only while it stays on the listing's
|
||||
// origin: every page is fetched with the tenant's token, and an
|
||||
// off-origin hop made here would carry it along.
|
||||
if (candidate === null) {
|
||||
next = null;
|
||||
} else if (candidate.origin === listing.origin) {
|
||||
next = candidate.toString();
|
||||
} else {
|
||||
next = null;
|
||||
incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
|
||||
}
|
||||
}
|
||||
|
||||
// A link still waiting when the budget ran out. Checked rather than assumed
|
||||
// from the page count: a workspace of exactly `MAX_CHANNEL_PAGES` pages was
|
||||
// read to the end.
|
||||
if (next) incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
|
||||
|
||||
return incomplete === null ? { channels } : { channels, incomplete };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackDefaultChannelSuccess {
|
||||
integration: IntegrationProps;
|
||||
}
|
||||
|
||||
export type SlackDefaultChannelResult =
|
||||
| SlackDefaultChannelSuccess
|
||||
| SlackActionError;
|
||||
|
||||
/**
|
||||
* Record the channel Prowler posts to, on the generic integration endpoint.
|
||||
*
|
||||
* A Slack action despite the generic `PATCH`: `channel_not_found` and
|
||||
* `not_in_channel` carry the same `detail`, so only `code` tells them apart,
|
||||
* and the generic action reads `detail` alone. Only `channel_id` travels — the
|
||||
* API derives `channel_name` server-side (design D6).
|
||||
*/
|
||||
export const setSlackDefaultChannel = async (
|
||||
integrationId: string,
|
||||
channelId: string,
|
||||
): Promise<SlackDefaultChannelResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
type: "integrations",
|
||||
id,
|
||||
attributes: {
|
||||
integration_type: "slack",
|
||||
configuration: { channel_id: channelId },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip
|
||||
// this `catch`.
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to save the destination channel: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => null);
|
||||
|
||||
// Before the guard and on both paths: the save happened, so a cache still
|
||||
// holding the previous channel would keep showing it.
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
// Guarded as deep as the caller reads: it names the saved channel from
|
||||
// `attributes.configuration`.
|
||||
if (!body?.data?.attributes?.configuration) {
|
||||
return { error: SLACK_UNREADABLE_RESULT_MESSAGE };
|
||||
}
|
||||
|
||||
return { integration: parseStringify(body.data) as IntegrationProps };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
interface SlackTestMessageSuccess {
|
||||
sent: true;
|
||||
}
|
||||
|
||||
export type SlackTestMessageResult = SlackTestMessageSuccess | SlackActionError;
|
||||
|
||||
interface SlackTestMessageTaskResult {
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
const TEST_MESSAGE_POLL = { maxAttempts: 20, delayMs: 3000 } as const;
|
||||
|
||||
/**
|
||||
* Post the test message to the integration's default channel.
|
||||
*
|
||||
* Async on the API's side — `202` plus a Task (design D9) — so this polls the
|
||||
* same task machinery the connection test uses. A `400` means no default
|
||||
* channel is recorded.
|
||||
*/
|
||||
export const sendSlackTestMessage = async (
|
||||
integrationId: string,
|
||||
): Promise<SlackTestMessageResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}/slack/test-message`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "POST", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to send the test message: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
// As above: an unreadable `202` is "no task to follow", not a parser
|
||||
// message.
|
||||
const body = await response.json().catch(() => null);
|
||||
const taskId = body?.data?.id;
|
||||
|
||||
if (!taskId) {
|
||||
return { error: "Slack did not start the test message." };
|
||||
}
|
||||
|
||||
const settled = await pollTaskUntilSettled<SlackTestMessageTaskResult>(
|
||||
taskId,
|
||||
TEST_MESSAGE_POLL,
|
||||
);
|
||||
|
||||
if (!settled.ok) {
|
||||
return { error: settled.error };
|
||||
}
|
||||
|
||||
// Slack's refusal travels in the task result, not in an HTTP error: the
|
||||
// post happens after the `202`. A known code gets Prowler's own wording, a
|
||||
// code-shaped reason is wrapped in one (contract, test-message), and prose
|
||||
// is shown as it arrived.
|
||||
const reason = settled.result?.error?.trim();
|
||||
if (reason) {
|
||||
return SLACK_REASON_TOKEN.test(reason)
|
||||
? {
|
||||
error: slackErrorMessage(
|
||||
{ code: reason },
|
||||
slackUnknownReasonMessage(reason),
|
||||
),
|
||||
// A dead grant can surface here too, so the reason travels on as
|
||||
// a `code`, not only as its sentence.
|
||||
code: reason,
|
||||
}
|
||||
: { error: reason };
|
||||
}
|
||||
if (settled.state !== "completed") {
|
||||
return { error: "Slack did not accept the test message." };
|
||||
}
|
||||
|
||||
return { sent: true };
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* What the API reports about revoking Prowler's token at Slack: one boolean in
|
||||
* `meta`, and nothing else — it sends no reason for a revocation that did not
|
||||
* happen, so there is no field here to hold one.
|
||||
*/
|
||||
export interface SlackRevocation {
|
||||
/**
|
||||
* Whether Slack confirmed the token no longer grants Prowler anything, or
|
||||
* `null` when the response carried no outcome. The contract says the outcome
|
||||
* is always reported, so `null` means the response is wrong, not the
|
||||
* revocation.
|
||||
*/
|
||||
revoked: boolean | null;
|
||||
}
|
||||
|
||||
interface SlackDisconnectSuccess {
|
||||
/** The integration is gone from Prowler, whatever Slack answered. */
|
||||
disconnected: true;
|
||||
revocation: SlackRevocation;
|
||||
}
|
||||
|
||||
export type SlackDisconnectResult = SlackDisconnectSuccess | SlackActionError;
|
||||
|
||||
/**
|
||||
* Disconnect the workspace: `DELETE /integrations/{id}`.
|
||||
*
|
||||
* The generic `deleteIntegration` cannot serve this: it discards the response
|
||||
* body, and the body is the whole point. Revocation at Slack is best-effort —
|
||||
* the row is removed either way and the outcome travels in JSON:API `meta` — so
|
||||
* a caller has to tell "gone and revoked" from "gone, but still installed in
|
||||
* Slack".
|
||||
*
|
||||
* A body without the field (an empty `204`, say) yields `null`, not `false`: an
|
||||
* unreported outcome must not send the user off to clean up Slack, nor be shown
|
||||
* as access revoked.
|
||||
*/
|
||||
export const disconnectSlackIntegration = async (
|
||||
integrationId: string,
|
||||
): Promise<SlackDisconnectResult> => {
|
||||
const id = parseIntegrationId(integrationId);
|
||||
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
|
||||
|
||||
const headers = await getAuthHeaders({ contentType: true });
|
||||
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), { method: "DELETE", headers });
|
||||
|
||||
if (!response.ok) {
|
||||
return await refusalFrom(
|
||||
response,
|
||||
`Unable to disconnect the Slack workspace: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
const meta = body?.meta ?? {};
|
||||
|
||||
revalidatePath("/integrations");
|
||||
revalidatePath("/integrations/slack");
|
||||
|
||||
return {
|
||||
disconnected: true,
|
||||
revocation: {
|
||||
revoked: typeof meta.revoked === "boolean" ? meta.revoked : null,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
|
||||
import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
import { worker } from "@/__tests__/msw/worker";
|
||||
import { render } from "@/__tests__/render-browser";
|
||||
import { setSlackDefaultChannel } from "@/actions/integrations/slack";
|
||||
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
|
||||
|
||||
import { IntegrationsContent } from "../integrations-content";
|
||||
@@ -28,6 +29,27 @@ export const CONNECTION_OUTCOME = {
|
||||
export type ConnectionOutcome =
|
||||
(typeof CONNECTION_OUTCOME)[keyof typeof CONNECTION_OUTCOME];
|
||||
|
||||
export const TEST_MESSAGE_OUTCOME = {
|
||||
SENT: "sent",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
export type TestMessageOutcome =
|
||||
(typeof TEST_MESSAGE_OUTCOME)[keyof typeof TEST_MESSAGE_OUTCOME];
|
||||
|
||||
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
|
||||
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
|
||||
|
||||
export const REVOCATION_OUTCOME = {
|
||||
REVOKED: "revoked",
|
||||
NOT_REVOKED: "not-revoked",
|
||||
/** The answer said nothing either way, so the page claims neither. */
|
||||
UNREPORTED: "unreported",
|
||||
} as const;
|
||||
|
||||
export type RevocationOutcome =
|
||||
(typeof REVOCATION_OUTCOME)[keyof typeof REVOCATION_OUTCOME];
|
||||
|
||||
interface CallbackParams {
|
||||
code?: string;
|
||||
state?: string;
|
||||
@@ -63,7 +85,37 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
window.history.replaceState(null, "", "/integrations/slack");
|
||||
this.wireHandlers();
|
||||
|
||||
render(await SlackIntegrationContent());
|
||||
const readsBefore = this.channelListCallCount;
|
||||
this.mounted = render(await SlackIntegrationContent());
|
||||
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
|
||||
}
|
||||
|
||||
private mounted: ReturnType<typeof render> | null = null;
|
||||
|
||||
/**
|
||||
* Open the management page again, the way a later visit does — the handlers in
|
||||
* place keep serving what the previous visit left behind. Unmounts the previous
|
||||
* render first: two live copies would make every assertion ambiguous.
|
||||
*/
|
||||
async revisit(): Promise<void> {
|
||||
(await this.mounted)?.unmount();
|
||||
const readsBefore = this.channelListCallCount;
|
||||
this.mounted = render(await SlackIntegrationContent());
|
||||
await this.mounted;
|
||||
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the page's server data under the open card, as `revalidatePath` does
|
||||
* after an action: new props, no unmount, so React state survives — unlike
|
||||
* `revisit()`, which re-seeds everything from scratch.
|
||||
*/
|
||||
async refreshPageData(): Promise<void> {
|
||||
const rendered = await this.mounted;
|
||||
if (!rendered) {
|
||||
throw new Error("refreshPageData: the page is not mounted");
|
||||
}
|
||||
await rendered.rerender(await SlackIntegrationContent());
|
||||
}
|
||||
|
||||
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
|
||||
@@ -317,4 +369,441 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
|
||||
offersRetry(): boolean {
|
||||
return this.backLink() !== null || this.offersInstall();
|
||||
}
|
||||
|
||||
// --- Choosing a destination channel --------------------------------------
|
||||
|
||||
/** Channel reads issued — one per cursor page the UI followed. */
|
||||
get channelListCallCount(): number {
|
||||
return this.countRequests("GET", "/slack/channels");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the channel read every connected mount starts, counting from the
|
||||
* reads already issued: one still in flight when the test ends lands in the
|
||||
* middle of the next, against a harness that never asked for it.
|
||||
*/
|
||||
private async waitForChannelsRead(readsBefore: number): Promise<void> {
|
||||
await this.waitFor(
|
||||
() => {
|
||||
const refresh = this.buttonByText(/Refresh channels/);
|
||||
return this.channelListCallCount > readsBefore &&
|
||||
refresh !== null &&
|
||||
!refresh.disabled
|
||||
? true
|
||||
: null;
|
||||
},
|
||||
15000,
|
||||
"the workspace's channels to be read",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the picker and hand back its options. A re-render landing mid-gesture
|
||||
* makes Radix drop the open state, so re-open from the keyboard when nothing
|
||||
* mounted at all.
|
||||
*/
|
||||
private async openChannelPicker(): Promise<HTMLElement[]> {
|
||||
const mounted = (): HTMLElement[] | null => {
|
||||
const options = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
);
|
||||
return options.length > 0 ? options : null;
|
||||
};
|
||||
|
||||
const alreadyOpen = mounted();
|
||||
if (alreadyOpen) return alreadyOpen;
|
||||
|
||||
const trigger = await this.waitFor<HTMLElement>(
|
||||
() => this.q("#slack-channel"),
|
||||
10000,
|
||||
"the channel picker",
|
||||
);
|
||||
|
||||
await this.clickElement(trigger, { fallbackToDomClick: true });
|
||||
|
||||
let options = await this.waitForOrNull(
|
||||
mounted,
|
||||
2000,
|
||||
"the channel options",
|
||||
);
|
||||
if (!options) {
|
||||
await this.user.keyboard("{Enter}");
|
||||
options = await this.waitForOrNull(mounted, 8000, "the channel options");
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
throw new Error("openChannelPicker: the channel picker offered nothing");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
private async closeChannelPicker(): Promise<void> {
|
||||
await this.user.keyboard("{Escape}");
|
||||
await this.waitForTransition();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the workspace's channels, the way a user does after inviting
|
||||
* `@Prowler` to one in Slack. Waits for the read to have settled, not for the
|
||||
* click alone.
|
||||
*/
|
||||
async refreshChannels(): Promise<void> {
|
||||
const readsBefore = this.channelListCallCount;
|
||||
await this.clickButton(/Refresh channels/);
|
||||
|
||||
await this.waitFor(
|
||||
() => {
|
||||
const button = this.buttonByText(/Refresh channels/);
|
||||
return (
|
||||
this.channelListCallCount > readsBefore &&
|
||||
button !== null &&
|
||||
!button.disabled
|
||||
);
|
||||
},
|
||||
15000,
|
||||
"the workspace's channels to be read again",
|
||||
);
|
||||
}
|
||||
|
||||
/** The channels the workspace offers, in the order the picker lists them. */
|
||||
async channelOptions(): Promise<string[]> {
|
||||
const options = await this.openChannelPicker();
|
||||
const names = options.map(
|
||||
(option) => option.getAttribute("data-channel") ?? "",
|
||||
);
|
||||
|
||||
await this.closeChannelPicker();
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the channel offered under `name` is presented as private — read
|
||||
* from the marker the user sees, not from how the option is wired up.
|
||||
*/
|
||||
async isChannelShownAsPrivate(name: string): Promise<boolean> {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(element) => element.getAttribute("data-channel") === name,
|
||||
);
|
||||
|
||||
await this.closeChannelPicker();
|
||||
|
||||
return /Private/.test(option?.textContent ?? "");
|
||||
}
|
||||
|
||||
private async pickAndSave(name: string): Promise<void> {
|
||||
const options = await this.openChannelPicker();
|
||||
const option = options.find(
|
||||
(element) => element.getAttribute("data-channel") === name,
|
||||
);
|
||||
|
||||
if (!option) {
|
||||
throw new Error(`pickAndSave: no channel named "${name}" is offered`);
|
||||
}
|
||||
|
||||
await this.user.click(option);
|
||||
await this.waitForTransition();
|
||||
await this.clickButton(/Save channel/);
|
||||
}
|
||||
|
||||
/** Pick a channel, save it, and wait for it to be recorded as the destination. */
|
||||
async chooseChannel(name: string): Promise<void> {
|
||||
await this.pickAndSave(name);
|
||||
await this.waitFor(
|
||||
() => this.defaultChannelName() === name,
|
||||
15000,
|
||||
`#${name} to be recorded as the destination`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a different destination away from this page — a second tab, or someone
|
||||
* else in the tenant. Goes through the same call the page makes, leaving this
|
||||
* page's own copy of it untouched.
|
||||
*/
|
||||
async channelRecordedElsewhere(name: string): Promise<void> {
|
||||
const channel = this.fixture.channels.find((c) => c.name === name);
|
||||
if (!channel) {
|
||||
throw new Error(
|
||||
`channelRecordedElsewhere: no channel named "${name}" is offered`,
|
||||
);
|
||||
}
|
||||
|
||||
const integrationId = this.fixture.install?.id;
|
||||
if (!integrationId) {
|
||||
throw new Error("channelRecordedElsewhere: no workspace is connected");
|
||||
}
|
||||
|
||||
const result = await setSlackDefaultChannel(integrationId, channel.id);
|
||||
if ("error" in result) {
|
||||
throw new Error(`channelRecordedElsewhere: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the picked channel can be saved — false when there is nothing new to save. */
|
||||
offersChannelSave(): boolean {
|
||||
const button = this.buttonByText(/Save channel/);
|
||||
return button !== null && !button.disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to save a channel the API refuses and hand back what the user is told. A
|
||||
* save that succeeds fails the test rather than timing out.
|
||||
*/
|
||||
async refusedChannelSave(name: string): Promise<string> {
|
||||
await this.pickAndSave(name);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.defaultChannelName() === name) {
|
||||
throw new Error(
|
||||
`refusedChannelSave: #${name} was recorded, not refused`,
|
||||
);
|
||||
}
|
||||
return this.toastText(/Could not save the destination channel/);
|
||||
},
|
||||
15000,
|
||||
"the refused channel save",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of the toast matching `pattern` — title and message together. Radix
|
||||
* portals each toast into its viewport as an `<li>`, outside the page's markup.
|
||||
*/
|
||||
private toastText(pattern: RegExp): string | null {
|
||||
const toast = Array.from(
|
||||
document.querySelectorAll<HTMLElement>("ol li"),
|
||||
).find((element) => pattern.test(element.textContent ?? ""));
|
||||
return toast ? (toast.textContent ?? "").replace(/\s+/g, " ").trim() : null;
|
||||
}
|
||||
|
||||
private defaultChannelName(): string | null {
|
||||
return (
|
||||
/Prowler posts to #(\S+?)\./.exec(
|
||||
this.container.textContent ?? "",
|
||||
)?.[1] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** The channel recorded as the integration's destination, if any. */
|
||||
async defaultChannel(): Promise<string | null> {
|
||||
const settled = await this.waitFor(
|
||||
() =>
|
||||
this.defaultChannelName() ??
|
||||
(this.containsText(/No destination channel recorded yet/)
|
||||
? NO_DEFAULT_CHANNEL
|
||||
: null),
|
||||
10000,
|
||||
"the recorded destination channel",
|
||||
);
|
||||
return settled === NO_DEFAULT_CHANNEL ? null : settled;
|
||||
}
|
||||
|
||||
/** What the user is told when the workspace exposes no channel at all. */
|
||||
async channelPickerMessage(): Promise<string> {
|
||||
const alert = await this.waitFor(
|
||||
() =>
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((element) =>
|
||||
/No channels available yet|Could not read the workspace/.test(
|
||||
element.textContent ?? "",
|
||||
),
|
||||
),
|
||||
10000,
|
||||
"the channel picker's message",
|
||||
);
|
||||
return (alert.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user is told about a list short of the workspace, shown beside a
|
||||
* picker that still works — unlike `channelPickerMessage()`, which replaces it.
|
||||
*/
|
||||
partialListNotice(): string | null {
|
||||
const notice = this.q("[data-channels-notice]");
|
||||
return notice
|
||||
? (notice.textContent ?? "").replace(/\s+/g, " ").trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Whether the picker was replaced by the "could not read them" alert. */
|
||||
saysChannelsUnreadable(): boolean {
|
||||
return this.containsText(/Could not read the workspace/);
|
||||
}
|
||||
|
||||
/** The invite copy that says how to make a private channel appear. */
|
||||
channelInviteHint(): string | null {
|
||||
const hint = Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>("p"),
|
||||
).find((element) => /invites? @Prowler/.test(element.textContent ?? ""));
|
||||
return hint ? (hint.textContent ?? "").trim() : null;
|
||||
}
|
||||
|
||||
// --- The test message ----------------------------------------------------
|
||||
|
||||
offersTestMessage(): boolean {
|
||||
return this.buttonByText(/Send test message/) !== null;
|
||||
}
|
||||
|
||||
private testMessageAlert(): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((element) =>
|
||||
/Test message (sent|failed)/.test(element.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async sendTestMessage(): Promise<TestMessageOutcome> {
|
||||
await this.clickButton(/Send test message/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
const alert = this.testMessageAlert();
|
||||
if (!alert) return null;
|
||||
return /Test message sent/.test(alert.textContent ?? "")
|
||||
? TEST_MESSAGE_OUTCOME.SENT
|
||||
: TEST_MESSAGE_OUTCOME.FAILED;
|
||||
},
|
||||
15000,
|
||||
"the test message outcome",
|
||||
);
|
||||
}
|
||||
|
||||
async lastTestMessageOutcome(): Promise<string> {
|
||||
const alert = await this.waitFor(
|
||||
() => this.testMessageAlert(),
|
||||
10000,
|
||||
"the test message outcome",
|
||||
);
|
||||
const description = alert.querySelector<HTMLElement>(
|
||||
'[data-slot="alert-description"]',
|
||||
);
|
||||
return (description?.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- Disconnecting ------------------------------------------------------
|
||||
|
||||
get disconnectCallCount(): number {
|
||||
return this.countRequests("DELETE", "/integrations/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects the workspace, confirming the way a user has to, and reports
|
||||
* what the page says about the revocation. The outcomes are mutually
|
||||
* exclusive, so asking for one also checks the others are absent.
|
||||
*
|
||||
* The revoked and unreported outcomes share a toast title, so each is read
|
||||
* from its own description: a title match would agree with either.
|
||||
*/
|
||||
async disconnect(): Promise<RevocationOutcome> {
|
||||
// The dialog's own button carries the noun too, hence the exact match on
|
||||
// the card's action.
|
||||
await this.clickButton(/^\s*Disconnect\s*$/);
|
||||
await this.clickButton(/Disconnect workspace/);
|
||||
|
||||
return this.waitFor(
|
||||
() => {
|
||||
if (this.alertMatching(/revocation/i)) {
|
||||
return REVOCATION_OUTCOME.NOT_REVOKED;
|
||||
}
|
||||
if (this.containsText(/has been revoked/)) {
|
||||
return REVOCATION_OUTCOME.REVOKED;
|
||||
}
|
||||
if (this.containsText(/is no longer connected to Prowler/)) {
|
||||
return REVOCATION_OUTCOME.UNREPORTED;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
15000,
|
||||
"the disconnect outcome",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the page is back to offering an install with no workspace
|
||||
* connected. The consent URL is minted after the disconnect, so the install
|
||||
* affordance appears a beat after the copy does.
|
||||
*/
|
||||
async returnedToUnconnectedState(): Promise<boolean> {
|
||||
await this.waitForText(/No workspace connected/, 10000);
|
||||
return (
|
||||
(await this.waitForOrNull(
|
||||
() => this.offersInstall(),
|
||||
5000,
|
||||
"the install to be offered again",
|
||||
)) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the page is asking the user to remove the access in Slack. */
|
||||
showsRevocationNotice(): boolean {
|
||||
return this.alertMatching(/revocation/i) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the user is told when the row was removed but Slack never confirmed
|
||||
* the revocation.
|
||||
*/
|
||||
async revocationNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(/revocation/i),
|
||||
10000,
|
||||
"the revocation notice",
|
||||
);
|
||||
return (notice.textContent ?? "").trim();
|
||||
}
|
||||
|
||||
// --- A credential Slack no longer accepts --------------------------------
|
||||
|
||||
/** What the user is told when Slack has stopped accepting the credential. */
|
||||
async revokedCredentialNotice(): Promise<string> {
|
||||
const notice = await this.waitFor(
|
||||
() => this.alertMatching(/no longer accepts Prowler's access/),
|
||||
10000,
|
||||
"the revoked-credential notice",
|
||||
);
|
||||
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Whether the page is saying Slack has stopped accepting the credential. */
|
||||
showsRevokedCredentialNotice(): boolean {
|
||||
return this.alertMatching(/no longer accepts Prowler's access/) !== null;
|
||||
}
|
||||
|
||||
private reconnectLink(): HTMLAnchorElement | null {
|
||||
return (
|
||||
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
|
||||
/Reconnect to Slack/.test(anchor.textContent ?? ""),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the page offers to approve Prowler in the workspace again. */
|
||||
offersReconnect(): boolean {
|
||||
return this.reconnectLink() !== null;
|
||||
}
|
||||
|
||||
/** The consent URL the reconnect affordance points at, once it is offered. */
|
||||
async reconnectUrl(): Promise<string> {
|
||||
const link = await this.waitFor(
|
||||
() => this.reconnectLink(),
|
||||
10000,
|
||||
"the reconnect link",
|
||||
);
|
||||
return link.href;
|
||||
}
|
||||
|
||||
/** The alert whose text matches, of however many the page is showing. */
|
||||
private alertMatching(pattern: RegExp): HTMLElement | null {
|
||||
return (
|
||||
Array.from(
|
||||
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
|
||||
).find((alert) => pattern.test(alert.textContent ?? "")) ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,15 +12,43 @@ import {
|
||||
configuredSlackFixture,
|
||||
connectedSlackFixture,
|
||||
INTEGRATIONS_SERVER_ERROR_DETAIL,
|
||||
partiallyReadSlackFixture,
|
||||
revokedTokenSlackFixture,
|
||||
revokeFailureSlackFixture,
|
||||
SLACK_CHANNEL_NOT_FOUND_REFUSAL,
|
||||
SLACK_MISSING_SCOPE_CODE,
|
||||
SLACK_MISSING_SCOPE_REFUSAL,
|
||||
SLACK_NOT_IN_CHANNEL_CODE,
|
||||
SLACK_NOT_IN_CHANNEL_REFUSAL,
|
||||
SLACK_PRIVATE_CHANNEL,
|
||||
SLACK_PUBLIC_CHANNEL,
|
||||
SLACK_RATE_LIMITED_REFUSAL,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL,
|
||||
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
SLACK_TOKEN_EXPIRED_CODE,
|
||||
SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
SLACK_TOKEN_REVOKED_CODE,
|
||||
SLACK_UNKNOWN_CHANNEL_DETAIL,
|
||||
SLACK_UNMAPPED_REASON_CODE,
|
||||
SLACK_UPSTREAM_REFUSAL,
|
||||
slackFixture,
|
||||
slackFixtureWithDefaultChannel,
|
||||
unreadableCheckTimeSlackFixture,
|
||||
unreportedRevocationSlackFixture,
|
||||
} from "@/__tests__/msw/handlers/slack.fixtures";
|
||||
|
||||
import {
|
||||
CONNECTION_OUTCOME,
|
||||
REVOCATION_OUTCOME,
|
||||
SlackIntegrationHarness,
|
||||
TEST_MESSAGE_OUTCOME,
|
||||
} from "./slack-integration.harness";
|
||||
|
||||
/** The shape the channel save is asserted against — only the id travels. */
|
||||
interface PatchIntegrationBody {
|
||||
data: { attributes: { configuration: { channel_id: string } } };
|
||||
}
|
||||
|
||||
/** The workspace the fixtures connect. */
|
||||
const WORKSPACE_NAME = "Prowler HQ";
|
||||
|
||||
@@ -43,9 +71,9 @@ describe("starting the install", () => {
|
||||
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
|
||||
"https://slack.com/oauth/v2/authorize",
|
||||
);
|
||||
expect((consentScreen.searchParams.get("scope") ?? "").split(",")).toEqual(
|
||||
expect.arrayContaining(REQUIRED_SCOPES),
|
||||
);
|
||||
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();
|
||||
@@ -220,3 +248,587 @@ describe("a connected workspace", () => {
|
||||
expect(harness.saysChannelIsNextStep()).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("choosing a destination channel", () => {
|
||||
it("offers the workspace's channels and remembers the one chosen", async () => {
|
||||
// Given — a connected tenant whose channels span two cursor pages.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// Then — every channel is offered, so the picker followed `links.next`
|
||||
// rather than stopping at the first page (design D6).
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
]);
|
||||
expect(harness.channelListCallCount).toBe(2);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — only the id is submitted: the API derives the name from it.
|
||||
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
|
||||
"PATCH",
|
||||
"/integrations/",
|
||||
);
|
||||
expect(saved?.data.attributes.configuration).toEqual({
|
||||
channel_id: SLACK_PUBLIC_CHANNEL.id,
|
||||
});
|
||||
|
||||
// And — a later visit shows it, under the name the API derived from the id.
|
||||
await harness.revisit();
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
}, 60000);
|
||||
|
||||
it("offers a private channel the app was invited to, marked as private, and saves it", async () => {
|
||||
// Given — `@Prowler` was invited to one private channel; `groups:read` is
|
||||
// membership-gated (D2).
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// Then
|
||||
expect(await harness.channelOptions()).toContain(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
expect(
|
||||
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await harness.isChannelShownAsPrivate(SLACK_PUBLIC_CHANNEL.name),
|
||||
).toBe(false);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PRIVATE_CHANNEL.name);
|
||||
|
||||
// Then
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PRIVATE_CHANNEL.name);
|
||||
}, 60000);
|
||||
|
||||
it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => {
|
||||
// Given — a workspace whose only channels are public: `groups:read` is
|
||||
// membership-gated (design D2).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channels: [
|
||||
{ ...SLACK_PUBLIC_CHANNEL },
|
||||
{ ...SLACK_SECOND_PUBLIC_CHANNEL },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.channelOptions()).not.toContain(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
|
||||
// When — `@Prowler` is invited to a private channel, and the user refreshes
|
||||
// instead of reconnecting the workspace.
|
||||
harness.fixture.channels.push({ ...SLACK_PRIVATE_CHANNEL });
|
||||
await harness.refreshChannels();
|
||||
|
||||
// Then
|
||||
expect(await harness.channelOptions()).toContain(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
expect(
|
||||
await harness.isChannelShownAsPrivate(SLACK_PRIVATE_CHANNEL.name),
|
||||
).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
it("says what to do when the workspace exposes no channel Prowler can post to", async () => {
|
||||
// Given — a connected workspace exposing no channels at all.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({ channels: [] }),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the user is told what to do, not merely that the list is empty.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/No channels available yet/);
|
||||
expect(message).toMatch(/invite @Prowler/);
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(harness.offersTestMessage()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("offers the connection check as soon as the destination is saved, without a revisit", async () => {
|
||||
// Given — connected with nothing recorded: the check posts to the
|
||||
// destination, so it is not offered yet.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
expect(await harness.offersConnectionTest()).toBe(false);
|
||||
expect(harness.saysChannelIsNextStep()).toBe(true);
|
||||
|
||||
// When
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — everything waiting on a destination moves with the save, in the
|
||||
// same paint: no reload to find the check on offer.
|
||||
expect(await harness.offersConnectionTest()).toBe(true);
|
||||
expect(harness.saysChannelIsNextStep()).toBe(false);
|
||||
// And — the check really runs.
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
|
||||
}, 60000);
|
||||
|
||||
it("follows the destination recorded elsewhere when the page's data refreshes under it", async () => {
|
||||
// Given — a finished setup, open on screen.
|
||||
const harness = new SlackIntegrationHarness(configuredSlackFixture());
|
||||
await harness.mount();
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When — the destination changes elsewhere (a second tab, another user) and
|
||||
// this page's server data refreshes under the open card, as
|
||||
// `revalidatePath` does after an action.
|
||||
await harness.channelRecordedElsewhere(SLACK_SECOND_PUBLIC_CHANNEL.name);
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — the card reports what is on record, not the copy it took at mount.
|
||||
expect(await harness.defaultChannel()).toBe(
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
);
|
||||
expect(harness.offersTestMessage()).toBe(true);
|
||||
// And — the picker followed too: the superseded destination is not left one
|
||||
// click from being saved back.
|
||||
expect(harness.offersChannelSave()).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
it("says which permission is missing when Slack refuses the channel listing, leaving the recorded channel alone", async () => {
|
||||
// Given — a recorded destination, and an install missing a scope the listing
|
||||
// needs. The API names it in `code` (contract, Errors), not in `detail`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_MISSING_SCOPE_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the reason, worded as a fix, with the invite copy still beside the
|
||||
// picker.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/missing a permission it needs in Slack/);
|
||||
expect(message).toMatch(/Connect the workspace again and approve/);
|
||||
// Slack's reason is a protocol token: it travels in `code` and is never
|
||||
// shown.
|
||||
expect(message).not.toMatch(SLACK_MISSING_SCOPE_CODE);
|
||||
expect(harness.channelInviteHint()).toMatch(/invites @Prowler/);
|
||||
|
||||
// And — a listing Prowler could not read says nothing about the channel
|
||||
// already recorded.
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(harness.offersTestMessage()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("names the wait Slack asked for when it rate limits the channel listing", async () => {
|
||||
// Given — `conversations.list` is Slack tier 2 and paginated (contract,
|
||||
// Errors); the `429` carries the wait in `Retry-After`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — when to come back, not just that it was refused: the wait is
|
||||
// asserted, not only the wording.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/rate limiting/);
|
||||
expect(message).toMatch(/about 30 seconds/);
|
||||
|
||||
// And — waiting is the fix, so nothing is said about permissions.
|
||||
expect(message).not.toMatch(/permission/);
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
}, 30000);
|
||||
|
||||
it("keeps the channels it did read on offer when Slack refuses a later page", async () => {
|
||||
// Given — a two-page workspace whose second page is rate limited
|
||||
// (`conversations.list` is tier 2, contract, Errors).
|
||||
const harness = new SlackIntegrationHarness(partiallyReadSlackFixture());
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the picker offers what was read rather than being replaced by the
|
||||
// refusal: every reload re-runs the same reads into the same limit.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
expect(harness.saysChannelsUnreadable()).toBe(false);
|
||||
|
||||
// And — the wait is still said, as the explanation for the short list.
|
||||
const notice = harness.partialListNotice();
|
||||
expect(notice).toMatch(/rate limiting/);
|
||||
expect(notice).toMatch(/about 30 seconds/);
|
||||
|
||||
// And — a partial read says nothing about the destination already recorded.
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
expect(harness.offersTestMessage()).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
it("says nothing about a short list when the whole workspace was read", async () => {
|
||||
// Given — the default workspace: two cursor pages, read to the end.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then
|
||||
expect(harness.partialListNotice()).toBeNull();
|
||||
}, 30000);
|
||||
|
||||
it("falls back to the API's wording when the listing fails upstream", async () => {
|
||||
// Given — a `502`, which names no `code` because there is nothing to act on
|
||||
// (contract, Errors).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
|
||||
channelsRefusal: SLACK_UPSTREAM_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then — the API's own `detail`, and not a wait that was never promised.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/Slack is temporarily unavailable/);
|
||||
expect(message).not.toMatch(/rate limiting/);
|
||||
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
|
||||
}, 30000);
|
||||
|
||||
it("says to invite @Prowler when Slack refuses the channel because the app is not in it", async () => {
|
||||
// Given — a private channel the app was removed from. The API validates the
|
||||
// channel against Slack on the way in and refuses with `not_in_channel`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channelSaveRefusal: SLACK_NOT_IN_CHANNEL_REFUSAL,
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
const refusal = await harness.refusedChannelSave(
|
||||
SLACK_PRIVATE_CHANNEL.name,
|
||||
);
|
||||
|
||||
// Then — the one fix the user can carry out themselves, in Slack.
|
||||
expect(refusal).toMatch(/Prowler is not in that channel/);
|
||||
expect(refusal).toMatch(/Invite @Prowler to it in Slack/);
|
||||
expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
|
||||
|
||||
// And — nothing was recorded, so nothing is offered to post with.
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(harness.offersTestMessage()).toBe(false);
|
||||
}, 60000);
|
||||
|
||||
it("says the channel is gone, not that @Prowler needs inviting, when Slack no longer has it", async () => {
|
||||
// Given — a channel archived since the listing was read. The API's `detail`
|
||||
// is word-for-word the one for `not_in_channel`, so only `code` tells them
|
||||
// apart.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
channelSaveRefusal: SLACK_CHANNEL_NOT_FOUND_REFUSAL,
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
const refusal = await harness.refusedChannelSave(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — a different problem, so different copy: nothing to invite to a
|
||||
// channel that no longer exists.
|
||||
expect(refusal).toMatch(/no longer exists in the workspace/);
|
||||
expect(refusal).toMatch(/Choose another one/);
|
||||
expect(refusal).not.toMatch(/Invite @Prowler/);
|
||||
expect(refusal).not.toMatch(SLACK_UNKNOWN_CHANNEL_DETAIL);
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("sending a test message", () => {
|
||||
it("is not offered until a destination channel is recorded", async () => {
|
||||
// Given — connected, but no channel chosen yet.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
|
||||
// When
|
||||
await harness.mount();
|
||||
|
||||
// Then
|
||||
expect(await harness.defaultChannel()).toBeNull();
|
||||
expect(harness.offersTestMessage()).toBe(false);
|
||||
}, 30000);
|
||||
|
||||
it("sends a test message to the recorded channel and reports it delivered", async () => {
|
||||
// Given — a tenant that has recorded where Prowler should post.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then — sent, and the user reads which channel it went to.
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.SENT);
|
||||
expect(await harness.lastTestMessageOutcome()).toMatch(
|
||||
`#${SLACK_PUBLIC_CHANNEL.name}`,
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it("surfaces the reason when Slack refuses the test message", async () => {
|
||||
// Given — the post fails, which the API reports on the task it handed back
|
||||
// (design D9), not on the request that started it, using the same stable
|
||||
// reason the synchronous endpoints put in `code`.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
testMessage: { accepted: false, error: SLACK_NOT_IN_CHANNEL_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then — the same copy the synchronous refusals get, not the raw token.
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
const reported = await harness.lastTestMessageOutcome();
|
||||
expect(reported).toMatch(/Prowler is not in that channel/);
|
||||
expect(reported).toMatch(/Invite @Prowler to it in Slack/);
|
||||
expect(reported).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
|
||||
}, 60000);
|
||||
|
||||
it("reports a refusal the task words itself, rather than swallowing it", async () => {
|
||||
// Given — a task result carrying prose instead of a stable reason; its exact
|
||||
// shape is the cloud lane's to pin down (contract, test-message).
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
testMessage: {
|
||||
accepted: false,
|
||||
error: SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
},
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
expect(await harness.lastTestMessageOutcome()).toMatch(
|
||||
SLACK_TEST_MESSAGE_REFUSED_DETAIL,
|
||||
);
|
||||
}, 60000);
|
||||
|
||||
it("keeps a reason it has no copy for inside its own sentence, not as the whole message", async () => {
|
||||
// Given — a real Slack reason this UI has no copy for; Slack's set is
|
||||
// open-ended, so this is the ordinary case.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
connectedSlackFixture({
|
||||
testMessage: { accepted: false, error: SLACK_UNMAPPED_REASON_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
|
||||
|
||||
// When
|
||||
const outcome = await harness.sendTestMessage();
|
||||
|
||||
// Then — Prowler's wording, with Slack's word for it kept for diagnosis.
|
||||
expect(outcome).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
const reported = await harness.lastTestMessageOutcome();
|
||||
expect(reported).toMatch(/Slack refused the message/);
|
||||
expect(reported).toMatch(SLACK_UNMAPPED_REASON_CODE);
|
||||
expect(reported).not.toBe(SLACK_UNMAPPED_REASON_CODE);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("disconnecting a workspace", () => {
|
||||
it("removes the integration and returns the card to its unconnected state", async () => {
|
||||
// Given — a tenant with a workspace connected.
|
||||
const harness = new SlackIntegrationHarness(connectedSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When — the user disconnects and confirms; Slack confirms the revocation.
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.REVOKED);
|
||||
|
||||
expect(harness.disconnectCallCount).toBe(1);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("still removes the integration when the revocation fails, and says access may need removing by hand", async () => {
|
||||
// Given — Slack will not accept the revocation; the row goes either way.
|
||||
const harness = new SlackIntegrationHarness(revokeFailureSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.NOT_REVOKED);
|
||||
|
||||
// And — the disconnect revalidates, so the copy below is read from props
|
||||
// that no longer carry an integration at all.
|
||||
await harness.refreshPageData();
|
||||
|
||||
// Then — what is true of both sides: nothing is left in Prowler to retry,
|
||||
// and the app may still be installed at Slack.
|
||||
const notice = await harness.revocationNotice();
|
||||
expect(notice).toMatch(/gone from Prowler/);
|
||||
expect(notice).toMatch(/nothing to retry here/);
|
||||
expect(notice).toMatch(/may still be installed in Prowler HQ/);
|
||||
expect(notice).toMatch(
|
||||
/remove it from that workspace's Slack app settings/,
|
||||
);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("says only that the workspace is no longer connected when nothing reports the revocation", async () => {
|
||||
// Given — the plain `204` a deployment that overrides nothing answers: no
|
||||
// body, so no `meta` to read the outcome from. The case users really meet.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
unreportedRevocationSlackFixture(),
|
||||
);
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.disconnect()).toBe(REVOCATION_OUTCOME.UNREPORTED);
|
||||
|
||||
// Then — nothing sends the user to Slack to finish a job no answer said
|
||||
// was unfinished.
|
||||
expect(harness.showsRevocationNotice()).toBe(false);
|
||||
expect(await harness.returnedToUnconnectedState()).toBe(true);
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("a credential Slack no longer accepts", () => {
|
||||
it("says the connection check found a dead credential, and offers to connect the workspace again", async () => {
|
||||
// Given — the token was revoked at Slack, so the row still reads connected
|
||||
// until a check runs (contract, Cross-cutting).
|
||||
const harness = new SlackIntegrationHarness(revokedTokenSlackFixture());
|
||||
await harness.mount();
|
||||
|
||||
// When
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
|
||||
// Then — a way forward rather than only an error: a revoked token is fixed
|
||||
// by approving Prowler again, not by checking a second time.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/no longer accepts Prowler's access to Prowler HQ/);
|
||||
expect(notice).toMatch(/Prowler's access to Slack was revoked/);
|
||||
expect(notice).toMatch(/Connect the workspace again to restore access/);
|
||||
// Slack's reason is a protocol token: it is what the UI switched on, never
|
||||
// what it showed.
|
||||
expect(notice).not.toMatch(new RegExp(SLACK_TOKEN_REVOKED_CODE));
|
||||
|
||||
const consentScreen = new URL(await harness.reconnectUrl());
|
||||
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
|
||||
"https://slack.com/oauth/v2/authorize",
|
||||
);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
it("offers the same recovery when the channel listing is what finds the credential dead", async () => {
|
||||
// Given — a finished setup whose credential expired. The listing runs on
|
||||
// arrival, so it meets Slack before any check does, and the contract says
|
||||
// any call can be the one that surfaces this.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({ channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL }),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page.
|
||||
await harness.mount();
|
||||
|
||||
// Then — the same answer the connection check gives, worded for how this
|
||||
// credential died rather than left as a channel problem.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(notice).toMatch(/Connect the workspace again to restore access/);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
|
||||
// And — the picker says the same, in the same words: `detail` names the raw
|
||||
// reason, and it is `code` the UI answered from.
|
||||
const message = await harness.channelPickerMessage();
|
||||
expect(message).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(message).not.toMatch(new RegExp(SLACK_TOKEN_EXPIRED_CODE));
|
||||
}, 30000);
|
||||
|
||||
it("offers it too when only a later cursor page is what Slack refuses", async () => {
|
||||
// Given — a two-page workspace whose second page is refused by a credential
|
||||
// Slack no longer accepts: the read stops short rather than failing.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
partiallyReadSlackFixture({
|
||||
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
}),
|
||||
);
|
||||
|
||||
// When — nothing but opening the page.
|
||||
await harness.mount();
|
||||
|
||||
// Then — what was read stays on offer, as it does for any short list.
|
||||
expect(await harness.channelOptions()).toEqual([
|
||||
SLACK_PUBLIC_CHANNEL.name,
|
||||
SLACK_SECOND_PUBLIC_CHANNEL.name,
|
||||
]);
|
||||
|
||||
// And — the dead credential is reported all the same: a picker that still
|
||||
// works is no reason to leave the user without the one fix there is.
|
||||
const notice = await harness.revokedCredentialNotice();
|
||||
expect(notice).toMatch(/Prowler's Slack credential has expired/);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
}, 60000);
|
||||
|
||||
it("keeps saying so when a later check fails without Slack naming a reason", async () => {
|
||||
// Given — the listing found the credential dead on arrival, and a later
|
||||
// check that fails naming no reason at all.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({
|
||||
channelsRefusal: SLACK_TOKEN_EXPIRED_REFUSAL,
|
||||
connection: { connected: false, error: null },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.revokedCredentialNotice()).toMatch(
|
||||
/Prowler's Slack credential has expired/,
|
||||
);
|
||||
|
||||
// When
|
||||
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.FAILURE);
|
||||
|
||||
// Then — a failure Slack never answered is no evidence the grant works
|
||||
// again, so the dead credential is still what the page reports.
|
||||
expect(await harness.revokedCredentialNotice()).toMatch(
|
||||
/Prowler's Slack credential has expired/,
|
||||
);
|
||||
expect(harness.offersReconnect()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
}, 60000);
|
||||
|
||||
it("stops saying so once a save Slack validated goes through", async () => {
|
||||
// Given — a finished setup whose test message found the grant revoked.
|
||||
const harness = new SlackIntegrationHarness(
|
||||
configuredSlackFixture({
|
||||
testMessage: { accepted: false, error: SLACK_TOKEN_REVOKED_CODE },
|
||||
}),
|
||||
);
|
||||
await harness.mount();
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
expect(await harness.sendTestMessage()).toBe(TEST_MESSAGE_OUTCOME.FAILED);
|
||||
expect(harness.showsRevokedCredentialNotice()).toBe(true);
|
||||
expect(await harness.connectionBadge()).toBe("Disconnected");
|
||||
|
||||
// When — the access is approved again in Slack, away from this page, and
|
||||
// the user saves a destination here. The API validates the channel against
|
||||
// Slack, so the save is an answer about the credential.
|
||||
await harness.chooseChannel(SLACK_SECOND_PUBLIC_CHANNEL.name);
|
||||
|
||||
// Then — Slack answered, so the notice about a credential it no longer
|
||||
// accepts goes, and the card is back to what it reported on arrival.
|
||||
expect(harness.showsRevokedCredentialNotice()).toBe(false);
|
||||
expect(harness.offersReconnect()).toBe(false);
|
||||
expect(await harness.connectionBadge()).toBe("Connected");
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
@@ -764,7 +764,6 @@ export const SlackIcon: React.FC<IconSvgProps> = ({
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height={height ?? size}
|
||||
role="presentation"
|
||||
viewBox="0 0 48 48"
|
||||
width={width ?? size}
|
||||
className={className}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
AlertTitle,
|
||||
Button,
|
||||
} from "@/components/shadcn";
|
||||
import { SLACK_REASON_TOKEN } from "@/lib/integrations/slack-errors";
|
||||
|
||||
const SLACK_INTEGRATION_PATH = "/integrations/slack";
|
||||
|
||||
@@ -35,15 +36,13 @@ const FAILURE_TITLE = "Slack workspace not connected";
|
||||
*/
|
||||
const UNCONFIRMED_TITLE = "Slack install not confirmed";
|
||||
|
||||
// `error` comes straight off the URL and is interpolated into Prowler's own copy,
|
||||
// so gate on the shape of a code: Slack publishes no closed set of values.
|
||||
const REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
|
||||
|
||||
const describeSlackError = (reason: string): string => {
|
||||
if (reason === "access_denied") {
|
||||
return "The install was not approved in Slack, so no workspace was connected.";
|
||||
}
|
||||
return REASON_TOKEN.test(reason)
|
||||
// `error` comes straight off the URL and is interpolated into Prowler's own
|
||||
// copy, so gate on the shape of a code: Slack publishes no closed set.
|
||||
return SLACK_REASON_TOKEN.test(reason)
|
||||
? `Slack could not complete the install (${reason}).`
|
||||
: "Slack could not complete the install.";
|
||||
};
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { Lock, RefreshCw } from "lucide-react";
|
||||
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Badge,
|
||||
Button,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/shadcn";
|
||||
import type { SlackChannelOption } from "@/types/integrations";
|
||||
|
||||
const INVITE_HINT =
|
||||
"A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh.";
|
||||
|
||||
interface SlackChannelSelectorProps {
|
||||
options: SlackChannelOption[];
|
||||
value: string | null;
|
||||
onChange: (channelId: string) => void;
|
||||
isLoading?: boolean;
|
||||
/** Why the channels could not be read — Slack's own reason, when it gave one. */
|
||||
error?: string | null;
|
||||
/** Why the list is partial. Shown with the picker, not instead of it. */
|
||||
incompleteNotice?: string | null;
|
||||
onRefresh?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/** Driven entirely by props (design D13) so the alert-rule form can reuse it. */
|
||||
export const SlackChannelSelector = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
incompleteNotice = null,
|
||||
onRefresh,
|
||||
disabled = false,
|
||||
}: SlackChannelSelectorProps) => {
|
||||
const isEmpty = !isLoading && !error && options.length === 0;
|
||||
// `htmlFor` may only name an element that exists, and the trigger is only
|
||||
// rendered in the picker branch.
|
||||
const hasPicker = !error && !isEmpty;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label htmlFor={hasPicker ? "slack-channel" : undefined}>
|
||||
Destination channel
|
||||
</Label>
|
||||
{onRefresh && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
{isLoading ? "Refreshing..." : "Refresh channels"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not read the workspace's channels</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : isEmpty ? (
|
||||
<Alert variant="info">
|
||||
<AlertTitle>No channels available yet</AlertTitle>
|
||||
<AlertDescription>
|
||||
Prowler cannot see a single channel in this workspace. Create a
|
||||
public channel, or invite @Prowler to a private one in Slack with
|
||||
<span className="font-medium"> /invite @Prowler</span>, then
|
||||
refresh.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{incompleteNotice && (
|
||||
<Alert variant="warning" data-channels-notice>
|
||||
<AlertTitle>Not every channel is listed</AlertTitle>
|
||||
<AlertDescription>{incompleteNotice}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Select
|
||||
value={value ?? undefined}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled || isLoading}
|
||||
>
|
||||
<SelectTrigger id="slack-channel" size="sm">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoading ? "Reading channels..." : "Choose a channel"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
// Name hook: the rendered label mixes it with a lock icon
|
||||
// and a "Private" badge.
|
||||
data-channel={option.name}
|
||||
>
|
||||
{option.is_private && <Lock size={14} aria-hidden="true" />}
|
||||
<span className="min-w-0 truncate">#{option.name}</span>
|
||||
{option.is_private && (
|
||||
<Badge variant="tag" size="sm">
|
||||
Private
|
||||
</Badge>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-text-neutral-secondary text-xs">{INVITE_HINT}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The case `slack-page.integration.test.tsx` cannot express: it asserts against
|
||||
* a hydrated, settled page, so it never sees the first frame the user is
|
||||
* served. The effect that reads the channels only runs in the browser, so the
|
||||
* channel state at render time is what the served HTML says until hydration.
|
||||
*/
|
||||
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
|
||||
|
||||
import { SlackIntegrationManager } from "./slack-integration-manager";
|
||||
|
||||
vi.mock("@/actions/integrations/slack", () => ({
|
||||
getSlackChannels: vi.fn(),
|
||||
sendSlackTestMessage: vi.fn(),
|
||||
setSlackDefaultChannel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/actions/integrations/integrations", () => ({
|
||||
testIntegrationConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* A connected workspace with no channel recorded, as the contract has it before
|
||||
* a save: with one, the picker would show that channel instead of the
|
||||
* placeholder this test reads.
|
||||
*/
|
||||
const CONNECTED_WORKSPACE: IntegrationProps = {
|
||||
type: "integrations",
|
||||
id: "slack-integration-1",
|
||||
attributes: {
|
||||
inserted_at: "2026-08-10T09:00:00Z",
|
||||
updated_at: "2026-08-10T09:00:00Z",
|
||||
enabled: true,
|
||||
connected: true,
|
||||
connection_last_checked_at: "2026-08-10T09:05:00Z",
|
||||
integration_type: INTEGRATION_TYPE.SLACK,
|
||||
configuration: {
|
||||
team_id: "T024BE7LD",
|
||||
team_name: "Prowler HQ",
|
||||
bot_user_id: "U0KRQLJ9H",
|
||||
},
|
||||
},
|
||||
links: { self: "/api/v1/integrations/slack-integration-1" },
|
||||
};
|
||||
|
||||
describe("the first paint of a connected workspace", () => {
|
||||
it("reads as still reading the channels rather than as a workspace with none", () => {
|
||||
// When
|
||||
const serverHtml = renderToString(
|
||||
<SlackIntegrationManager
|
||||
integration={CONNECTED_WORKSPACE}
|
||||
authorizeUrl={null}
|
||||
unavailable={false}
|
||||
rateLimitMessage={null}
|
||||
loadError={null}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(serverHtml).toContain("Reading channels...");
|
||||
expect(serverHtml).not.toContain("No channels available yet");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { format, isValid, parseISO } from "date-fns";
|
||||
import { TestTube } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Send, TestTube, Unplug } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { testIntegrationConnection } from "@/actions/integrations/integrations";
|
||||
import {
|
||||
disconnectSlackIntegration,
|
||||
getSlackAuthorizeUrl,
|
||||
getSlackChannels,
|
||||
sendSlackTestMessage,
|
||||
setSlackDefaultChannel,
|
||||
} from "@/actions/integrations/slack";
|
||||
import { SlackIcon } from "@/components/icons/services/IconServices";
|
||||
import { IntegrationCardHeader } from "@/components/integrations/shared";
|
||||
import { SlackChannelSelector } from "@/components/integrations/slack/slack-channel-selector";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
@@ -17,7 +25,100 @@ import {
|
||||
CardHeader,
|
||||
useToast,
|
||||
} from "@/components/shadcn";
|
||||
import type { IntegrationProps } from "@/types/integrations";
|
||||
import { Modal } from "@/components/shadcn/modal";
|
||||
import {
|
||||
isSlackTokenErrorCode,
|
||||
SLACK_REASON_TOKEN,
|
||||
slackErrorMessage,
|
||||
} from "@/lib/integrations/slack-errors";
|
||||
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
|
||||
import type {
|
||||
IntegrationProps,
|
||||
SlackChannelOption,
|
||||
} from "@/types/integrations";
|
||||
|
||||
const CHANNELS_STATUS = {
|
||||
LOADING: "loading",
|
||||
ERROR: "error",
|
||||
LOADED: "loaded",
|
||||
} as const;
|
||||
|
||||
interface ChannelsLoading {
|
||||
status: typeof CHANNELS_STATUS.LOADING;
|
||||
}
|
||||
|
||||
interface ChannelsFailed {
|
||||
status: typeof CHANNELS_STATUS.ERROR;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface ChannelsLoaded {
|
||||
status: typeof CHANNELS_STATUS.LOADED;
|
||||
channels: SlackChannelOption[];
|
||||
// Rides with the list it qualifies, so it can never outlive it.
|
||||
notice: string | null;
|
||||
}
|
||||
|
||||
type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
|
||||
|
||||
const TEST_MESSAGE_STATUS = {
|
||||
IDLE: "idle",
|
||||
SENDING: "sending",
|
||||
SENT: "sent",
|
||||
FAILED: "failed",
|
||||
} as const;
|
||||
|
||||
interface TestMessageIdle {
|
||||
status: typeof TEST_MESSAGE_STATUS.IDLE;
|
||||
}
|
||||
|
||||
interface TestMessageSending {
|
||||
status: typeof TEST_MESSAGE_STATUS.SENDING;
|
||||
}
|
||||
|
||||
interface TestMessageSent {
|
||||
status: typeof TEST_MESSAGE_STATUS.SENT;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface TestMessageFailed {
|
||||
status: typeof TEST_MESSAGE_STATUS.FAILED;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
type TestMessageState =
|
||||
| TestMessageIdle
|
||||
| TestMessageSending
|
||||
| TestMessageSent
|
||||
| TestMessageFailed;
|
||||
|
||||
/**
|
||||
* A disconnect that removed the row without Slack confirming the revocation.
|
||||
* The workspace name travels with it: the notice exists to name the workspace
|
||||
* to clean up, and the record is gone by the time revalidation lands.
|
||||
*/
|
||||
interface UnconfirmedRevocation {
|
||||
workspace: string | null;
|
||||
}
|
||||
|
||||
// The name may be missing: the id decides what the UI can do with it.
|
||||
interface SlackChannelRef {
|
||||
id: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
const channelRefEquals = (
|
||||
a: SlackChannelRef | null,
|
||||
b: SlackChannelRef | null,
|
||||
) => a?.id === b?.id && a?.name === b?.name;
|
||||
|
||||
/**
|
||||
* Slack's own reason, when the string is one: the connection check reports a
|
||||
* reason and its own prose in the same field, and only a reason is an answer
|
||||
* from Slack about the credential.
|
||||
*/
|
||||
const asReasonCode = (reason: string | null): string | null =>
|
||||
reason && SLACK_REASON_TOKEN.test(reason) ? reason : null;
|
||||
|
||||
interface SlackIntegrationManagerProps {
|
||||
/** At most one exists per tenant (one workspace). */
|
||||
@@ -37,24 +138,263 @@ export const SlackIntegrationManager = ({
|
||||
loadError,
|
||||
}: SlackIntegrationManagerProps) => {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
|
||||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||||
// The row is gone the moment the API says so; the server component's
|
||||
// revalidation only catches up on the next navigation.
|
||||
const [disconnected, setDisconnected] = useState(false);
|
||||
const [unconfirmedRevocation, setUnconfirmedRevocation] =
|
||||
useState<UnconfirmedRevocation | null>(null);
|
||||
/**
|
||||
* The `code` of the last refusal any Slack-backed call ran into, or `null`
|
||||
* when the last answer was not a refusal. A dead grant can surface from any
|
||||
* of them (contract, Cross-cutting), so every call reports here instead of
|
||||
* deciding on its own.
|
||||
*/
|
||||
const [lastRefusalCode, setLastRefusalCode] = useState<string | null>(null);
|
||||
// A connected workspace arrives with no consent URL, since no install is left
|
||||
// to start (design D10), so one is minted only if a reconnect turns out to be
|
||||
// the way out.
|
||||
const [mintedInstallUrl, setMintedInstallUrl] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const integrationId = integration?.id ?? null;
|
||||
const recordedChannelId =
|
||||
integration?.attributes.configuration.channel_id ?? null;
|
||||
const recordedChannelName =
|
||||
integration?.attributes.configuration.channel_name ?? null;
|
||||
|
||||
const recordedChannel: SlackChannelRef | null = recordedChannelId
|
||||
? { id: recordedChannelId, name: recordedChannelName }
|
||||
: null;
|
||||
|
||||
// Seeded `loading`, not by the effect: the effect never runs on the server,
|
||||
// so anything else would server-render a "no channels" picker until
|
||||
// hydration.
|
||||
const [channelsState, setChannelsState] = useState<ChannelsState>(
|
||||
integrationId
|
||||
? { status: CHANNELS_STATUS.LOADING }
|
||||
: { status: CHANNELS_STATUS.LOADED, channels: [], notice: null },
|
||||
);
|
||||
// Bumped by refresh: a channel invited after load only shows on a re-read.
|
||||
const [channelReloads, setChannelReloads] = useState(0);
|
||||
// Local state needed: the pick is buffered until the user saves it.
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
|
||||
recordedChannelId,
|
||||
);
|
||||
// Mirrored in state, not read from the prop, so channel-gated affordances
|
||||
// move on save instead of waiting for the revalidation.
|
||||
const [defaultChannel, setDefaultChannel] = useState(recordedChannel);
|
||||
// The prop the mirror was last taken from: the card never unmounts, so a
|
||||
// mirror seeded only at mount would go stale when the record changes.
|
||||
const [syncedChannel, setSyncedChannel] = useState(recordedChannel);
|
||||
const [isSavingChannel, setIsSavingChannel] = useState(false);
|
||||
const [testMessageState, setTestMessageState] = useState<TestMessageState>({
|
||||
status: TEST_MESSAGE_STATUS.IDLE,
|
||||
});
|
||||
|
||||
if (!channelRefEquals(recordedChannel, syncedChannel)) {
|
||||
const previousSyncedId = syncedChannel?.id ?? null;
|
||||
setSyncedChannel(recordedChannel);
|
||||
setDefaultChannel(recordedChannel);
|
||||
// Follow the record only while the buffered pick still matches it: an
|
||||
// unsaved pick is the user's, not ours to overwrite mid-edit.
|
||||
if (selectedChannelId === previousSyncedId) {
|
||||
setSelectedChannelId(recordedChannel?.id ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
// Only an answer from Slack moves the bus: a call that never got one proves
|
||||
// nothing and leaves the last answer standing.
|
||||
const provedCredentialAlive = () => setLastRefusalCode(null);
|
||||
|
||||
const recordRefusal = (code: string | null | undefined) => {
|
||||
if (code) setLastRefusalCode(code);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the last refusal proves the grant itself is dead, rather than a
|
||||
* channel unreachable or Slack busy. Derived, not stored, so it self-clears:
|
||||
* a later call Slack answered at all (even to refuse a channel) is proof the
|
||||
* credential works again, and the notice goes with it.
|
||||
*/
|
||||
const credentialFailure: SlackTokenErrorCode | null = isSlackTokenErrorCode(
|
||||
lastRefusalCode,
|
||||
)
|
||||
? lastRefusalCode
|
||||
: null;
|
||||
|
||||
const needsInstallUrl = disconnected || credentialFailure !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsInstallUrl) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
getSlackAuthorizeUrl()
|
||||
.then((result) => {
|
||||
if (cancelled || !("authorizeUrl" in result)) return;
|
||||
setMintedInstallUrl(result.authorizeUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
// Nothing to say: the page loses a shortcut, not a way to reconnect.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [needsInstallUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!integrationId) return;
|
||||
|
||||
let cancelled = false;
|
||||
setChannelsState({ status: CHANNELS_STATUS.LOADING });
|
||||
|
||||
getSlackChannels(integrationId)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setChannelsState(
|
||||
"error" in result
|
||||
? { status: CHANNELS_STATUS.ERROR, message: result.error }
|
||||
: {
|
||||
status: CHANNELS_STATUS.LOADED,
|
||||
channels: result.channels,
|
||||
notice: result.incomplete ?? null,
|
||||
},
|
||||
);
|
||||
// The listing runs on arrival, so it is where a dead credential shows
|
||||
// up first. A read cut short still names its refusal's code, so a grant
|
||||
// that died on a later cursor page is heard too; a truncation naming
|
||||
// none was Slack busy, not refusing.
|
||||
if ("error" in result || result.code) recordRefusal(result.code);
|
||||
else provedCredentialAlive();
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setChannelsState({
|
||||
status: CHANNELS_STATUS.ERROR,
|
||||
message: "Could not reach Slack to read the channel list.",
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [integrationId, channelReloads]);
|
||||
|
||||
const channels =
|
||||
channelsState.status === CHANNELS_STATUS.LOADED
|
||||
? channelsState.channels
|
||||
: [];
|
||||
|
||||
const handleSaveChannel = async () => {
|
||||
if (!integrationId || !selectedChannelId) return;
|
||||
|
||||
setIsSavingChannel(true);
|
||||
try {
|
||||
// Only the id travels — the API validates it and derives the name
|
||||
// (design D6).
|
||||
const result = await setSlackDefaultChannel(
|
||||
integrationId,
|
||||
selectedChannelId,
|
||||
);
|
||||
|
||||
if ("error" in result) {
|
||||
// The API validates the channel against Slack, so the save can
|
||||
// discover the credential is gone.
|
||||
recordRefusal(result.code);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not save the destination channel",
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer the API's derived name: a channel renamed in Slack since the
|
||||
// list was read would otherwise show its old name.
|
||||
const savedName =
|
||||
result.integration.attributes.configuration.channel_name ??
|
||||
channels.find((channel) => channel.id === selectedChannelId)?.name ??
|
||||
null;
|
||||
|
||||
provedCredentialAlive();
|
||||
setDefaultChannel({ id: selectedChannelId, name: savedName });
|
||||
// An outcome about the previous destination would mislead here.
|
||||
setTestMessageState({ status: TEST_MESSAGE_STATUS.IDLE });
|
||||
toast({
|
||||
title: "Destination channel saved",
|
||||
description: savedName
|
||||
? `Prowler will post to #${savedName}.`
|
||||
: "Prowler will post to the channel you chose.",
|
||||
});
|
||||
} catch (_error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Could not save the destination channel",
|
||||
description: "Something went wrong. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsSavingChannel(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendTestMessage = async () => {
|
||||
if (!integrationId) return;
|
||||
|
||||
setTestMessageState({ status: TEST_MESSAGE_STATUS.SENDING });
|
||||
try {
|
||||
const result = await sendSlackTestMessage(integrationId);
|
||||
|
||||
// The post happens in a task, so a credential that died since the last
|
||||
// check surfaces here.
|
||||
if ("error" in result) recordRefusal(result.code);
|
||||
else provedCredentialAlive();
|
||||
|
||||
setTestMessageState(
|
||||
"sent" in result
|
||||
? {
|
||||
status: TEST_MESSAGE_STATUS.SENT,
|
||||
detail: defaultChannel?.name
|
||||
? `Prowler posted a test message to #${defaultChannel.name}.`
|
||||
: "Prowler posted a test message to your default channel.",
|
||||
}
|
||||
: { status: TEST_MESSAGE_STATUS.FAILED, detail: result.error },
|
||||
);
|
||||
} catch (_error) {
|
||||
setTestMessageState({
|
||||
status: TEST_MESSAGE_STATUS.FAILED,
|
||||
detail: "Something went wrong. Please try again.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async (id: string) => {
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const result = await testIntegrationConnection(id);
|
||||
|
||||
if (result.success) {
|
||||
provedCredentialAlive();
|
||||
toast({
|
||||
title: "Connection test successful!",
|
||||
description:
|
||||
result.message || "Prowler can reach your Slack workspace.",
|
||||
});
|
||||
} else {
|
||||
// A dead credential named here is not a failure checking again can
|
||||
// fix, so the reason is recorded and not only reported.
|
||||
const reason = result.error?.trim() || null;
|
||||
|
||||
recordRefusal(asReasonCode(reason));
|
||||
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Connection test failed",
|
||||
description: result.error || "Failed to reach your Slack workspace.",
|
||||
description: reason
|
||||
? slackErrorMessage({ code: reason, detail: reason })
|
||||
: "Failed to reach your Slack workspace.",
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
@@ -68,10 +408,61 @@ export const SlackIntegrationManager = ({
|
||||
}
|
||||
};
|
||||
|
||||
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 handleDisconnect = async (id: string) => {
|
||||
const recordedWorkspace =
|
||||
integration?.attributes.configuration.team_name ?? null;
|
||||
const workspace = recordedWorkspace ?? "your Slack workspace";
|
||||
|
||||
setIsDisconnecting(true);
|
||||
try {
|
||||
const result = await disconnectSlackIntegration(id);
|
||||
|
||||
if ("error" in result) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Disconnect failed",
|
||||
description: result.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { revoked } = result.revocation;
|
||||
|
||||
// The row is gone whatever Slack answered, so the page goes back to its
|
||||
// unconnected state either way, and a dead credential is moot once the
|
||||
// row it belonged to is gone.
|
||||
setDisconnected(true);
|
||||
setLastRefusalCode(null);
|
||||
// Only an explicit `false` sends the user to finish the job in Slack: an
|
||||
// unreported outcome is neither a failed revocation nor a confirmed one,
|
||||
// so it claims neither.
|
||||
setUnconfirmedRevocation(
|
||||
revoked === false ? { workspace: recordedWorkspace } : null,
|
||||
);
|
||||
|
||||
if (revoked !== false) {
|
||||
toast({
|
||||
title: "Slack workspace disconnected",
|
||||
description:
|
||||
revoked === true
|
||||
? `Prowler's access to ${workspace} has been revoked.`
|
||||
: `${workspace} is no longer connected to Prowler.`,
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Failed to disconnect Slack. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsDisconnecting(false);
|
||||
setIsDisconnectOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const workspaceName = integration?.attributes.configuration.team_name;
|
||||
const installUrl = mintedInstallUrl ?? authorizeUrl;
|
||||
|
||||
const checkedAt = integration?.attributes.connection_last_checked_at;
|
||||
const checkedOn = checkedAt ? parseISO(checkedAt) : null;
|
||||
@@ -90,6 +481,36 @@ export const SlackIntegrationManager = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={isDisconnectOpen}
|
||||
onOpenChange={setIsDisconnectOpen}
|
||||
title="Disconnect Slack workspace"
|
||||
description={`Prowler will revoke its access at Slack and stop posting to ${workspaceName ?? "this workspace"}. Connecting again means approving Prowler in Slack.`}
|
||||
>
|
||||
<div className="flex w-full justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => setIsDisconnectOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="lg"
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => integration && handleDisconnect(integration.id)}
|
||||
>
|
||||
{!isDisconnecting && <Unplug size={20} />}
|
||||
{isDisconnecting ? "Disconnecting..." : "Disconnect workspace"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{loadError && (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>Could not load your Slack integration</AlertTitle>
|
||||
@@ -97,6 +518,45 @@ export const SlackIntegrationManager = ({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{unconfirmedRevocation && (
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
Slack disconnected — remove Prowler's access in Slack
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
The integration and the token Prowler had stored are gone from
|
||||
Prowler, so there is nothing to retry here. Slack did not confirm
|
||||
the revocation, so the Prowler app may still be installed in{" "}
|
||||
{unconfirmedRevocation.workspace ?? "the workspace"} — remove it
|
||||
from that workspace's Slack app settings.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{credentialFailure && (
|
||||
<Alert variant="error">
|
||||
<AlertTitle>
|
||||
Slack no longer accepts Prowler's access to{" "}
|
||||
{workspaceName ?? "this workspace"}
|
||||
</AlertTitle>
|
||||
{/* Each mapped sentence already ends in the thing that fixes it. */}
|
||||
<AlertDescription>
|
||||
{slackErrorMessage({ code: credentialFailure })} Until then, nothing
|
||||
Prowler sends will reach the workspace.
|
||||
</AlertDescription>
|
||||
{installUrl && (
|
||||
<div className="col-start-2 mt-3">
|
||||
<Button asChild size="sm">
|
||||
<a href={installUrl} rel="noopener noreferrer">
|
||||
<SlackIcon size={16} />
|
||||
Reconnect to Slack
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Replaces the cards, not the whole page: an early return here would
|
||||
swallow the rate-limit and load-error notices above. */}
|
||||
{unavailable ? (
|
||||
@@ -110,7 +570,7 @@ export const SlackIntegrationManager = ({
|
||||
soon as it is.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : integration ? (
|
||||
) : integration && !disconnected ? (
|
||||
<Card variant="base">
|
||||
<CardHeader>
|
||||
<IntegrationCardHeader
|
||||
@@ -118,7 +578,11 @@ export const SlackIntegrationManager = ({
|
||||
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
|
||||
subtitle="Prowler posts to this workspace only."
|
||||
connectionStatus={{
|
||||
connected: integration.attributes.connected,
|
||||
// A dead token outranks the state the page was loaded with.
|
||||
connected:
|
||||
credentialFailure === null
|
||||
? integration.attributes.connected
|
||||
: false,
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
@@ -132,24 +596,114 @@ export const SlackIntegrationManager = ({
|
||||
{lastCheckedOn}
|
||||
</p>
|
||||
)}
|
||||
{!channelId && (
|
||||
{!defaultChannel && (
|
||||
<p>
|
||||
Choosing a destination channel is the next step — the
|
||||
connection is checked against it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{/* The check posts to the destination channel: the API answers
|
||||
400 when none is recorded yet. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting || !channelId}
|
||||
onClick={() => handleTestConnection(integration.id)}
|
||||
>
|
||||
<TestTube size={14} />
|
||||
{isTesting ? "Testing..." : "Test connection"}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* The check posts to the destination channel: the API answers
|
||||
400 when none is recorded yet. */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isTesting || !defaultChannel}
|
||||
onClick={() => handleTestConnection(integration.id)}
|
||||
>
|
||||
<TestTube size={14} />
|
||||
{isTesting ? "Testing..." : "Test connection"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={isDisconnecting}
|
||||
onClick={() => setIsDisconnectOpen(true)}
|
||||
>
|
||||
<Unplug size={14} />
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-border-neutral-secondary mt-6 flex flex-col gap-4 border-t pt-6">
|
||||
<SlackChannelSelector
|
||||
options={channels}
|
||||
value={selectedChannelId}
|
||||
onChange={setSelectedChannelId}
|
||||
isLoading={channelsState.status === CHANNELS_STATUS.LOADING}
|
||||
error={
|
||||
channelsState.status === CHANNELS_STATUS.ERROR
|
||||
? channelsState.message
|
||||
: null
|
||||
}
|
||||
incompleteNotice={
|
||||
channelsState.status === CHANNELS_STATUS.LOADED
|
||||
? channelsState.notice
|
||||
: null
|
||||
}
|
||||
onRefresh={() => setChannelReloads((reloads) => reloads + 1)}
|
||||
disabled={isSavingChannel}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-text-neutral-secondary text-xs">
|
||||
{/* The id decides, not the name: a missing name would deny
|
||||
a destination the test button posts to. */}
|
||||
{defaultChannel
|
||||
? defaultChannel.name
|
||||
? `Prowler posts to #${defaultChannel.name}.`
|
||||
: "Prowler posts to the channel you saved."
|
||||
: "No destination channel recorded yet."}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
!selectedChannelId ||
|
||||
selectedChannelId === (defaultChannel?.id ?? null) ||
|
||||
isSavingChannel
|
||||
}
|
||||
onClick={handleSaveChannel}
|
||||
>
|
||||
{isSavingChannel ? "Saving..." : "Save channel"}
|
||||
</Button>
|
||||
{defaultChannel && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={
|
||||
testMessageState.status === TEST_MESSAGE_STATUS.SENDING
|
||||
}
|
||||
onClick={handleSendTestMessage}
|
||||
>
|
||||
<Send size={14} />
|
||||
{testMessageState.status === TEST_MESSAGE_STATUS.SENDING
|
||||
? "Sending..."
|
||||
: "Send test message"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(testMessageState.status === TEST_MESSAGE_STATUS.SENT ||
|
||||
testMessageState.status === TEST_MESSAGE_STATUS.FAILED) && (
|
||||
<Alert
|
||||
variant={
|
||||
testMessageState.status === TEST_MESSAGE_STATUS.SENT
|
||||
? "success"
|
||||
: "error"
|
||||
}
|
||||
>
|
||||
<AlertTitle>
|
||||
{testMessageState.status === TEST_MESSAGE_STATUS.SENT
|
||||
? "Test message sent"
|
||||
: "Test message failed"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>{testMessageState.detail}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -169,9 +723,9 @@ export const SlackIntegrationManager = ({
|
||||
Prowler asks for permission to post messages and to read the
|
||||
workspace's channel list.
|
||||
</p>
|
||||
{authorizeUrl ? (
|
||||
{installUrl ? (
|
||||
<Button asChild>
|
||||
<a href={authorizeUrl} rel="noopener noreferrer">
|
||||
<a href={installUrl} rel="noopener noreferrer">
|
||||
<SlackIcon size={16} />
|
||||
Add to Slack
|
||||
</a>
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
SLACK_ERROR_MESSAGES,
|
||||
SLACK_GENERIC_ERROR_MESSAGE,
|
||||
SLACK_RATE_LIMITED_MESSAGE,
|
||||
SLACK_REASON_TOKEN,
|
||||
SLACK_TOKEN_ERROR_CODES,
|
||||
readSlackFailure,
|
||||
slackErrorMessage,
|
||||
slackRateLimitMessage,
|
||||
slackUnknownReasonMessage,
|
||||
} from "./slack-errors";
|
||||
|
||||
describe("slackErrorMessage", () => {
|
||||
@@ -91,6 +93,63 @@ describe("slackErrorMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("slackUnknownReasonMessage", () => {
|
||||
/** A real Slack reason this UI has no copy of its own for. */
|
||||
const UNMAPPED_REASON = "is_archived";
|
||||
|
||||
it("keeps an unmapped reason diagnosable without letting it be the message", () => {
|
||||
const message = slackUnknownReasonMessage(UNMAPPED_REASON);
|
||||
|
||||
expect(message).toMatch(/Slack refused the message/);
|
||||
expect(message).toContain(UNMAPPED_REASON);
|
||||
expect(message).not.toBe(UNMAPPED_REASON);
|
||||
expect(message).toMatch(/Choose another channel/);
|
||||
});
|
||||
|
||||
it("is only reached for a code the mapping does not cover", () => {
|
||||
expect(
|
||||
slackErrorMessage(
|
||||
{ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL },
|
||||
slackUnknownReasonMessage(SLACK_ERROR_CODE.NOT_IN_CHANNEL),
|
||||
),
|
||||
).toBe(SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL]);
|
||||
|
||||
// No `detail`: one holding the same token would make the raw token the
|
||||
// whole message again.
|
||||
expect(
|
||||
slackErrorMessage(
|
||||
{ code: UNMAPPED_REASON },
|
||||
slackUnknownReasonMessage(UNMAPPED_REASON),
|
||||
),
|
||||
).toBe(slackUnknownReasonMessage(UNMAPPED_REASON));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SLACK_REASON_TOKEN", () => {
|
||||
it("recognises a reason code and refuses anything that reads as a sentence", () => {
|
||||
// Slack publishes no closed set of reasons, so the guard is on shape rather
|
||||
// than an allowlist.
|
||||
for (const reason of [
|
||||
"is_archived",
|
||||
"restricted_action",
|
||||
"team_access_not_granted",
|
||||
"ekm_access_denied",
|
||||
"messages_tab_disabled",
|
||||
]) {
|
||||
expect(SLACK_REASON_TOKEN.test(reason)).toBe(true);
|
||||
}
|
||||
|
||||
for (const prose of [
|
||||
"Slack rejected the message: the channel is archived.",
|
||||
"). Contact support at +1-555-0100 (",
|
||||
"",
|
||||
"a".repeat(49),
|
||||
]) {
|
||||
expect(SLACK_REASON_TOKEN.test(prose)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("slackRateLimitMessage", () => {
|
||||
it("names the wait Slack asked for", () => {
|
||||
expect(slackRateLimitMessage(30)).toMatch(/about 30 seconds/);
|
||||
|
||||
@@ -39,6 +39,13 @@ export const SLACK_GENERIC_ERROR_MESSAGE =
|
||||
export const SLACK_RATE_LIMITED_MESSAGE =
|
||||
"Slack is rate limiting Prowler right now. Try again in a few moments.";
|
||||
|
||||
/**
|
||||
* For a channel list that stopped short of the workspace: the page budget ran
|
||||
* out, or `links.next` left the API's origin.
|
||||
*/
|
||||
export const SLACK_PARTIAL_CHANNEL_LIST_MESSAGE =
|
||||
"This workspace has more channels than Prowler reads in one go, so this list is not all of them. A channel missing from it is not necessarily one @Prowler has to be invited to.";
|
||||
|
||||
/**
|
||||
* For a `2xx` the UI could not read. Not phrased as a failure: the install
|
||||
* happened, only the workspace cannot be named.
|
||||
@@ -46,6 +53,19 @@ export const SLACK_RATE_LIMITED_MESSAGE =
|
||||
export const SLACK_UNREADABLE_RESULT_MESSAGE =
|
||||
"Prowler could not read the result of the install. Open the Slack integration page to see the workspace — if none is listed there, start the install again.";
|
||||
|
||||
/**
|
||||
* The shape of a Slack reason code, as opposed to a sentence: the set is
|
||||
* open-ended, so a reason is gated on its shape before being interpolated.
|
||||
*/
|
||||
export const SLACK_REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
|
||||
|
||||
/**
|
||||
* Copy for a reason code Prowler has no wording of its own for — the ordinary
|
||||
* case, since the set is open-ended.
|
||||
*/
|
||||
export const slackUnknownReasonMessage = (reason: string): string =>
|
||||
`Slack refused the message (${reason}). Choose another channel, or check the channel in Slack.`;
|
||||
|
||||
const RECONNECT = "Connect the workspace again to restore access.";
|
||||
|
||||
export const SLACK_ERROR_MESSAGES = {
|
||||
|
||||
@@ -113,6 +113,16 @@ export interface IntegrationProps {
|
||||
links: { self: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* A channel Prowler can post to: every active public channel, plus the private
|
||||
* ones `@Prowler` was invited to. `is_private` keeps the API's own naming.
|
||||
*/
|
||||
export interface SlackChannelOption {
|
||||
id: string;
|
||||
name: string;
|
||||
is_private: boolean;
|
||||
}
|
||||
|
||||
// Jira dispatch types
|
||||
export interface JiraDispatchRequest {
|
||||
data: {
|
||||
|
||||
Reference in New Issue
Block a user