Compare commits

..
Author SHA1 Message Date
Pablo F.G 18e0df02e1 fix(ui): align alert rule-write refusals with the signed contract
- Every ineligible channel selection answers 400 with the single signed
  code slack_channel_not_eligible; the per-condition detail is kept
- Rule PATCH validates only newly added channels, so a reinstall that
  reset confirmations never blocks editing a rule that retains them
- The confirmed-only channel listing is signed, no longer an assumption
- Refusal surfacing covered through a channel gone stale mid-edit
2026-08-21 15:29:49 +02:00
Pablo F.G d9a2612f15 feat(ui): align alert Slack destinations with the signed API contract
- Offer the channels the eligible-channels endpoint returns
- Write slack_channels as objects carrying only the channel id
- Drop the destination states the server-side cascade made impossible
- Point the empty-pool notice at the connection check
2026-08-21 15:29:49 +02:00
Pablo F.G b3dee53df4 feat(ui): let alert rules target authorized Slack channels
- Add the channels field to the alert modal below Recipients
- Keep stored channels visible when de-authorized or disconnected
- Cover the alerts page spec with browser-mode integration tests
- Repair the unit tests the form rework touched
2026-08-21 15:29:49 +02:00
Pablo F.G a77b818ab9 feat(ui): scaffold Slack channel destinations for alerts
- Remove the alert form's method literal; destinations are the truth
- Add slackChannels to form values, schema, adapter and rule payload
- Add MSW alerts handlers and fixtures with the D3 channel contract
- Add the alerts page browser harness with channel vocabulary
2026-08-21 15:29:49 +02:00
Pablo F.G b9c967d239 fix(ui): name the failing channel from the polled connection status too 2026-08-21 15:29:10 +02:00
Pablo F.G 9cb7e8db11 fix(ui): read the failing channel by id from the connection task result
- The signed contract names the failing channel under `channel` as its id,
  null when the failure is not channel-specific
- Map the id to the authorized channel's name for the refusal toast; an
  unknown id falls back to the workspace-wide copy
- A check chained onto a save names the channel from the just-saved set
2026-08-21 15:05:25 +02:00
Pablo F.G f8a06ad7e1 fix(ui): apply CodeRabbit review fixes to Slack channel authorization
- Disable the connection test while a channel save runs
- Make the channel picker's DOM id configurable for reuse
- Assert the authorized channel read-back exactly in the page test
2026-08-21 14:54:13 +02:00
Pablo F.G b718ca6ca9 fix(ui): speak of authorized channels on the Slack entry points
- Rework the page and card copy to the authorized-set vocabulary
- Send the install callback back to authorize channels, not pick one
- Drop the stale single-channel wording from the channels endpoint
2026-08-21 14:40:04 +02:00
Pablo F.G 2a91d9dba0 feat(ui): align Slack channel authorization with the signed API contract
- Write the authorized set as channel objects naming only their ids
- Read the stored confirmations, workspace ids and verification state
- Replace the test-message copy with the check's one-time confirmation
- Warn that dropping a channel drops it from the alert rules too
2026-08-21 14:40:04 +02:00
Pablo F.G 9436a4bd2b feat(ui): authorize multiple Slack destination channels
- Add shared SlackChannelMultiSelect with private chips identified
- Rework the Slack manager to the authorized-set model
- Test message and connection check cover every authorized channel
- Retire the single-channel selector and its vocabulary
2026-08-21 14:40:04 +02:00
Pablo F.G 4001b544bf docs: state the Test connection precondition in the status table 2026-08-21 14:11:23 +02:00
Pablo F.G e704e86b28 docs: state the bounds of the Slack channel refresh and search 2026-08-21 12:53:24 +02:00
Pablo F.G 12e133e718 docs: reflect the attempted-revocation copy in the disconnect screenshot
- Recapture the confirmation dialog on its current wording
- Drop the retired test-message button the old frame still showed
2026-08-21 12:43:49 +02:00
Pablo F.G 0b4050f5a6 docs: drop the Slack test message from the tutorial
- The UI verifies the destination through the connection check the save
  runs, so the test-message section, its screenshot and its reference
  row no longer describe anything the page offers
2026-08-21 12:43:49 +02:00
Pablo F.G 1d6612bb3b docs: move the Slack tutorial availability to 5.40.0
- The Slack integration ships in 5.40.0; the released 5.39.x line does
  not contain it
- Recapture all six screenshots so their footers name the same version
2026-08-21 12:43:49 +02:00
Pablo F.G 042aa61c23 docs: scope the Slack channel listing to what Prowler reads
- Stop saying the picker lists every public channel: the read is
  bounded, so a very large workspace can get a partial list
- Explain the partial-list report: listed channels stay usable, and a
  missing one is not necessarily missing an invite
2026-08-21 12:43:49 +02:00
Pablo F.G 6106b65350 docs: stop promising unconditional Slack revocation on disconnect
- Say disconnecting attempts to revoke access, everywhere the guide
  claimed it simply does
- Document the unreported outcome next to success and failure: it makes
  no revocation claim, and certainty means checking Slack app settings
2026-08-21 12:43:49 +02:00
Pablo F.G 31224f39aa docs: re-stage the connected-state Slack screenshots on the contract
- Capture the post-install card as "Not checked yet" with no check time,
  the state the contract defines right after the OAuth return
- Show the next-step sentence and the disabled Test connection the API
  refuses while no destination channel is recorded
- Describe that state in the prose, replacing the Connected status and
  last-checked time the shots never could have shown
2026-08-21 12:43:49 +02:00
Pablo F.G 161f76198e docs: name both cloud products in the Slack tutorial availability
- The Prerequisites line said Prowler Cloud only, contradicting the
  SubscriptionBanner above it
- Prowler Private Cloud runs the cloud codebase, so the isCloud() gate is
  true and the Slack endpoints are served there too
2026-08-21 12:43:49 +02:00
Pablo F.G 1d98a7b7f3 docs: name Prowler Local Server in the Slack tutorial prerequisites 2026-08-21 12:43:49 +02:00
Pablo F.G bfd1805e44 docs: mark the Slack tutorial as subscription-gated
- Swap the single-product AppliesTo for the SubscriptionBanner
- Add the sidebar Cloud marker for the Slack tutorial page
2026-08-21 12:43:49 +02:00
Pablo F.G 5790c50be1 chore(ui): drop the docs-link changelog entry
The Slack integration ships one changelog entry on the stack's first PR
2026-08-21 12:43:49 +02:00
Pablo F.G 2178650204 docs: add Slack integration guide for Prowler Cloud
- Cover the install flow via Add to Slack, choosing the default channel,
  sending a test message, and what disconnecting revokes
- State the four bot scopes Prowler requests, that `chat:write.public`
  reads broader than it behaves, and that a private channel appears only
  once `@Prowler` is invited to it in Slack
- Point the Slack card's Learn more link at the new page
- Distinguish the integration from the Prowler CLI's `--slack` output
2026-08-21 12:43:49 +02:00
41 changed files with 3198 additions and 598 deletions
+2 -1
View File
@@ -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: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+1
View File
@@ -92,6 +92,7 @@ li[id="/user-guide/tutorials/prowler-alerts"] a > div > div > span:first-child::
li[id="/user-guide/tutorials/prowler-app-attack-paths-active-queries"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-findings-triage"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-scan-configuration"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-app-slack-integration"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-aws-organizations"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-azure-management-groups"] a > div > div > span:first-child::after,
li[id="/user-guide/tutorials/prowler-cloud-gcp-organizations"] a > div > div > span:first-child::after,
@@ -0,0 +1,171 @@
---
title: "Slack Integration"
sidebarTitle: 'Slack'
description: 'Connect a Slack workspace to Prowler Cloud, choose the channel Prowler posts to, and verify the connection.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.40.0" />
<SubscriptionBanner />
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 destination:** Saving a channel checks it, so a channel Prowler cannot reach is reported straight away rather than when something 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: the workspace's public channels, plus the private channels the Prowler app has been invited to.
3. One channel is recorded on the integration as the default destination.
4. Saving that channel checks the connection against it, covering both the credential and the channel.
5. Disconnecting removes the integration from Prowler and attempts to revoke Prowler's access at Slack.
## Prerequisites
The Slack integration is available only in **Prowler Cloud** and **Prowler Private Cloud**. Prowler Local Server 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 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**.
![Slack card on the Integrations page in Prowler Cloud](/images/prowler-app/slack/integrations-tab.png)
3. Click **Add to Slack**.
![Slack management page before a workspace is connected, showing the Add to Slack action](/images/prowler-app/slack/no-workspace-connected.png)
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.
![Connected Slack workspace with no destination channel recorded yet](/images/prowler-app/slack/connected-workspace.png)
The connected card reports the workspace name and a **Not checked yet** status: the connection is checked against the destination channel, so no check has run at this point. Choosing that channel is the next step. Once one is recorded, **Test connection** verifies that Prowler can still reach both the workspace and that 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 the workspace's public channels, plus the private channels the Prowler app has been invited to, each marked **Private**.
![Destination channel picker listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png)
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**.
A workspace can hold more channels than Prowler reads in one go. When that happens, the picker says so and lists what was read: every listed channel is usable, and a channel missing from a partial list is not necessarily one `@Prowler` has to be invited to. Only listed channels can be selected: **Refresh channels** repeats the same bounded read rather than reading further, and the picker's search filters what was already read, so neither surfaces a channel the read left out.
## Disconnecting a Slack Workspace
Disconnecting removes the integration from Prowler **and** attempts to revoke Prowler's access at Slack.
1. On the Slack management page, click **Disconnect**.
2. Review the confirmation, then click **Disconnect workspace**.
![Disconnect Slack workspace confirmation dialog](/images/prowler-app/slack/disconnect-confirmation.png)
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.
* **Revocation unreported:** Slack's answer carried no outcome either way. The integration is gone from Prowler, and the disconnect is reported without any claim about revocation. When certainty matters, check the workspace's Slack app settings and remove the Prowler app if it is still installed.
Prowler reports the outcome it received: a failed revocation always names the manual cleanup step, and an unreported one is never presented as revoked.
<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. Cannot be run until a destination channel is recorded |
| **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 |
| **Disconnect** | Remove the integration and attempt to 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 Connection Check Fails on the Channel
* 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 channel, the reason Slack gave is shown there — an archived or deleted channel surfaces here rather than failing silently.
* For a private channel, confirm the Prowler app is still a member of it.
@@ -0,0 +1,224 @@
/**
* Fixture data for the alerts handlers. The alert-rule shapes mirror the API
* the alerts UI already consumes; the Slack-channel shapes follow the signed
* contract (`openspec/changes/add-slack-alert-channels/contract/`), addendum
* section 6 included — it leaves nothing open.
*
* The disabled/empty channel states are driven by what the fixture OMITS
* (no integration, no configured channels, no confirmations), never by
* handing the UI a pre-disabled state (design D9).
*/
/** A Slack channel configured on the integration. */
export interface AlertsSlackChannelFixture {
id: string;
name: string;
isPrivate: boolean;
/**
* Null until the connection check posts its one-time confirmation. Only a
* confirmed channel is eligible as an alert destination, and a
* same-workspace reinstall resets every timestamp.
*/
confirmationSentAt: string | null;
}
/**
* The tenant's Slack integration. `slackIntegration: null` is the tenant with
* no workspace connected at all.
*/
export interface AlertsSlackIntegrationFixture {
id: string;
workspaceName: string;
/** Null until a connection check has run; a reinstall resets it. */
connected: boolean | null;
/** The channels authorized on the integration. */
channels: AlertsSlackChannelFixture[];
}
export const ALERT_RULE_TRIGGERS = {
AFTER_SCAN: "after_scan",
DAILY: "daily",
BOTH: "both",
} as const;
export type AlertRuleTriggerFixture =
(typeof ALERT_RULE_TRIGGERS)[keyof typeof ALERT_RULE_TRIGGERS];
export interface AlertRuleFixture {
id: string;
name: string;
description: string;
enabled: boolean;
trigger: AlertRuleTriggerFixture;
/** The condition DSL travels through the UI opaquely. */
condition: Record<string, unknown>;
recipientEmails: string[];
/**
* Stored destinations, by channel id — all the mapping table holds. The
* read enriches them from the integration, so an id its workspace no longer
* carries simply disappears, exactly as the server-side cascade leaves it.
*/
slackChannelIds: string[];
}
export const ALERT_RECIPIENT_STATUSES = {
PENDING: "pending",
CONFIRMED: "confirmed",
UNSUBSCRIBED: "unsubscribed",
BOUNCED: "bounced",
} as const;
export type AlertRecipientStatusFixture =
(typeof ALERT_RECIPIENT_STATUSES)[keyof typeof ALERT_RECIPIENT_STATUSES];
export interface AlertRecipientFixture {
email: string;
status: AlertRecipientStatusFixture;
}
export interface AlertsFixture {
rules: AlertRuleFixture[];
recipients: AlertRecipientFixture[];
slackIntegration: AlertsSlackIntegrationFixture | null;
/** The rules list read answers `500`. */
listServerError: boolean;
}
/** UUIDs, as the API's ids are. */
export const ALERTS_SLACK_INTEGRATION_ID =
"7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c";
export const ALERT_RULE_ID = "1f6d3c2b-8a4e-4b7d-9c5f-0e1a2b3c4d5e";
const CONFIRMED_AT = "2026-08-18T10:15:00Z";
/**
* The channel ids and names match the Slack fixtures' workspace so an
* end-to-end reading of both pages tells one story, without importing from
* `slack.fixtures.ts` (that file belongs to the integrations lane).
*/
export const ALERTS_PUBLIC_CHANNEL: AlertsSlackChannelFixture = {
id: "C0123AB",
name: "security",
isPrivate: false,
confirmationSentAt: CONFIRMED_AT,
};
export const ALERTS_PRIVATE_CHANNEL: AlertsSlackChannelFixture = {
id: "C0456CD",
name: "security-alerts",
isPrivate: true,
confirmationSentAt: CONFIRMED_AT,
};
export const ALERTS_CONFIGURED_CHANNELS: AlertsSlackChannelFixture[] = [
ALERTS_PUBLIC_CHANNEL,
ALERTS_PRIVATE_CHANNEL,
];
/**
* The signed refusal code for the rule-write validation (contract section 6.1):
* one code, and a 400, for every ineligible condition — only the human
* `detail` says which one refused. Spelled out rather than imported from any
* UI mapping: a rename on our side must fail these tests.
*/
export const ALERTS_SLACK_CHANNEL_NOT_ELIGIBLE_CODE =
"slack_channel_not_eligible";
export const ALERTS_SLACK_NOT_CONNECTED_DETAIL =
"Slack must be connected before an alert rule can name channel destinations.";
export const alertsChannelNotAuthorizedDetail = (channelId: string): string =>
`Channel ${channelId} is not configured on the Slack integration.`;
export const alertsChannelNotConfirmedDetail = (channelId: string): string =>
`Channel ${channelId} has not been confirmed yet. Run the Slack connection check first.`;
export const ALERTS_LIST_SERVER_ERROR_DETAIL = "A server error occurred.";
export const alertRuleFixture = (
overrides: Partial<AlertRuleFixture> = {},
): AlertRuleFixture => ({
id: ALERT_RULE_ID,
name: "Critical findings",
description: "Notify security when critical findings land.",
enabled: true,
trigger: ALERT_RULE_TRIGGERS.AFTER_SCAN,
condition: {
op: "count_gte",
filter: { severity: ["critical"] },
value: 1,
},
recipientEmails: ["security@example.com"],
slackChannelIds: [],
...overrides,
});
/**
* The baseline tenant: a connected workspace with two confirmed channels (one
* private) and one email-only rule to edit.
*/
export const alertsFixture = (
overrides: Partial<AlertsFixture> = {},
): AlertsFixture => ({
rules: [alertRuleFixture()],
recipients: [
{
email: "security@example.com",
status: ALERT_RECIPIENT_STATUSES.CONFIRMED,
},
{ email: "ops@example.com", status: ALERT_RECIPIENT_STATUSES.CONFIRMED },
],
slackIntegration: {
id: ALERTS_SLACK_INTEGRATION_ID,
workspaceName: "Prowler HQ",
connected: true,
channels: ALERTS_CONFIGURED_CHANNELS.map((channel) => ({ ...channel })),
},
listServerError: false,
...overrides,
});
/** No Slack workspace connected: the channel destination must say why (D9). */
export const noSlackAlertsFixture = (
overrides: Partial<AlertsFixture> = {},
): AlertsFixture => alertsFixture({ slackIntegration: null, ...overrides });
/** Workspace connected, nothing authorized yet: the empty-pool state (D9). */
export const emptyChannelPoolAlertsFixture = (
overrides: Partial<AlertsFixture> = {},
): AlertsFixture =>
alertsFixture({
slackIntegration: {
id: ALERTS_SLACK_INTEGRATION_ID,
workspaceName: "Prowler HQ",
connected: true,
channels: [],
},
...overrides,
});
/**
* A same-workspace reinstall: the channels and the rules' mappings survive,
* every confirmation and the connection state are reset. The only way the API
* can still hand the form a stored channel it does not offer.
*/
export const reinstalledWorkspaceAlertsFixture = (
overrides: Partial<AlertsFixture> = {},
): AlertsFixture =>
alertsFixture({
slackIntegration: {
id: ALERTS_SLACK_INTEGRATION_ID,
workspaceName: "Prowler HQ",
connected: null,
channels: ALERTS_CONFIGURED_CHANNELS.map((channel) => ({
...channel,
confirmationSentAt: null,
})),
},
rules: [
alertRuleFixture({
slackChannelIds: [ALERTS_PUBLIC_CHANNEL.id, ALERTS_PRIVATE_CHANNEL.id],
}),
],
...overrides,
});
+414
View File
@@ -0,0 +1,414 @@
/**
* MSW handlers for the alerts pages: rules CRUD, recipients, the eligible
* Slack channels the destination field offers, the integration read that
* tells an empty pool from no workspace at all, and the sibling reads the
* alerts page issues on mount (providers, scans, findings metadata).
*
* The Slack shapes follow the signed contract
* (`openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`,
* section 2 and the section 6 addendum, which closed every open point).
*
* State is per-call: a create is visible to the next rules read. Wire them
* per test via `worker.use(...handlersForAlerts(fx))`.
*/
import { http, HttpResponse } from "msw";
import {
ALERTS_LIST_SERVER_ERROR_DETAIL,
ALERTS_SLACK_CHANNEL_NOT_ELIGIBLE_CODE,
ALERTS_SLACK_NOT_CONNECTED_DETAIL,
alertsChannelNotAuthorizedDetail,
alertsChannelNotConfirmedDetail,
} from "./alerts.fixtures";
import type {
AlertRuleFixture,
AlertsFixture,
AlertsSlackChannelFixture,
} from "./alerts.fixtures";
const API = process.env.UI_API_BASE_URL;
const TS = "2026-08-20T09:00:00Z";
/**
* `status` is a string, per the JSON:API spec — same taxonomy the Slack
* handlers answer with, since the validation is about Slack state.
*/
const errorBody = (detail: string, status: number, code?: string) => ({
errors: [
{
status: String(status),
...(code ? { code } : {}),
detail,
source: { pointer: "/data" },
},
],
});
const collection = (data: unknown[]) => ({
data,
meta: {
version: "v1",
pagination: { page: 1, pages: 1, count: data.length },
},
});
/** The rule read's channel shape: resolved name and privacy, no Slack call. */
const storedChannelAttribute = (channel: AlertsSlackChannelFixture) => ({
id: channel.id,
name: channel.name,
is_private: channel.isPrivate,
});
interface RuleWriteAttributes {
name?: string;
description?: string;
enabled?: boolean;
trigger?: AlertRuleFixture["trigger"];
condition?: AlertRuleFixture["condition"];
recipient_emails?: string[];
/** Objects carrying only `id`; name and privacy are server-derived. */
slack_channels?: { id: string }[];
}
const parseRuleAttributes = async (
request: Request,
): Promise<RuleWriteAttributes> => {
const body = (await request.json().catch(() => null)) as {
data?: { attributes?: RuleWriteAttributes };
} | null;
return body?.data?.attributes ?? {};
};
export const handlersForAlerts = (fx: AlertsFixture) => {
// Mutable copies: writes must not reach through to the caller's fixture.
const rules: AlertRuleFixture[] = fx.rules.map((rule) => ({
...rule,
recipientEmails: [...rule.recipientEmails],
slackChannelIds: [...rule.slackChannelIds],
}));
let createdCount = 0;
const isConnected = fx.slackIntegration?.connected === true;
const configuredChannel = (
channelId: string,
): AlertsSlackChannelFixture | undefined =>
fx.slackIntegration?.channels.find(
(candidate) => candidate.id === channelId,
);
/**
* What `GET /alerts/slack-channels` offers, as signed (section 6.2): only
* the confirmed channels of the enabled and connected integration. An
* unconfirmed one would just be an offer of a refusal.
*/
const eligibleChannels = (): AlertsSlackChannelFixture[] =>
isConnected
? (fx.slackIntegration?.channels ?? []).filter(
(channel) => channel.confirmationSentAt !== null,
)
: [];
/** Every ineligible condition answers the one signed pair (section 6.1). */
const notEligible = (detail: string): Response =>
HttpResponse.json(
errorBody(detail, 400, ALERTS_SLACK_CHANNEL_NOT_ELIGIBLE_CODE),
{ status: 400 },
);
/**
* The rule-write validation the contract signs: every channel a write ADDS
* needs an enabled and connected integration, the channel configured on it,
* and a non-null `confirmation_sent_at`. Only newly added channels are
* validated (section 6.3), so ids the rule already stores never block an
* edit — a same-workspace reinstall that reset their confirmations does not
* freeze the rule. Answered before any write lands, so a refusal leaves the
* stored rule unchanged.
*/
const refuseInvalidChannels = (
channelIds: string[] | undefined,
retainedIds: readonly string[] = [],
): Response | null => {
const added = (channelIds ?? []).filter(
(channelId) => !retainedIds.includes(channelId),
);
if (added.length === 0) return null;
if (!isConnected) return notEligible(ALERTS_SLACK_NOT_CONNECTED_DETAIL);
for (const channelId of added) {
const channel = configuredChannel(channelId);
if (!channel) {
return notEligible(alertsChannelNotAuthorizedDetail(channelId));
}
if (channel.confirmationSentAt === null) {
return notEligible(alertsChannelNotConfirmedDetail(channelId));
}
}
return null;
};
/** Deduplicated, as the contract requires of every Slack id list. */
const storedIds = (written: { id: string }[]): string[] =>
Array.from(new Set(written.map((channel) => channel.id)));
/**
* The read enriches the mapping's ids from the integration — the mapping
* table holds no metadata — so a channel the workspace no longer carries is
* simply absent, exactly as the cascade leaves it.
*/
const ruleResource = (rule: AlertRuleFixture) => ({
id: rule.id,
type: "alert-rules",
attributes: {
name: rule.name,
description: rule.description,
enabled: rule.enabled,
trigger: rule.trigger,
condition: rule.condition,
schema_version: 1,
recipient_emails: rule.recipientEmails,
slack_channels: rule.slackChannelIds.flatMap((channelId) => {
const channel = configuredChannel(channelId);
return channel ? [storedChannelAttribute(channel)] : [];
}),
inserted_at: TS,
updated_at: TS,
},
});
const integrationResource = (
integration: NonNullable<AlertsFixture["slackIntegration"]>,
) => ({
id: integration.id,
type: "integrations",
attributes: {
inserted_at: TS,
updated_at: TS,
enabled: true,
connected: integration.connected,
connection_last_checked_at: integration.connected === null ? null : TS,
integration_type: "slack",
configuration: {
team_id: "T01PROWLER",
team_name: integration.workspaceName,
bot_user_id: "U01PROWLERBOT",
channels: integration.channels.map((channel) => ({
...storedChannelAttribute(channel),
confirmation_sent_at: channel.confirmationSentAt,
})),
verification: {
task_id: null,
started_at: null,
finished_at: integration.connected === null ? null : TS,
},
},
},
links: { self: `${API}/integrations/${integration.id}` },
});
return [
// --- Rules -------------------------------------------------------------
http.get(`${API}/alerts/rules`, () => {
if (fx.listServerError) {
return HttpResponse.json(
errorBody(ALERTS_LIST_SERVER_ERROR_DETAIL, 500),
{ status: 500 },
);
}
return HttpResponse.json(collection(rules.map(ruleResource)));
}),
/**
* Registered before the `:id` routes so the literal paths win. The seed
* echoes the filter bag back as a leaf condition — the UI treats the DSL
* opaquely, so the exact translation is this fixture's business alone.
*/
http.post(`${API}/alerts/rules/seed`, async ({ request }) => {
const body = (await request.json().catch(() => null)) as {
data?: { attributes?: { filter_bag?: Record<string, unknown> } };
} | null;
const bag = body?.data?.attributes?.filter_bag ?? {};
const severity = bag["filter[severity__in]"];
const severityValues = Array.isArray(severity)
? severity
: typeof severity === "string"
? severity.split(",")
: ["critical"];
return HttpResponse.json({
data: {
id: "seeded-rule",
type: "alert-rule-seedings",
attributes: {
condition: {
op: "count_gte",
filter: { severity: severityValues },
value: 1,
},
},
},
});
}),
http.post(`${API}/alerts/rules/preview`, () =>
HttpResponse.json({
data: {
id: "preview",
type: "alert-rule-previews",
attributes: {
summary: { finding_count_total: 3, top_severity: "critical" },
evaluation_failed: false,
},
},
}),
),
http.post(`${API}/alerts/rules`, async ({ request }) => {
const attributes = await parseRuleAttributes(request);
const written = storedIds(attributes.slack_channels ?? []);
// A create retains nothing: everything supplied is newly added.
const refusal = refuseInvalidChannels(written);
if (refusal) return refusal;
createdCount += 1;
const created: AlertRuleFixture = {
id: `created-rule-${createdCount}`,
name: attributes.name ?? "",
description: attributes.description ?? "",
enabled: attributes.enabled ?? true,
trigger: attributes.trigger ?? "after_scan",
condition: attributes.condition ?? {},
recipientEmails: attributes.recipient_emails ?? [],
// Omission defaults both destination lists to empty.
slackChannelIds: written,
};
rules.push(created);
return HttpResponse.json(
{ data: ruleResource(created) },
{ status: 201 },
);
}),
http.get<{ id: string }>(`${API}/alerts/rules/:id`, ({ params }) => {
const rule = rules.find((candidate) => candidate.id === params.id);
if (!rule) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
}
return HttpResponse.json({ data: ruleResource(rule) });
}),
http.patch<{ id: string }>(
`${API}/alerts/rules/:id`,
async ({ params, request }) => {
const rule = rules.find((candidate) => candidate.id === params.id);
if (!rule) {
return HttpResponse.json(errorBody("Not found.", 404), {
status: 404,
});
}
const attributes = await parseRuleAttributes(request);
const written = attributes.slack_channels
? storedIds(attributes.slack_channels)
: undefined;
// Retained ids are the rule's stored selection: never re-validated.
const refusal = refuseInvalidChannels(written, rule.slackChannelIds);
if (refusal) return refusal;
if (attributes.name !== undefined) rule.name = attributes.name;
if (attributes.description !== undefined) {
rule.description = attributes.description;
}
if (attributes.enabled !== undefined) rule.enabled = attributes.enabled;
if (attributes.trigger !== undefined) rule.trigger = attributes.trigger;
if (attributes.condition !== undefined) {
rule.condition = attributes.condition;
}
if (attributes.recipient_emails !== undefined) {
rule.recipientEmails = attributes.recipient_emails;
}
// A supplied list replaces the whole Slack selection atomically, `[]`
// clears it, and omitting the key leaves it untouched.
if (written !== undefined) {
rule.slackChannelIds = written;
}
return HttpResponse.json({ data: ruleResource(rule) });
},
),
http.delete<{ id: string }>(`${API}/alerts/rules/:id`, ({ params }) => {
const index = rules.findIndex((candidate) => candidate.id === params.id);
if (index === -1) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
}
rules.splice(index, 1);
return new HttpResponse(null, { status: 204 });
}),
// --- The channels the alert form offers ---------------------------------
http.get(`${API}/alerts/slack-channels`, () =>
HttpResponse.json({
data: eligibleChannels().map((channel) => ({
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: channel.isPrivate },
})),
}),
),
// --- Recipients ---------------------------------------------------------
http.get(`${API}/alerts/recipients`, () =>
HttpResponse.json(
collection(
fx.recipients.map((recipient, index) => ({
id: `recipient-${index + 1}`,
type: "alert-recipients",
attributes: {
email: recipient.email,
status: recipient.status,
inserted_at: TS,
updated_at: TS,
},
})),
),
),
),
// --- The integration read that tells the two empty states apart ---------
http.get(`${API}/integrations`, ({ request }) => {
const type = new URL(request.url).searchParams.get(
"filter[integration_type]",
);
// An unfiltered read would pull every type into the alert form.
const install =
type === "slack" && fx.slackIntegration ? fx.slackIntegration : null;
return HttpResponse.json(
collection(install ? [integrationResource(install)] : []),
);
}),
// --- Sibling reads the alerts page issues on mount -----------------------
http.get(`${API}/providers`, () => HttpResponse.json(collection([]))),
http.get(`${API}/scans`, () => HttpResponse.json(collection([]))),
http.get(`${API}/findings/metadata/latest`, () =>
HttpResponse.json({
data: {
id: "latest",
type: "findings-metadata",
attributes: {
regions: [],
services: [],
resource_types: [],
categories: [],
groups: [],
},
},
}),
),
];
};
+97 -26
View File
@@ -1,6 +1,6 @@
/**
* Fixture data for the Slack handlers. Shapes follow the API contract in
* `openspec/changes/add-slack-integration/design.md`.
* Fixture data for the Slack handlers. Shapes follow the signed API contract in
* `openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`.
*/
export interface SlackWorkspaceFixture {
@@ -8,19 +8,35 @@ export interface SlackWorkspaceFixture {
teamName: string;
botUserId: string;
/**
* Absent from the serialized configuration until a channel is chosen: the API
* omits the keys rather than sending nulls.
* The authorized set. A new install has none, which the API serializes as an
* empty `channels` array rather than by omitting the key.
*/
channelId?: string;
channelName?: string;
authorizedChannels?: SlackAuthorizedChannelFixture[];
}
/**
* The connection check the API last recorded. Every field is null until one is
* queued; a same-workspace reinstall puts them back that way.
*/
export interface SlackVerificationFixture {
taskId: string | null;
startedAt: string | null;
finishedAt: string | null;
}
export const NO_VERIFICATION: SlackVerificationFixture = {
taskId: null,
startedAt: null,
finishedAt: null,
};
export interface SlackInstallFixture {
id: string;
/** `null` until the first connection check runs. */
connected: boolean | null;
connectionLastCheckedAt: string | null;
workspace: SlackWorkspaceFixture;
verification?: SlackVerificationFixture;
}
export const SLACK_EXCHANGE_OUTCOME = {
@@ -46,6 +62,13 @@ export type SlackExchangeOutcome =
export interface SlackConnectionFixture {
connected: boolean;
error: string | null;
/**
* The id of the channel a channel-level failure is about, which the task
* result names so the user hears which one Slack refused (contract,
* Connection). Absent for credential-level failures, which are about the
* workspace as a whole.
*/
failedChannelId?: string | null;
}
/** A channel the listing endpoint offers for the picker. */
@@ -56,6 +79,15 @@ export interface SlackChannelFixture {
isPrivate: boolean;
}
/**
* A channel authorized on the integration: the listing's fields plus when the
* one-time confirmation landed in it. `null` means the next connection check
* posts one there; a check never posts to a channel that already has one.
*/
export interface SlackAuthorizedChannelFixture extends SlackChannelFixture {
confirmationSentAt: 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`.
@@ -351,12 +383,6 @@ export const SLACK_CHANNELS: SlackChannelFixture[] = [
/** 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",
teamName: "Prowler HQ",
@@ -397,34 +423,79 @@ export const connectedSlackFixture = (
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...PROWLER_HQ },
workspace: { ...PROWLER_HQ, authorizedChannels: [] },
verification: { ...NO_VERIFICATION },
},
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED,
...overrides,
});
/** When the check that left this install connected posted its confirmations. */
export const SLACK_CONFIRMED_AT = "2026-08-10T09:30:00Z";
/** The verification of that same check, as the configuration carries it. */
const SETTLED_VERIFICATION: SlackVerificationFixture = {
taskId: "5d408881-3e9e-4195-a281-a8d1be849472",
startedAt: "2026-08-10T09:29:58Z",
finishedAt: SLACK_CONFIRMED_AT,
};
/**
* A channel as the integration stores it, confirmed by default: an install
* reporting itself connected has had a check post to every channel on it.
*/
export const authorizedChannel = (
channel: SlackChannelFixture,
confirmationSentAt: string | null = SLACK_CONFIRMED_AT,
): SlackAuthorizedChannelFixture => ({ ...channel, confirmationSentAt });
const configuredInstall = (
channel: SlackChannelFixture = SLACK_DEFAULT_CHANNEL,
channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL],
): SlackInstallFixture => ({
id: SLACK_INTEGRATION_ID,
connected: true,
connectionLastCheckedAt: "2026-08-10T09:30:00Z",
connectionLastCheckedAt: SLACK_CONFIRMED_AT,
workspace: {
...PROWLER_HQ,
channelId: channel.id,
channelName: channel.name,
authorizedChannels: channels.map((channel) => authorizedChannel(channel)),
},
verification: { ...SETTLED_VERIFICATION },
});
/**
* The same tenant with a destination channel already on record: the state a
* second visit starts from.
* The same tenant with destination channels already authorized and confirmed:
* the state a second visit starts from.
*/
export const slackFixtureWithDefaultChannel = (
channel: SlackChannelFixture = SLACK_PUBLIC_CHANNEL,
export const slackFixtureWithAuthorizedChannels = (
channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL],
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({ install: configuredInstall(channel), ...overrides });
connectedSlackFixture({ install: configuredInstall(channels), ...overrides });
/**
* Channels authorized and none of them confirmed: what a same-workspace
* reinstall leaves behind, which keeps the set but resets every confirmation
* along with the connection and verification state (contract, OAuth and reads).
*/
export const unconfirmedChannelsSlackFixture = (
channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL],
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({
install: {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: {
...PROWLER_HQ,
authorizedChannels: channels.map((channel) =>
authorizedChannel(channel, null),
),
},
verification: { ...NO_VERIFICATION },
},
...overrides,
});
/**
* The same finished setup, with a check time no parser can read: a zero date
@@ -446,20 +517,20 @@ export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
export const partiallyReadSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
slackFixtureWithAuthorizedChannels([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.
* A workspace connected *and* channels authorized. Anything the API refuses
* while the set is empty (the connection check) needs this fixture.
*/
export const configuredSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithDefaultChannel(SLACK_DEFAULT_CHANNEL, overrides);
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], overrides);
/**
* A connected tenant whose disconnect removes the row but cannot revoke at
+155 -53
View File
@@ -1,8 +1,9 @@
/**
* MSW handlers for the Slack integration, derived from the API contract in
* `openspec/changes/add-slack-integration/design.md` (the API itself lives in
* the cloud repository). State is per-call: an exchange creates the install the
* subsequent `GET /integrations` returns.
* MSW handlers for the Slack integration, derived from the signed API contract
* in `openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`
* (the API itself lives in the cloud repository). State is per-call: an
* exchange creates the install the subsequent `GET /integrations` returns, and
* a save or a connection check writes to it.
*
* Wire them per test via `worker.use(...handlersForSlack(fx))`.
*/
@@ -11,6 +12,7 @@ import { http, HttpResponse } from "msw";
import {
INTEGRATIONS_SERVER_ERROR_DETAIL,
NO_VERIFICATION,
PROXY_CHALLENGE_PAGE,
SLACK_AUTHORIZE_URL,
SLACK_DIFFERENT_WORKSPACE_DETAIL,
@@ -27,6 +29,7 @@ import {
SLACK_WORKSPACE_CONFLICT_CODE,
} from "./slack.fixtures";
import type {
SlackAuthorizedChannelFixture,
SlackExchangeOutcome,
SlackFixture,
SlackInstallFixture,
@@ -35,9 +38,15 @@ import type {
const API = process.env.UI_API_BASE_URL;
const TS = "2026-08-10T09:00:00Z";
/** When a check run by these handlers lands, and stamps its confirmations. */
const CHECK_TS = "2026-08-10T10:15:00Z";
const CONNECTION_TASK_PREFIX = "slack-conn-task-";
/** Order-insensitive: a reorder is not a changed set (contract, PATCH). */
const sameChannelIds = (a: string[], b: string[]) =>
a.length === b.length && new Set([...a, ...b]).size === a.length;
/** Opaque to the UI, which only ever follows `links.next` (design D6). */
const CHANNEL_CURSOR_PARAM = "page[cursor]";
@@ -71,14 +80,27 @@ const refuse = (refusal: SlackRefusalFixture) =>
},
);
const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
team_id: workspace.teamId,
team_name: workspace.teamName,
bot_user_id: workspace.botUserId,
// The API omits these keys until a channel is chosen, never sending nulls.
...(workspace.channelId ? { channel_id: workspace.channelId } : {}),
...(workspace.channelName ? { channel_name: workspace.channelName } : {}),
});
const configuration = (install: SlackInstallFixture) => {
const verification = install.verification ?? NO_VERIFICATION;
return {
team_id: install.workspace.teamId,
team_name: install.workspace.teamName,
bot_user_id: install.workspace.botUserId,
// Always an array: a new install carries an empty one rather than omitting
// the key (contract, OAuth and reads).
channels: (install.workspace.authorizedChannels ?? []).map((channel) => ({
id: channel.id,
name: channel.name,
is_private: channel.isPrivate,
confirmation_sent_at: channel.confirmationSentAt,
})),
verification: {
task_id: verification.taskId,
started_at: verification.startedAt,
finished_at: verification.finishedAt,
},
};
};
const integrationResource = (install: SlackInstallFixture) => ({
id: install.id,
@@ -91,7 +113,7 @@ const integrationResource = (install: SlackInstallFixture) => ({
connection_last_checked_at: install.connectionLastCheckedAt,
integration_type: "slack",
// No credentials: the bot token is encrypted at rest and never serialized.
configuration: configuration(install.workspace),
configuration: configuration(install),
},
links: { self: `${API}/integrations/${install.id}` },
});
@@ -112,6 +134,18 @@ const taskResource = (id: string, state: string, result: unknown) => ({
data: { id, type: "tasks", attributes: { state, result } },
});
/**
* The confirmation a check posts, applied to one channel: only where none has
* landed yet, and stamped only once Slack accepted the post — so the channel a
* failure names keeps none, and a retry has it left to do.
*/
const confirmedByThisRun =
(failedChannelId: string | null) =>
(channel: SlackAuthorizedChannelFixture): SlackAuthorizedChannelFixture =>
channel.confirmationSentAt === null && channel.id !== failedChannelId
? { ...channel, confirmationSentAt: CHECK_TS }
: channel;
/**
* All three are `2xx`: the first two make `response.json()` throw, the third
* parses into a body that names no resource.
@@ -133,6 +167,15 @@ export const handlersForSlack = (fx: SlackFixture) => {
? { ...fx.install, workspace: { ...fx.install.workspace } }
: null;
/** What the exchange leaves behind: no channels, nothing verified yet. */
const freshInstall = (id?: string): SlackInstallFixture => ({
id: id ?? SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace, authorizedChannels: [] },
verification: { ...NO_VERIFICATION },
});
const unconfigured = () =>
HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), {
status: 503,
@@ -189,28 +232,24 @@ export const handlersForSlack = (fx: SlackFixture) => {
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA:
// The install still happened: the API upserts before it answers.
install = {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
install = freshInstall();
return unreadableExchange(fx.exchangeOutcome);
case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
install = {
id: install?.id ?? SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
...freshInstall(install?.id),
workspace: {
...fx.exchangeWorkspace,
// A same-workspace reinstall keeps the authorized channels and
// resets every confirmation, along with the connection and
// verification state (contract, OAuth and reads).
authorizedChannels: (
install?.workspace.authorizedChannels ?? []
).map((channel) => ({ ...channel, confirmationSentAt: null })),
},
};
return HttpResponse.json({ data: integrationResource(install) });
default:
install = {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
install = freshInstall();
return HttpResponse.json(
{ data: integrationResource(install) },
{ status: 201 },
@@ -237,32 +276,56 @@ export const handlersForSlack = (fx: SlackFixture) => {
http.post<{ id: string }>(
`${API}/integrations/:id/connection`,
({ params }) => {
// The check posts to the channel, so the API refuses until one exists.
if (!install?.workspace.channelId) {
// The check reaches every configured channel, so the API requires at
// least one (contract, Connection).
if (!install?.workspace.authorizedChannels?.length) {
return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), {
status: 400,
});
}
return HttpResponse.json(
taskResource(
`${CONNECTION_TASK_PREFIX}${params.id}`,
"executing",
null,
),
{ status: 202 },
);
// The task id is pre-generated and stored before the task is
// published; the worker is what stamps `started_at`.
const taskId = `${CONNECTION_TASK_PREFIX}${params.id}`;
install.verification = {
taskId,
startedAt: null,
finishedAt: null,
};
return HttpResponse.json(taskResource(taskId, "executing", null), {
status: 202,
});
},
),
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
const { connected, error } = fx.connection;
if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) {
const { connected, error, failedChannelId } = fx.connection;
// Only a task whose id still matches may write: a late one must not
// overwrite a newer check (contract, Connection).
if (
install &&
params.taskId.startsWith(CONNECTION_TASK_PREFIX) &&
install.verification?.taskId === params.taskId
) {
install.connected = connected;
install.connectionLastCheckedAt = TS;
install.connectionLastCheckedAt = CHECK_TS;
install.verification = {
taskId: params.taskId,
startedAt: CHECK_TS,
finishedAt: CHECK_TS,
};
install.workspace.authorizedChannels = (
install.workspace.authorizedChannels ?? []
).map(confirmedByThisRun(failedChannelId ?? null));
}
return HttpResponse.json(
taskResource(params.taskId, "completed", { connected, error }),
// The result names the failing channel by id under `channel`.
taskResource(params.taskId, "completed", {
connected,
error,
channel: failedChannelId ?? null,
}),
);
}),
@@ -307,8 +370,9 @@ export const handlersForSlack = (fx: SlackFixture) => {
),
/**
* 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).
* The generic PATCH. The write carries objects that name only `id`; the
* names and privacy are derived from them here, as the API derives them
* from Slack (contract, PATCH).
*/
http.patch(`${API}/integrations/:id`, async ({ request }) => {
const body = (await request.json().catch(() => null)) as {
@@ -316,10 +380,9 @@ export const handlersForSlack = (fx: SlackFixture) => {
} | null;
const attributes = body?.data?.attributes ?? {};
const configurationPatch = attributes.configuration as
| { channel_id?: string }
| { channels?: { id: string }[] }
| undefined;
const channelId = configurationPatch?.channel_id;
const channel = fx.channels.find((c) => c.id === channelId);
const requested = configurationPatch?.channels;
if (!install) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
@@ -338,17 +401,56 @@ export const handlersForSlack = (fx: SlackFixture) => {
status: 400,
});
}
// Checked before the id lookup: the picker did offer this channel, and
// Slack refused it anyway when the API validated it.
// An omitted list leaves the set alone; an empty one clears it. Nothing
// to validate against Slack either, so no refusal is reachable here.
if (requested === undefined) {
return HttpResponse.json({ data: integrationResource(install) });
}
// Deduplicated before anything is validated or saved.
const channelIds = Array.from(
new Set(requested.map((channel) => channel.id)),
);
const matched = channelIds
.map((channelId) =>
fx.channels.find((channel) => channel.id === channelId),
)
.filter((channel) => channel !== undefined);
// Checked before the id lookup: the picker did offer these channels, and
// Slack refused one anyway when the API validated the set.
if (fx.channelSaveRefusal) return refuse(fx.channelSaveRefusal);
if (!channel) {
// One unknown id refuses the whole save: the set is validated together.
if (matched.length !== channelIds.length) {
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
status: 400,
});
}
install.workspace.channelId = channel.id;
install.workspace.channelName = channel.name;
const previous = install.workspace.authorizedChannels ?? [];
const confirmedAt = new Map(
previous.map((channel) => [channel.id, channel.confirmationSentAt]),
);
install.workspace.authorizedChannels = matched.map((channel) => ({
...channel,
// Retained ids keep their confirmation; new ones start without one.
confirmationSentAt: confirmedAt.get(channel.id) ?? null,
}));
// A changed id set resets the connection and verification state, so the
// record never claims a check covered a channel it never saw. Reordering
// the same ids changes nothing.
if (
!sameChannelIds(
previous.map((channel) => channel.id),
channelIds,
)
) {
install.connected = null;
install.connectionLastCheckedAt = null;
install.verification = { ...NO_VERIFICATION };
}
return HttpResponse.json({ data: integrationResource(install) });
}),
+11 -1
View File
@@ -18,6 +18,8 @@ type TestConnectionResponse = {
taskId?: string;
data?: TaskStartResponse;
error?: string;
/** The id of the channel a channel-level failure named, when it named one. */
failedChannelId?: string | null;
};
export const getIntegrations = async (searchParams?: URLSearchParams) => {
@@ -265,7 +267,13 @@ export const deleteIntegration = async (
}
};
type ConnectionTaskResult = { connected?: boolean; error?: string | null };
type ConnectionTaskResult = {
connected?: boolean;
error?: string | null;
// The failing channel's id, or null when the failure is not about one
// channel. The error text never carries it, so it is never parsed for it.
channel?: string | null;
};
type PollConnectionResult =
| {
@@ -358,6 +366,7 @@ export const testIntegrationConnection = async (
return {
success: false,
error: pollResult.message || "Connection test failed.",
failedChannelId: pollResult.result?.channel ?? null,
};
}
} else {
@@ -404,6 +413,7 @@ export const pollConnectionTestStatus = async (
return {
success: false,
error: pollResult.message || "Connection test failed.",
failedChannelId: pollResult.result?.channel ?? null,
};
}
} catch (_error) {
+51 -19
View File
@@ -63,7 +63,7 @@ import {
exchangeSlackOAuthCode,
getSlackAuthorizeUrl,
getSlackChannels,
setSlackDefaultChannel,
setSlackAuthorizedChannels,
} from "./slack";
/** The status the contract reserves for an upstream Slack failure. */
@@ -510,10 +510,10 @@ const expectNoParserProse = (result: unknown) => {
const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`;
const saveChannel = () =>
setSlackDefaultChannel(SLACK_INTEGRATION_ID, FIRST_CHANNEL.id);
const saveChannels = () =>
setSlackAuthorizedChannels(SLACK_INTEGRATION_ID, [FIRST_CHANNEL.id]);
/** The save as the API answers it: the channel's name derived server-side. */
/** The save as the API answers it: the channels' names derived server-side. */
const savedIntegration = () =>
new Response(
JSON.stringify({
@@ -523,8 +523,14 @@ const savedIntegration = () =>
attributes: {
integration_type: "slack",
configuration: {
channel_id: FIRST_CHANNEL.id,
channel_name: FIRST_CHANNEL.name,
channels: [
{
id: FIRST_CHANNEL.id,
name: FIRST_CHANNEL.name,
is_private: false,
confirmation_sent_at: null,
},
],
},
},
},
@@ -542,16 +548,20 @@ const expectIntegrationsRevalidated = () => {
]);
};
describe("setSlackDefaultChannel", () => {
describe("setSlackAuthorizedChannels", () => {
it("returns the saved integration and revalidates the pages listing it", async () => {
fetchMock.mockResolvedValueOnce(savedIntegration());
const result = await saveChannel();
const result = await saveChannels();
expect(requestedUrls()).toEqual([INTEGRATION_URL]);
expect(result).toMatchObject({
integration: {
attributes: { configuration: { channel_name: FIRST_CHANNEL.name } },
attributes: {
configuration: {
channels: [expect.objectContaining({ name: FIRST_CHANNEL.name })],
},
},
},
});
expectIntegrationsRevalidated();
@@ -560,16 +570,38 @@ describe("setSlackDefaultChannel", () => {
// The write serializer names whatever it will not take and refuses the whole
// save, so a body that also carried the integration's own (immutable) type
// came back as `Invalid fields: {'integration_type'}` and recorded nothing.
it("submits the channel as the save's only attribute", async () => {
it("submits the channels as the save's only attribute, naming nothing but their ids", async () => {
fetchMock.mockResolvedValueOnce(savedIntegration());
await saveChannel();
await saveChannels();
expect(sentBody()).toEqual({
data: {
type: "integrations",
id: SLACK_INTEGRATION_ID,
attributes: { configuration: { channel_id: FIRST_CHANNEL.id } },
attributes: {
configuration: { channels: [{ id: FIRST_CHANNEL.id }] },
},
},
});
});
// The API deduplicates too; a caller that named a channel twice never meant
// to authorize it twice, and the write is what the whole set is validated
// from.
it("submits a channel once, however many times the caller named it", async () => {
fetchMock.mockResolvedValueOnce(savedIntegration());
await setSlackAuthorizedChannels(SLACK_INTEGRATION_ID, [
FIRST_CHANNEL.id,
FIRST_CHANNEL.id,
]);
expect(sentBody()).toMatchObject({
data: {
attributes: {
configuration: { channels: [{ id: FIRST_CHANNEL.id }] },
},
},
});
});
@@ -582,11 +614,11 @@ describe("setSlackDefaultChannel", () => {
async ({ body }) => {
fetchMock.mockResolvedValueOnce(unreadableOk(body));
const result = await saveChannel();
const result = await saveChannels();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
// The API recorded the channel before answering, so both pages refresh.
// The API recorded the channels before answering, so both pages refresh.
expectIntegrationsRevalidated();
},
);
@@ -607,7 +639,7 @@ describe("setSlackDefaultChannel", () => {
}),
);
const result = await saveChannel();
const result = await saveChannels();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
@@ -623,8 +655,8 @@ const COPY_ONLY_ACTIONS = [
call: (id: string) => getSlackChannels(id),
},
{
name: "setSlackDefaultChannel",
call: (id: string) => setSlackDefaultChannel(id, FIRST_CHANNEL.id),
name: "setSlackAuthorizedChannels",
call: (id: string) => setSlackAuthorizedChannels(id, [FIRST_CHANNEL.id]),
},
{
name: "disconnectSlackIntegration",
@@ -687,8 +719,8 @@ describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
{
status: 400,
why: "a refusal the API meant to give",
response: () => errorResponse(400, "No default channel is set."),
expected: "No default channel is set.",
response: () => errorResponse(400, "The integration is not connected."),
expected: "The integration is not connected.",
},
])("reports nothing for a $status: that is $why", async (refusal) => {
fetchMock.mockResolvedValue(refusal.response());
+30 -18
View File
@@ -330,11 +330,11 @@ 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.
* The durable primitive, not the channels recorded 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,
@@ -422,26 +422,29 @@ export const getSlackChannels = async (
}
};
interface SlackDefaultChannelSuccess {
interface SlackAuthorizedChannelsSuccess {
integration: IntegrationProps;
}
export type SlackDefaultChannelResult =
| SlackDefaultChannelSuccess
export type SlackAuthorizedChannelsResult =
| SlackAuthorizedChannelsSuccess
| SlackActionError;
/**
* Record the channel Prowler posts to, on the generic integration endpoint.
* Record the set of channels Prowler is authorized to post to, on the generic
* integration endpoint. The list replaces the whole set: an empty one clears
* it, and every id in it stays authorized, keeping the confirmation it already
* has.
*
* 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).
* and the generic action reads `detail` alone. Only ids travel — the API
* derives each name and its privacy server-side.
*/
export const setSlackDefaultChannel = async (
export const setSlackAuthorizedChannels = async (
integrationId: string,
channelId: string,
): Promise<SlackDefaultChannelResult> => {
channelIds: string[],
): Promise<SlackAuthorizedChannelsResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
@@ -460,7 +463,16 @@ export const setSlackDefaultChannel = async (
// serializer refuses whatever it does not accept, so naming the
// integration's own (immutable) type is answered with a 400,
// "Invalid fields: {'integration_type'}".
attributes: { configuration: { channel_id: channelId } },
attributes: {
configuration: {
// Objects carrying only `id`, per the signed contract. The API
// deduplicates too; doing it here keeps a caller from asking for
// a set it did not mean.
channels: Array.from(new Set(channelIds), (channelId) => ({
id: channelId,
})),
},
},
},
}),
});
@@ -470,18 +482,18 @@ export const setSlackDefaultChannel = async (
// this `catch`.
return await refusalFrom(
response,
`Unable to save the destination channel: ${response.statusText}`,
`Unable to save the destination channels: ${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.
// holding the previous channels would keep showing them.
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
// Guarded as deep as the caller reads: it names the saved channel from
// Guarded as deep as the caller reads: it names the saved channels from
// `attributes.configuration`.
if (!body?.data?.attributes?.configuration) {
return { error: SLACK_UNREADABLE_RESULT_MESSAGE };
@@ -24,6 +24,12 @@ export interface AlertPayload {
* emails. Recipient IDs are NOT used by the rule write path.
*/
recipientEmails?: string[];
/**
* Slack channel ids from the eligible set. Replace-not-additive like
* `recipientEmails`: a supplied list replaces the whole Slack selection,
* `[]` clears it, and omitting the key leaves it unchanged.
*/
slackChannels?: string[];
}
const buildRuleEnvelope = (payload: AlertPayload, alertId?: string) => ({
@@ -40,6 +46,10 @@ const buildRuleEnvelope = (payload: AlertPayload, alertId?: string) => ({
...(payload.recipientEmails !== undefined
? { recipient_emails: payload.recipientEmails }
: {}),
// Objects carrying only `id`; the API derives name and privacy.
...(payload.slackChannels !== undefined
? { slack_channels: payload.slackChannels.map((id) => ({ id })) }
: {}),
},
},
});
@@ -0,0 +1,23 @@
"use server";
import { apiBaseUrl, getAuthHeaders } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
const ALERT_SLACK_CHANNELS_PATH = "/alerts/slack-channels";
/**
* The channels eligible as alert destinations, from the tenant's enabled and
* connected Slack integration. Server-side only: no Slack round-trip, so the
* alert form has neither pagination nor a listing-failure state.
*/
export const getAlertSlackChannels = async () => {
const headers = await getAuthHeaders({ contentType: false });
const url = new URL(`${apiBaseUrl}${ALERT_SLACK_CHANNELS_PATH}`);
try {
const response = await fetch(url.toString(), { headers });
return handleApiResponse(response);
} catch (error) {
return handleApiError(error);
}
};
@@ -26,11 +26,29 @@ const alertsActionMocks = vi.hoisted(() => ({
seedAlertRule: vi.fn(),
}));
const integrationsActionMocks = vi.hoisted(() => ({
getIntegrations: vi.fn(),
}));
const slackChannelsActionMocks = vi.hoisted(() => ({
getAlertSlackChannels: vi.fn(),
}));
vi.mock(
"@/app/(prowler)/alerts/_actions/recipients",
() => recipientsActionMocks,
);
// The channels field reads the eligible channels and the Slack integration on
// mount; its behavior is covered by the page integration tests (no-overlap
// rule), so the unit lane only keeps the fetches from escaping jsdom.
vi.mock("@/actions/integrations/integrations", () => integrationsActionMocks);
vi.mock(
"@/app/(prowler)/alerts/_actions/slack-channels",
() => slackChannelsActionMocks,
);
vi.mock("@/app/(prowler)/alerts/_actions", () => alertsActionMocks);
vi.mock(
@@ -247,6 +265,16 @@ describe("AlertFormModal", () => {
recipientsActionMocks.listAlertRecipients.mockReturnValue(
new Promise(() => {}),
);
integrationsActionMocks.getIntegrations.mockReset();
slackChannelsActionMocks.getAlertSlackChannels.mockReset();
// Never resolve, like the recipients read above: the channels field's
// settled states are integration-tested; the unit lane keeps it loading.
integrationsActionMocks.getIntegrations.mockReturnValue(
new Promise(() => {}),
);
slackChannelsActionMocks.getAlertSlackChannels.mockReturnValue(
new Promise(() => {}),
);
alertsActionMocks.previewAlertCondition.mockReset();
alertsActionMocks.seedAlertRule.mockReset();
alertsActionMocks.seedAlertRule.mockResolvedValue({
@@ -279,7 +307,8 @@ describe("AlertFormModal", () => {
expect(screen.getByLabelText(/^description$/i)).toBeVisible();
expect(screen.getByLabelText(/^frequency$/i)).toBeVisible();
expect(screen.getByLabelText(/^recipients$/i)).toBeVisible();
expect(screen.getAllByRole("combobox")).toHaveLength(2);
// Frequency, Recipients, and the Slack destination channels trigger.
expect(screen.getAllByRole("combobox")).toHaveLength(3);
expect(screen.queryByText("Alert criteria")).not.toBeInTheDocument();
expect(screen.queryByText(/delivery settings/i)).not.toBeInTheDocument();
expect(
@@ -112,13 +112,13 @@ vi.mock("../alert-form-modal", () => ({
const result = await onSubmit({
name: "Updated alert",
description: "",
method: "email",
frequency: ALERT_TRIGGER_KINDS.AFTER_SCAN,
condition: {
op: ALERT_AGGREGATE_OPS.ANY,
filter: { severity: ["critical"] },
},
recipientEmails: [],
slackChannels: [],
enabled: true,
});
setError(result.ok ? null : (result.error ?? null));
@@ -79,13 +79,13 @@ vi.mock("@/app/(prowler)/alerts/_components/alert-form-modal", () => ({
onSubmit({
name: defaultName ?? "Findings filter alert",
description: "",
method: "email",
frequency: "after_scan",
condition: seededCondition ?? {
op: "any",
filter: { severity: ["critical"] },
},
recipientEmails: ["security@example.com"],
slackChannels: [],
enabled: true,
})
}
@@ -60,7 +60,8 @@ import type {
AlertFormSubmitResult,
AlertFormValues,
} from "../_types/alert-form";
import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form";
import { SlackChannelsField } from "./slack-channels-field";
interface AlertFormModalProps {
open: boolean;
@@ -369,6 +370,10 @@ const AlertFormModalContent = ({
const [selectedRecipientEmails, setSelectedRecipientEmails] = useState(
() => new Set(defaults.recipientEmails.map(normalizeEmail)),
);
// Local state needed: channel picks are buffered until the form submits.
const [selectedSlackChannels, setSelectedSlackChannels] = useState<string[]>(
defaults.slackChannels,
);
const [errors, setErrors] = useState<FormErrors>({});
const [saving, setSaving] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
@@ -395,10 +400,10 @@ const AlertFormModalContent = ({
const buildCurrentValues = (condition: AlertCondition): AlertFormValues => ({
name,
description,
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency,
condition,
recipientEmails: getRecipientEmails(selectedRecipientEmails),
slackChannels: selectedSlackChannels,
enabled: defaults.enabled,
});
@@ -554,6 +559,13 @@ const AlertFormModalContent = ({
<FieldError>{errors.recipientEmails}</FieldError>
)}
</Field>
<Field>
<SlackChannelsField
selectedChannelIds={selectedSlackChannels}
storedChannels={editingAlert?.attributes.slack_channels ?? []}
onValuesChange={setSelectedSlackChannels}
/>
</Field>
{editingAlert && (
<div className="flex flex-col gap-3">
<Card variant="inner" padding="sm">
@@ -0,0 +1,227 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { getIntegrations } from "@/actions/integrations/integrations";
import { getAlertSlackChannels } from "@/app/(prowler)/alerts/_actions/slack-channels";
import { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select";
import {
Button,
Label,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import {
MultiSelect,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useMountEffect } from "@/hooks/use-mount-effect";
import type {
IntegrationProps,
SlackChannelOption,
} from "@/types/integrations";
const SLACK_INTEGRATION_HREF = "/integrations/slack";
const NO_INTEGRATION_COPY =
"Posting alerts to Slack channels needs a connected Slack workspace.";
const EMPTY_POOL_COPY =
"No channels are ready yet. Authorize channels on the Slack integration and run its connection check to offer them here.";
/**
* The three presentations the spec allows (design D2/D4). Read by the page
* harness off `data-alert-channels-state`.
*/
const FIELD_STATE = {
NO_INTEGRATION: "no-integration",
EMPTY_POOL: "empty-pool",
POPULATED: "populated",
} as const;
type FieldState = (typeof FIELD_STATE)[keyof typeof FIELD_STATE];
/** `GET /alerts/slack-channels` — id is the channel id (contract section 2). */
interface EligibleChannelResource {
id: string;
attributes: { name: string; is_private: boolean };
}
interface SlackChannelsFieldProps {
selectedChannelIds: string[];
/**
* The rule's stored channels from the read model (id, name, privacy). They
* are merged into the options so a channel that is configured but not yet
* confirmed — what a same-workspace reinstall leaves behind — still renders
* by name and privacy instead of blanking the stored selection.
*/
storedChannels: SlackChannelOption[];
onValuesChange: (channelIds: string[]) => void;
}
const toChannelOptions = (data: unknown): SlackChannelOption[] =>
Array.isArray(data)
? (data as EligibleChannelResource[]).map((resource) => ({
id: resource.id,
name: resource.attributes.name,
is_private: resource.attributes.is_private,
}))
: [];
const mergeOptions = (
eligible: SlackChannelOption[],
stored: SlackChannelOption[],
): SlackChannelOption[] => {
const byId = new Map(eligible.map((channel) => [channel.id, channel]));
stored.forEach((channel) => {
if (!byId.has(channel.id)) byId.set(channel.id, channel);
});
return Array.from(byId.values());
};
const ManageIntegrationLink = () => (
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
<Link href={SLACK_INTEGRATION_HREF}>Manage the Slack integration</Link>
</Button>
);
const FieldNotice = ({ copy }: { copy: string }) => (
<p
data-alert-channels-notice
className="text-text-neutral-secondary flex flex-wrap items-center gap-1 text-xs"
>
<span>{copy}</span>
<ManageIntegrationLink />
</p>
);
/**
* Slack channel destinations for an alert rule (design D2/D4): the options
* come from the dedicated eligible-channels endpoint — never the workspace
* listing, so there is no pagination and no listing-failure state — and the
* integration is read only to tell an empty pool from no workspace at all,
* which an empty collection cannot say on its own.
*/
export const SlackChannelsField = ({
selectedChannelIds,
storedChannels,
onValuesChange,
}: SlackChannelsFieldProps) => {
const [loading, setLoading] = useState(true);
const [eligibleChannels, setEligibleChannels] = useState<
SlackChannelOption[]
>([]);
const [integrationUsable, setIntegrationUsable] = useState(false);
useMountEffect(() => {
Promise.all([
getAlertSlackChannels(),
getIntegrations(
new URLSearchParams({ "filter[integration_type]": "slack" }),
),
]).then(([channelsResult, integrationsResult]) => {
setLoading(false);
// A failed read collapses to the disabled presentation: the spec allows
// exactly three states, and the integration page is where a read
// problem gets diagnosed.
if (!channelsResult?.error) {
setEligibleChannels(toChannelOptions(channelsResult?.data));
}
if (integrationsResult?.error) return;
const integration = (
integrationsResult?.data as IntegrationProps[] | undefined
)?.[0];
setIntegrationUsable(
Boolean(integration?.attributes.enabled) &&
integration?.attributes.connected === true,
);
});
});
const options = mergeOptions(eligibleChannels, storedChannels);
// Eligibility decides the state; the merged stored channels only decide what
// an already-saved rule renders.
const state: FieldState =
eligibleChannels.length > 0
? FIELD_STATE.POPULATED
: integrationUsable
? FIELD_STATE.EMPTY_POOL
: FIELD_STATE.NO_INTEGRATION;
if (loading) {
return (
<div className="flex flex-col gap-2">
<SlackChannelMultiSelect
options={options}
values={selectedChannelIds}
onChange={onValuesChange}
isLoading
disabled
/>
</div>
);
}
if (state === FIELD_STATE.NO_INTEGRATION) {
return (
<div data-alert-channels-state={state} className="flex flex-col gap-2">
{options.length > 0 ? (
// A rule keeps its channels while its workspace is unverified — a
// reinstall resets the confirmations, not the mappings — so the
// stored selection stays readable, and retained ids never refuse a
// save: only channels just added are validated (contract 6.3).
<SlackChannelMultiSelect
options={options}
values={selectedChannelIds}
onChange={onValuesChange}
disabled
/>
) : (
<>
<Label>Destination channels</Label>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex w-full" tabIndex={0}>
<MultiSelect values={[]} onValuesChange={() => undefined}>
<MultiSelectTrigger
id="slack-channels"
aria-label="Destination channels"
disabled
>
<MultiSelectValue placeholder="Requires a connected Slack workspace" />
</MultiSelectTrigger>
</MultiSelect>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{NO_INTEGRATION_COPY}
</TooltipContent>
</Tooltip>
</>
)}
<FieldNotice copy={NO_INTEGRATION_COPY} />
</div>
);
}
if (state === FIELD_STATE.EMPTY_POOL) {
return (
<div data-alert-channels-state={state} className="flex flex-col gap-2">
<Label>Destination channels</Label>
<FieldNotice copy={EMPTY_POOL_COPY} />
</div>
);
}
return (
<div data-alert-channels-state={state} className="flex flex-col gap-2">
<SlackChannelMultiSelect
options={options}
values={selectedChannelIds}
onChange={onValuesChange}
/>
</div>
);
};
@@ -23,10 +23,10 @@ const condition: AlertCondition = {
const baseValues = {
name: " Critical findings ",
description: " Notify security ",
method: "email",
frequency: ALERT_TRIGGER_KINDS.DAILY,
condition,
recipientEmails: [" Security@Example.COM ", "ops@example.com"],
slackChannels: [" C0123AB ", "C0123AB", ""],
enabled: true,
} satisfies AlertFormValues;
@@ -65,6 +65,7 @@ describe("simple alert adapter", () => {
trigger: ALERT_TRIGGER_KINDS.DAILY,
condition,
recipientEmails: ["security@example.com", "ops@example.com"],
slackChannels: ["C0123AB"],
});
expect(payload.condition).toBe(condition);
expect(payload).not.toHaveProperty("method");
@@ -78,10 +79,10 @@ describe("simple alert adapter", () => {
expect(defaults).toEqual({
name: "Existing alert",
description: "Existing description",
method: "email",
frequency: ALERT_TRIGGER_KINDS.BOTH,
condition,
recipientEmails: ["alerts@example.com"],
slackChannels: [],
enabled: false,
});
});
+11 -6
View File
@@ -8,10 +8,7 @@ import {
type AlertRule,
} from "@/app/(prowler)/alerts/_types";
import {
ALERT_NOTIFICATION_METHODS,
type AlertFormValues,
} from "../_types/alert-form";
import type { AlertFormValues } from "../_types/alert-form";
const DEFAULT_CONDITION: AlertCondition = {
op: ALERT_AGGREGATE_OPS.COUNT_GTE,
@@ -24,6 +21,11 @@ const normalizeRecipientEmails = (emails: string[]): string[] =>
.map((email) => email.trim().toLowerCase())
.filter((email) => email.length > 0);
const normalizeSlackChannels = (channelIds: string[]): string[] =>
Array.from(
new Set(channelIds.map((id) => id.trim()).filter((id) => id.length > 0)),
);
export const toAlertPayload = (values: AlertFormValues): AlertPayload => ({
name: values.name.trim(),
description: values.description.trim(),
@@ -31,6 +33,7 @@ export const toAlertPayload = (values: AlertFormValues): AlertPayload => ({
trigger: values.frequency,
condition: values.condition,
recipientEmails: normalizeRecipientEmails(values.recipientEmails),
slackChannels: normalizeSlackChannels(values.slackChannels),
});
export const getEmptyAlertFormDefaults = (
@@ -39,20 +42,22 @@ export const getEmptyAlertFormDefaults = (
): AlertFormValues => ({
name: "",
description: "",
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency,
condition,
recipientEmails: [],
slackChannels: [],
enabled: true,
});
export const getAlertFormDefaults = (alert: AlertRule): AlertFormValues => ({
name: alert.attributes.name,
description: alert.attributes.description,
method: ALERT_NOTIFICATION_METHODS.EMAIL,
frequency: alert.attributes.trigger,
condition: alert.attributes.condition,
recipientEmails: alert.attributes.recipient_emails ?? [],
slackChannels: (alert.attributes.slack_channels ?? []).map(
(channel) => channel.id,
),
enabled: alert.attributes.enabled,
});
@@ -5,8 +5,6 @@ import {
type AlertCondition,
} from "@/app/(prowler)/alerts/_types";
import { ALERT_NOTIFICATION_METHODS } from "../_types/alert-form";
const alertConditionSchema = z.custom<AlertCondition>(
(value) => typeof value === "object" && value !== null,
"Alert condition is required.",
@@ -15,11 +13,11 @@ const alertConditionSchema = z.custom<AlertCondition>(
export const alertFormSchema = z.object({
name: z.string().trim().min(1, { error: "Name is required." }).max(120),
description: z.string().trim().max(2000).default(""),
method: z.literal(ALERT_NOTIFICATION_METHODS.EMAIL),
frequency: z.enum(ALERT_TRIGGER_KIND_VALUES),
condition: alertConditionSchema,
recipientEmails: z
.array(z.email({ error: "Enter a valid email address." }))
.default([]),
slackChannels: z.array(z.string().trim().min(1)).default([]),
enabled: z.boolean(),
});
+2 -8
View File
@@ -3,20 +3,14 @@ import type {
AlertTriggerKind,
} from "@/app/(prowler)/alerts/_types";
export const ALERT_NOTIFICATION_METHODS = {
EMAIL: "email",
} as const;
export type AlertNotificationMethod =
(typeof ALERT_NOTIFICATION_METHODS)[keyof typeof ALERT_NOTIFICATION_METHODS];
export interface AlertFormValues {
name: string;
description: string;
method: AlertNotificationMethod;
frequency: AlertTriggerKind;
condition: AlertCondition;
recipientEmails: string[];
/** Slack channel ids drawn from the integration's authorized set. */
slackChannels: string[];
enabled: boolean;
}
+7
View File
@@ -1,3 +1,4 @@
import type { SlackChannelOption } from "@/types/integrations";
import { SEVERITY_LEVELS } from "@/types/severities";
// Canonical DSL vocabulary and resource types for the Alerts UI.
@@ -147,6 +148,12 @@ export interface AlertRuleAttributes {
* `recipient_emails` attribute), not as a JSON:API relationships block.
*/
recipient_emails?: string[];
/**
* Slack channel destinations, resolved by the API to id + name + privacy so
* the UI renders stored channels without a Slack round-trip. The write side
* takes ids only.
*/
slack_channels?: SlackChannelOption[];
created_by?: string | null;
inserted_at: string;
updated_at: string;
@@ -0,0 +1,473 @@
/**
* Page-level test harness for the Alerts page (Vitest Browser Mode).
*
* A client renderer cannot render an async server component, so the page is
* called and the element it returns is what gets rendered — the providers and
* Slack harnesses' pattern.
*
* The channel-destination readers are the harness side of the S2 contract:
* the alert form's channels field renders a wrapper carrying
* `data-alert-channels-state` (`no-integration` | `empty-pool` | `populated`),
* its explanatory copy under `data-alert-channels-notice`, and the shared
* multi-select's trigger as `#slack-channels`.
*/
import { createElement } from "react";
import { BrowserHarness } from "@/__tests__/browser-harness";
import { handlersForAlerts } from "@/__tests__/msw/handlers/alerts";
import type { AlertsFixture } from "@/__tests__/msw/handlers/alerts.fixtures";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import type { AlertsFilterBag } from "@/app/(prowler)/alerts/_types";
import { SeedFromFindingsButton } from "./_components/seed-from-findings-button";
import AlertsPage from "./page";
export const CHANNEL_FIELD_STATE = {
/** No enabled and connected Slack workspace: visible, disabled, explains itself. */
NO_INTEGRATION: "no-integration",
/** Workspace connected, no channel eligible yet. */
EMPTY_POOL: "empty-pool",
/** The eligible channels are on offer. */
POPULATED: "populated",
} as const;
export type ChannelFieldState =
(typeof CHANNEL_FIELD_STATE)[keyof typeof CHANNEL_FIELD_STATE];
/** A selected channel as the user reads it off the closed field. */
export interface SelectedChannelChip {
name: string;
isPrivate: boolean;
}
const DEFAULT_CREATE_FILTER_BAG: AlertsFilterBag = {
"filter[severity__in]": ["critical"],
};
export class AlertsPageHarness extends BrowserHarness<AlertsFixture> {
private wireHandlers(): void {
worker.use(...handlersForAlerts(this.fixture));
this.trackRequests(worker);
}
// --- Mounting -----------------------------------------------------------
/** Open the alerts page, the way a visit does. */
async mount(
searchParams: Record<string, string | undefined> = {},
): Promise<void> {
window.history.replaceState(null, "", "/alerts");
this.wireHandlers();
render(await AlertsPage({ searchParams: Promise.resolve(searchParams) }));
await this.waitForText(/Get notified when findings match/);
}
/**
* Mount the creation entry as the findings page composes it — the alerts
* page itself only edits; rules are created from Findings (seed flow).
*/
mountCreateEntry(
filterBag: AlertsFilterBag = DEFAULT_CREATE_FILTER_BAG,
): void {
window.history.replaceState(null, "", "/findings");
this.wireHandlers();
render(createElement(SeedFromFindingsButton, { filterBag }));
}
// --- The alert modal ------------------------------------------------------
private dialog(): HTMLElement | null {
return document.querySelector<HTMLElement>('[role="dialog"]');
}
/** Seed from the mounted create entry and wait for the modal. */
async openCreateModal(): Promise<void> {
await this.clickButton(/Create Alert/);
await this.waitFor(() => this.dialog(), 10000, "the create alert modal");
}
/** Open a listed rule for editing, by its name. */
async openEditModal(ruleName: string): Promise<void> {
const nameButton = await this.waitFor(
() =>
Array.from(
this.container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => (button.textContent ?? "").trim() === ruleName),
10000,
`the listed rule "${ruleName}"`,
);
await this.clickElement(nameButton, { fallbackToDomClick: true });
await this.waitFor(() => this.dialog(), 10000, "the edit alert modal");
}
/** Submit the open modal (Create or Save) and wait for it to close. */
async saveRule(): Promise<void> {
await this.submitModal();
await this.waitFor(
() => this.dialog() === null,
10000,
"the alert modal to close after saving",
);
}
/**
* Submit the open modal expecting a refusal, and hand back what the user is
* told. A save that closes the modal fails the test rather than timing out.
* Only newly added channels are validated (contract section 6.3), so a
* channel refusal needs options that went stale mid-edit.
*/
async refusedRuleSave(): Promise<string> {
await this.submitModal();
return this.waitFor(
() => {
if (this.dialog() === null) {
throw new Error("refusedRuleSave: the save landed, not refused");
}
return this.modalErrorText();
},
10000,
"the refused rule save",
);
}
private async submitModal(): Promise<void> {
const dialog = this.dialog();
if (!dialog) throw new Error("submitModal: no alert modal is open");
const submit = await this.waitFor(
() => this.buttonByText(/^(Create|Save)$/, dialog),
5000,
"the modal submit button",
);
await this.clickElement(submit, { fallbackToDomClick: true });
}
private modalErrorText(): string | null {
const dialog = this.dialog();
if (!dialog) return null;
const error = dialog.querySelector<HTMLElement>(".text-text-error-primary");
const text = (error?.textContent ?? "").trim();
return text.length > 0 ? text : null;
}
/**
* The channel ids the last rule write actually submitted, read off the
* contract's `[{id}, …]` write shape.
*/
async savedRuleChannels(): Promise<string[] | undefined> {
const method =
this.countRequests("POST", "/alerts/rules") >
this.countRequests("PATCH", "/alerts/rules")
? "POST"
: "PATCH";
const body = await this.lastRequestBody<{
data?: { attributes?: { slack_channels?: { id: string }[] } };
}>(method, "/alerts/rules");
return body?.data?.attributes?.slack_channels?.map((channel) => channel.id);
}
// --- The channel destination field ---------------------------------------
private channelField(): HTMLElement | null {
return document.querySelector<HTMLElement>("[data-alert-channels-state]");
}
/** Which of its three states the channel destination is presenting. */
async channelFieldState(): Promise<ChannelFieldState> {
const field = await this.waitFor(
() => this.channelField(),
10000,
"the channel destination field",
);
return field.getAttribute("data-alert-channels-state") as ChannelFieldState;
}
/** The copy explaining a degraded state, wherever the field renders it. */
async channelFieldNotice(): Promise<string> {
const notice = await this.waitFor(
() => document.querySelector<HTMLElement>("[data-alert-channels-notice]"),
10000,
"the channel destination notice",
);
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
}
/** The affordance a degraded state offers toward the integration page. */
integrationAffordanceHref(): string | null {
const scopes: (ParentNode | null)[] = [
document.querySelector("[data-alert-channels-notice]"),
this.channelField(),
];
for (const scope of scopes) {
const link = scope?.querySelector<HTMLAnchorElement>(
'a[href="/integrations/slack"]',
);
if (link) return link.getAttribute("href");
}
return null;
}
/**
* The options inside the popover THIS trigger controls, or null. Correlated
* through `aria-controls`: two pickers share the page (channels,
* recipients) and the MultiSelect also keeps a hidden mirror of its items,
* so any unscoped `[role="option"]` query can answer for the wrong picker.
*/
private static optionsControlledBy(
trigger: HTMLElement,
): HTMLElement[] | null {
const contentId = trigger.getAttribute("aria-controls");
const content = contentId ? document.getElementById(contentId) : null;
if (!content || content.getAttribute("data-state") !== "open") return null;
const options = Array.from(
content.querySelectorAll<HTMLElement>('[role="option"]'),
);
return options.length > 0 ? options : null;
}
private async openPicker(triggerSelector: string): Promise<HTMLElement[]> {
const trigger = await this.waitFor<HTMLElement>(
() => this.q(triggerSelector),
10000,
`the ${triggerSelector} picker`,
);
const mounted = () => AlertsPageHarness.optionsControlledBy(trigger);
const alreadyOpen = mounted();
if (alreadyOpen) return alreadyOpen;
await this.clickElement(trigger, { fallbackToDomClick: true });
let options = await this.waitForOrNull(mounted, 2000, "the picker options");
if (!options) {
await this.user.keyboard("{Enter}");
options = await this.waitForOrNull(mounted, 8000, "the picker options");
}
if (!options) {
throw new Error(`openPicker: ${triggerSelector} offered nothing`);
}
return options;
}
private async openChannelPicker(): Promise<HTMLElement[]> {
return this.openPicker("#slack-channels");
}
/** Whether the channel picker is rendered but refuses interaction. */
async channelPickerDisabled(): Promise<boolean> {
const trigger = await this.waitFor(
() => this.q("#slack-channels"),
10000,
"the channel picker trigger",
);
return (
(trigger as HTMLButtonElement).disabled ||
trigger.getAttribute("aria-disabled") === "true" ||
trigger.hasAttribute("disabled")
);
}
/**
* Toggle the named recipient emails in the recipients picker, verifying
* each pick against its chip like `pickChannels` does.
*/
async pickRecipients(emails: string[]): Promise<void> {
for (const email of emails) {
let picked = false;
for (let attempt = 0; attempt < 3 && !picked; attempt += 1) {
const options = await this.openPicker("#alert-recipients");
const option = options.find((candidate) =>
(candidate.textContent ?? "").includes(email),
);
if (!option) {
await this.closePicker("#alert-recipients");
throw new Error(`pickRecipients: no recipient "${email}" is offered`);
}
await this.clickElement(option, { fallbackToDomClick: true });
picked =
(await this.waitForOrNull(
() => this.chipContaining(email),
2000,
`the ${email} chip`,
)) !== null;
}
if (!picked) {
throw new Error(`pickRecipients: ${email} never showed as selected`);
}
}
await this.closePicker("#alert-recipients");
}
/**
* Close an open picker by clicking a neutral spot inside the dialog (its
* title). Not a bare Escape — with focus outside the popover it reaches the
* dialog and closes the whole modal. Not the trigger either — its chips
* remove-on-click, so a click landing on one silently drops a selection.
*/
private async closePicker(triggerSelector: string): Promise<void> {
const trigger = this.q(triggerSelector);
const isOpen = () => {
const contentId = trigger?.getAttribute("aria-controls");
const content = contentId ? document.getElementById(contentId) : null;
return content?.getAttribute("data-state") === "open";
};
if (trigger && isOpen()) {
const neutral =
this.dialog()?.querySelector<HTMLElement>("h2") ?? trigger;
await this.clickElement(neutral, { fallbackToDomClick: true });
await this.waitForOrNull(() => !isOpen(), 2000, "the picker to close");
}
await this.waitForTransition();
}
private async closeChannelPicker(): Promise<void> {
await this.closePicker("#slack-channels");
}
private static optionChannelName(option: HTMLElement): string {
return (
option.getAttribute("data-channel") ??
(option.textContent ?? "")
.replace(/Private/g, "")
.trim()
.replace(/^#/, "")
);
}
/** The channels offered for the rule, in the order the picker lists them. */
async offeredChannels(): Promise<string[]> {
const options = await this.openChannelPicker();
const names = options.map(AlertsPageHarness.optionChannelName);
await this.closeChannelPicker();
return names;
}
/** Whether the channel offered under `name` is presented as private. */
async isChannelOfferedAsPrivate(name: string): Promise<boolean> {
const options = await this.openChannelPicker();
const option = options.find(
(candidate) => AlertsPageHarness.optionChannelName(candidate) === name,
);
await this.closeChannelPicker();
return /Private/.test(option?.textContent ?? "");
}
/** A visible chip whose text contains `text`, anywhere in the open form. */
private chipContaining(text: string): HTMLElement | null {
return (
Array.from(
document.querySelectorAll<HTMLElement>("[data-selected-item]"),
).find((chip) => (chip.textContent ?? "").includes(text)) ?? null
);
}
/**
* Toggle the named channels in the picker, then close it. Each pick is
* verified against the chip the user sees and retried when the click
* landed on a node the selection re-render had already replaced.
*/
async pickChannels(names: string[]): Promise<void> {
for (const name of names) {
let picked = false;
for (let attempt = 0; attempt < 3 && !picked; attempt += 1) {
const options = await this.openChannelPicker();
const option = options.find(
(candidate) =>
AlertsPageHarness.optionChannelName(candidate) === name,
);
if (!option) {
await this.closeChannelPicker();
throw new Error(
`pickChannels: no channel named "${name}" is offered`,
);
}
await this.clickElement(option, { fallbackToDomClick: true });
picked =
(await this.waitForOrNull(
() => this.chipContaining(`#${name}`),
2000,
`the #${name} chip`,
)) !== null;
}
if (!picked) {
throw new Error(`pickChannels: #${name} never showed as selected`);
}
}
await this.closeChannelPicker();
}
/**
* The rule's selected channels as the closed field shows them — name and
* privacy read from the chip the user sees.
*/
async selectedChannelChips(): Promise<SelectedChannelChip[]> {
const field = await this.waitFor(
() => this.channelField(),
10000,
"the channel destination field",
);
const chips = Array.from(
field.querySelectorAll<HTMLElement>("[data-selected-item]"),
);
return chips.map((chip) => {
const text = (chip.textContent ?? "").replace(/\s+/g, " ").trim();
return {
isPrivate: /Private/.test(text),
// The chip renders `#name` with an sr-only "Private" marker.
name: text
.replace(/Private/g, "")
.trim()
.replace(/^#/, ""),
};
});
}
// --- The alerts list ------------------------------------------------------
/** The names of the listed rules, in table order. */
async listedRules(): Promise<string[]> {
const rows = await this.waitFor(
() => {
const found = Array.from(
this.container.querySelectorAll<HTMLTableRowElement>("tbody tr"),
);
return found.length > 0 ? found : null;
},
10000,
"the alerts list",
);
return rows.map((row) =>
(row.querySelector("button")?.textContent ?? "").trim(),
);
}
/**
* The destinations summary the list shows for a rule, without opening it —
* the Destinations column once S3 lands (Recipients until then).
*/
async ruleDestinationsSummary(ruleName: string): Promise<string> {
const headers = Array.from(
this.container.querySelectorAll<HTMLElement>("thead th"),
);
const columnIndex = headers.findIndex((header) =>
/Destinations|Recipients/.test(header.textContent ?? ""),
);
if (columnIndex === -1) {
throw new Error("ruleDestinationsSummary: no destinations column");
}
const row = await this.waitFor(
() =>
Array.from(
this.container.querySelectorAll<HTMLTableRowElement>("tbody tr"),
).find((candidate) => (candidate.textContent ?? "").includes(ruleName)),
10000,
`the listed rule "${ruleName}"`,
);
const cell = row.querySelectorAll("td")[columnIndex];
return (cell?.textContent ?? "").replace(/\s+/g, " ").trim();
}
}
@@ -0,0 +1,252 @@
/**
* Browser-mode tests for the Alerts page (`/alerts`) and the alert modal's
* Slack channel destinations, driven through `AlertsPageHarness`. MSW answers
* from handlers encoding the signed contract
* (`openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`).
*
* The no-integration and empty-pool states are driven through fixtures that
* OMIT the integration, its channels or their confirmations (design D9) —
* never by handing the UI a pre-disabled state.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
ALERTS_PRIVATE_CHANNEL,
ALERTS_PUBLIC_CHANNEL,
alertRuleFixture,
alertsChannelNotConfirmedDetail,
alertsFixture,
emptyChannelPoolAlertsFixture,
noSlackAlertsFixture,
reinstalledWorkspaceAlertsFixture,
} from "@/__tests__/msw/handlers/alerts.fixtures";
import { AlertsPageHarness, CHANNEL_FIELD_STATE } from "./alerts-page.harness";
const RULE_NAME = "Critical findings";
interface RuleWriteBody {
data: { attributes: { recipient_emails?: string[] } };
}
describe("alert rules target Slack channels", () => {
it("creates a rule with channels and an email, keeping both destination kinds", async () => {
const harness = new AlertsPageHarness(alertsFixture());
harness.mountCreateEntry();
await harness.openCreateModal();
await harness.pickChannels([
ALERTS_PUBLIC_CHANNEL.name,
ALERTS_PRIVATE_CHANNEL.name,
]);
await harness.pickRecipients(["security@example.com"]);
await harness.saveRule();
expect(await harness.savedRuleChannels()).toEqual([
ALERTS_PUBLIC_CHANNEL.id,
ALERTS_PRIVATE_CHANNEL.id,
]);
const body = await harness.lastRequestBody<RuleWriteBody>(
"POST",
"/alerts/rules",
);
expect(body?.data.attributes.recipient_emails).toContain(
"security@example.com",
);
});
it("accepts a rule whose only destinations are channels", async () => {
const harness = new AlertsPageHarness(alertsFixture());
harness.mountCreateEntry();
await harness.openCreateModal();
await harness.pickChannels([ALERTS_PRIVATE_CHANNEL.name]);
await harness.saveRule();
expect(await harness.savedRuleChannels()).toEqual([
ALERTS_PRIVATE_CHANNEL.id,
]);
const body = await harness.lastRequestBody<RuleWriteBody>(
"POST",
"/alerts/rules",
);
expect(body?.data.attributes.recipient_emails).toEqual([]);
});
it("offers exactly the eligible channels", async () => {
const harness = new AlertsPageHarness(alertsFixture());
harness.mountCreateEntry();
await harness.openCreateModal();
const offered = await harness.offeredChannels();
expect(offered).toEqual(
expect.arrayContaining([
ALERTS_PUBLIC_CHANNEL.name,
ALERTS_PRIVATE_CHANNEL.name,
]),
);
expect(offered).toHaveLength(2);
});
it("identifies a private channel in the listing and on its chip", async () => {
const harness = new AlertsPageHarness(alertsFixture());
harness.mountCreateEntry();
await harness.openCreateModal();
expect(
await harness.isChannelOfferedAsPrivate(ALERTS_PRIVATE_CHANNEL.name),
).toBe(true);
expect(
await harness.isChannelOfferedAsPrivate(ALERTS_PUBLIC_CHANNEL.name),
).toBe(false);
await harness.pickChannels([ALERTS_PRIVATE_CHANNEL.name]);
expect(await harness.selectedChannelChips()).toEqual([
{ name: ALERTS_PRIVATE_CHANNEL.name, isPrivate: true },
]);
});
it("says channels must be authorized and checked when nothing is eligible, and still saves", async () => {
const harness = new AlertsPageHarness(emptyChannelPoolAlertsFixture());
await harness.mount();
await harness.openEditModal(RULE_NAME);
expect(await harness.channelFieldState()).toBe(
CHANNEL_FIELD_STATE.EMPTY_POOL,
);
const notice = await harness.channelFieldNotice();
expect(notice).toMatch(/authorize/i);
expect(notice).toMatch(/connection check/i);
expect(harness.integrationAffordanceHref()).toBe("/integrations/slack");
// The rule's other fields and destinations still save.
await harness.saveRule();
expect(await harness.savedRuleChannels()).toEqual([]);
});
it("explains the disabled destination when no workspace is connected", async () => {
const harness = new AlertsPageHarness(noSlackAlertsFixture());
await harness.mount();
await harness.openEditModal(RULE_NAME);
expect(await harness.channelFieldState()).toBe(
CHANNEL_FIELD_STATE.NO_INTEGRATION,
);
expect(await harness.channelPickerDisabled()).toBe(true);
// Deleting the workspace deletes the rules' channel mappings with it.
expect(await harness.selectedChannelChips()).toEqual([]);
expect(await harness.channelFieldNotice()).toMatch(
/connected Slack workspace/i,
);
expect(harness.integrationAffordanceHref()).toBe("/integrations/slack");
});
it("shows the stored selection, by name and privacy, when editing", async () => {
const harness = new AlertsPageHarness(
alertsFixture({
rules: [
alertRuleFixture({
slackChannelIds: [
ALERTS_PUBLIC_CHANNEL.id,
ALERTS_PRIVATE_CHANNEL.id,
],
}),
],
}),
);
await harness.mount();
await harness.openEditModal(RULE_NAME);
expect(await harness.channelFieldState()).toBe(
CHANNEL_FIELD_STATE.POPULATED,
);
expect(await harness.selectedChannelChips()).toEqual([
{ name: ALERTS_PUBLIC_CHANNEL.name, isPrivate: false },
{ name: ALERTS_PRIVATE_CHANNEL.name, isPrivate: true },
]);
});
it("submits the complete selection, not just the added channel", async () => {
const harness = new AlertsPageHarness(
alertsFixture({
rules: [
alertRuleFixture({ slackChannelIds: [ALERTS_PUBLIC_CHANNEL.id] }),
],
}),
);
await harness.mount();
await harness.openEditModal(RULE_NAME);
await harness.pickChannels([ALERTS_PRIVATE_CHANNEL.name]);
await harness.saveRule();
expect(await harness.savedRuleChannels()).toEqual([
ALERTS_PUBLIC_CHANNEL.id,
ALERTS_PRIVATE_CHANNEL.id,
]);
});
it("keeps a stored channel readable after a reinstall reset its confirmation", async () => {
const harness = new AlertsPageHarness(reinstalledWorkspaceAlertsFixture());
await harness.mount();
await harness.openEditModal(RULE_NAME);
// The workspace still carries both channels, but an unverified install
// offers none of them: the options come from the eligible-channels
// endpoint, never from the integration's configuration.
expect(await harness.channelFieldState()).toBe(
CHANNEL_FIELD_STATE.NO_INTEGRATION,
);
expect(await harness.channelPickerDisabled()).toBe(true);
expect(await harness.selectedChannelChips()).toEqual([
{ name: ALERTS_PUBLIC_CHANNEL.name, isPrivate: false },
{ name: ALERTS_PRIVATE_CHANNEL.name, isPrivate: true },
]);
});
it("saves an edit that retains channels a reinstall left unconfirmed", async () => {
const harness = new AlertsPageHarness(reinstalledWorkspaceAlertsFixture());
await harness.mount();
await harness.openEditModal(RULE_NAME);
await harness.saveRule();
// Only newly added channels are validated, so the retained selection goes
// back untouched even though the reinstall confirmed none of it — a rule
// never becomes uneditable behind the user's back.
expect(await harness.savedRuleChannels()).toEqual([
ALERTS_PUBLIC_CHANNEL.id,
ALERTS_PRIVATE_CHANNEL.id,
]);
});
it("surfaces the refusal when a channel just added went stale before the save", async () => {
const fixture = alertsFixture();
const harness = new AlertsPageHarness(fixture);
await harness.mount();
await harness.openEditModal(RULE_NAME);
await harness.pickChannels([ALERTS_PRIVATE_CHANNEL.name]);
// The picker offered a confirmed channel; the connection state it was
// offered on is reset before the submit. Only a channel the user just
// added can reach the write ineligible, so this is the one path a refusal
// travels.
fixture.slackIntegration?.channels.forEach((channel) => {
if (channel.id === ALERTS_PRIVATE_CHANNEL.id) {
channel.confirmationSentAt = null;
}
});
const refusal = await harness.refusedRuleSave();
// The modal stays open on the unchanged rule and repeats the API's own
// detail, which the UI never parses.
expect(refusal).toContain(
alertsChannelNotConfirmedDetail(ALERTS_PRIVATE_CHANNEL.id),
);
});
});
+2 -1
View File
@@ -16,7 +16,8 @@ export default async function SlackIntegrationPage() {
<ContentLayout title="Slack">
<div className="flex flex-col gap-6">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect a Slack workspace so Prowler can post to one of its channels.
Connect a Slack workspace so Prowler can post to the channels you
authorize.
</p>
<SlackIntegrationContent />
@@ -14,7 +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 { setSlackAuthorizedChannels } from "@/actions/integrations/slack";
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { IntegrationsContent } from "../integrations-content";
@@ -29,8 +29,15 @@ export const CONNECTION_OUTCOME = {
export type ConnectionOutcome =
(typeof CONNECTION_OUTCOME)[keyof typeof CONNECTION_OUTCOME];
/** Sentinel: the page settled on "no channel recorded", rather than not yet. */
const NO_DEFAULT_CHANNEL = "<no channel recorded>";
/** Sentinel: the page settled on "no channels authorized", rather than not yet. */
const NO_AUTHORIZED_CHANNELS = "<no channels authorized>";
/** A chip the closed picker shows for an authorized channel. */
interface ChannelChip {
name: string;
/** Whether the chip itself identifies the channel as private. */
isPrivate: boolean;
}
export const REVOCATION_OUTCOME = {
REVOKED: "revoked",
@@ -125,6 +132,9 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
}
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
// A different route: whatever this harness had mounted goes first, or two
// copies of the page would answer every query.
(await this.mounted)?.unmount();
const params = new URLSearchParams();
if (code) params.set("code", code);
if (state) params.set("state", state);
@@ -136,7 +146,9 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
);
this.wireHandlers();
render(createElement(SlackCallback));
// Held like any other mount: a reinstall is followed by a `revisit()`,
// which has to take this render down before the management page goes up.
this.mounted = render(createElement(SlackCallback));
}
/** Mount the integrations catalogue. No handlers: every card there is static. */
@@ -295,10 +307,26 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
}
/**
* Why the check cannot run, read from the copy the button points at: a reason
* found anywhere else on the page never reaches whoever sees the control.
* What the check does — or why it cannot run read from the copy the button
* points at: a reason found anywhere else on the page never reaches whoever
* sees the control.
*/
connectionCheckBlockedReason(): string | null {
/**
* The same copy, waited for: what the check will do follows the page's data,
* which lands a beat after an action that changed it.
*/
async connectionCheckHintMatching(pattern: RegExp): Promise<string> {
return this.waitFor(
() => {
const hint = this.connectionCheckHint();
return hint && pattern.test(hint) ? hint : null;
},
10000,
`the connection check hint to match ${pattern}`,
);
}
connectionCheckHint(): string | null {
const button = this.buttonByText(/Test connection/);
const describedBy = button?.getAttribute("aria-describedby");
if (!describedBy) return null;
@@ -307,6 +335,15 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
return reason ? (reason.textContent ?? "").trim() : null;
}
/** What the failure toast of a connection test told the user. */
async connectionFailureToast(): Promise<string> {
return this.waitFor(
() => this.toastText(/Connection test failed/),
15000,
"the connection failure toast",
);
}
/**
* The "last checked" line as rendered, or null when the page shows none —
* which is what a workspace whose connection was never checked shows.
@@ -421,24 +458,31 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
);
}
/**
* The options of the open picker. Scoped to the popover: the multi-select
* also renders a hidden mirror of its items for the chips to read labels
* from, and that mirror exists with the listing closed.
*/
private openPickerOptions(): HTMLElement[] | null {
const options = Array.from(
document.querySelectorAll<HTMLElement>(
'[data-slot="multiselect-content"] [role="option"]',
),
);
return options.length > 0 ? options : null;
}
/**
* 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();
const alreadyOpen = this.openPickerOptions();
if (alreadyOpen) return alreadyOpen;
const trigger = await this.waitFor<HTMLElement>(
() => this.q("#slack-channel"),
() => this.q("#slack-channels"),
10000,
"the channel picker",
);
@@ -446,13 +490,17 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
await this.clickElement(trigger, { fallbackToDomClick: true });
let options = await this.waitForOrNull(
mounted,
() => this.openPickerOptions(),
2000,
"the channel options",
);
if (!options) {
await this.user.keyboard("{Enter}");
options = await this.waitForOrNull(mounted, 8000, "the channel options");
options = await this.waitForOrNull(
() => this.openPickerOptions(),
8000,
"the channel options",
);
}
if (!options) {
@@ -535,97 +583,170 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
// would never settle — the tests only narrow.
await this.waitFor(
() =>
document.querySelectorAll('[role="option"]').length !== all.length ||
document.querySelector("[cmdk-empty]") !== null ||
(this.openPickerOptions() ?? []).length !== all.length ||
document.querySelector(
'[data-slot="multiselect-content"] [cmdk-empty]',
) !== null ||
null,
5000,
"the search to narrow the channels",
);
const offered = Array.from(
document.querySelectorAll<HTMLElement>('[role="option"]'),
).map((option) => option.getAttribute("data-channel") ?? "");
const offered = (this.openPickerOptions() ?? []).map(
(option) => option.getAttribute("data-channel") ?? "",
);
const emptyNote =
document.querySelector<HTMLElement>("[cmdk-empty]")?.textContent ?? null;
document.querySelector<HTMLElement>(
'[data-slot="multiselect-content"] [cmdk-empty]',
)?.textContent ?? null;
await this.closeChannelPicker();
return { offered, emptyNote };
}
private async pickAndSave(name: string): Promise<void> {
const options = await this.openChannelPicker();
const option = options.find(
(element) => element.getAttribute("data-channel") === name,
);
/** Toggle the named channels in the open picker, then close it. */
private async pickChannels(names: string[]): Promise<void> {
for (const name of names) {
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`);
if (!option) {
throw new Error(`pickChannels: no channel named "${name}" is offered`);
}
await this.user.click(option);
await this.waitForTransition();
}
await this.user.click(option);
await this.waitForTransition();
await this.clickButton(/Save channel/);
await this.closeChannelPicker();
}
/** 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);
/**
* Toggle the named channels without saving: the picks stay buffered, which is
* the state the de-authorization warning is about.
*/
async chooseChannels(names: string[]): Promise<void> {
await this.pickChannels(names);
}
/** Save whatever channels are picked right now. */
async saveChannels(): Promise<void> {
await this.clickButton(/Save channels/);
}
/**
* What the page says before a save that drops channels — the cascade into the
* alert rules (design D11), read from the warning the pending selection
* raises rather than from the page at large.
*/
deauthorizationWarning(): string | null {
const warning = this.q("[data-deauthorize-warning]");
return warning
? (warning.textContent ?? "").replace(/\s+/g, " ").trim()
: null;
}
/**
* Drop the named channels from the authorized set and save, waiting for each
* to be gone from the record.
*/
async deauthorizeChannels(names: string[]): Promise<void> {
await this.pickChannels(names);
await this.saveChannels();
await this.waitFor(
() => this.defaultChannelName() === name,
() => {
const authorized = this.authorizedChannelNames();
const settled =
authorized ??
(this.containsText(/No destination channels authorized yet/)
? []
: null);
return settled && names.every((name) => !settled.includes(name))
? true
: null;
},
15000,
`#${name} to be recorded as the destination`,
`${names.map((name) => `#${name}`).join(", ")} to be de-authorized`,
);
}
/**
* Record a different destination away from this page — a second tab, or someone
* Toggle the named channels and save, waiting for each to be recorded among
* the authorized destinations.
*/
async authorizeChannels(names: string[]): Promise<void> {
await this.pickChannels(names);
await this.saveChannels();
await this.waitFor(
() => {
const authorized = this.authorizedChannelNames();
return authorized && names.every((name) => authorized.includes(name))
? true
: null;
},
15000,
`${names.map((name) => `#${name}`).join(", ")} to be authorized`,
);
}
/**
* Authorize a different set 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`,
);
}
async channelsRecordedElsewhere(names: string[]): Promise<void> {
const channels = names.map((name) => {
const channel = this.fixture.channels.find((c) => c.name === name);
if (!channel) {
throw new Error(
`channelsRecordedElsewhere: no channel named "${name}" is offered`,
);
}
return channel;
});
const integrationId = this.fixture.install?.id;
if (!integrationId) {
throw new Error("channelRecordedElsewhere: no workspace is connected");
throw new Error("channelsRecordedElsewhere: no workspace is connected");
}
const result = await setSlackDefaultChannel(integrationId, channel.id);
const result = await setSlackAuthorizedChannels(
integrationId,
channels.map((channel) => channel.id),
);
if ("error" in result) {
throw new Error(`channelRecordedElsewhere: ${result.error}`);
throw new Error(`channelsRecordedElsewhere: ${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/);
/** Whether the picked channels can be saved — false when there is nothing new to save. */
offersChannelsSave(): boolean {
const button = this.buttonByText(/Save channels/);
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.
* Try to save channels 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);
async refusedChannelsSave(names: string[]): Promise<string> {
await this.pickChannels(names);
await this.saveChannels();
return this.waitFor(
() => {
if (this.defaultChannelName() === name) {
const authorized = this.authorizedChannelNames() ?? [];
if (names.every((name) => authorized.includes(name))) {
throw new Error(
`refusedChannelSave: #${name} was recorded, not refused`,
`refusedChannelsSave: ${names.join(", ")} were recorded, not refused`,
);
}
return this.toastText(/Could not save the destination channel/);
return this.toastText(/Could not save the destination channels/);
},
15000,
"the refused channel save",
"the refused channels save",
);
}
@@ -640,26 +761,59 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
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 names the "Prowler posts to …" summary carries, or null while unsettled. */
private authorizedChannelNames(): string[] | null {
// The keyed element, not a text search: the card's subtitle also starts
// with "Prowler posts to".
const summary = this.q("[data-authorized-channels]");
const text = (summary?.textContent ?? "").trim();
if (!/^Prowler posts to /.test(text)) return null;
return Array.from(text.matchAll(/#([^\s,.]+)/g), (match) => match[1]);
}
/** The channel recorded as the integration's destination, if any. */
async defaultChannel(): Promise<string | null> {
const settled = await this.waitFor(
/** The channels recorded as the integration's authorized destinations. */
async authorizedChannels(): Promise<string[]> {
const settled = await this.waitFor<
string[] | typeof NO_AUTHORIZED_CHANNELS
>(
() =>
this.defaultChannelName() ??
(this.containsText(/No destination channel recorded yet/)
? NO_DEFAULT_CHANNEL
this.authorizedChannelNames() ??
(this.containsText(/No destination channels authorized yet/)
? NO_AUTHORIZED_CHANNELS
: null),
10000,
"the recorded destination channel",
"the authorized destination channels",
);
return settled === NO_DEFAULT_CHANNEL ? null : settled;
return settled === NO_AUTHORIZED_CHANNELS ? [] : settled;
}
/**
* The chips the closed picker shows for the current selection, each with
* whether it identifies its channel as private — the spec's "identified with
* the listing closed" is read from here.
*/
async authorizedChannelChips(): Promise<ChannelChip[]> {
const chips = await this.waitFor(
() => {
const found = Array.from(
this.container.querySelectorAll<HTMLElement>(
'[data-slot="multiselect-value"] [data-selected-item]',
),
);
return found.length > 0 ? found : null;
},
10000,
"the authorized channel chips",
);
return chips.map((chip) => {
const text = (chip.textContent ?? "").trim();
return {
name: text.replace(/^Private/, "").replace(/^#/, ""),
isPrivate: /^Private/.test(text),
};
});
}
/** What the user is told when the workspace exposes no channel at all. */
@@ -22,6 +22,8 @@ import {
SLACK_MISSING_SCOPE_REFUSAL,
SLACK_NOT_IN_CHANNEL_CODE,
SLACK_NOT_IN_CHANNEL_REFUSAL,
SLACK_OAUTH_CODE,
SLACK_OAUTH_STATE,
SLACK_PRIVATE_CHANNEL,
SLACK_PUBLIC_CHANNEL,
SLACK_RATE_LIMITED_REFUSAL,
@@ -32,7 +34,7 @@ import {
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UPSTREAM_REFUSAL,
slackFixture,
slackFixtureWithDefaultChannel,
slackFixtureWithAuthorizedChannels,
unreadableCheckTimeSlackFixture,
unreportedRevocationSlackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
@@ -43,7 +45,7 @@ import {
SlackIntegrationHarness,
} from "./slack-integration.harness";
/** The shape the channel save is asserted against — only the id travels. */
/** The shape the channels save is asserted against — only ids travel. */
interface PatchIntegrationBody {
data: PatchIntegrationData;
}
@@ -57,12 +59,18 @@ interface PatchIntegrationAttributes {
}
interface PatchChannelConfiguration {
channel_id: string;
channels: { id: string }[];
}
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
/**
* One channel named in copy. The tail guard is what keeps `#security` from
* matching `#security-alerts`, which is exactly the pair the fixtures use.
*/
const channelMention = (name: string) => new RegExp(`#${name}(?![\\w-])`);
/** The only scopes Prowler asks a workspace for (design D2). */
const REQUIRED_SCOPES = [
"chat:write",
@@ -248,30 +256,28 @@ describe("a connected workspace", () => {
}, 30000);
it("does not offer a connection check the API is bound to refuse", async () => {
// Given — a workspace connected and no destination channel recorded.
// Given — a workspace connected and no channels authorized yet.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// The check posts to the destination channel, so with none recorded the API
// answers 400 rather than `connected: false`.
// The check posts to the authorized channels, so with none the API answers
// 400 rather than `connected: false`.
expect(await harness.offersConnectionTest()).toBe(false);
// And — the control itself says what unblocks it.
expect(harness.connectionCheckBlockedReason()).toMatch(
/destination channel/i,
);
expect(harness.connectionCheckHint()).toMatch(/destination channel/i);
}, 30000);
});
describe("choosing a destination channel", () => {
it("offers the workspace's channels and remembers the one chosen", async () => {
describe("authorizing destination channels", () => {
it("offers the workspace's channels and remembers the several authorized", async () => {
// Given — a connected tenant whose channels span two cursor pages.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// Then — every channel is offered, so the picker followed `links.next`
// rather than stopping at the first page (design D6). Alphabetically: the
// picker sorts, so the API's page order is not the offered order.
// rather than stopping at the first page. Alphabetically: the picker
// sorts, so the API's page order is not the offered order.
expect(await harness.channelOptions()).toEqual([
SLACK_SECOND_PUBLIC_CHANNEL.name,
SLACK_PUBLIC_CHANNEL.name,
@@ -279,23 +285,61 @@ describe("choosing a destination channel", () => {
]);
expect(harness.channelListCallCount).toBe(2);
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
// When — more than one channel is authorized in a single save.
await harness.authorizeChannels([
SLACK_PUBLIC_CHANNEL.name,
SLACK_PRIVATE_CHANNEL.name,
]);
// Then — only the id is submitted: the API derives the name from it.
// Then — objects naming nothing but the id: the API derives each name and
// its privacy from Slack itself.
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
"PATCH",
"/integrations/",
);
expect(saved?.data.attributes.configuration).toEqual({
channel_id: SLACK_PUBLIC_CHANNEL.id,
});
const written = saved?.data.attributes.configuration.channels ?? [];
expect(written).toHaveLength(2);
expect(written).toEqual(
expect.arrayContaining([
{ id: SLACK_PUBLIC_CHANNEL.id },
{ id: SLACK_PRIVATE_CHANNEL.id },
]),
);
// And — a later visit shows it, under the name the API derived from the id.
// And — a later visit shows the set, under the names the API derived.
await harness.revisit();
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
expect(await harness.authorizedChannels()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
SLACK_PRIVATE_CHANNEL.name,
]);
}, 60000);
it("keeps a private channel identified on its chip with the listing closed", async () => {
// Given — an authorized set holding a private channel next to a public
// one, as a later visit reads it back.
const harness = new SlackIntegrationHarness(
slackFixtureWithAuthorizedChannels([
SLACK_PUBLIC_CHANNEL,
SLACK_PRIVATE_CHANNEL,
]),
);
// When — nothing but opening the page: the listing stays closed.
await harness.mount();
// Then — the chips themselves carry the identification, not only the rows
// inside the open listing (spec: identified while a selected value).
const chips = await harness.authorizedChannelChips();
expect(chips).toContainEqual({
name: SLACK_PRIVATE_CHANNEL.name,
isPrivate: true,
});
expect(chips).toContainEqual({
name: SLACK_PUBLIC_CHANNEL.name,
isPrivate: false,
});
}, 30000);
it("narrows the offered channels as the user types", async () => {
// Given — a connected workspace whose channels were read.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
@@ -313,7 +357,7 @@ describe("choosing a destination channel", () => {
expect(none.emptyNote).toMatch(/No channel matches/);
}, 60000);
it("offers a private channel the app was invited to, marked as private, and saves it", async () => {
it("offers a private channel the app was invited to, marked as private, and authorizes it", async () => {
// Given — `@Prowler` was invited to one private channel; `groups:read` is
// membership-gated (D2).
const harness = new SlackIntegrationHarness(connectedSlackFixture());
@@ -331,10 +375,12 @@ describe("choosing a destination channel", () => {
).toBe(false);
// When
await harness.chooseChannel(SLACK_PRIVATE_CHANNEL.name);
await harness.authorizeChannels([SLACK_PRIVATE_CHANNEL.name]);
// Then
expect(await harness.defaultChannel()).toBe(SLACK_PRIVATE_CHANNEL.name);
expect(await harness.authorizedChannels()).toEqual([
SLACK_PRIVATE_CHANNEL.name,
]);
}, 60000);
it("offers a private channel once @Prowler is invited to it and the list is refreshed", async () => {
@@ -380,79 +426,106 @@ describe("choosing a destination channel", () => {
const message = await harness.channelPickerMessage();
expect(message).toMatch(/No channels available yet/);
expect(message).toMatch(/invite @Prowler/);
expect(await harness.defaultChannel()).toBeNull();
expect(await harness.authorizedChannels()).toEqual([]);
expect(await harness.offersConnectionTest()).toBe(false);
}, 30000);
it("checks the connection itself as soon as the destination is saved", async () => {
// Given — connected with nothing recorded: the check posts to the
// destination, so it is not offered yet.
it("checks the connection itself as soon as the channels are saved", async () => {
// Given — connected with nothing authorized: the check posts to the set,
// so it is not offered yet.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
expect(await harness.offersConnectionTest()).toBe(false);
expect(harness.connectionCheckBlockedReason()).toMatch(
/destination channel/i,
);
expect(harness.connectionCheckHint()).toMatch(/destination channel/i);
expect(harness.connectionCheckCallCount).toBe(0);
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
await harness.authorizeChannels([SLACK_PUBLIC_CHANNEL.name]);
// Then
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS);
expect(harness.connectionCheckCallCount).toBe(1);
// And — everything waiting on a destination moves with the save, in the
// same paint: no reload to find the check on offer for later.
// same paint: no reload to find the check on offer for later. The copy now
// says what the check does over the set, before the user clicks.
expect(await harness.offersConnectionTest()).toBe(true);
expect(harness.connectionCheckBlockedReason()).toBeNull();
expect(harness.connectionCheckHint()).toMatch(/every authorized channel/);
// And — a check is never a message to everyone: it confirms each channel
// once (design D7), so the copy promises exactly one post per channel.
expect(harness.connectionCheckHint()).not.toMatch(/test message/i);
}, 60000);
it("reports a saved destination the check cannot reach, without losing the save", async () => {
// Given — a channel the API records, then refuses to reach: the bot is not
// in it, which only the check finds out.
it("reports a saved destination the check cannot reach, naming the channel, without losing the save", async () => {
// Given — channels the API records, then refuses to reach one of: the bot
// is not in it, which only the check finds out, and only the check can say
// which channel of the set it was (design D7).
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
connection: { connected: false, error: SLACK_NOT_IN_CHANNEL_CODE },
connection: {
connected: false,
error: SLACK_NOT_IN_CHANNEL_CODE,
failedChannelId: SLACK_PRIVATE_CHANNEL.id,
},
}),
);
await harness.mount();
// When
await harness.chooseChannel(SLACK_PUBLIC_CHANNEL.name);
await harness.authorizeChannels([
SLACK_PUBLIC_CHANNEL.name,
SLACK_PRIVATE_CHANNEL.name,
]);
// Then — only the check failed, so the destination stays on record.
// Then — only the check failed, so the destinations stay on record, and
// the failure names the one channel Slack refused.
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.FAILURE);
expect(await harness.defaultChannel()).toBe(SLACK_PUBLIC_CHANNEL.name);
expect(await harness.connectionFailureToast()).toMatch(
new RegExp(`Slack refused #${SLACK_PRIVATE_CHANNEL.name}`),
);
expect(await harness.authorizedChannels()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
SLACK_PRIVATE_CHANNEL.name,
]);
expect(await harness.offersConnectionTest()).toBe(true);
// And — the confirmation is stamped only where Slack accepted it, so the
// next check has the refused channel alone left to confirm.
await harness.refreshPageData();
const hint = await harness.connectionCheckHintMatching(
channelMention(SLACK_PRIVATE_CHANNEL.name),
);
expect(hint).not.toMatch(channelMention(SLACK_PUBLIC_CHANNEL.name));
}, 60000);
it("follows the destination recorded elsewhere when the page's data refreshes under it", async () => {
it("follows the set 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);
expect(await harness.authorizedChannels()).toEqual([
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);
// When — the set 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.channelsRecordedElsewhere([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(
expect(await harness.authorizedChannels()).toEqual([
SLACK_SECOND_PUBLIC_CHANNEL.name,
);
]);
expect(await harness.offersConnectionTest()).toBe(true);
// And — the picker followed too: the superseded destination is not left one
// click from being saved back.
expect(harness.offersChannelSave()).toBe(false);
// And — the picker followed too: the superseded set is not left one click
// from being saved back.
expect(harness.offersChannelsSave()).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
it("says which permission is missing when Slack refuses the channel listing, leaving the authorized set alone", async () => {
// Given — an authorized set, 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, {
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], {
channelsRefusal: SLACK_MISSING_SCOPE_REFUSAL,
}),
);
@@ -470,9 +543,11 @@ describe("choosing a destination channel", () => {
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);
// And — a listing Prowler could not read says nothing about the channels
// already authorized.
expect(await harness.authorizedChannels()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
]);
expect(await harness.offersConnectionTest()).toBe(true);
}, 30000);
@@ -480,7 +555,7 @@ describe("choosing a destination channel", () => {
// 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, {
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], {
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
}),
);
@@ -496,7 +571,9 @@ describe("choosing a destination channel", () => {
// 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);
expect(await harness.authorizedChannels()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
]);
}, 30000);
it("keeps the channels it did read on offer when Slack refuses a later page", async () => {
@@ -521,8 +598,10 @@ describe("choosing a destination channel", () => {
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);
// And — a partial read says nothing about the channels already authorized.
expect(await harness.authorizedChannels()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
]);
expect(await harness.offersConnectionTest()).toBe(true);
}, 60000);
@@ -541,7 +620,7 @@ describe("choosing a destination channel", () => {
// Given — a `502`, which names no `code` because there is nothing to act on
// (contract, Errors).
const harness = new SlackIntegrationHarness(
slackFixtureWithDefaultChannel(SLACK_PUBLIC_CHANNEL, {
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], {
channelsRefusal: SLACK_UPSTREAM_REFUSAL,
}),
);
@@ -553,12 +632,14 @@ describe("choosing a destination channel", () => {
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);
expect(await harness.authorizedChannels()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
]);
}, 30000);
it("says to invite @Prowler when Slack refuses the channel because the app is not in it", async () => {
it("says to invite @Prowler when Slack refuses a 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`.
// set against Slack on the way in and refuses with `not_in_channel`.
const harness = new SlackIntegrationHarness(
connectedSlackFixture({
channelSaveRefusal: SLACK_NOT_IN_CHANNEL_REFUSAL,
@@ -567,9 +648,9 @@ describe("choosing a destination channel", () => {
await harness.mount();
// When
const refusal = await harness.refusedChannelSave(
const refusal = await harness.refusedChannelsSave([
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/);
@@ -577,7 +658,7 @@ describe("choosing a destination channel", () => {
expect(refusal).not.toMatch(SLACK_NOT_IN_CHANNEL_CODE);
// And — nothing was recorded, so there is still nothing to check against.
expect(await harness.defaultChannel()).toBeNull();
expect(await harness.authorizedChannels()).toEqual([]);
expect(await harness.offersConnectionTest()).toBe(false);
}, 60000);
@@ -593,7 +674,9 @@ describe("choosing a destination channel", () => {
await harness.mount();
// When
const refusal = await harness.refusedChannelSave(SLACK_PUBLIC_CHANNEL.name);
const refusal = await harness.refusedChannelsSave([
SLACK_PUBLIC_CHANNEL.name,
]);
// Then — a different problem, so different copy: nothing to invite to a
// channel that no longer exists.
@@ -601,7 +684,162 @@ describe("choosing a destination channel", () => {
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();
expect(await harness.authorizedChannels()).toEqual([]);
}, 60000);
it("promises the confirmation only to the channels that have not had one", async () => {
// Given — one channel authorized, confirmed by the check that left the
// install connected.
const harness = new SlackIntegrationHarness(configuredSlackFixture());
await harness.mount();
// Then — checking again posts nothing: the confirmation is one-time
// (design D7), not a fresh message every run.
expect(harness.connectionCheckHint()).toMatch(/nothing is posted/);
expect(harness.connectionCheckHint()).not.toMatch(/test message/i);
// When — a second channel is authorized.
await harness.authorizeChannels([SLACK_SECOND_PUBLIC_CHANNEL.name]);
// Then — the copy names the newly authorized channel alone, and says what
// will land in it.
const hint = harness.connectionCheckHint() ?? "";
expect(hint).toMatch(channelMention(SLACK_SECOND_PUBLIC_CHANNEL.name));
expect(hint).not.toMatch(channelMention(SLACK_PUBLIC_CHANNEL.name));
expect(hint).toMatch(
/✅ Prowler connection verified\. Notifications will be delivered to this channel\./,
);
// And — once the check the save ran has landed, there is nothing left to
// confirm at all.
expect(await harness.connectionOutcome()).toBe(CONNECTION_OUTCOME.SUCCESS);
await harness.refreshPageData();
expect(
await harness.connectionCheckHintMatching(/nothing is posted/),
).toMatch(/every authorized channel/);
}, 60000);
it("warns that dropping a channel drops it from the alert rules too, before saving", async () => {
// Given — two channels authorized, either of which an alert rule may
// target.
const harness = new SlackIntegrationHarness(
slackFixtureWithAuthorizedChannels([
SLACK_PUBLIC_CHANNEL,
SLACK_PRIVATE_CHANNEL,
]),
);
await harness.mount();
// Nothing pending, nothing to warn about.
expect(harness.deauthorizationWarning()).toBeNull();
// When — one is deselected and nothing is saved yet.
await harness.chooseChannels([SLACK_PRIVATE_CHANNEL.name]);
// Then — what the save would do server-side, said where it is decided
// (design D11): the rules lose the channel, delivery stops, history stays.
const warning = harness.deauthorizationWarning() ?? "";
expect(warning).toMatch(channelMention(SLACK_PRIVATE_CHANNEL.name));
expect(warning).not.toMatch(channelMention(SLACK_PUBLIC_CHANNEL.name));
expect(warning).toMatch(/alert rule/i);
expect(warning).toMatch(/stops delivering/);
expect(warning).toMatch(/already delivered stay in Slack/);
// And — it belongs to the pending selection, not to the record: putting the
// channel back takes it away.
await harness.chooseChannels([SLACK_PRIVATE_CHANNEL.name]);
expect(harness.deauthorizationWarning()).toBeNull();
// When — the removal is saved after all.
await harness.deauthorizeChannels([SLACK_PRIVATE_CHANNEL.name]);
// Then — the record follows and the warning has nothing left to say.
expect(await harness.authorizedChannels()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
]);
expect(harness.deauthorizationWarning()).toBeNull();
}, 60000);
it("clears the authorized set when the last channel is dropped", async () => {
// Given — a single authorized channel.
const harness = new SlackIntegrationHarness(configuredSlackFixture());
await harness.mount();
// When
await harness.deauthorizeChannels([SLACK_PUBLIC_CHANNEL.name]);
// Then — an empty list clears the set, where an omitted one would leave it
// untouched (contract, PATCH).
const saved = await harness.lastRequestBody<PatchIntegrationBody>(
"PATCH",
"/integrations/",
);
expect(saved?.data.attributes.configuration.channels).toEqual([]);
expect(await harness.authorizedChannels()).toEqual([]);
// And — with nothing to post to, the check is not offered and says why.
expect(await harness.offersConnectionTest()).toBe(false);
expect(harness.connectionCheckHint()).toMatch(
/Authorize at least one destination channel/,
);
}, 60000);
it("keeps the connection when the same channels are recorded in another order", async () => {
// Given — two channels authorized and a connection checked against them.
const harness = new SlackIntegrationHarness(
slackFixtureWithAuthorizedChannels([
SLACK_PUBLIC_CHANNEL,
SLACK_SECOND_PUBLIC_CHANNEL,
]),
);
await harness.mount();
expect(await harness.connectionBadge()).toBe("Connected");
// When — the same ids are written back in the other order.
await harness.channelsRecordedElsewhere([
SLACK_SECOND_PUBLIC_CHANNEL.name,
SLACK_PUBLIC_CHANNEL.name,
]);
await harness.refreshPageData();
// Then — a reorder is not a changed set, so the check that ran still
// covers it (contract, PATCH).
expect(await harness.connectionBadge()).toBe("Connected");
// When — the set itself changes.
await harness.channelsRecordedElsewhere([SLACK_PUBLIC_CHANNEL.name]);
await harness.refreshPageData();
// Then — no check has covered this set, and the card says exactly that
// rather than claiming a connection it cannot vouch for.
expect(await harness.connectionBadge()).toBe("Not checked yet");
}, 60000);
it("keeps the authorized channels through a reinstall, with their confirmations to run again", async () => {
// Given — a finished setup whose channel an earlier check confirmed.
const harness = new SlackIntegrationHarness(configuredSlackFixture());
await harness.mount();
expect(harness.connectionCheckHint()).toMatch(/nothing is posted/);
// When — the same workspace is approved again.
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
expect(await harness.completedInstall()).toBe(true);
await harness.revisit();
// Then — a same-workspace reinstall keeps the channels and resets every
// confirmation along with the connection state (contract, OAuth and
// reads), so the check has each channel to confirm again.
expect(await harness.authorizedChannels()).toEqual([
SLACK_PUBLIC_CHANNEL.name,
]);
expect(await harness.connectionBadge()).toBe("Not checked yet");
expect(
await harness.connectionCheckHintMatching(
channelMention(SLACK_PUBLIC_CHANNEL.name),
),
).toMatch(/Prowler connection verified/);
}, 60000);
});
@@ -808,10 +1046,10 @@ describe("a credential Slack no longer accepts", () => {
expect(harness.showsRevokedCredentialNotice()).toBe(true);
expect(await harness.connectionBadge()).toBe("Disconnected");
// When — the access is approved again in Slack and the user saves a
// destination: both the save and the check it runs answer for the grant.
// When — the access is approved again in Slack and the user saves a wider
// set: both the save and the check it runs answer for the grant.
harness.fixture.connection = { connected: true, error: null };
await harness.chooseChannel(SLACK_SECOND_PUBLIC_CHANNEL.name);
await harness.authorizeChannels([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.
@@ -0,0 +1 @@
Slack channels confirmed on the Slack integration as alert rule destinations, selectable in the alert modal alongside email recipients
@@ -0,0 +1 @@
Slack integration: authorize several destination channels at once — the connection check confirms each authorized channel with a one-time message and names the one Slack refuses
@@ -137,8 +137,8 @@ export const SlackCallback = () => {
Connected to {workspaceName ?? "your Slack workspace"}
</AlertTitle>
<AlertDescription>
Taking you back to the Slack integration, where you can choose the
channel Prowler posts to.
Taking you back to the Slack integration, where you can authorize the
channels Prowler posts to.
</AlertDescription>
</Alert>
);
@@ -0,0 +1,168 @@
"use client";
import { Lock, RefreshCw } from "lucide-react";
import {
Alert,
AlertDescription,
AlertTitle,
Badge,
Button,
Label,
} from "@/components/shadcn";
import {
MultiSelect,
MultiSelectContent,
MultiSelectItem,
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
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 SlackChannelMultiSelectProps {
options: SlackChannelOption[];
values: string[];
onChange: (channelIds: 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;
/** Override when two pickers share a page: the trigger's id has to stay unique. */
id?: string;
}
/**
* The chip a selected channel renders with the listing closed: a private one
* keeps its identification there, not only inside the open listing.
*/
const chipLabel = (option: SlackChannelOption) => (
<span className="flex min-w-0 items-center gap-1">
{option.is_private && (
<>
<Lock size={12} aria-hidden="true" />
<span className="sr-only">Private</span>
</>
)}
<span className="truncate">#{option.name}</span>
</span>
);
/** Driven entirely by props (design D1) so any consumer can reuse it. */
export const SlackChannelMultiSelect = ({
options,
values,
onChange,
isLoading = false,
error = null,
incompleteNotice = null,
onRefresh,
disabled = false,
id = "slack-channels",
}: SlackChannelMultiSelectProps) => {
const isEmpty = !isLoading && !error && options.length === 0;
// `htmlFor` may only name an element that exists, and the trigger is only
// rendered in the picker branch.
const hasPicker = !error && !isEmpty;
// A copy: the list belongs to the caller. Sorted here rather than upstream so
// every consumer of the picker offers the same order.
const listed = [...options].sort((left, right) =>
left.name.localeCompare(right.name, undefined, {
sensitivity: "base",
numeric: true,
}),
);
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<Label htmlFor={hasPicker ? id : undefined}>Destination channels</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&apos;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>
)}
<MultiSelect values={values} onValuesChange={onChange}>
<MultiSelectTrigger
id={id}
aria-label="Destination channels"
disabled={disabled || isLoading}
>
<MultiSelectValue
placeholder={
isLoading ? "Reading channels..." : "Choose channels"
}
/>
</MultiSelectTrigger>
<MultiSelectContent
search={{
placeholder: "Search channels",
emptyMessage: "No channel matches that search.",
}}
>
{listed.map((option) => (
<MultiSelectItem
key={option.id}
value={option.id}
badgeLabel={chipLabel(option)}
// The search matches on `value`, which is the id here, so the
// name the user types has to be searchable on its own.
keywords={[option.name]}
// Name hook: the rendered label mixes it with a "Private"
// badge.
data-channel={option.name}
>
<span className="min-w-0 truncate">#{option.name}</span>
{option.is_private && (
<Badge variant="tag" size="sm">
Private
</Badge>
)}
</MultiSelectItem>
))}
</MultiSelectContent>
</MultiSelect>
</>
)}
<p className="text-text-neutral-secondary text-xs">{INVITE_HINT}</p>
</div>
);
};
@@ -1,195 +0,0 @@
"use client";
import { ChevronDown, RefreshCw } from "lucide-react";
import { useState } from "react";
import {
Alert,
AlertDescription,
AlertTitle,
Badge,
Button,
Label,
} from "@/components/shadcn";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/shadcn/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/shadcn/popover";
import type { SlackChannelOption } from "@/types/integrations";
const INVITE_HINT =
"A private channel only appears here after someone invites @Prowler to it in Slack. Invite it, then refresh.";
interface SlackChannelSelectorProps {
options: SlackChannelOption[];
value: string | null;
onChange: (channelId: string) => void;
isLoading?: boolean;
/** Why the channels could not be read — Slack's own reason, when it gave one. */
error?: string | null;
/** Why the list is partial. Shown with the picker, not instead of it. */
incompleteNotice?: string | null;
onRefresh?: () => void;
disabled?: boolean;
}
/** Driven entirely by props (design D13) so the alert-rule form can reuse it. */
export const SlackChannelSelector = ({
options,
value,
onChange,
isLoading = false,
error = null,
incompleteNotice = null,
onRefresh,
disabled = false,
}: SlackChannelSelectorProps) => {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const isEmpty = !isLoading && !error && options.length === 0;
// `htmlFor` may only name an element that exists, and the trigger is only
// rendered in the picker branch.
const hasPicker = !error && !isEmpty;
// A copy: the list belongs to the caller. Sorted here rather than upstream so
// every consumer of the picker offers the same order.
const listed = [...options].sort((left, right) =>
left.name.localeCompare(right.name, undefined, {
sensitivity: "base",
numeric: true,
}),
);
const selected = options.find((option) => option.id === value) ?? null;
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
// Drop the search with the popover, so re-opening it never starts filtered.
if (!open) setQuery("");
};
const handleSelect = (channelId: string) => {
onChange(channelId);
handleOpenChange(false);
};
return (
<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&apos;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>
)}
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
id="slack-channel"
variant="outline"
size="lg"
role="combobox"
aria-expanded={isOpen}
disabled={disabled || isLoading}
className="w-full justify-between"
>
<span className="min-w-0 truncate">
{selected
? `#${selected.name}`
: isLoading
? "Reading channels..."
: "Choose a channel"}
</span>
<ChevronDown size={16} aria-hidden="true" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-(--radix-popover-trigger-width) p-0"
>
<Command>
<CommandInput
placeholder="Search channels"
value={query}
onValueChange={setQuery}
aria-label="Search channels"
/>
<CommandList>
{/* A workspace with no channels at all is a different
situation, answered by the alert above. */}
<CommandEmpty>No channel matches that search.</CommandEmpty>
<CommandGroup>
{listed.map((option) => (
<CommandItem
key={option.id}
// The search matches on this value, so it carries the
// name the user types; the id travels to `onChange`
// through the closure. A name can be empty on the wire.
value={option.name || option.id}
onSelect={() => handleSelect(option.id)}
// Name hook: the rendered label mixes it with a
// "Private" badge.
data-channel={option.name}
>
<span className="min-w-0 truncate">#{option.name}</span>
{option.is_private && (
<Badge variant="tag" size="sm">
Private
</Badge>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</>
)}
<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 (
@@ -46,8 +45,8 @@ export const SlackIntegrationCard = () => {
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect a Slack workspace and pick the channel Prowler posts to, so
your team gets security updates where it already works.
Connect a Slack workspace and authorize the channels Prowler posts to,
so your team gets security updates where it already works.
</p>
</CardContent>
</Card>
@@ -14,7 +14,7 @@ import { SlackIntegrationManager } from "./slack-integration-manager";
vi.mock("@/actions/integrations/slack", () => ({
getSlackChannels: vi.fn(),
setSlackDefaultChannel: vi.fn(),
setSlackAuthorizedChannels: vi.fn(),
}));
vi.mock("@/actions/integrations/integrations", () => ({
@@ -9,11 +9,11 @@ import {
disconnectSlackIntegration,
getSlackAuthorizeUrl,
getSlackChannels,
setSlackDefaultChannel,
setSlackAuthorizedChannels,
} 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 { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select";
import {
Alert,
AlertDescription,
@@ -33,6 +33,7 @@ import {
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
import type {
IntegrationProps,
SlackAuthorizedChannel,
SlackChannelOption,
} from "@/types/integrations";
@@ -60,7 +61,15 @@ interface ChannelsLoaded {
type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
const CHECK_BLOCKED_REASON_ID = "slack-connection-check-blocked";
const CHECK_HINT_ID = "slack-connection-check-hint";
/**
* What the connection check posts, word for word (contract, Connection). Named
* here because the copy promises it: a user about to run the check sees exactly
* what will land in their channels.
*/
const CONFIRMATION_MESSAGE =
"✅ Prowler connection verified. Notifications will be delivered to this channel.";
/**
* A disconnect that removed the row without Slack confirming the revocation.
@@ -71,16 +80,71 @@ 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;
}
/** Order-insensitive: the mirror must not re-seed on a mere reordering. */
const sameChannelIds = (a: string[], b: string[]) =>
a.length === b.length && new Set([...a, ...b]).size === a.length;
const channelRefEquals = (
a: SlackChannelRef | null,
b: SlackChannelRef | null,
) => a?.id === b?.id && a?.name === b?.name;
/**
* Confirmation counts as part of the record: a check that confirms a channel
* changes nothing else, and the mirror has to follow it or the card would keep
* offering to confirm what is already confirmed.
*/
const sameChannelSets = (
a: SlackAuthorizedChannel[],
b: SlackAuthorizedChannel[],
) => {
if (a.length !== b.length) return false;
const byId = new Map(a.map((channel) => [channel.id, channel]));
return b.every((channel) => {
const other = byId.get(channel.id);
return (
other?.name === channel.name &&
other?.confirmation_sent_at === channel.confirmation_sent_at
);
});
};
/**
* The listing's copy of a channel wins over the stored one (a rename in Slack
* shows up there first), and a stored channel the listing no longer carries
* stays offered: dropping it silently would deselect it behind the user's back.
*/
const mergeChannelOptions = (
listed: SlackChannelOption[],
stored: SlackAuthorizedChannel[],
): SlackChannelOption[] => {
const merged = new Map(listed.map((channel) => [channel.id, channel]));
stored.forEach(({ id, name, is_private }) => {
// Narrowed on the way in: the picker offers channels, and whether Prowler
// has confirmed one is no part of choosing it.
if (!merged.has(id)) merged.set(id, { id, name, is_private });
});
return Array.from(merged.values());
};
/**
* What the save recorded, for an answer that carried no channels back. Retained
* ids keep the confirmation they had and new ones start without one, which is
* the rule the API applies.
*/
const recordedFromSelection = (
channelIds: string[],
options: SlackChannelOption[],
previous: SlackAuthorizedChannel[],
): SlackAuthorizedChannel[] =>
channelIds.flatMap((channelId) => {
const option = options.find((channel) => channel.id === channelId);
if (!option) return [];
const stored = previous.find((channel) => channel.id === channelId);
return [
{ ...option, confirmation_sent_at: stored?.confirmation_sent_at ?? null },
];
});
const channelList = (names: string[]): string =>
new Intl.ListFormat("en", { style: "long", type: "conjunction" }).format(
names.map((name) => `#${name}`),
);
/**
* Slack's own reason, when the string is one: the connection check reports a
@@ -129,14 +193,8 @@ export const SlackIntegrationManager = ({
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;
const recordedChannels: SlackAuthorizedChannel[] =
integration?.attributes.configuration.channels ?? [];
// Seeded `loading`, not by the effect: the effect never runs on the server,
// so anything else would server-render a "no channels" picker until
@@ -148,26 +206,27 @@ export const SlackIntegrationManager = ({
);
// 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,
// Local state needed: the picks are buffered until the user saves them.
const [selectedChannelIds, setSelectedChannelIds] = useState<string[]>(
recordedChannels.map((channel) => channel.id),
);
// 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);
const [authorizedChannels, setAuthorizedChannels] =
useState(recordedChannels);
// 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 [syncedChannels, setSyncedChannels] = useState(recordedChannels);
const [isSavingChannels, setIsSavingChannels] = useState(false);
if (!channelRefEquals(recordedChannel, syncedChannel)) {
const previousSyncedId = syncedChannel?.id ?? null;
setSyncedChannel(recordedChannel);
setDefaultChannel(recordedChannel);
// Follow the record only while the buffered pick still matches it: an
if (!sameChannelSets(recordedChannels, syncedChannels)) {
const previousSyncedIds = syncedChannels.map((channel) => channel.id);
setSyncedChannels(recordedChannels);
setAuthorizedChannels(recordedChannels);
// Follow the record only while the buffered picks still match it: an
// unsaved pick is the user's, not ours to overwrite mid-edit.
if (selectedChannelId === previousSyncedId) {
setSelectedChannelId(recordedChannel?.id ?? null);
if (sameChannelIds(selectedChannelIds, previousSyncedIds)) {
setSelectedChannelIds(recordedChannels.map((channel) => channel.id));
}
}
@@ -250,68 +309,110 @@ export const SlackIntegrationManager = ({
};
}, [integrationId, channelReloads]);
const channels =
const listedChannels =
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.channels
: [];
const channelOptions = mergeChannelOptions(
listedChannels,
authorizedChannels,
);
const handleSaveChannel = async () => {
if (!integrationId || !selectedChannelId) return;
// The check posts its confirmation only where none has landed yet, so these
// are the channels the next one would post to (contract, Connection).
const unconfirmedChannels = authorizedChannels.filter(
(channel) => channel.confirmation_sent_at === null,
);
// Channels the buffered selection would drop. Removing one cascades into the
// alert rules that target it, in the same transaction (design D11), so the
// warning belongs to the pending save rather than to what is on record.
const droppedChannels = authorizedChannels.filter(
(channel) => !selectedChannelIds.includes(channel.id),
);
let saved = false;
setIsSavingChannel(true);
const checkHint = (): string => {
if (authorizedChannels.length === 0) {
return "Authorize at least one destination channel below to enable this check.";
}
return unconfirmedChannels.length > 0
? `Checks every authorized channel and posts “${CONFIRMATION_MESSAGE}” once to ${channelList(
unconfirmedChannels.map((channel) => channel.name),
)}.`
: "Checks every authorized channel. Each was confirmed once already, so nothing is posted.";
};
const handleSaveChannels = async () => {
if (!integrationId) return;
let savedChannels: SlackAuthorizedChannel[] = [];
setIsSavingChannels(true);
try {
// Only the id travels — the API validates it and derives the name
// (design D6).
const result = await setSlackDefaultChannel(
// Only ids travel — the API validates them and derives the names.
const result = await setSlackAuthorizedChannels(
integrationId,
selectedChannelId,
selectedChannelIds,
);
if ("error" in result) {
// The API validates the channel against Slack, so the save can
// The API validates the channels against Slack, so the save can
// discover the credential is gone.
recordRefusal(result.code);
toast({
variant: "destructive",
title: "Could not save the destination channel",
title: "Could not save the destination channels",
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;
// Prefer the API's own record: it derives the names (a channel renamed
// in Slack since the list was read would otherwise show its old name)
// and it is what says which channels are confirmed.
savedChannels =
result.integration.attributes.configuration.channels ??
recordedFromSelection(
selectedChannelIds,
channelOptions,
authorizedChannels,
);
provedCredentialAlive();
setDefaultChannel({ id: selectedChannelId, name: savedName });
saved = true;
setAuthorizedChannels(savedChannels);
setSelectedChannelIds(savedChannels.map((channel) => channel.id));
toast({
title: "Destination channel saved",
description: savedName
? `Prowler will post to #${savedName}.`
: "Prowler will post to the channel you chose.",
title: "Destination channels saved",
description:
savedChannels.length > 0
? `Prowler will post to ${channelList(
savedChannels.map((channel) => channel.name),
)}.`
: "No destination channels are authorized any more.",
});
} catch (_error) {
toast({
variant: "destructive",
title: "Could not save the destination channel",
title: "Could not save the destination channels",
description: "Something went wrong. Please try again.",
});
} finally {
setIsSavingChannel(false);
setIsSavingChannels(false);
}
// Recording a destination is what makes a check possible (design D7), and
// the save alone only proves the API took the id.
if (saved) await handleTestConnection(integrationId);
// Recording destinations is what makes a check possible (design D7), and
// the save alone only proves the API took the ids: the check is what
// reaches each channel and confirms it. A save that cleared the set has
// nothing to post to.
if (savedChannels.length > 0)
await handleTestConnection(integrationId, savedChannels);
};
const handleTestConnection = async (id: string) => {
const handleTestConnection = async (
id: string,
// The set the failing channel is named from. A check chained onto a save
// runs before the state the save queued lands, so that caller passes the
// set it just recorded.
channels: SlackAuthorizedChannel[] = authorizedChannels,
) => {
setIsTesting(true);
try {
const result = await testIntegrationConnection(id);
@@ -321,7 +422,8 @@ export const SlackIntegrationManager = ({
toast({
title: "Connection test successful!",
description:
result.message || "Prowler can reach your Slack workspace.",
result.message ||
"Prowler can reach your Slack workspace and every authorized channel.",
});
} else {
// A dead credential named here is not a failure checking again can
@@ -330,12 +432,24 @@ export const SlackIntegrationManager = ({
recordRefusal(asReasonCode(reason));
const explanation = reason
? slackErrorMessage({ code: reason, detail: reason })
: "Failed to reach your Slack workspace.";
// The failure names the channel it is about by id (design D7): the
// fix is in Slack, on that channel, not on the integration as a whole.
// An id the authorized set no longer holds names nothing to the user,
// so a raw Slack id is never shown — it reads as workspace-wide.
const refusedChannel = result.failedChannelId
? channels.find((channel) => channel.id === result.failedChannelId)
: undefined;
toast({
variant: "destructive",
title: "Connection test failed",
description: reason
? slackErrorMessage({ code: reason, detail: reason })
: "Failed to reach your Slack workspace.",
description: refusedChannel
? `Slack refused #${refusedChannel.name}: ${explanation}`
: explanation,
});
}
} catch (_error) {
@@ -540,17 +654,19 @@ export const SlackIntegrationManager = ({
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{/* The check posts to the destination channel: the API answers
400 when none is recorded yet. */}
{/* The check reaches the authorized channels: the API
answers 400 while the set is empty. */}
<Button
size="sm"
variant="outline"
disabled={isTesting || !defaultChannel}
// The reason travels with the control: a disabled button
// whose explanation sits across the row reads as broken.
aria-describedby={
defaultChannel ? undefined : CHECK_BLOCKED_REASON_ID
disabled={
isTesting ||
isSavingChannels ||
authorizedChannels.length === 0
}
// What the control does — or why it cannot — travels with
// it: an explanation across the row reads as unrelated.
aria-describedby={CHECK_HINT_ID}
onClick={() => handleTestConnection(integration.id)}
>
<TestTube size={14} />
@@ -566,22 +682,20 @@ export const SlackIntegrationManager = ({
Disconnect
</Button>
</div>
{!defaultChannel && (
<p
id={CHECK_BLOCKED_REASON_ID}
className="text-xs text-gray-500 dark:text-gray-300"
>
Choose a destination channel below to enable this check.
</p>
)}
<p
id={CHECK_HINT_ID}
className="max-w-prose text-xs text-gray-500 sm:text-right dark:text-gray-300"
>
{checkHint()}
</p>
</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}
<SlackChannelMultiSelect
options={channelOptions}
values={selectedChannelIds}
onChange={setSelectedChannelIds}
isLoading={channelsState.status === CHANNELS_STATUS.LOADING}
error={
channelsState.status === CHANNELS_STATUS.ERROR
@@ -594,30 +708,52 @@ export const SlackIntegrationManager = ({
: null
}
onRefresh={() => setChannelReloads((reloads) => reloads + 1)}
disabled={isSavingChannel}
disabled={isSavingChannels}
/>
{droppedChannels.length > 0 && (
<Alert variant="warning" data-deauthorize-warning>
<AlertTitle>
Dropped channels leave your alert rules too
</AlertTitle>
<AlertDescription>
Saving this selection drops{" "}
{channelList(
droppedChannels.map((channel) => channel.name),
)}
. Every alert rule targeting a dropped channel stops
targeting it, and Prowler stops delivering there.
Notifications already delivered stay in Slack.
</AlertDescription>
</Alert>
)}
<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 check runs against. */}
{defaultChannel
? defaultChannel.name
? `Prowler posts to #${defaultChannel.name}.`
: "Prowler posts to the channel you saved."
: "No destination channel recorded yet."}
{/* The card's subtitle also starts "Prowler posts to", so the
recorded set is keyed for what reads it. */}
<p
className="text-text-neutral-secondary text-xs"
data-authorized-channels
>
{authorizedChannels.length > 0
? `Prowler posts to ${channelList(
authorizedChannels.map((channel) => channel.name),
)}.`
: "No destination channels authorized yet."}
</p>
<Button
size="sm"
disabled={
!selectedChannelId ||
selectedChannelId === (defaultChannel?.id ?? null) ||
isSavingChannel ||
sameChannelIds(
selectedChannelIds,
authorizedChannels.map((channel) => channel.id),
) ||
isSavingChannels ||
isTesting
}
onClick={handleSaveChannel}
onClick={handleSaveChannels}
>
{isSavingChannel ? "Saving..." : "Save channel"}
{isSavingChannels ? "Saving..." : "Save channels"}
</Button>
</div>
</div>
+32 -4
View File
@@ -98,13 +98,15 @@ export interface IntegrationProps {
domain?: string;
projects?: { [key: string]: string };
issue_types?: { [key: string]: string[] };
// Slack specific configuration, server-owned. The channel keys are absent
// until one is chosen, not present and null: read them with `?? null`.
// Slack specific configuration, server-owned. A new install carries an
// empty `channels` array and a `verification` whose fields are all null;
// the keys are optional here because this shape is shared with the other
// integration types, so read them with `?? []`.
team_id?: string;
team_name?: string;
bot_user_id?: string;
channel_id?: string;
channel_name?: string;
channels?: SlackAuthorizedChannel[];
verification?: SlackVerification;
[key: string]: unknown;
};
url?: string;
@@ -116,6 +118,10 @@ export interface IntegrationProps {
/**
* 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.
*
* The picker's option type, deliberately without the integration's stored
* fields: whoever renders a choice has no business knowing whether Prowler has
* already confirmed the channel.
*/
export interface SlackChannelOption {
id: string;
@@ -123,6 +129,28 @@ export interface SlackChannelOption {
is_private: boolean;
}
/**
* A channel authorized on the integration. `confirmation_sent_at` is when the
* one-time confirmation the connection check posts landed in it: null until a
* check posts one, and null again after a same-workspace reinstall, which keeps
* the channels but resets every confirmation.
*/
export interface SlackAuthorizedChannel extends SlackChannelOption {
confirmation_sent_at: string | null;
}
/**
* The connection check the API last recorded. `task_id` is pre-generated when
* the check is queued, so it exists before the worker starts: `started_at` is
* what says execution began, and only a task whose id still matches may write
* here — which is what keeps a late check from overwriting a newer one.
*/
export interface SlackVerification {
task_id: string | null;
started_at: string | null;
finished_at: string | null;
}
// Jira dispatch types
export interface JiraDispatchRequest {
data: {