mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
Compare commits
38
Commits
ebf73bd26d
...
a383199d8d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a383199d8d | ||
|
|
b9cba66662 | ||
|
|
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 |
+2
-1
@@ -182,7 +182,8 @@
|
||||
"pages": [
|
||||
"user-guide/tutorials/prowler-app-s3-integration",
|
||||
"user-guide/tutorials/prowler-app-security-hub-integration",
|
||||
"user-guide/tutorials/prowler-app-jira-integration"
|
||||
"user-guide/tutorials/prowler-app-jira-integration",
|
||||
"user-guide/tutorials/prowler-app-slack-integration"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,181 @@
|
||||
---
|
||||
title: "Slack Integration"
|
||||
sidebarTitle: 'Slack'
|
||||
description: 'Connect a Slack workspace to Prowler Cloud, choose the channel Prowler posts to, and verify delivery with a test message.'
|
||||
---
|
||||
import { VersionBadge } from "/snippets/version-badge.mdx"
|
||||
import { AppliesTo } from "/snippets/applies-to.mdx"
|
||||
|
||||
<VersionBadge version="5.39.0" />
|
||||
|
||||
<AppliesTo products={["Prowler Cloud"]} />
|
||||
|
||||
Prowler Cloud connects to a Slack workspace so security updates arrive where teams already work. Connecting takes one approval in Slack — there is no bot token to create, copy, or store by hand — and Prowler records a single destination channel it posts to.
|
||||
|
||||
Integrating Prowler Cloud with Slack provides:
|
||||
|
||||
* **Approval-based setup:** Approve Prowler once in Slack instead of building a Slack app and pasting a token.
|
||||
* **A verified delivery path:** Send a test message and confirm the channel receives it before anything depends on it.
|
||||
* **Controlled reach:** Prowler posts only to the channel recorded on the integration, and private channels stay invisible until the Prowler app is invited to them.
|
||||
|
||||
<Note>
|
||||
This guide covers the Slack integration in Prowler Cloud. It is unrelated to the Prowler CLI `--slack` flag, which posts a scan summary from the command line using a self-created Slack app and the `SLACK_API_TOKEN` and `SLACK_CHANNEL_NAME` environment variables — see [CLI Integrations](/user-guide/cli/tutorials/integrations) for that feature.
|
||||
</Note>
|
||||
|
||||
## How the Slack Integration Works
|
||||
|
||||
When connected and configured:
|
||||
|
||||
1. A Slack workspace is approved once through Slack's app install flow, and Prowler stores the resulting credential encrypted.
|
||||
2. Prowler reads the channels it can post to: every public channel, plus the private channels the Prowler app has been invited to.
|
||||
3. One channel is recorded on the integration as the default destination.
|
||||
4. A test message proves the delivery path end to end.
|
||||
5. Disconnecting removes the integration from Prowler and revokes Prowler's access at Slack.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
The Slack integration is available in **Prowler Cloud** only. A self-hosted deployment does not serve the Slack endpoints at all, so the Slack card does not appear on the Integrations page and the management page redirects away.
|
||||
|
||||
Configuring and using the Slack integration requires the **Manage Integrations** permission. The integration is tenant-wide, so it does not require **Unlimited Visibility** or any specific Provider Group.
|
||||
|
||||
One Slack workspace connects per tenant. Approving Prowler again in the same workspace refreshes the stored credential, while approving it in a *different* workspace is refused until the current workspace is disconnected — a workspace is never swapped out silently.
|
||||
|
||||
## Permissions Prowler Requests in Slack
|
||||
|
||||
Slack shows a consent screen listing everything the Prowler app asks for. Prowler requests exactly four bot scopes:
|
||||
|
||||
| Scope | Why Prowler Requests It |
|
||||
|-------|-------------------------|
|
||||
| `chat:write` | Post the test message, and any later message, to the recorded channel. |
|
||||
| `chat:write.public` | Post to a public channel without first inviting the Prowler app to it. |
|
||||
| `channels:read` | List public channels for the destination-channel picker and resolve the chosen one. |
|
||||
| `groups:read` | List the private channels the Prowler app has been invited to, so they appear in the picker. |
|
||||
|
||||
Two of these read more broadly than they behave, and both are worth understanding before approving the app.
|
||||
|
||||
### What `chat:write.public` Does Not Grant
|
||||
|
||||
On the consent screen, `chat:write.public` reads as permission to post in any public channel. Prowler never uses it that way: **Prowler only ever posts to the channel recorded on the integration.** The scope exists so that recording a public channel does not also require someone to invite the Prowler app to it first.
|
||||
|
||||
### Why a Private Channel Is Missing From the Picker
|
||||
|
||||
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the picker only after someone invites `@Prowler` to it in Slack:
|
||||
|
||||
```text
|
||||
/invite @Prowler
|
||||
```
|
||||
|
||||
That invite is issued in Slack, by that channel's own members, and **the invite itself is the permission grant** — no scope bypasses it. Prowler ships no in-product flow to get the app invited, because the decision belongs to the channel's members. After inviting the app, click **Refresh channels** to re-read the list.
|
||||
|
||||
## Connecting a Slack Workspace
|
||||
|
||||
To connect a Slack workspace to Prowler Cloud:
|
||||
|
||||
1. Navigate to **Integrations** in Prowler Cloud.
|
||||
2. Locate the **Slack** card and click **Manage**.
|
||||
|
||||

|
||||
|
||||
3. Click **Add to Slack**.
|
||||
|
||||

|
||||
|
||||
4. In Slack, select the workspace to connect and approve the permissions listed on the consent screen.
|
||||
5. Slack returns to Prowler Cloud, which completes the install and shows the connected workspace.
|
||||
|
||||

|
||||
|
||||
The connected card reports the workspace name, a **Connected** status, and when the connection was last checked. Click **Test connection** at any time to verify that Prowler can still reach both the workspace and the recorded channel.
|
||||
|
||||
<Note>
|
||||
Declining the consent screen creates nothing. Prowler reports that the workspace was not connected and offers to start again.
|
||||
</Note>
|
||||
|
||||
## Choosing the Default Channel
|
||||
|
||||
Prowler posts to one channel, recorded on the integration as its default destination.
|
||||
|
||||
1. Open the **Destination channel** picker. It lists every public channel in the workspace, plus the private channels the Prowler app has been invited to, each marked **Private**.
|
||||
|
||||

|
||||
|
||||
2. Select a channel.
|
||||
3. Click **Save channel**.
|
||||
|
||||
Prowler validates the selection against Slack and derives the channel name itself, so the recorded name can never drift from the channel it belongs to. Once a channel is saved, the page reports where Prowler posts.
|
||||
|
||||
If the picker reports that no channels are available, the workspace exposes nothing Prowler can see. Create a public channel, or invite `@Prowler` to a private one, then click **Refresh channels**.
|
||||
|
||||
## Sending a Test Message
|
||||
|
||||
Sending a test message is how the delivery path gets verified before anything depends on it.
|
||||
|
||||
1. Confirm a destination channel is saved. **Send test message** appears only once one is recorded.
|
||||
2. Click **Send test message**.
|
||||
3. Check the channel in Slack for the message.
|
||||
|
||||

|
||||
|
||||
Prowler reports the outcome on the page. When Slack refuses the message, the reason Slack gave is shown — an archived or deleted channel, or an app that has been removed from a private channel, all surface here rather than failing silently.
|
||||
|
||||
## Disconnecting a Slack Workspace
|
||||
|
||||
Disconnecting removes the integration from Prowler **and** revokes Prowler's access at Slack.
|
||||
|
||||
1. On the Slack management page, click **Disconnect**.
|
||||
2. Review the confirmation, then click **Disconnect workspace**.
|
||||
|
||||

|
||||
|
||||
The page returns to its unconnected state, ready for a new install.
|
||||
|
||||
### What Revocation Means
|
||||
|
||||
Revocation is attempted at Slack, and it is best-effort:
|
||||
|
||||
* **Revocation succeeded:** The stored credential no longer grants Prowler anything, and the integration is gone from Prowler.
|
||||
* **Revocation failed:** The integration and the stored credential are gone from Prowler either way, so there is nothing to retry. Slack did not confirm the revocation, which means the Prowler app may still be installed in the workspace. Remove it from that workspace's Slack app settings.
|
||||
|
||||
Prowler states which of the two happened, so a manual cleanup step is never left implicit.
|
||||
|
||||
<Warning>
|
||||
Disconnecting cannot be undone. Reconnecting means approving Prowler in Slack again, and the destination channel has to be chosen again.
|
||||
</Warning>
|
||||
|
||||
## Integration Status
|
||||
|
||||
The Slack management page reports the state of the connection and offers these actions:
|
||||
|
||||
| Button | Purpose | Notes |
|
||||
|--------|---------|-------|
|
||||
| **Test connection** | Verify Prowler can reach the workspace and the recorded channel | Checks both the credential and the channel, and updates the last-checked time |
|
||||
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
|
||||
| **Save channel** | Record the selected channel as the default destination | Enabled once a channel other than the current default is selected |
|
||||
| **Send test message** | Post a test message to the recorded channel | Offered only once a destination channel is recorded |
|
||||
| **Disconnect** | Remove the integration and revoke access at Slack | ⚠️ **Cannot be undone** — confirm before disconnecting |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Slack Is Not Available in This Environment Yet
|
||||
|
||||
The Prowler Slack app is not configured for the deployment being used, so no workspace can be connected. This resolves without any action on the tenant's side — the page starts working as soon as the app is configured.
|
||||
|
||||
### A Private Channel Does Not Appear in the Picker
|
||||
|
||||
The Prowler app has not been invited to it. In Slack, run `/invite @Prowler` in that channel, then click **Refresh channels**. Membership is the permission: no scope reveals a private channel the app is not in.
|
||||
|
||||
### Connection Test Fails
|
||||
|
||||
* Confirm the recorded channel still exists and has not been archived.
|
||||
* For a private destination channel, confirm the Prowler app is still a member of it.
|
||||
* Confirm the Prowler app is still installed in the workspace.
|
||||
|
||||
### Prowler's Access Has Been Revoked
|
||||
|
||||
When Slack stops accepting the stored credential — because a workspace administrator revoked it, or the app was removed from the workspace — Prowler reports the workspace as disconnected and offers **Reconnect to Slack**. Approving Prowler in Slack again restores access.
|
||||
|
||||
### The Test Message Does Not Arrive
|
||||
|
||||
* Confirm the destination channel saved on the integration is the channel being watched in Slack.
|
||||
* Check the outcome reported on the page: when Slack refuses the message, the reason Slack gave is shown there.
|
||||
* For a private channel, confirm the Prowler app is still a member of it.
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -5,9 +5,8 @@ import { SlackIcon } from "@/components/icons/services/IconServices";
|
||||
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
|
||||
import { CustomLink } from "@/components/shadcn/custom/custom-link";
|
||||
|
||||
// Placeholder slug: the docs slice writes the page and confirms it.
|
||||
const SLACK_DOCS_URL =
|
||||
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-slack-integration/";
|
||||
"https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration";
|
||||
|
||||
export const SlackIntegrationCard = () => {
|
||||
return (
|
||||
|
||||
@@ -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