Compare commits

...
Author SHA1 Message Date
Pablo F.G 641aa6eb8e docs: follow the Slack copy fix into the screenshots and tutorial
- Re-capture every Slack frame carrying the page description and the
  integrations card, now that both name the authorized channels
- Say the connection check cannot be run until a channel is authorized,
  which is what the disabled control and its hint really report
2026-08-20 17:00:16 +02:00
Pablo F.G a6236a9a1a docs: re-stage the Slack and alert screenshots on the reworked UI
- Slack: the authorized set several channels wide, a selected private
  channel keeping its Private identification, and the connection check
  withheld while nothing is authorized
- Alerts: the Destination channels field below Recipients on both the
  create and the edit form, and the Destinations column summarizing
  emails and channels side by side
- No frame offers a test message any more: the connection check is the
  only proof of delivery
2026-08-20 16:54:04 +02:00
Pablo F.G e110da0018 docs: align Slack channel docs with the signed API contract
- De-authorizing a channel or disconnecting the integration posts no
  removal message and keeps the notifications already delivered
- Changing which channels are authorized resets the connection state,
  while reordering the same channels does not
2026-08-20 16:54:04 +02:00
Pablo F.G cbaa2212a0 docs: document Slack channel destinations for alerts
- Alerts: the Destination channels field below Recipients, the pool of
  confirmed channels it draws from, its unavailable states, and the
  Destinations column, cross-linked to the Slack tutorial
- Slack: the channel section becomes an authorized set several channels
  wide, confirmed by the connection check, and removing a channel from
  it also removes it from the alerts that targeted it
2026-08-20 16:54:04 +02:00
Pablo F.G 32222627dd fix(ui): feed the destinations column from the reworked alerts fixtures
- Seed a rule's channels by id, as the mapping table now holds them
- Anchor the list reader on the Destinations column it really renders
2026-08-20 16:53:47 +02:00
Pablo F.G 6b166576ba feat(ui): show a rule's destinations in the alerts list
- Retitle the Recipients column to Destinations per design D6
- Summarize emails and Slack channels side by side, at a glance
- Cover list-altitude destination summaries in the integration tests
2026-08-20 16:53:47 +02:00
Pablo F.G 0f643be330 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-20 16:53:30 +02:00
Pablo F.G 8600a334ba 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-20 16:53:30 +02:00
Pablo F.G f2a97b8549 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-20 16:53:30 +02:00
Pablo F.G a1003491f1 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-20 16:49:42 +02:00
Pablo F.G 7feaf213e6 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-20 16:01:19 +02:00
Pablo F.G 82e286c37d 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-20 14:02:51 +02:00
Pablo F.G 4a832cdd0d 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-20 11:38:42 +02:00
Pablo F.G 221886d637 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-20 11:38:42 +02:00
Pablo F.G 28a60d8e14 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-20 11:38:42 +02:00
Pablo F.G 6f1be74f71 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-20 11:38:42 +02:00
Pablo F.G 110a1693cc 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-20 11:38:42 +02:00
Pablo F.G 196df313f0 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-20 11:38:42 +02:00
Pablo F.G b3e0099ed3 docs: name Prowler Local Server in the Slack tutorial prerequisites 2026-08-20 11:38:42 +02:00
Pablo F.G 0ffa2f538b 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-20 11:38:42 +02:00
Pablo F.G a041130693 chore(ui): drop the docs-link changelog entry
The Slack integration ships one changelog entry on the stack's first PR
2026-08-20 11:38:42 +02:00
Pablo F.G 317f270f48 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-20 11:38:42 +02:00
Pablo F.G ea7cdcd00f test(ui): wait for the Slack reconnect affordance instead of sampling it
- The link needs a consent URL the page mints on demand, so it lands a
  beat after the notice that explains it; a synchronous read raced it
  and failed in CI while passing locally
2026-08-20 11:38:00 +02:00
Pablo F.G dcf32f0300 docs(ui): trim the Slack revoked-credential test comment
- Compress the four-line setup rationale to the claim it carries
2026-08-20 11:26:34 +02:00
Pablo F.G c6c2915eaa test(ui): reach the revoked credential through the connection check
- The test message is gone, so the recovery test now gets into the
  revoked state the way the page still can, and lets Slack accept the
  credential again before the save that clears the notice
2026-08-20 11:25:25 +02:00
Pablo F.G e992be8190 test(ui): follow the sorted Slack picker in the partial-read case
- The picker sorts what it offers, so the assertion for a refused later
  cursor page still expected the API's page order and failed
2026-08-20 11:25:25 +02:00
Pablo F.G 03e53c26dc docs(ui): trim the Slack disconnect test and fixture comments
- Drop assertion narration and keep contract facts stated once
2026-08-20 11:25:25 +02:00
Pablo F.G 9529e48fa7 docs(ui): trim the Slack disconnect component comments
- Cut each rationale to its load-bearing clause and drop restatements
2026-08-20 11:25:25 +02:00
Pablo F.G d51d754fe4 docs(ui): trim the Slack disconnect action and unit-test comments
- Keep only the non-obvious why, stated once at its load-bearing site
2026-08-20 11:25:25 +02:00
Pablo F.G 1cab262607 fix(ui): keep the refusal code when only part of the channel list loads
- Carry the code of the refusal that truncated the read, so a grant
  refused on a later page still offers the reconnect path
2026-08-20 11:25:25 +02:00
Pablo F.G b5e0c51183 fix(ui): keep naming the workspace in the failed-revocation notice
- Snapshot the workspace name when disconnecting, so the notice keeps
  naming it after the refresh removes the integration record
2026-08-20 11:25:25 +02:00
Pablo F.G 4bc7d63c9d fix(ui): move the revoked-credential notice only on Slack's answer
- Clear the notice when a save Slack validated goes through
- Keep it up when a later check fails without Slack naming a reason
2026-08-20 11:25:25 +02:00
Pablo F.G 7d2e0023f3 fix(ui): validate the integration id in the Slack disconnect action
- Refuse a malformed id before the request is built, closing the open
  request-forgery alert
- Put the disconnect action under the shared malformed-id contract
2026-08-20 11:25:24 +02:00
Pablo F.G bf1e75a3a7 test(ui): cover the unreported Slack revocation outcome
- Let the revocation fixture answer with no body at all
- Anchor the disconnect harness on each outcome's own wording instead
  of the shared toast title
- Assert the neutral copy when nothing reports the revocation
2026-08-20 11:25:24 +02:00
Pablo F.G 198ce8d640 fix(ui): keep a Slack upstream fault inside the action's answer
- Await the refusal mapping so a server fault becomes the action's own
  error instead of escaping the catch
- Expect the refusal code the actions now carry in their unit tests
2026-08-20 11:25:24 +02:00
Pablo F.G 154df41456 fix(ui): keep a never-checked integration off the disconnected badge
- Restore the neutral badge for Jira, S3 and Security Hub when the
  connection has not been checked yet
2026-08-20 11:25:24 +02:00
Pablo F.G 0c9e650407 chore(ui): drop the disconnect and reconnect changelog entries
The Slack integration ships one changelog entry on the stack's first PR
2026-08-20 11:25:24 +02:00
Pablo F.G 845551c091 fix(ui): say when Slack has stopped accepting Prowler's access
- Recognise a dead credential through the shared Slack error vocabulary,
  so the channel listing, the channel save, the test message and the
  connection check all offer a reconnect rather than a retry
- Word the notice from the refusal's code instead of showing Slack's
  raw reason, which is a protocol token and not user-facing copy
- Report a failed revocation as the API states it: the integration is
  gone from Prowler, and access may still need removing by hand
2026-08-20 11:25:24 +02:00
Pablo F.G 7d24a5303a feat(ui): add Slack disconnect and revoked-credential recovery
- Disconnect a Slack workspace behind a confirmation, returning the
  page to its unconnected state
- Report the revocation outcome from the DELETE response meta, telling
  the user when access still has to be removed in Slack by hand
- Offer to connect the workspace again when Slack stops accepting the
  stored credential
- Widen the integration `connected` attribute to allow null, which the
  OAuth exchange returns until the first connection check runs
2026-08-20 11:25:24 +02:00
Pablo F.G cc0a1c89cf refactor(ui): drop the Slack copy left behind by the test message
- Remove slackUnknownReasonMessage: its only caller was the test-message
  action, and its wording named a message nothing posts any more
- Keep the fallback assertion it carried, on slackErrorMessage itself
- Correct the unit-test header, which named flows the page now has
2026-08-20 11:22:40 +02:00
Pablo F.G 8e78fa94ad docs(ui): trim the Slack channel-check comments
- Compress the save-runs-the-check rationale to one-liners
- Drop the docstring restating the constant and the bug-report aside
2026-08-20 11:20:18 +02:00
Pablo F.G d18f2b7fdb feat(ui): check the Slack connection when the channel is saved
- Drop the Send test message affordance and everything behind it: the
  action, its MSW handler and fixtures, and the harness helpers
- Run the connection check on a successful channel save, so a channel
  Prowler cannot reach is reported then rather than on a second click
- Keep the destination on record when that check fails: the save
  succeeded, only the check did not
2026-08-20 11:13:30 +02:00
Pablo F.G 2451074021 fix(ui): say what unblocks the Slack connection check
- Move the reason under the disabled Test connection button and tie it
  to the control with aria-describedby: the explanation sat across the
  row from it, so a check waiting on a channel read as a broken one
- Assert the reason through the control it blocks, not through copy
  found anywhere on the page
2026-08-20 11:13:30 +02:00
Pablo F.G c1a6461430 feat(ui): make the Slack channel picker searchable and sorted
- Replace the plain select with a command popover the user can type into
- Offer the channels alphabetically rather than in the API's page order
- Drop the private lock icon; the Private tag alone carries the marker
2026-08-20 11:13:30 +02:00
Pablo F.G eb4319408b fix(ui): stop naming the integration type when saving the Slack channel
- Send only configuration.channel_id in the channel save: the write
  serializer refuses any attribute it does not accept, so the save
  answered 400 and recorded nothing
- Pin the exact save body in the action test
- Refuse extra attributes in the MSW handler, as the API does
2026-08-20 11:13:30 +02:00
Pablo F.G 86b257e298 fix(ui): harden the Slack channel listing against malformed pages
- Keep already-read channel pages when a later page answers a 5xx
- Skip channel resources without a usable id
- Flatten the PATCH body test interface per TypeScript guidelines
2026-08-20 11:13:30 +02:00
Pablo F.G bc87d8e20e refactor(ui): model Slack channel and test-message state as unions
- Collapse the channel-list fetch state into one discriminated union
- Collapse the test-message flag and outcome into one status union
- Group the saved-channel mirror and sync marker into channel refs
2026-08-20 11:13:30 +02:00
Pablo F.G 256f6f4150 chore(ui): drop the channel test-message changelog entry
The Slack integration ships one changelog entry on the stack's first PR
2026-08-20 11:13:30 +02:00
Pablo F.G e9d49fe3bc docs(ui): trim the Slack channel page-test and harness comments
- Shorten Given/When/Then notes to one clause
- Compress harness JSDoc to single informative lines
2026-08-20 11:13:30 +02:00
Pablo F.G 525e5e4c00 docs(ui): trim the Slack channel unit-test comments
- Keep the Given/When/Then scaffold without essayistic tails
- Keep pagination-origin and body-read gotchas as one-liners
2026-08-20 11:13:30 +02:00
Pablo F.G 9c005a7736 docs(ui): trim the Slack channel MSW comments
- Keep fixture semantics and handler precedence notes
- Drop narrative around refusal shapes
2026-08-20 11:13:30 +02:00
Pablo F.G 1e936df7fd docs(ui): trim the Slack channel component comments
- Compress state and rendering rationale to one-liners
- Drop prose restating the JSX
2026-08-20 11:13:30 +02:00
Pablo F.G e5bb46ac23 docs(ui): trim the Slack channel action comments
- Cut narrative prose to the load-bearing why
- Keep ordering, security, and contract anchors
2026-08-20 11:13:30 +02:00
Pablo F.G 39c730c3ae fix(ui): label the Slack channel picker only where there is one
- Stop the destination label pointing at an element the error and empty
  states never render
- Match the Slack copy in tests as the sentences it is, so rewording it
  cannot turn an assertion into an invalid pattern
2026-08-20 11:13:30 +02:00
Pablo F.G dafd384307 chore(ui): drop the unread Slack wiring in the generic integration paths
- Remove the Slack entry from the shared test-connection copy map and
  the Slack branch of the generic update, both unreachable: the Slack
  page runs its connection test and channel save through its own actions
2026-08-20 11:13:30 +02:00
Pablo F.G 665507fa23 fix(ui): stop the Slack card contradicting its own channel state
- Read as still loading on the server-rendered first paint, instead of
  telling a healthy workspace to go create or invite in Slack
- Say a saved channel is recorded even when its name is not on hand
2026-08-20 11:13:30 +02:00
Pablo F.G 4be3b97422 fix(ui): keep an unworded Slack refusal inside Prowler's sentence
- Wrap a reason code this UI has no copy for instead of showing the
  raw protocol token as the whole message
- Share the reason-shape gate with the OAuth callback instead of
  keeping a private copy of it
2026-08-20 11:13:30 +02:00
Pablo F.G 7afb01805e fix(ui): show the Slack channels that were read when the list is partial
- Keep the picker usable when a later page is refused, with the refusal
  as the explanation instead of a blocking error
- Say when the workspace has more channels than one read covers, so a
  missing channel is not misread as a missing @Prowler invite
2026-08-20 11:13:30 +02:00
Pablo F.G 2efcd17274 fix(ui): report a Slack channel-flow server fault to Sentry
- Give the channel listing, the channel save and the test message the
  same upstream-fault reporting the OAuth actions already have
- Keep every user-facing message exactly as it was
2026-08-20 11:13:30 +02:00
Pablo F.G dbea8642c6 fix(ui): report an unreadable Slack channel save as its own outcome
- Read the save result like the install result: an empty or non-JSON
  answer is an unknown outcome, not a parser message shown verbatim
- Refresh the cached pages before judging the answer, since the API
  records the channel before answering
- Stop a resource without a configuration from reaching the card
2026-08-20 11:13:30 +02:00
Pablo F.G 4fa70864b4 fix(ui): track the recorded Slack channel from a single source
- Gate the connection check and its next-step hint on the same
  acknowledged save that reveals the test message
- Follow a destination recorded elsewhere when the page data refreshes,
  without clobbering a pick the user has not saved yet
2026-08-20 11:13:30 +02:00
Pablo F.G aad35d4ed9 fix(ui): validate the integration id in the Slack channel actions
- Refuse an id that is not API-shaped before it reaches a request URL,
  answering the open request-forgery alerts on these actions
- Align the Slack fixtures with the ids the API actually issues
2026-08-20 11:13:29 +02:00
Pablo F.G bd0eca4d29 fix(ui): keep the Slack channel pagination on the API origin
- Resolve a relative `links.next` against the page it arrived on, not
  the API root, so a cursor-only link keeps the listing's path
- End pagination instead of following a link that leaves the API origin
  carrying the tenant's token
2026-08-20 11:13:29 +02:00
Pablo F.G 08760dc681 fix(ui): tell the user why Slack refused a channel or message
Read the refusal's `code` on every Slack-backed flow, not only on the
OAuth ones: the channel listing kept the wait Slack asked for nowhere,
the channel save reported the two channel refusals as one sentence, and
the test message showed Slack's raw reason as if it were copy.

- Keep `Retry-After` on a rate-limited channel listing, so the user is
  told when to come back instead of only that it failed
- Record the destination through a Slack action, so "the channel is
  gone" and "invite @Prowler to it" stop collapsing into one message
- Map the reason the test-message task settles with, falling back to the
  API's own wording when it is not one Prowler has copy for
- Give the MSW refusals the API's real body shape, code and all
2026-08-20 11:13:29 +02:00
Pablo F.G 225d499ee3 feat(ui): pick a Slack channel and send a test message
Offer the connected workspace's channels — public, plus the private ones
@Prowler has been invited to — through a props-driven picker the alerts
form can import unchanged, record the choice as the integration's default
(only channel_id travels; the API derives the name), and prove delivery
with a test message that polls the task the API hands back.
2026-08-20 11:13:29 +02:00
Pablo F.G d6c8570195 docs(ui): trim the comments on the dropped Slack check
- Compress the rationale to one-liners, as the rest of the stack reads
2026-08-20 11:12:15 +02:00
Pablo F.G 84aa8668b5 fix(ui): drop the Slack connection check nothing can enable yet
- Stop rendering Test connection on the connect slice: the API refuses
  a check until a destination channel is recorded, and no state this
  slice can reach records one, so the control could never be enabled
- Read an absent control as "not offered" in the harness, so the test
  covers the page as shipped rather than a fixture-only state
2026-08-20 09:27:25 +02:00
Pablo F.G 3ff71a49f7 docs(ui): trim the Slack install-guard comment 2026-08-19 13:36:36 +02:00
Pablo F.G 3bb8e1c9b5 test(ui): pin the exact OAuth scopes Prowler asks Slack for 2026-08-19 13:36:36 +02:00
Pablo F.G d4dc8acc4e fix(ui): hide the decorative Slack icon from assistive technology 2026-08-19 13:36:36 +02:00
Pablo F.G 43a8d62b11 fix(ui): confirm a Slack install only from a Slack-typed resource
- Require a non-empty id, the integrations resource type and the slack
  integration kind before reporting the workspace as installed
2026-08-19 13:36:36 +02:00
Pablo F.G 5fb4014842 fix(ui): keep the Slack page usable on an unreadable check time
- Guard the last-checked timestamp with isValid before formatting
- Fall back to the never-checked rendering instead of the error boundary
2026-08-19 13:36:36 +02:00
Pablo F.G f4b9dd6eb5 fix(ui): confirm a Slack install only from a readable integration
- Validate the exchange body as a minimal JSON:API resource before the cast
- Report truthy-but-unreadable payloads as the existing unconfirmed result
2026-08-19 13:36:36 +02:00
Pablo F.G 0ec94b4759 fix(ui): only link an install to Slack's own consent screen
- Require HTTPS, the Slack hostname, and the OAuth v2 path
- Refuse hostile schemes, origins, and lookalike hosts with the existing copy
2026-08-19 13:36:36 +02:00
Pablo F.G 014234d262 fix(ui): validate the input to the Slack exchange action
- Reject a malformed exchange argument before it reaches the API
- Derive IntegrationType from a const object
- Honor explicit width and height on the Slack icon
- Pin the revalidated paths on a completed install
2026-08-19 13:36:36 +02:00
Pablo F.G 1e36cb322e fix(ui): refresh the Slack page after a connection test
- Revalidate the Slack path so the badge and last-checked date update
- Hide the decorative Slack icon from assistive technology
- Keep the Slack integration card a Server Component
- Match Slack error codes on own properties only
- Assert the callback redirects back to the integration page
2026-08-19 13:36:36 +02:00
Pablo F.G e5f6250070 test(ui): give the Slack OAuth callback its own page test file
`/integrations/slack/callback` is a route of its own, so its 11 tests move
out of the Slack page file, which now covers only `/integrations/slack`.
2026-08-19 13:36:36 +02:00
Pablo F.G 23e3d0da88 docs(ui): focus the Slack changelog entry on what the user can do 2026-08-19 13:36:36 +02:00
Pablo F.G e72f81b0ce test(ui): stop compiling Server Components in the browser suite
Next runs the React Compiler on the client compilation only, so a Server
Component ships uncompiled. The browser project compiled every module,
and its injected `useMemoCache` needs a dispatcher the page harnesses
cannot provide, which is why two components carried `"use no memo"`.

- Skip the compiler for `app/` modules without `"use client"`
- Pre-bundle `react/compiler-runtime`, which plugin-react no longer adds
- Drop both `"use no memo"` directives
2026-08-19 13:36:36 +02:00
Pablo F.G 063f991cb5 docs(ui): trim the Slack harness and page comments
- Drop the JSDoc that restated harness method names
- Keep the server-component, cache-stub and copy-overlap gotchas
2026-08-19 13:36:36 +02:00
Pablo F.G 8533de96a5 docs(ui): trim the Slack page integration-test comments
- Remove the product arguments the assertions make on their own
- Keep the contract statuses and why each rejection path is covered
2026-08-19 13:36:36 +02:00
Pablo F.G 6baf78af00 docs(ui): trim the Slack MSW handler and fixture comments
- Cut the retellings of the API contract each fixture already shows
- Keep the status meanings and the sources of the fixture values
2026-08-19 13:36:36 +02:00
Pablo F.G 87bbbfea8a docs(ui): trim the Slack unit-test comments
- Remove the Given/When/Then prose that restated the assertions
- Keep the notes explaining why a case exists at all
2026-08-19 13:36:36 +02:00
Pablo F.G e2251ad2af docs(ui): trim the Slack action and error-mapping comments
- Drop the outcome-by-outcome rationale the result types already state
- Keep the status contract, the read-before-throw order and the revalidate why
2026-08-19 13:36:36 +02:00
Pablo F.G d83cec328b docs(ui): trim the Slack component comments to the non-obvious why
- Cut the design essays on the callback, the manager and the card header
- Keep the single-use code, never-checked badge and 400-on-no-channel notes
2026-08-19 13:36:36 +02:00
Pablo F.G 912f6efdcb fix(ui): stop titling an unconfirmed Slack install as not connected
- Give the unreadable success answer its own outcome instead of an error
  string, matching how the other Slack outcomes are modelled
- Title the two outcomes whose result is unknown for what they are, and
  keep the failure wording for the outcomes that really are failures
- Anchor the callback test helpers on the escape link rather than on the
  title copy
2026-08-19 13:36:36 +02:00
Pablo F.G 17b812e649 docs(ui): credit the right mechanism in the Slack callback comment
- Name router.replace, not the ref, as what keeps a back navigation away
  from a completed install
2026-08-19 13:36:36 +02:00
Pablo F.G e9a88ba99e fix(ui): keep a failed install read visible when Slack is unavailable
- Render the notice stack before the cards, so an unavailable
  environment no longer hides that the tenant's install could not be read
- Pin the ordering with the combined-failure case
2026-08-19 13:36:36 +02:00
Pablo F.G de29f2994a fix(ui): report a Slack upstream server fault to Sentry
- Route a 5xx other than the ship-dark 503 through the shared server
  error handling, so an upstream fault is no longer only user copy
- Await the classifier so its throw reaches the action's own catch
- Answer a 502 the API described in HTML or an empty body in Prowler's
  own words
- Cover both the reported and the deliberately unreported statuses
2026-08-19 13:36:36 +02:00
Pablo F.G 2eff780d95 fix(ui): only echo a Slack error reason that looks like a code
- Render the reason from the callback URL only when it has the shape of
  a Slack error token, so URL text cannot pose as Prowler's own copy
- Fall back to owned wording for anything else
- Keep an unrecognised but real code visible for diagnosis
2026-08-19 13:36:36 +02:00
Pablo F.G 3dfbc35c49 fix(ui): report an unreadable Slack install result as its own outcome
- Guard the OAuth success paths against a response body the UI cannot
  parse, instead of leaking a parser message to the user
- Keep the install pages revalidated on that path, since the API has
  already connected the workspace
- Add owned wording for a result Prowler could not read
- Cover the empty, HTML and resource-less answers
2026-08-19 13:36:36 +02:00
Pablo F.G cff28c1f7f fix(ui): stop the Slack callback spinning when the exchange fails
- Report an unconfirmed result instead of spinning forever when the
  exchange call never returns
- Tolerate a created integration that carries no configuration
- Cover both paths with callback unit tests
2026-08-19 13:36:36 +02:00
Pablo F.G 639753d0e0 fix(ui): keep the Slack page usable when the install read fails
- Handle a server error from the integrations read instead of
  letting it reach the error boundary
- Reuse the existing server-error wording rather than surfacing the
  API's own message
- Add a server-error scenario to the Slack test handlers
2026-08-19 13:36:36 +02:00
Pablo F.G 30c898be87 fix(ui): report an unverified integration as never checked
- Widen the shared integration type so `connected` carries the
  never-checked state the API can return
- Show a neutral badge instead of a red "Disconnected" for an
  integration whose connection has not been checked yet
- Stop offering the Slack connection check while no destination
  channel is recorded, since the API refuses it
- Cover the post-install state in the Slack page tests
2026-08-19 13:36:36 +02:00
Pablo F.G 60c8f3cc64 fix(ui): match the Slack error model the API implements
- Carry Slack's reason in the JSON:API error code, not in the detail
- Map each code to copy that says what to do, falling back to the detail
- Refuse a second workspace with a conflict, named by its code
- Say when to come back when Slack is rate limiting, instead of
  reporting Slack as unavailable in the environment
- Serialize the bot user, and omit the channel keys until one is chosen
2026-08-19 13:36:36 +02:00
Pablo F.G eaa856152d feat(ui): add Slack integration connect flow for Prowler Cloud
- Add Slack card and management page, gated on Prowler Cloud
- Connect a workspace by approving Prowler in Slack, with no token to paste
- Complete the install on return from Slack and report the outcome
- Cover the flow with browser-mode integration tests
2026-08-19 13:36:36 +02:00
61 changed files with 8709 additions and 107 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.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 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,
+46 -6
View File
@@ -1,7 +1,7 @@
---
title: 'Alerts'
sidebarTitle: 'Alerts'
description: 'Create email alerts from Prowler Cloud findings to monitor relevant security changes after scans or in daily digests.'
description: 'Create alerts from Prowler Cloud findings, deliver them to email recipients and Slack channels, and monitor relevant security changes after scans or in daily digests.'
---
import { VersionBadge } from "/snippets/version-badge.mdx"
@@ -9,7 +9,7 @@ import { SubscriptionBanner } from "/snippets/subscription-banner.mdx"
<VersionBadge version="5.26.0" />
Alerts notify recipients by email when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
Alerts notify their destinations — email recipients, Slack channels, or both — when security findings match saved filter conditions. Use Alerts to track high-priority findings, monitor specific providers or services, and keep teams informed about scan results that match defined criteria.
<SubscriptionBanner />
@@ -19,12 +19,13 @@ Before creating Alerts, ensure that:
* At least one scan has completed and produced findings.
* The user role includes the `manage_alerts` permission.
* To deliver Alerts to Slack channels, a Slack workspace is connected, at least one channel is authorized on it, and the integration's connection check has confirmed that channel. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration).
The `manage_alerts` permission is required to create, edit, test, enable, disable, and delete Alerts. See [RBAC Administrative Permissions](/user-guide/tutorials/prowler-app-rbac#rbac-administrative-permissions) for details.
## How Alerts Work
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and sends an email digest when matching findings exist.
Alerts are created from Findings filters. When an Alert runs, Prowler Cloud evaluates the saved conditions against findings and notifies the Alert's destinations when matching findings exist: an email digest to each recipient, a message to each Slack channel, or both. Destination kinds are independent — neither requires the other, and neither displaces the other.
<Note>
Alerts evaluate findings with status `FAIL` only. Findings with status `PASS` or `MANUAL`, and muted findings, never trigger an Alert regardless of the saved filters.
@@ -53,6 +54,7 @@ To create an Alert:
* **Description:** Add optional context for the Alert.
* **Frequency:** Select when Prowler Cloud should evaluate the Alert.
* **Recipients:** Select the recipients who should receive the email digest.
* **Destination channels:** Select the Slack channels that should receive the Alert. See [Slack Channel Destinations](#slack-channel-destinations).
![Create Alert Modal](/images/prowler-app/alerts/create-alert-modal.png)
@@ -86,11 +88,18 @@ Navigate to **Alerts** to review and manage existing Alerts.
![Alerts List](/images/prowler-app/alerts/alerts-list.png)
The **Destinations** column summarizes where each Alert delivers, without the Alert being opened:
* **Email recipients:** The first address, plus a count of the rest, such as `security@example.com +2 more`.
* **Slack channels:** The first channel, plus a count of the rest, such as `#sec-alerts +1 more`.
Each summary is omitted when that destination kind is empty, and the column reads **No destinations** when an Alert has neither.
Each Alert provides these actions:
| Action | Description |
|--------|-------------|
| Edit | Update name, description, recipients, frequency, or filters. |
| Edit | Update name, description, recipients, Slack channels, frequency, or filters. |
| Enable/Disable | Start or stop Alert evaluation without deleting the Alert. |
| Delete | Permanently remove the Alert. |
@@ -125,6 +134,37 @@ By default, the **organization owner** receives a **daily digest** for **critica
If a recipient unsubscribes from Alerts, that address stops receiving digests until it is reconfirmed.
An Alert does not require email recipients: an Alert that targets Slack channels only is accepted with those channels as its sole destinations. An Alert with no destinations at all stays valid and keeps evaluating its filters, but it delivers nothing.
## Slack Channel Destinations
<VersionBadge version="5.40.0" />
An Alert can post to Slack channels alongside its email recipients, or instead of them. The **Destination channels** field sits directly below **Recipients** in the Alert form, both when creating an Alert and when editing one. When the Alert matches findings, Prowler Cloud posts a message to each of its channels and sends the email digest to each of its recipients, independently of each other.
The channels offered are the confirmed channels of the connected Slack integration, never the whole Slack workspace. Widening the pool takes two steps on the integration: authorize the channel there, then run its connection check, which confirms the channel by posting a one-time confirmation message to it. Once confirmed, the channel is selectable on every Alert. See [Slack Integration](/user-guide/tutorials/prowler-app-slack-integration) for connecting a workspace, authorizing its channels, and confirming them.
A channel that was authorized a moment ago but does not appear in the Alert form has not been confirmed yet. Run **Test connection** on the Slack integration, then reopen the Alert form.
Private channels are identified as **Private** both in the open channel list and on the selected channels once the list is closed, so a private destination is never mistaken for a public one.
### When Slack Channels Cannot Be Selected
The field is always present, so channel delivery is never silently missing. It reports why it cannot be used:
| State | What the Alert form shows |
|-------|---------------------------|
| No Slack workspace connected | The field is visible but cannot be edited, explaining that posting Alerts to Slack channels needs a connected Slack workspace, with a link to the Slack integration. |
| Workspace connected, no confirmed channels | A notice that no channels are available yet and that they are authorized and confirmed on the Slack integration, with the same link. |
In both states the rest of the Alert is unaffected: it can still be created or saved with its filters, frequency, and email recipients.
<Note>
Slack destinations stay in step with the integration. Removing a channel from the integration's authorized set — or disconnecting the Slack integration altogether — removes that channel from every Alert that targeted it, so an Alert never keeps a destination Prowler can no longer deliver to. The Alert keeps its filters, frequency, and email recipients, and future delivery to that channel stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Restoring delivery means authorizing and confirming the channel again on the integration, then selecting it again on the Alert.
</Note>
Saving an Alert that names a channel which is not a confirmed channel of a connected Slack integration is refused, and the reason is reported on the Alert form. Authorize and confirm the channel on the Slack integration, or remove it from the Alert, and save again.
## Email Notifications
When an Alert matches findings, Prowler Cloud sends a security alert email that summarizes the matching findings. The email includes:
@@ -141,6 +181,6 @@ When an Alert matches findings, Prowler Cloud sends a security alert email that
* **Start with focused filters:** Create Alerts for specific high-priority scopes, such as critical findings, production providers, or important services.
* **Use clear names:** Choose names that explain the intent of the Alert.
* **Review recipients regularly:** Keep recipient lists aligned with current ownership.
* **Review destinations regularly:** Keep recipient lists and channel selections aligned with current ownership.
* **Test before saving edits:** Use **Test** after changing filters to confirm that the Alert matches the expected findings.
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or recipients.
* **Disable instead of deleting during tuning:** Disable Alerts temporarily when adjusting filters or destinations.
@@ -0,0 +1,195 @@
---
title: "Slack Integration"
sidebarTitle: 'Slack'
description: 'Connect a Slack workspace to Prowler Cloud, authorize the channels 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 the set of channels it is authorized to post 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.
* **Confirmed destinations:** The connection check verifies every authorized channel and confirms each new one in the channel itself, so a channel Prowler cannot reach is reported before anything depends on it.
* **Controlled reach:** Prowler posts only to the channels authorized 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. Several of those channels are selected and saved as the integration's authorized channels.
4. The connection check verifies the credential and every authorized channel, and posts a one-time confirmation message to each channel it has not confirmed yet.
5. Features that deliver to Slack, such as [Alerts](/user-guide/tutorials/prowler-alerts), choose their destinations from the confirmed channels.
6. 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 and keeps the authorized channels, but it resets their confirmations and the connection state — the connection check has to be run again. Approving Prowler in a *different* workspace is refused until the current workspace is disconnected: a workspace is never swapped out silently.
## Permissions Prowler Requests in Slack
Slack shows a consent screen listing everything the Prowler app asks for. Prowler requests exactly four bot scopes:
| Scope | Why Prowler Requests It |
|-------|-------------------------|
| `chat:write` | Post the confirmation message, and any later notification, to the authorized channels. |
| `chat:write.public` | Post to a public channel without first inviting the Prowler app to it. |
| `channels:read` | List public channels for the channel selection and resolve the chosen ones. |
| `groups:read` | List the private channels the Prowler app has been invited to, so they appear in the channel selection. |
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 channels authorized on the integration.** The scope exists so that authorizing a public channel does not also require someone to invite the Prowler app to it first.
### Why a Private Channel Is Missing From the Channel List
`groups:read` reveals only the private channels the Prowler app is already a member of. A private channel therefore appears in the channel list 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 channels authorized 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 authorized channels, and none are authorized at this point. Authorizing them is the next step. Once at least one channel is authorized, **Test connection** verifies the credential and every authorized channel, and confirms the ones not confirmed yet.
<Note>
Declining the consent screen creates nothing. Prowler reports that the workspace was not connected and offers to start again.
</Note>
## Authorizing Destination Channels
Prowler posts to the channels authorized on the integration. Several channels can be authorized at once, and once the connection check has confirmed them they are the pool every consumer of the integration draws from: an [Alert](/user-guide/tutorials/prowler-alerts) picks its Slack destinations from the confirmed channels, never from the whole workspace.
1. Open the **Destination channels** selection. It lists the workspace's public channels, plus the private channels the Prowler app has been invited to, each marked **Private**.
![Destination channels selection listing public channels and an invited private channel marked Private](/images/prowler-app/slack/channel-picker.png)
2. Select one or more channels. A selected private channel keeps its lock and **Private** identification with the list closed, so the authorized set stays readable at a glance.
3. Click **Save channels**.
Prowler validates the selection against Slack and derives each channel name itself, so a recorded name can never drift from the channel it belongs to. Once the set is saved, the page reports where Prowler posts and runs the connection check over it.
If the selection 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 selection 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.
Saving a new selection replaces the authorized set: channels left out of it stop being authorized, and channels added to it are authorized but not yet confirmed. Changing which channels are in the set also resets the integration's connection state, so the check runs again over the new set — reordering the same channels does not. Saving an empty selection leaves the integration with no authorized channels, and **Test connection** cannot be run again until at least one channel is authorized.
<Warning>
Removing a channel from the authorized set also removes it from every Alert that targeted it. Those Alerts keep their filters, frequency, and email recipients, and future delivery to that channel simply stops: nothing is posted to announce the removal, and the notifications already delivered stay in the channel. Disconnecting the integration has the same effect on every channel it had authorized. Restoring delivery means authorizing and confirming the channel again here, then selecting it again on each Alert.
</Warning>
### Confirming the Authorized Channels
A channel becomes usable as a destination once the connection check has confirmed it. Click **Test connection**: it verifies the stored credential and every authorized channel, and posts a one-time message to each channel it has not confirmed yet.
```text
✅ Prowler connection verified. Notifications will be delivered to this channel.
```
Later checks never post that message again to a channel that is already confirmed, so it arrives once per channel. The integration reports as connected only when every check and every required confirmation succeeded; a failure names the channel that failed. The check needs at least one authorized channel — with none authorized, it cannot be run yet.
Confirmation is what makes a channel selectable elsewhere in Prowler Cloud. A channel authorized a moment ago is missing from an Alert's channel list until a connection check confirms it.
## 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, and it removes the Slack channels from every Alert that targeted them. Reconnecting means approving Prowler in Slack again, authorizing the destination channels again, confirming them with a connection check, and selecting them again on each Alert that posts to Slack.
</Warning>
## Integration Status
The Slack management page reports the state of the connection and offers these actions:
| Button | Purpose | Notes |
|--------|---------|-------|
| **Test connection** | Verify the credential and every authorized channel, and confirm the ones not confirmed yet | Posts the confirmation message once per channel and updates the last-checked time. Cannot be run until at least one channel is authorized |
| **Refresh channels** | Re-read the workspace's channel list | Use after inviting `@Prowler` to a private channel |
| **Save channels** | Record the selected channels as the integration's authorized set | Enabled once the selection differs from the authorized set |
| **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 Channel List
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 every authorized channel still exists and has not been archived. A failure names the channel Slack refused, and the integration reports as connected only when every authorized channel passes.
* For a private authorized channel, confirm the Prowler app is still a member of it.
* Confirm the Prowler app is still installed in the workspace.
### A Channel Is Missing From an Alert's Channel List
The channel is authorized here but not confirmed yet. Click **Test connection**: it confirms every authorized channel it has not confirmed, and confirmed channels become selectable on Alerts.
### 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 a Channel
* Check the outcome reported on the page: it names the channel Slack refused and the reason Slack gave — an archived or deleted channel surfaces here rather than failing silently.
* Confirm that channel is still one of the intended destinations, and that it has not been archived or deleted in Slack.
* For a private channel, confirm the Prowler app is still a member of it.
* One unreachable channel is enough to report the integration as not connected, so removing a retired channel from the authorized set clears the failure — bearing in mind that removing it also removes it from every Alert that targeted it.
@@ -0,0 +1,227 @@
/**
* 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/`), and what
* the contract still leaves open carries a `TODO(Josema)`.
*
* 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,
];
/**
* Refusal wire values for the rule-write validation, spelled out rather than
* imported from any UI mapping: a rename on our side must fail these tests.
* TODO(Josema): the refusal's HTTP status and error codes are the one thing
* the signed contract leaves open (D3, Validation row); these stay the
* working assumption until it answers.
*/
export const ALERTS_SLACK_NOT_CONNECTED_CODE = "slack_not_connected";
export const ALERTS_CHANNEL_NOT_AUTHORIZED_CODE =
"slack_channel_not_authorized";
export const ALERTS_CHANNEL_NOT_CONFIRMED_CODE = "slack_channel_not_confirmed";
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,
});
+428
View File
@@ -0,0 +1,428 @@
/**
* 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); what it leaves open carries a `TODO(Josema)`.
*
* 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_CHANNEL_NOT_AUTHORIZED_CODE,
ALERTS_CHANNEL_NOT_CONFIRMED_CODE,
ALERTS_LIST_SERVER_ERROR_DETAIL,
ALERTS_SLACK_NOT_CONNECTED_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: the enabled and connected
* integration's channels.
* TODO(Josema): the contract does not literally say the listing filters to
* *confirmed* channels (D3, Eligibility row). Assumed here — the rule write
* refuses unconfirmed ones, so offering them would offer a refusal.
*/
const eligibleChannels = (): AlertsSlackChannelFixture[] =>
isConnected
? (fx.slackIntegration?.channels ?? []).filter(
(channel) => channel.confirmationSentAt !== null,
)
: [];
/**
* The rule-write validation the contract signs: an enabled and connected
* integration, the channel configured on it, and a non-null
* `confirmation_sent_at`. Answered before any write lands, so a refusal
* leaves the stored rule unchanged.
* TODO(Josema): whether PATCH re-validates channels a rule already stores is
* open (tasks 7.14). The supplied list is validated on both writes here —
* the literal reading — and the UI only surfaces what comes back.
*/
const refuseInvalidChannels = (
channelIds: string[] | undefined,
): Response | null => {
if (!channelIds || channelIds.length === 0) return null;
if (!isConnected) {
return HttpResponse.json(
errorBody(
ALERTS_SLACK_NOT_CONNECTED_DETAIL,
400,
ALERTS_SLACK_NOT_CONNECTED_CODE,
),
{ status: 400 },
);
}
for (const channelId of channelIds) {
const channel = configuredChannel(channelId);
if (!channel) {
return HttpResponse.json(
errorBody(
alertsChannelNotAuthorizedDetail(channelId),
400,
ALERTS_CHANNEL_NOT_AUTHORIZED_CODE,
),
{ status: 400 },
);
}
if (channel.confirmationSentAt === null) {
return HttpResponse.json(
errorBody(
alertsChannelNotConfirmedDetail(channelId),
400,
ALERTS_CHANNEL_NOT_CONFIRMED_CODE,
),
{ status: 400 },
);
}
}
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 ?? []);
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;
const refusal = refuseInvalidChannels(written);
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: [],
},
},
}),
),
];
};
+546
View File
@@ -0,0 +1,546 @@
/**
* 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 {
teamId: string;
teamName: string;
botUserId: string;
/**
* The authorized set. A new install has none, which the API serializes as an
* empty `channels` array rather than by omitting the key.
*/
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 = {
CREATED: "created",
/** Same workspace re-installed: the existing row keeps its id. */
REINSTALLED: "reinstalled",
REFUSED_STATE: "refused-state",
SLACK_REFUSED: "slack-refused",
/** A `409` named by its `code`: one workspace per tenant. */
DIFFERENT_WORKSPACE: "different-workspace",
/**
* The three below are `2xx`: the install happened, but the answer is
* unreadable, so nothing on the failure path sees them.
*/
UNREADABLE_NO_CONTENT: "unreadable-no-content",
UNREADABLE_HTML: "unreadable-html",
UNREADABLE_NO_DATA: "unreadable-no-data",
} as const;
export type SlackExchangeOutcome =
(typeof SLACK_EXCHANGE_OUTCOME)[keyof typeof SLACK_EXCHANGE_OUTCOME];
export interface SlackConnectionFixture {
connected: boolean;
error: string | null;
/**
* The channel a channel-level failure is about, named by the task result so
* the user hears which one Slack refused (contract, Connection). Absent for
* credential-level failures, which are about the workspace as a whole.
* TODO(Josema): the key the result names it under.
*/
failedChannelName?: string | null;
}
/** A channel the listing endpoint offers for the picker. */
export interface SlackChannelFixture {
id: string;
name: string;
/** Private channels are listed only where `@Prowler` has been invited. */
isPrivate: boolean;
}
/**
* 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`.
*/
export interface SlackRefusalFixture {
status: number;
/** Slack's stable reason. `null` for the failures classified by status. */
code: string | null;
detail: string;
/** Seconds `Retry-After` asked for; only a `429` carries one. */
retryAfterSeconds: number | null;
}
/**
* What `DELETE /integrations/{id}` reports about revoking the token at Slack.
* Revocation is best-effort: the row goes either way, and the outcome travels in
* JSON:API `meta`.
*
* One boolean is the whole of it: the API sends no reason for a revocation that
* did not happen, so modelling one would let a test prove copy the real
* deployment can never produce.
*/
export interface SlackRevocationFixture {
/**
* Slack confirmed the token no longer grants Prowler anything. `null` when the
* answer reports nothing at all — the plain `204` a deployment without a
* `destroy` override sends, which is what the UI meets today.
*/
revoked: boolean | null;
}
export interface SlackFixture {
/**
* The deployment has `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET` /
* `SLACK_REDIRECT_URI`. Without them every Slack OAuth call answers `503`.
*/
appConfigured: boolean;
install: SlackInstallFixture | null;
exchangeWorkspace: SlackWorkspaceFixture;
exchangeOutcome: SlackExchangeOutcome;
connection: SlackConnectionFixture;
/** The Slack OAuth calls answer `429` with a `Retry-After`. */
rateLimited: boolean;
/**
* The shared `GET /integrations` read answers `500`, which the UI's own
* helper turns into a thrown error rather than a result.
*/
listServerError: boolean;
/** The consent-URL call answers `200` with a proxy's HTML page, not JSON. */
authorizeUrlUnreadable: boolean;
/**
* Both Slack OAuth calls answer `502`, the contract's status for upstream and
* transport failures. Distinct from `appConfigured: false`, which is a `503`.
*/
oauthUpstreamError: boolean;
channels: SlackChannelFixture[];
/**
* Small on purpose: the default workspace spans two pages, so a UI that
* stopped at `data` instead of following `links.next` would lose channels.
*/
channelsPageSize: number;
/** Slack refused the listing outright, with the reason named in `code`. */
channelsRefusal: SlackRefusalFixture | null;
/**
* The cursor the refusal starts at. Absent, the whole read fails; a page
* size serves the first page and refuses the second — the partial read.
*/
channelsRefusalFromCursor?: number;
/**
* Slack refused the chosen channel when the `PATCH` validated it — the
* listing itself answered fine.
*/
channelSaveRefusal: SlackRefusalFixture | null;
revocation: SlackRevocationFixture;
}
/**
* A UUID, as the API's ids are: it travels in the URL of every Slack call and
* the actions accept no other shape.
*/
export const SLACK_INTEGRATION_ID = "7c9e6a1b-2d3f-4e5a-8b6c-9d0e1f2a3b4c";
/** The scopes the channel picker and the posting need (design D2). */
export const SLACK_BOT_SCOPES = [
"chat:write",
"chat:write.public",
"channels:read",
"groups:read",
] as const;
export const SLACK_REDIRECT_URI =
"https://cloud.prowler.com/integrations/slack/callback";
/** Server-minted, single-use, bound to the tenant and user (design D5). */
export const SLACK_OAUTH_STATE = "st-2f1c9d7a";
export const SLACK_OAUTH_CODE = "slack-code-1f4a";
export const SLACK_AUTHORIZE_URL =
"https://slack.com/oauth/v2/authorize" +
"?client_id=1234567890.0987654321" +
`&scope=${encodeURIComponent(SLACK_BOT_SCOPES.join(","))}` +
`&state=${SLACK_OAUTH_STATE}` +
`&redirect_uri=${encodeURIComponent(SLACK_REDIRECT_URI)}`;
/**
* The `detail` strings the implementation sends. Human copy; the
* machine-readable reason travels in `code`, which is what the UI maps.
*/
export const SLACK_UNCONFIGURED_DETAIL =
"Slack integration is not configured or temporarily unavailable.";
export const SLACK_REFUSED_STATE_DETAIL =
"OAuth state is invalid, expired, or already consumed.";
export const SLACK_INVALID_CODE_DETAIL = "The Slack OAuth code is invalid.";
export const SLACK_DIFFERENT_WORKSPACE_DETAIL =
"This tenant is already connected to a different Slack workspace.";
export const SLACK_UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
/**
* The `code` on the contract's `502`. The UI maps no copy of its own to it, so
* the `detail` is what reaches the user.
*/
export const SLACK_UPSTREAM_ERROR_CODE = "service_unavailable";
/**
* Raised as a `ValidationError({"channel_id": ...})` that still points at
* `/data` rather than at the attribute.
*/
export const SLACK_NO_CHANNEL_DETAIL =
"This Slack integration has no channel configured.";
export const SLACK_RATE_LIMITED_DETAIL =
"Slack is rate limiting requests from Prowler.";
/**
* What a `500` from the shared `GET /integrations` read carries. Nothing here
* is for the user to act on, so the UI answers a server error in its own words.
*/
export const INTEGRATIONS_SERVER_ERROR_DETAIL = "A server error occurred.";
export const SLACK_MISSING_SCOPE_DETAIL =
"Slack refused the request: missing_scope.";
/**
* Names the raw reason, as the missing-scope wording does: what lets a test tell
* copy the UI mapped from `code` apart from an echoed `detail`.
*/
export const SLACK_TOKEN_EXPIRED_DETAIL =
"Slack refused the request: token_expired.";
/**
* The same sentence for "it is gone" and "the app was removed from it": only
* `code` separates them, which is why a client must read `code`.
*/
export const SLACK_UNKNOWN_CHANNEL_DETAIL =
"That channel is not one Prowler can post to.";
/**
* A `200` challenge page from a proxy or WAF that took the call instead of the
* API. V8 truncates the parser message for this body before the word `html`, so
* the UI's own detection (`HTML_ERROR_PATTERN`) cannot recognise it either.
*/
export const PROXY_CHALLENGE_PAGE = [
"<!DOCTYPE html>",
"<html><head><title>Attention Required</title></head>",
"<body><h1>Checking your browser before you proceed.</h1></body></html>",
].join("\n");
/**
* The `code` values the refusals below are named by. Wire values, spelled out
* rather than imported from the UI's own mapping: a rename on our side must
* fail these tests, not quietly agree with itself.
*/
export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict";
export const SLACK_MISSING_SCOPE_CODE = "missing_scope";
export const SLACK_CHANNEL_NOT_FOUND_CODE = "channel_not_found";
export const SLACK_NOT_IN_CHANNEL_CODE = "not_in_channel";
/**
* A reason Slack really sends that the UI's mapping does not cover — the set is
* open-ended, so having no copy for one is the ordinary case.
*/
export const SLACK_UNMAPPED_REASON_CODE = "is_archived";
/**
* Two of the four dead-grant codes the contract lists. Whichever call surfaces
* one, the integration is disconnected and the only way out is connecting the
* workspace again (contract, Cross-cutting).
*/
export const SLACK_TOKEN_REVOKED_CODE = "token_revoked";
export const SLACK_TOKEN_EXPIRED_CODE = "token_expired";
export const SLACK_RETRY_AFTER_SECONDS = 30;
/** The install never granted a scope the call needs: actionable, so a `400`. */
export const SLACK_MISSING_SCOPE_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_MISSING_SCOPE_CODE,
detail: SLACK_MISSING_SCOPE_DETAIL,
retryAfterSeconds: null,
};
/**
* Where this really happens is the channel listing: `conversations.list` is
* tier 2 and paginated.
*/
export const SLACK_RATE_LIMITED_REFUSAL: SlackRefusalFixture = {
status: 429,
code: null,
detail: SLACK_RATE_LIMITED_DETAIL,
retryAfterSeconds: SLACK_RETRY_AFTER_SECONDS,
};
/**
* The stored grant is no longer usable: a `400` like any other actionable
* refusal, deliberately not the `401` that would read as an expired Prowler
* session (contract, Errors).
*/
export const SLACK_TOKEN_EXPIRED_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_TOKEN_EXPIRED_CODE,
detail: SLACK_TOKEN_EXPIRED_DETAIL,
retryAfterSeconds: null,
};
/** Slack-side or transport failure — a `502` naming no reason at all. */
export const SLACK_UPSTREAM_REFUSAL: SlackRefusalFixture = {
status: 502,
code: null,
detail: SLACK_UPSTREAM_DETAIL,
retryAfterSeconds: null,
};
/** The chosen channel is archived, deleted, or was never in the workspace. */
export const SLACK_CHANNEL_NOT_FOUND_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_CHANNEL_NOT_FOUND_CODE,
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
retryAfterSeconds: null,
};
/**
* The channel is fine, the Prowler app is simply not in it — fixed with
* `/invite @Prowler`. Identical `detail` to the refusal above, deliberately.
*/
export const SLACK_NOT_IN_CHANNEL_REFUSAL: SlackRefusalFixture = {
status: 400,
code: SLACK_NOT_IN_CHANNEL_CODE,
detail: SLACK_UNKNOWN_CHANNEL_DETAIL,
retryAfterSeconds: null,
};
/**
* Two public channels and one private the Prowler app was invited to, ordered
* so the private one lands on the second cursor page.
*/
export const SLACK_PUBLIC_CHANNEL: SlackChannelFixture = {
id: "C0123AB",
name: "security",
isPrivate: false,
};
export const SLACK_SECOND_PUBLIC_CHANNEL: SlackChannelFixture = {
id: "C0789EF",
name: "platform",
isPrivate: false,
};
export const SLACK_PRIVATE_CHANNEL: SlackChannelFixture = {
id: "C0456CD",
name: "security-alerts",
isPrivate: true,
};
export const SLACK_CHANNELS: SlackChannelFixture[] = [
SLACK_PUBLIC_CHANNEL,
SLACK_SECOND_PUBLIC_CHANNEL,
SLACK_PRIVATE_CHANNEL,
];
/** Two channels per page, so `SLACK_CHANNELS` spans exactly two pages. */
export const SLACK_CHANNELS_PAGE_SIZE = 2;
const PROWLER_HQ: SlackWorkspaceFixture = {
teamId: "T01PROWLER",
teamName: "Prowler HQ",
botUserId: "U01PROWLERBOT",
};
export const slackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture => ({
appConfigured: true,
install: null,
exchangeWorkspace: { ...PROWLER_HQ },
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.CREATED,
connection: { connected: true, error: null },
rateLimited: false,
listServerError: false,
authorizeUrlUnreadable: false,
oauthUpstreamError: false,
channels: SLACK_CHANNELS.map((channel) => ({ ...channel })),
channelsPageSize: SLACK_CHANNELS_PAGE_SIZE,
channelsRefusal: null,
channelSaveRefusal: null,
revocation: { revoked: true },
...overrides,
});
/**
* A workspace approved with no destination channel yet. `connected` is `null`,
* not `true`: the check runs against the channel, so it has never run
* (design.md, "Connection state, in order").
*/
export const connectedSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixture({
install: {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
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 = (
channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL],
): SlackInstallFixture => ({
id: SLACK_INTEGRATION_ID,
connected: true,
connectionLastCheckedAt: SLACK_CONFIRMED_AT,
workspace: {
...PROWLER_HQ,
authorizedChannels: channels.map((channel) => authorizedChannel(channel)),
},
verification: { ...SETTLED_VERIFICATION },
});
/**
* The same tenant with destination channels already authorized and confirmed:
* the state a second visit starts from.
*/
export const slackFixtureWithAuthorizedChannels = (
channels: SlackChannelFixture[] = [SLACK_PUBLIC_CHANNEL],
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
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
* from a bad write or a serializer change. The contract types the attribute as
* a string and rules nothing else out.
*/
export const unreadableCheckTimeSlackFixture = (): SlackFixture =>
connectedSlackFixture({
install: {
...configuredInstall(),
connectionLastCheckedAt: "0000-00-00T00:00:00Z",
},
});
/**
* The first cursor page is served and Slack rate limits the second: what is
* already read stays usable, the refusal only says why the list is short.
*/
export const partiallyReadSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], {
channelsRefusal: SLACK_RATE_LIMITED_REFUSAL,
channelsRefusalFromCursor: SLACK_CHANNELS_PAGE_SIZE,
...overrides,
});
/**
* 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 =>
slackFixtureWithAuthorizedChannels([SLACK_PUBLIC_CHANNEL], overrides);
/**
* A connected tenant whose disconnect removes the row but cannot revoke at
* Slack — the outcome the user has to finish by hand in the workspace.
*/
export const revokeFailureSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({
revocation: { revoked: false },
...overrides,
});
/**
* A connected tenant whose disconnect answers a plain `204` with no body: the
* row is gone and the revocation is unreported.
*/
export const unreportedRevocationSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({
revocation: { revoked: null },
...overrides,
});
/**
* A connected tenant whose token has been revoked at Slack: the row still says
* connected until a check runs, and the check is what surfaces it.
*/
export const revokedTokenSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
configuredSlackFixture({
connection: { connected: false, error: SLACK_TOKEN_REVOKED_CODE },
...overrides,
});
+468
View File
@@ -0,0 +1,468 @@
/**
* MSW handlers for the Slack integration, derived from the signed API contract
* in `openspec/changes/add-slack-alert-channels/contract/slack-alerts-api.md`
* (the API itself lives in the cloud repository). State is per-call: an
* exchange creates the install the subsequent `GET /integrations` returns, and
* a save or a connection check writes to it.
*
* Wire them per test via `worker.use(...handlersForSlack(fx))`.
*/
import { http, HttpResponse } from "msw";
import {
INTEGRATIONS_SERVER_ERROR_DETAIL,
NO_VERIFICATION,
PROXY_CHALLENGE_PAGE,
SLACK_AUTHORIZE_URL,
SLACK_DIFFERENT_WORKSPACE_DETAIL,
SLACK_EXCHANGE_OUTCOME,
SLACK_INTEGRATION_ID,
SLACK_INVALID_CODE_DETAIL,
SLACK_NO_CHANNEL_DETAIL,
SLACK_RATE_LIMITED_REFUSAL,
SLACK_REFUSED_STATE_DETAIL,
SLACK_UNCONFIGURED_DETAIL,
SLACK_UNKNOWN_CHANNEL_DETAIL,
SLACK_UPSTREAM_DETAIL,
SLACK_UPSTREAM_ERROR_CODE,
SLACK_WORKSPACE_CONFLICT_CODE,
} from "./slack.fixtures";
import type {
SlackAuthorizedChannelFixture,
SlackExchangeOutcome,
SlackFixture,
SlackInstallFixture,
SlackRefusalFixture,
} from "./slack.fixtures";
const API = process.env.UI_API_BASE_URL;
const TS = "2026-08-10T09:00:00Z";
/** 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]";
/**
* `status` is a string, per the JSON:API spec. `source.pointer` is `/data` even
* for a field-shaped `ValidationError`: the errors are about the request.
*/
const errorBody = (detail: string, status: number, code?: string) => ({
errors: [
{
status: String(status),
...(code ? { code } : {}),
detail,
source: { pointer: "/data" },
},
],
});
/**
* Answer a fixture's refusal as the API would: its own status, its `code`
* when it names one, and `Retry-After` only where the status carries a wait.
*/
const refuse = (refusal: SlackRefusalFixture) =>
HttpResponse.json(
errorBody(refusal.detail, refusal.status, refusal.code ?? undefined),
{
status: refusal.status,
...(refusal.retryAfterSeconds === null
? {}
: { headers: { "Retry-After": String(refusal.retryAfterSeconds) } }),
},
);
const configuration = (install: SlackInstallFixture) => {
const verification = install.verification ?? NO_VERIFICATION;
return {
team_id: install.workspace.teamId,
team_name: install.workspace.teamName,
bot_user_id: install.workspace.botUserId,
// Always an array: 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,
type: "integrations",
attributes: {
inserted_at: TS,
updated_at: TS,
enabled: true,
connected: install.connected,
connection_last_checked_at: install.connectionLastCheckedAt,
integration_type: "slack",
// No credentials: the bot token is encrypted at rest and never serialized.
configuration: configuration(install),
},
links: { self: `${API}/integrations/${install.id}` },
});
const collection = (install: SlackInstallFixture | null) => ({
data: install ? [integrationResource(install)] : [],
meta: {
version: "v1",
pagination: {
page: 1,
pages: 1,
count: install ? 1 : 0,
},
},
});
const taskResource = (id: string, state: string, result: unknown) => ({
data: { id, type: "tasks", attributes: { state, result } },
});
/**
* 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 =
(failedChannelName: string | null) =>
(channel: SlackAuthorizedChannelFixture): SlackAuthorizedChannelFixture =>
channel.confirmationSentAt === null && channel.name !== failedChannelName
? { ...channel, confirmationSentAt: CHECK_TS }
: channel;
/**
* All three are `2xx`: the first two make `response.json()` throw, the third
* parses into a body that names no resource.
*/
const unreadableExchange = (outcome: SlackExchangeOutcome): Response => {
switch (outcome) {
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
return new HttpResponse(null, { status: 204 });
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
default:
return HttpResponse.json({ meta: { version: "v1" } });
}
};
export const handlersForSlack = (fx: SlackFixture) => {
// Mutable copy: the exchange must not write through to the caller's fixture.
let install: SlackInstallFixture | null = fx.install
? { ...fx.install, workspace: { ...fx.install.workspace } }
: null;
/** 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,
});
const rateLimited = () => refuse(SLACK_RATE_LIMITED_REFUSAL);
/** A `502` per the contract's taxonomy: a server fault, not a Slack state. */
const upstreamError = () =>
HttpResponse.json(
errorBody(SLACK_UPSTREAM_DETAIL, 502, SLACK_UPSTREAM_ERROR_CODE),
{ status: 502, statusText: "Bad Gateway" },
);
return [
// --- OAuth ------------------------------------------------------------
http.post(`${API}/integrations/slack/oauth/authorize-url`, () => {
if (!fx.appConfigured) return unconfigured();
if (fx.rateLimited) return rateLimited();
if (fx.oauthUpstreamError) return upstreamError();
if (fx.authorizeUrlUnreadable) {
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
}
// The URL travels in `meta`; the call creates nothing.
return HttpResponse.json({
meta: { authorize_url: SLACK_AUTHORIZE_URL },
});
}),
http.post(`${API}/integrations/slack/oauth/exchange`, () => {
if (!fx.appConfigured) return unconfigured();
if (fx.rateLimited) return rateLimited();
if (fx.oauthUpstreamError) return upstreamError();
switch (fx.exchangeOutcome) {
case SLACK_EXCHANGE_OUTCOME.REFUSED_STATE:
return HttpResponse.json(errorBody(SLACK_REFUSED_STATE_DETAIL, 400), {
status: 400,
});
case SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED:
return HttpResponse.json(errorBody(SLACK_INVALID_CODE_DETAIL, 400), {
status: 400,
});
case SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE:
return HttpResponse.json(
errorBody(
SLACK_DIFFERENT_WORKSPACE_DETAIL,
409,
SLACK_WORKSPACE_CONFLICT_CODE,
),
{ status: 409 },
);
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA:
// The install still happened: the API upserts before it answers.
install = freshInstall();
return unreadableExchange(fx.exchangeOutcome);
case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
install = {
...freshInstall(install?.id),
workspace: {
...fx.exchangeWorkspace,
// A 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 = freshInstall();
return HttpResponse.json(
{ data: integrationResource(install) },
{ status: 201 },
);
}
}),
// --- Generic integration endpoints the Slack UI reuses -----------------
http.get(`${API}/integrations`, ({ request }) => {
if (fx.listServerError) {
return HttpResponse.json(
errorBody(INTEGRATIONS_SERVER_ERROR_DETAIL, 500),
{ status: 500 },
);
}
const type = new URL(request.url).searchParams.get(
"filter[integration_type]",
);
// An unfiltered read would pull every type into the Slack page.
return HttpResponse.json(collection(type === "slack" ? install : null));
}),
http.post<{ id: string }>(
`${API}/integrations/:id/connection`,
({ params }) => {
// 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,
});
}
// 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, failedChannelName } = 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 = CHECK_TS;
install.verification = {
taskId: params.taskId,
startedAt: CHECK_TS,
finishedAt: CHECK_TS,
};
install.workspace.authorizedChannels = (
install.workspace.authorizedChannels ?? []
).map(confirmedByThisRun(failedChannelName ?? null));
}
return HttpResponse.json(
// TODO(Josema): the key the result names a failing channel under.
taskResource(params.taskId, "completed", {
connected,
error,
channel: failedChannelName ?? null,
}),
);
}),
// --- Channels ----------------------------------------------------------
http.get<{ id: string }>(
`${API}/integrations/:id/slack/channels`,
({ params, request }) => {
// The UI follows `links.next` opaquely, so the cursor's shape is this
// fixture's business alone. Read first: the page decides the refusal.
const cursor = Number(
new URL(request.url).searchParams.get(CHANNEL_CURSOR_PARAM) ?? "0",
);
// An endpoint-specific refusal wins over the blanket rate limiting,
// and applies from the named cursor, so a partial read is expressible.
if (
fx.channelsRefusal &&
cursor >= (fx.channelsRefusalFromCursor ?? 0)
) {
return refuse(fx.channelsRefusal);
}
if (fx.rateLimited) return rateLimited();
const nextCursor = cursor + fx.channelsPageSize;
const page = fx.channels.slice(cursor, nextCursor);
const hasMore = nextCursor < fx.channels.length;
return HttpResponse.json({
data: page.map((channel) => ({
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: channel.isPrivate },
})),
links: {
next: hasMore
? `${API}/integrations/${params.id}/slack/channels` +
`?${CHANNEL_CURSOR_PARAM}=${nextCursor}`
: null,
},
});
},
),
/**
* The generic PATCH. The write 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 {
data?: { attributes?: Record<string, unknown> };
} | null;
const attributes = body?.data?.attributes ?? {};
const configurationPatch = attributes.configuration as
| { channels?: { id: string }[] }
| undefined;
const requested = configurationPatch?.channels;
if (!install) {
return HttpResponse.json(errorBody("Not found.", 404), { status: 404 });
}
// The API's write serializer names the attributes it will not take and
// refuses the whole save, rather than quietly dropping the extra one:
// sending `integration_type` here refused every channel save.
const refusedAttributes = Object.keys(attributes).filter(
(attribute) => attribute !== "configuration",
);
if (refusedAttributes.length > 0) {
const named = refusedAttributes
.map((attribute) => `'${attribute}'`)
.join(", ");
return HttpResponse.json(errorBody(`Invalid fields: {${named}}`, 400), {
status: 400,
});
}
// 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);
// One unknown id refuses the whole save: the set is validated together.
if (matched.length !== channelIds.length) {
return HttpResponse.json(errorBody(SLACK_UNKNOWN_CHANNEL_DETAIL, 400), {
status: 400,
});
}
const previous = install.workspace.authorizedChannels ?? [];
const confirmedAt = new Map(
previous.map((channel) => [channel.id, channel.confirmationSentAt]),
);
install.workspace.authorizedChannels = matched.map((channel) => ({
...channel,
// Retained ids keep their confirmation; new ones start without one.
confirmationSentAt: confirmedAt.get(channel.id) ?? null,
}));
// A changed id set resets the connection and verification state, so the
// record never claims a check covered a channel it never saw. 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) });
}),
// Disconnect. Revocation at Slack is best-effort: the row is removed either
// way and the outcome travels in `meta` — or nowhere at all, in the plain
// `204` a deployment with no `destroy` override sends.
http.delete(`${API}/integrations/:id`, () => {
install = null;
if (fx.revocation.revoked === null) {
return new HttpResponse(null, { status: 204 });
}
return HttpResponse.json({ meta: { revoked: fx.revocation.revoked } });
}),
];
};
+13 -1
View File
@@ -18,6 +18,8 @@ type TestConnectionResponse = {
taskId?: string;
data?: TaskStartResponse;
error?: string;
/** The channel a channel-level failure named, when the task named one. */
failedChannel?: string | null;
};
export const getIntegrations = async (searchParams?: URLSearchParams) => {
@@ -265,7 +267,14 @@ export const deleteIntegration = async (
}
};
type ConnectionTaskResult = { connected?: boolean; error?: string | null };
type ConnectionTaskResult = {
connected?: boolean;
error?: string | null;
// The signed contract keeps the result at `{connected, error}` and has it
// name the failing channel, without spelling out the key.
// TODO(Josema): the key a channel-level failure names the channel under.
channel?: string | null;
};
type PollConnectionResult =
| {
@@ -341,6 +350,7 @@ export const testIntegrationConnection = async (
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
revalidatePath("/integrations/jira");
revalidatePath("/integrations/slack");
if ("error" in pollResult) {
return { success: false, error: pollResult.error };
@@ -357,6 +367,7 @@ export const testIntegrationConnection = async (
return {
success: false,
error: pollResult.message || "Connection test failed.",
failedChannel: pollResult.result?.channel ?? null,
};
}
} else {
@@ -387,6 +398,7 @@ export const pollConnectionTestStatus = async (
revalidatePath("/integrations/amazon-s3");
revalidatePath("/integrations/aws-security-hub");
revalidatePath("/integrations/jira");
revalidatePath("/integrations/slack");
if ("error" in pollResult) {
return { success: false, error: pollResult.error };
+751
View File
@@ -0,0 +1,751 @@
/**
* What the Slack actions do off the DOM, which
* `slack-page.integration.test.tsx` cannot cover: which failures reach Sentry,
* and the URLs the channel listing's cursor pagination follows.
*/
import { revalidatePath } from "next/cache";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
SLACK_UNREADABLE_RESULT_MESSAGE,
} from "@/lib/integrations/slack-errors";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
const { captureExceptionMock, captureMessageMock, fetchMock } = vi.hoisted(
() => ({
/**
* The real SDK marks the exception `__sentry_captured__`, and
* `handleApiError` reads that mark to avoid reporting the same throw twice.
*/
captureExceptionMock: vi.fn((exception: unknown, _options?: unknown) => {
if (exception !== null && typeof exception === "object") {
Object.defineProperty(exception, "__sentry_captured__", {
configurable: true,
value: true,
});
}
}),
captureMessageMock: vi.fn(),
fetchMock: vi.fn(),
}),
);
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
captureMessage: captureMessageMock,
// The task poll leaves breadcrumbs on every read it makes.
addBreadcrumb: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
// The real `handleApiResponse` reads its copy from `lib/helper`, which reaches
// next-auth through `@/auth.config`; stubbing the session lets that copy load.
vi.mock("@/auth.config", () => ({
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.test/api/v1",
getAuthHeaders: vi.fn(() =>
Promise.resolve({ Authorization: "Bearer test-token" }),
),
parseStringify: (value: unknown) => value,
}));
import {
disconnectSlackIntegration,
exchangeSlackOAuthCode,
getSlackAuthorizeUrl,
getSlackChannels,
setSlackAuthorizedChannels,
} from "./slack";
/** The status the contract reserves for an upstream Slack failure. */
const UPSTREAM_STATUS = 502;
const UPSTREAM_DETAIL = "Slack is temporarily unavailable.";
const GENERIC_SERVER_ERROR_MESSAGE =
"Server is temporarily unavailable. Please try again in a few minutes.";
const errorResponse = (status: number, detail: string, code?: string) =>
new Response(
JSON.stringify({
errors: [
{
status: String(status),
...(code ? { code } : {}),
detail,
source: { pointer: "/data" },
},
],
}),
{ status, headers: { "content-type": "application/vnd.api+json" } },
);
const exchange = () =>
exchangeSlackOAuthCode({ code: "slack-code-1f4a", state: "st-2f1c9d7a" });
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
vi.spyOn(console, "error").mockImplementation(() => undefined);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe.each([
{ action: getSlackAuthorizeUrl, name: "getSlackAuthorizeUrl" },
{ action: exchange, name: "exchangeSlackOAuthCode" },
])("$name", ({ action }) => {
it("reports an upstream Slack failure instead of only turning it into copy", async () => {
// 502 covers `internal_error`, `fatal_error`, `service_unavailable` and
// transport failures.
fetchMock.mockResolvedValue(
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL, "service_unavailable"),
);
const result = await action();
// Once, not twice: `handleApiResponse` reports and throws, and the action's
// catch sees the mark.
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
tags: {
api_error: true,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
error_type: SentryErrorType.SERVER_ERROR,
status_code: String(UPSTREAM_STATUS),
},
});
expect(captureMessageMock).not.toHaveBeenCalled();
// The throw lands in the action's catch, so the page gets a result to
// render rather than a rejection that strands the callback on its spinner.
expect(result).toEqual({ error: UPSTREAM_DETAIL });
});
it("answers a 5xx the API described in HTML in Prowler's own words", async () => {
fetchMock.mockResolvedValue(
new Response("<html><body><h1>502 Bad Gateway</h1></body></html>", {
status: UPSTREAM_STATUS,
statusText: "Bad Gateway",
headers: { "content-type": "text/html" },
}),
);
const result = await action();
expect(result).toEqual({ error: GENERIC_SERVER_ERROR_MESSAGE });
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
});
it.each([503, 404])(
"reports nothing for a %s: that is the feature being dark, not a fault",
async (status) => {
// 503 means `SLACK_CLIENT_*` is unset; 404 means no Slack API is served
// in this deployment at all.
fetchMock.mockResolvedValue(
errorResponse(status, "Slack integration is not configured."),
);
const result = await action();
// Capturing this would report the deliberate ship-dark state from every
// tenant on every page load.
expect(result).toEqual({ unavailable: true });
expect(captureExceptionMock).not.toHaveBeenCalled();
},
);
it("reports nothing when Slack is rate limiting: it is a wait, not a fault", async () => {
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
errors: [{ status: "429", detail: "Slack is rate limiting." }],
}),
{
status: 429,
headers: {
"content-type": "application/vnd.api+json",
"Retry-After": "30",
},
},
),
);
const result = await action();
expect(result).toMatchObject({ rateLimited: true, retryAfterSeconds: 30 });
expect(captureExceptionMock).not.toHaveBeenCalled();
});
});
/**
* The URL is rendered as the `Add to Slack` link's `href`, so a value the API
* got wrong must not become a redirect to somewhere that is not Slack.
*/
describe("getSlackAuthorizeUrl authorize URL", () => {
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
const CONSENT_SCREEN_URL =
"https://slack.com/oauth/v2/authorize" +
"?client_id=1234567890.0987654321&state=st-2f1c9d7a";
const authorizeUrlResponse = (authorizeUrl: unknown) =>
new Response(JSON.stringify({ meta: { authorize_url: authorizeUrl } }), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
});
it.each([
["a hostile scheme", "javascript:alert(document.domain)"],
["plain HTTP", "http://slack.com/oauth/v2/authorize?client_id=1"],
["another origin", "https://evil.test/oauth/v2/authorize?client_id=1"],
["a lookalike hostname", "https://slack.com.evil.test/oauth/v2/authorize"],
[
"another Slack path",
"https://slack.com/redirect?to=https%3A%2F%2Fevil.test",
],
["a value that is not a URL", "oauth/v2/authorize"],
])(
"refuses %s instead of offering it as the install link",
async (_label, authorizeUrl) => {
// Given — a 2xx whose `meta.authorize_url` is not Slack's consent screen.
fetchMock.mockResolvedValue(authorizeUrlResponse(authorizeUrl));
// When
const result = await getSlackAuthorizeUrl();
// Then — the answer for no URL at all: nothing here is safe to link to.
expect(result).toEqual({ error: NO_AUTHORIZE_URL_MESSAGE });
},
);
it("hands over Slack's consent screen with its query untouched", async () => {
// Given
fetchMock.mockResolvedValue(authorizeUrlResponse(CONSENT_SCREEN_URL));
// When / Then
expect(await getSlackAuthorizeUrl()).toEqual({
authorizeUrl: CONSENT_SCREEN_URL,
});
});
});
/**
* The callback names the workspace and redirects on `integration` alone, so a
* `2xx` body it cannot read back as an integration must not reach it.
*/
describe("exchangeSlackOAuthCode result shape", () => {
const INTEGRATION = {
id: "9b1f4c22-5e7a-4c2e-8f0d-6a3b1c9d7e42",
type: "integrations",
attributes: {
integration_type: "slack",
configuration: { team_name: "Prowler HQ" },
},
};
const exchangeResponse = (data: unknown) =>
new Response(JSON.stringify({ data }), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
});
it.each<[string, unknown]>([
["an empty object", {}],
["an array", []],
["a bare string", "invalid"],
["a resource with no id", { type: "integrations", attributes: {} }],
["a resource with an empty id", { ...INTEGRATION, id: "" }],
["a resource of another type", { ...INTEGRATION, type: "tasks" }],
[
"a resource with no attributes",
{ id: INTEGRATION.id, type: "integrations" },
],
[
"another kind of integration",
{
...INTEGRATION,
attributes: { ...INTEGRATION.attributes, integration_type: "jira" },
},
],
])("cannot confirm the install from %s", async (_label, data) => {
// Given — a 2xx whose `data` is truthy but is not an integration resource.
fetchMock.mockResolvedValue(exchangeResponse(data));
// When
const result = await exchange();
// Then — the answer for a body with no `data`: the install happened, only
// its result is unknown.
expect(result).toEqual({
unconfirmed: true,
message: SLACK_UNREADABLE_RESULT_MESSAGE,
});
});
it("hands over the workspace the API upserted", async () => {
// Given
fetchMock.mockResolvedValue(exchangeResponse(INTEGRATION));
// When / Then
expect(await exchange()).toEqual({ integration: INTEGRATION });
});
});
/** The shape the API's integration ids have, which is the only shape accepted. */
const SLACK_INTEGRATION_ID = "b2c7fd0a-3e51-4d8f-9a6c-1f0e2d3c4b5a";
const CHANNELS_URL =
`https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}` +
"/slack/channels";
const FIRST_CHANNEL = { id: "C0123AB", name: "security" };
const SECOND_CHANNEL = { id: "C0789EF", name: "platform" };
const channelPage = (
channel: { id: string; name: string },
next: string | null,
) =>
new Response(
JSON.stringify({
data: [
{
type: "slack-channels",
id: channel.id,
attributes: { name: channel.name, is_private: false },
},
],
links: { next },
}),
{ status: 200, headers: { "content-type": "application/vnd.api+json" } },
);
const channelOption = (channel: { id: string; name: string }) => ({
id: channel.id,
name: channel.name,
is_private: false,
});
const requestedUrls = (): string[] =>
fetchMock.mock.calls.map(([url]) => String(url));
const sentBody = (callIndex = 0): unknown =>
JSON.parse(String(fetchMock.mock.calls[callIndex]?.[1]?.body));
/**
* `MAX_CHANNEL_PAGES` in the action, which a `"use server"` module cannot
* export: only async functions may leave one.
*/
const MAX_CHANNEL_PAGES = 20;
const channelOptions = (count: number) =>
Array.from({ length: count }, () => channelOption(FIRST_CHANNEL));
/** What a `429` carrying `Retry-After: 30` is turned into. */
const RATE_LIMITED_MESSAGE =
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.";
/** A dead grant as the API reports it: reason in `code`, prose in `detail`. */
const TOKEN_EXPIRED_CODE = "token_expired";
const TOKEN_EXPIRED_DETAIL = "Slack refused the request: token_expired.";
const TOKEN_EXPIRED_MESSAGE =
"Prowler's Slack credential has expired. Connect the workspace again to restore access.";
describe("getSlackChannels", () => {
it("follows a cursor-only `next` on the listing's own URL, not on the API root", async () => {
// The link is opaque (design D6), so the API may answer with the cursor
// alone; resolved against the API root it loses the listing's own path.
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(channelPage(SECOND_CHANNEL, null));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(requestedUrls()).toEqual([
CHANNELS_URL,
`${CHANNELS_URL}?page[cursor]=2`,
]);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL), channelOption(SECOND_CHANNEL)],
});
});
it.each([
{
shape: "an absolute",
next: "https://evil.test/api/v1/integrations/x/slack/channels?cursor=2",
},
{ shape: "a protocol-relative", next: "//evil.test/api/v1/channels?c=2" },
])(
"stops at $shape off-origin `next` rather than sending the tenant's token to it",
async ({ next }) => {
// `fetch` strips the tenant's `Authorization` on a redirect that leaves
// the origin, but not on a hop the UI makes itself.
fetchMock.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, next));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(requestedUrls()).toEqual([CHANNELS_URL]);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
});
},
);
it("answers an unreadable page as no channels rather than parser prose", async () => {
fetchMock.mockResolvedValueOnce(unreadableOk(HTML_INTERSTITIAL));
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ channels: [] });
expectNoParserProse(result);
});
it("says the list is short of the workspace when the page budget runs out", async () => {
// The budget exists because `conversations.list` is tier 2 and a workspace
// can outgrow it (design.md, Risks). A fresh `Response` per call: one
// instance is already consumed on its second read.
fetchMock.mockImplementation(() =>
Promise.resolve(channelPage(FIRST_CHANNEL, "?page[cursor]=next")),
);
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
expect(result).toEqual({
channels: channelOptions(MAX_CHANNEL_PAGES),
incomplete: SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
});
});
it("says nothing about a short list for a workspace that just fits the budget", async () => {
let page = 0;
fetchMock.mockImplementation(() => {
page += 1;
return Promise.resolve(
channelPage(
FIRST_CHANNEL,
page < MAX_CHANNEL_PAGES ? `?page[cursor]=${page}` : null,
),
);
});
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(fetchMock).toHaveBeenCalledTimes(MAX_CHANNEL_PAGES);
expect(result).toEqual({ channels: channelOptions(MAX_CHANNEL_PAGES) });
expect(result).not.toHaveProperty("incomplete");
});
it("keeps the pages it read when a later one is refused, saying why the list stops", async () => {
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(rateLimitedResponse());
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
// A rate limit says nothing about the grant, so the truncation names none.
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: RATE_LIMITED_MESSAGE,
code: null,
});
});
it("names the reason a later page was refused, not only the wording", async () => {
fetchMock
.mockResolvedValueOnce(channelPage(FIRST_CHANNEL, "?page[cursor]=2"))
.mockResolvedValueOnce(
errorResponse(400, TOKEN_EXPIRED_DETAIL, TOKEN_EXPIRED_CODE),
);
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({
channels: [channelOption(FIRST_CHANNEL)],
incomplete: TOKEN_EXPIRED_MESSAGE,
code: TOKEN_EXPIRED_CODE,
});
});
it("answers a refusal on the first page as a failure, having nothing to show", async () => {
fetchMock.mockResolvedValueOnce(rateLimitedResponse());
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE, code: null });
});
});
/**
* A `2xx` whose body is not JSON:API: an empty answer, or the HTML a proxy or
* WAF puts in front of one. The raw `SyntaxError` survives
* `sanitizeErrorMessage` (V8 truncates the snippet to ten characters, so its
* `<!doctype html>` branch never matches) and would be shown verbatim.
*/
const HTML_INTERSTITIAL =
"<!DOCTYPE html><html><body><h1>Checking your browser</h1></body></html>";
const unreadableOk = (body: string) =>
new Response(body, {
status: 200,
headers: { "content-type": body ? "text/html" : "application/json" },
});
/** V8's parser wording, which no user should ever be shown. */
const PARSER_PROSE = /unexpected (token|end of json)|not valid json/i;
const expectNoParserProse = (result: unknown) => {
const message = (result as { error?: string }).error ?? "";
expect(message).not.toMatch(PARSER_PROSE);
};
const INTEGRATION_URL = `https://api.test/api/v1/integrations/${SLACK_INTEGRATION_ID}`;
const saveChannels = () =>
setSlackAuthorizedChannels(SLACK_INTEGRATION_ID, [FIRST_CHANNEL.id]);
/** The save as the API answers it: the channels' names derived server-side. */
const savedIntegration = () =>
new Response(
JSON.stringify({
data: {
type: "integrations",
id: SLACK_INTEGRATION_ID,
attributes: {
integration_type: "slack",
configuration: {
channels: [
{
id: FIRST_CHANNEL.id,
name: FIRST_CHANNEL.name,
is_private: false,
confirmation_sent_at: null,
},
],
},
},
},
}),
{
status: 200,
headers: { "content-type": "application/vnd.api+json" },
},
);
const expectIntegrationsRevalidated = () => {
expect(vi.mocked(revalidatePath).mock.calls).toEqual([
["/integrations"],
["/integrations/slack"],
]);
};
describe("setSlackAuthorizedChannels", () => {
it("returns the saved integration and revalidates the pages listing it", async () => {
fetchMock.mockResolvedValueOnce(savedIntegration());
const result = await saveChannels();
expect(requestedUrls()).toEqual([INTEGRATION_URL]);
expect(result).toMatchObject({
integration: {
attributes: {
configuration: {
channels: [expect.objectContaining({ name: FIRST_CHANNEL.name })],
},
},
},
});
expectIntegrationsRevalidated();
});
// 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 channels as the save's only attribute, naming nothing but their ids", async () => {
fetchMock.mockResolvedValueOnce(savedIntegration());
await saveChannels();
expect(sentBody()).toEqual({
data: {
type: "integrations",
id: SLACK_INTEGRATION_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 }] },
},
},
});
});
it.each([
{ shape: "empty", body: "" },
{ shape: "an HTML interstitial", body: HTML_INTERSTITIAL },
])(
"answers a $shape `200` as an unread result, not as a failed save",
async ({ body }) => {
fetchMock.mockResolvedValueOnce(unreadableOk(body));
const result = await saveChannels();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
// The API recorded the channels before answering, so both pages refresh.
expectIntegrationsRevalidated();
},
);
// The caller reads `integration.attributes.configuration`, so a shallower
// guard lets the miss surface later as the manager's generic catch.
it.each([
{ shape: "no `data`", body: {} },
{ shape: "a null `data`", body: { data: null } },
{ shape: "a `data` with no configuration", body: { data: {} } },
])(
"answers a `200` carrying $shape as an unread result",
async ({ body }) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/vnd.api+json" },
}),
);
const result = await saveChannels();
expect(result).toEqual({ error: SLACK_UNREADABLE_RESULT_MESSAGE });
expectNoParserProse(result);
expectIntegrationsRevalidated();
},
);
});
/** The calls whose only failure path is one line of copy. */
const COPY_ONLY_ACTIONS = [
{
name: "getSlackChannels",
call: (id: string) => getSlackChannels(id),
},
{
name: "setSlackAuthorizedChannels",
call: (id: string) => setSlackAuthorizedChannels(id, [FIRST_CHANNEL.id]),
},
{
name: "disconnectSlackIntegration",
call: (id: string) => disconnectSlackIntegration(id),
},
];
const rateLimitedResponse = () =>
new Response(
JSON.stringify({
errors: [{ status: "429", detail: "Slack is rate limiting." }],
}),
{
status: 429,
headers: {
"content-type": "application/vnd.api+json",
"Retry-After": "30",
},
},
);
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
it("reports an upstream Slack failure and still answers in the same words", async () => {
fetchMock.mockResolvedValue(
errorResponse(UPSTREAM_STATUS, UPSTREAM_DETAIL),
);
const result = await call(SLACK_INTEGRATION_ID);
// Once, not twice: `handleApiResponse` reports and throws, and the action's
// catch sees the mark.
expect(captureExceptionMock).toHaveBeenCalledTimes(1);
expect(captureExceptionMock.mock.calls[0]?.[1]).toMatchObject({
tags: {
api_error: true,
error_source: SentryErrorSource.HANDLE_API_RESPONSE,
error_type: SentryErrorType.SERVER_ERROR,
status_code: String(UPSTREAM_STATUS),
},
});
expect(captureMessageMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: UPSTREAM_DETAIL });
});
it.each([
{
status: 503,
why: "Slack being unavailable, not a fault",
response: () => errorResponse(503, "Slack is unavailable."),
expected: "Slack is unavailable.",
},
{
status: 429,
why: "a wait, not a fault",
response: rateLimitedResponse,
expected:
"Slack is rate limiting Prowler right now. Try again in about 30 seconds.",
},
{
status: 400,
why: "a refusal the API meant to give",
response: () => errorResponse(400, "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());
const result = await call(SLACK_INTEGRATION_ID);
expect(captureExceptionMock).not.toHaveBeenCalled();
expect(captureMessageMock).not.toHaveBeenCalled();
// None of these refusals names a `code`.
expect(result).toEqual({ error: refusal.expected, code: null });
});
});
/**
* The integration id is interpolated into every one of these URLs, so a
* malformed one is refused before the request is built.
*/
describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
it.each(["../../users", "not-a-uuid", ""])(
"asks the API nothing when the integration id is %o",
async (id) => {
const result = await call(id);
expect(fetchMock).not.toHaveBeenCalled();
expect(result).toEqual({ error: SLACK_GENERIC_ERROR_MESSAGE });
},
);
});
+578
View File
@@ -0,0 +1,578 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import {
readSlackFailure,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_PARTIAL_CHANNEL_LIST_MESSAGE,
SLACK_UNREADABLE_RESULT_MESSAGE,
slackErrorMessage,
slackRateLimitMessage,
} from "@/lib/integrations/slack-errors";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import {
INTEGRATION_TYPE,
type IntegrationProps,
type SlackChannelOption,
} from "@/types/integrations";
interface SlackUnavailable {
unavailable: true;
}
interface SlackRateLimited {
rateLimited: true;
retryAfterSeconds: number | null;
message: string;
}
/**
* The API accepted the exchange (`2xx`) and the UI could not read the workspace
* back: the install happened, only its result is unknown.
*/
interface SlackUnconfirmed {
unconfirmed: true;
message: string;
}
interface SlackActionError {
error: string;
/**
* The refusal's `code`, when it named one, alongside the copy. A caller reads
* it to recognise a class of failure the wording cannot be pattern-matched
* for — a Slack grant that has stopped working, which the contract allows
* from any of these calls (Cross-cutting) and is recovered from by
* reconnecting rather than by retrying.
*/
code?: string | null;
}
interface SlackAuthorizeUrl {
authorizeUrl: string;
}
export type SlackAuthorizeUrlResult =
| SlackAuthorizeUrl
| SlackUnavailable
| SlackRateLimited
| SlackActionError;
interface SlackExchangeInput {
code: string;
state: string;
}
const slackExchangeInputSchema = z.object({
code: z.string().min(1),
state: z.string().min(1),
});
/**
* SSRF guard: the integration id is interpolated into the request URL, so only
* the shape the API's ids have reaches it.
*/
const integrationIdSchema = z.uuid();
const parseIntegrationId = (integrationId: string): string | null => {
const parsed = integrationIdSchema.safeParse(integrationId);
return parsed.success ? parsed.data : null;
};
interface SlackExchangeSuccess {
integration: IntegrationProps;
}
export type SlackExchangeResult =
| SlackExchangeSuccess
| SlackUnavailable
| SlackRateLimited
| SlackUnconfirmed
| SlackActionError;
/**
* `503`: no Slack app configured in this deployment. `404`: no Slack API at
* all. Both mean "not available here", unlike `429`/`502` which mean "not now".
*/
const isUnavailableStatus = (status: number): boolean =>
status === 503 || status === 404;
const RATE_LIMITED_STATUS = 429;
const SLACK_AUTHORIZE_HOSTNAME = "slack.com";
const SLACK_AUTHORIZE_PATHNAME = "/oauth/v2/authorize";
const NO_AUTHORIZE_URL_MESSAGE = "Slack did not return an authorization URL.";
/**
* The URL is rendered as the `Add to Slack` link's `href`, so anything that is
* not Slack's consent screen is a redirect to an origin the user did not choose.
*/
const isSlackAuthorizeUrl = (value: string): boolean => {
try {
const url = new URL(value);
return (
url.protocol === "https:" &&
url.hostname === SLACK_AUTHORIZE_HOSTNAME &&
url.pathname === SLACK_AUTHORIZE_PATHNAME
);
} catch {
return false;
}
};
const INTEGRATIONS_RESOURCE_TYPE = "integrations";
/**
* The callback names the workspace and redirects on this value alone, so a `2xx`
* payload that is not a JSON:API resource (`{}`, `[]`, `"invalid"`) must read as
* unreadable rather than as a connected workspace. Identity too: a resource
* that is not a linkable Slack integration would be shown as the workspace
* just installed.
*/
const isIntegrationResource = (value: unknown): boolean => {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const { id, type, attributes } = value as Record<string, unknown>;
if (
typeof id !== "string" ||
id === "" ||
type !== INTEGRATIONS_RESOURCE_TYPE ||
typeof attributes !== "object" ||
attributes === null ||
Array.isArray(attributes)
) {
return false;
}
return (
(attributes as Record<string, unknown>).integration_type ===
INTEGRATION_TYPE.SLACK
);
};
const failureFrom = async (
response: Response,
fallback: string,
): Promise<SlackUnavailable | SlackRateLimited | SlackActionError> => {
if (isUnavailableStatus(response.status)) return { unavailable: true };
// A 5xx (including the `502` the contract reserves for "Slack upstream
// broke") goes through the repo's 5xx handling, which reports to Sentry and
// throws, so the caller's catch answers the user. Must run before
// `readSlackFailure`: a body can only be read once.
if (response.status >= 500) await handleApiResponse(response);
const failure = await readSlackFailure(response);
if (failure.status === RATE_LIMITED_STATUS) {
return {
rateLimited: true,
retryAfterSeconds: failure.retryAfterSeconds,
message: slackRateLimitMessage(failure.retryAfterSeconds),
};
}
return { error: slackErrorMessage(failure, fallback), code: failure.code };
};
/**
* `failureFrom` flattened to one refusal, for the calls whose only outcome is
* "it did not work". Rate limiting keeps its own wording: `conversations.list`
* is Slack tier 2, so a `429` shows up here (contract, Errors) and the wait it
* names is the useful part.
*/
const refusalFrom = async (
response: Response,
fallback: string,
): Promise<SlackActionError> => {
// Same 5xx handling as `failureFrom`, `503` excepted: here too it means Slack
// is unavailable. Must run before `readSlackFailure`: a body can only be read
// once.
if (response.status >= 500 && response.status !== 503) {
await handleApiResponse(response);
}
const failure = await readSlackFailure(response);
return {
error:
failure.status === RATE_LIMITED_STATUS
? slackRateLimitMessage(failure.retryAfterSeconds)
: slackErrorMessage(failure, fallback),
code: failure.code,
};
};
/** Mint an OAuth state and get the consent URL. Creates no integration. */
export const getSlackAuthorizeUrl =
async (): Promise<SlackAuthorizeUrlResult> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/authorize-url`);
try {
const response = await fetch(url.toString(), { method: "POST", headers });
if (!response.ok) {
// Awaited inside the `try`: a returned promise's rejection would skip
// this `catch`, and a 5xx rejects.
return await failureFrom(
response,
`Unable to start the Slack install: ${response.statusText}`,
);
}
// The URL travels in JSON:API `meta`: the call creates no resource. A
// non-JSON `2xx` reads as "no URL" instead of throwing a parser message
// the user would be shown verbatim.
const body = await response.json().catch(() => null);
const authorizeUrl = body?.meta?.authorize_url;
// A URL that is not Slack's own is no more usable than a missing one.
if (
typeof authorizeUrl !== "string" ||
!isSlackAuthorizeUrl(authorizeUrl)
) {
return { error: NO_AUTHORIZE_URL_MESSAGE };
}
return { authorizeUrl };
} catch (error) {
return handleApiError(error);
}
};
/**
* Complete the install with what Slack put in the callback URL. The API
* consumes the `state`, exchanges the single-use `code`, and upserts the
* tenant's Slack integration.
*/
export const exchangeSlackOAuthCode = async (
input: SlackExchangeInput,
): Promise<SlackExchangeResult> => {
const parsed = slackExchangeInputSchema.safeParse(input);
if (!parsed.success) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const { code, state } = parsed.data;
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/slack/oauth/exchange`);
try {
const response = await fetch(url.toString(), {
method: "POST",
headers,
body: JSON.stringify({
data: {
type: "slack-oauth-exchanges",
attributes: { code, state },
},
}),
});
if (!response.ok) {
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip this
// `catch` and leave the callback on its spinner.
return await failureFrom(
response,
`Unable to connect the Slack workspace: ${response.statusText}`,
);
}
const body = await response.json().catch(() => null);
// Before the guard and on both paths: the API upserted the integration
// before answering, so a cache filled when there was none would list the
// connected workspace as missing.
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
if (!isIntegrationResource(body?.data)) {
return { unconfirmed: true, message: SLACK_UNREADABLE_RESULT_MESSAGE };
}
return { integration: parseStringify(body.data) as IntegrationProps };
} catch (error) {
return handleApiError(error);
}
};
interface SlackChannelsSuccess {
channels: SlackChannelOption[];
/**
* Present when these channels are only part of the workspace's, carrying the
* sentence that says why: a partial read is a success, so the caller renders
* the picker *and* the reason.
*/
incomplete?: string;
/**
* The `code` of the refusal that cut the read short, when it named one. A
* grant that has stopped working refuses the second cursor page exactly as it
* refuses the first, and a caller reading only the failure path would never
* hear about it.
*/
code?: string | null;
}
export type SlackChannelsResult = SlackChannelsSuccess | SlackActionError;
/**
* Cursor pages followed before giving up: `conversations.list` is a tier-2,
* rate-limited Slack call (design.md, Risks), so the aggregation is bounded
* rather than open-ended.
*/
const MAX_CHANNEL_PAGES = 20;
/**
* Every channel Prowler can post to in the connected workspace — the picker's
* options.
*
* The durable primitive, not the 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,
): Promise<SlackChannelsResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: false });
const channels: SlackChannelOption[] = [];
const listing = new URL(`${apiBaseUrl}/integrations/${id}/slack/channels`);
let next: string | null = listing.toString();
let incomplete: string | null = null;
try {
for (let page = 0; next && page < MAX_CHANNEL_PAGES; page += 1) {
const current: string = next;
const response: Response = await fetch(current, {
method: "GET",
headers,
});
if (!response.ok) {
let refusal: SlackActionError;
try {
refusal = await refusalFrom(
response,
`Unable to read the workspace's channels: ${response.statusText}`,
);
} catch (error) {
// `handleApiResponse` reported the 5xx and threw; a first-page
// failure stays a failure, but later pages keep what was read.
if (channels.length === 0) throw error;
return { channels, incomplete: SLACK_GENERIC_ERROR_MESSAGE };
}
return channels.length > 0
? { channels, incomplete: refusal.error, code: refusal.code }
: refusal;
}
// A page that is not JSON reads as no channels, rather than throwing a
// parser message the user would be shown verbatim.
const body = await response.json().catch(() => null);
for (const resource of body?.data ?? []) {
// Radix `Select.Item` refuses an empty value; one malformed resource
// would break the whole picker.
const channelId = resource?.id;
if (typeof channelId !== "string" || channelId.length === 0) continue;
channels.push({
id: channelId,
name: resource?.attributes?.name ?? "",
is_private: Boolean(resource?.attributes?.is_private),
});
}
const rawNext = body?.links?.next;
const candidate =
typeof rawNext === "string" && rawNext.length > 0
? new URL(rawNext, current)
: null;
// Resolved against the page it arrived on, so a cursor-only `next` keeps
// this listing's path. Followed only while it stays on the listing's
// origin: every page is fetched with the tenant's token, and an
// off-origin hop made here would carry it along.
if (candidate === null) {
next = null;
} else if (candidate.origin === listing.origin) {
next = candidate.toString();
} else {
next = null;
incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
}
}
// A link still waiting when the budget ran out. Checked rather than assumed
// from the page count: a workspace of exactly `MAX_CHANNEL_PAGES` pages was
// read to the end.
if (next) incomplete = SLACK_PARTIAL_CHANNEL_LIST_MESSAGE;
return incomplete === null ? { channels } : { channels, incomplete };
} catch (error) {
return handleApiError(error);
}
};
interface SlackAuthorizedChannelsSuccess {
integration: IntegrationProps;
}
export type SlackAuthorizedChannelsResult =
| SlackAuthorizedChannelsSuccess
| SlackActionError;
/**
* 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 ids travel — the API
* derives each name and its privacy server-side.
*/
export const setSlackAuthorizedChannels = async (
integrationId: string,
channelIds: string[],
): Promise<SlackAuthorizedChannelsResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), {
method: "PATCH",
headers,
body: JSON.stringify({
data: {
type: "integrations",
id,
// `configuration` is the only attribute the save may carry: the write
// 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: {
// 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,
})),
},
},
},
}),
});
if (!response.ok) {
// Awaited inside the `try`: unawaited, a 5xx's rejection would skip
// this `catch`.
return await refusalFrom(
response,
`Unable to save the destination 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 channels would keep showing them.
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
// 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 };
}
return { integration: parseStringify(body.data) as IntegrationProps };
} catch (error) {
return handleApiError(error);
}
};
/**
* What the API reports about revoking Prowler's token at Slack: one boolean in
* `meta`, and nothing else — it sends no reason for a revocation that did not
* happen, so there is no field here to hold one.
*/
export interface SlackRevocation {
/**
* Whether Slack confirmed the token no longer grants Prowler anything, or
* `null` when the response carried no outcome. The contract says the outcome
* is always reported, so `null` means the response is wrong, not the
* revocation.
*/
revoked: boolean | null;
}
interface SlackDisconnectSuccess {
/** The integration is gone from Prowler, whatever Slack answered. */
disconnected: true;
revocation: SlackRevocation;
}
export type SlackDisconnectResult = SlackDisconnectSuccess | SlackActionError;
/**
* Disconnect the workspace: `DELETE /integrations/{id}`.
*
* The generic `deleteIntegration` cannot serve this: it discards the response
* body, and the body is the whole point. Revocation at Slack is best-effort —
* the row is removed either way and the outcome travels in JSON:API `meta` — so
* a caller has to tell "gone and revoked" from "gone, but still installed in
* Slack".
*
* A body without the field (an empty `204`, say) yields `null`, not `false`: an
* unreported outcome must not send the user off to clean up Slack, nor be shown
* as access revoked.
*/
export const disconnectSlackIntegration = async (
integrationId: string,
): Promise<SlackDisconnectResult> => {
const id = parseIntegrationId(integrationId);
if (!id) return { error: SLACK_GENERIC_ERROR_MESSAGE };
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}`);
try {
const response = await fetch(url.toString(), { method: "DELETE", headers });
if (!response.ok) {
return await refusalFrom(
response,
`Unable to disconnect the Slack workspace: ${response.statusText}`,
);
}
const body = await response.json().catch(() => ({}));
const meta = body?.meta ?? {};
revalidatePath("/integrations");
revalidatePath("/integrations/slack");
return {
disconnected: true,
revocation: {
revoked: typeof meta.revoked === "boolean" ? meta.revoked : null,
},
};
} catch (error) {
return handleApiError(error);
}
};
@@ -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">
@@ -29,11 +29,26 @@ const TRIGGER_LABELS = {
both: "After scan and daily",
} as const satisfies Record<AlertRule["attributes"]["trigger"], string>;
const formatRecipients = (alert: AlertRule): string => {
const summarize = (first: string, count: number): string | null => {
if (count === 0) return null;
if (count === 1) return first;
return `${first} +${count - 1} more`;
};
/**
* One compact answer to "where does this go?" (design D6): the email summary
* and the channel summary side by side, either omitted when empty.
*/
const formatDestinations = (alert: AlertRule): string => {
const recipients = alert.attributes.recipient_emails ?? [];
if (recipients.length === 0) return "No recipients";
if (recipients.length === 1) return recipients[0];
return `${recipients[0]} +${recipients.length - 1} more`;
const channels = alert.attributes.slack_channels ?? [];
const summaries = [
summarize(recipients[0], recipients.length),
summarize(channels[0] ? `#${channels[0].name}` : "", channels.length),
].filter((summary): summary is string => summary !== null);
return summaries.length > 0 ? summaries.join(" · ") : "No destinations";
};
interface GetAlertsTableColumnsOptions {
@@ -148,14 +163,14 @@ const getAlertsTableColumns = ({
cell: ({ row }) => TRIGGER_LABELS[row.original.attributes.trigger],
},
{
id: "recipients",
id: "destinations",
size: 220,
minSize: 180,
accessorFn: (alert) => formatRecipients(alert),
accessorFn: (alert) => formatDestinations(alert),
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Recipients" />
<DataTableColumnHeader column={column} title="Destinations" />
),
cell: ({ row }) => formatRecipients(row.original),
cell: ({ row }) => formatDestinations(row.original),
},
{
id: "inserted_at",
@@ -0,0 +1,226 @@
"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 until the check runs again.
<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,468 @@
/**
* 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.
*/
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. */
async ruleDestinationsSummary(ruleName: string): Promise<string> {
const headers = Array.from(
this.container.querySelectorAll<HTMLElement>("thead th"),
);
const columnIndex = headers.findIndex((header) =>
/Destinations/.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,273 @@
/**
* 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,
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("surfaces the refusal when a stored channel is not confirmed and connected", async () => {
const harness = new AlertsPageHarness(reinstalledWorkspaceAlertsFixture());
await harness.mount();
await harness.openEditModal(RULE_NAME);
const refusal = await harness.refusedRuleSave();
expect(refusal).toMatch(/Slack must be connected/i);
});
});
describe("the alerts list shows destinations", () => {
it("shows a rule's channels by name alongside its emails, without opening it", async () => {
const harness = new AlertsPageHarness(
alertsFixture({
rules: [
alertRuleFixture({
slackChannelIds: [
ALERTS_PUBLIC_CHANNEL.id,
ALERTS_PRIVATE_CHANNEL.id,
],
}),
],
}),
);
await harness.mount();
expect(await harness.ruleDestinationsSummary(RULE_NAME)).toBe(
`security@example.com · #${ALERTS_PUBLIC_CHANNEL.name} +1 more`,
);
});
it("reads correctly for emails-only, channels-only and empty rules", async () => {
const harness = new AlertsPageHarness(
alertsFixture({
rules: [
alertRuleFixture({ id: "rule-emails", name: "Emails only" }),
alertRuleFixture({
id: "rule-channels",
name: "Channels only",
recipientEmails: [],
slackChannelIds: [ALERTS_PRIVATE_CHANNEL.id],
}),
alertRuleFixture({
id: "rule-none",
name: "No destinations yet",
recipientEmails: [],
}),
],
}),
);
await harness.mount();
expect(await harness.ruleDestinationsSummary("Emails only")).toBe(
"security@example.com",
);
expect(await harness.ruleDestinationsSummary("Channels only")).toBe(
`#${ALERTS_PRIVATE_CHANNEL.name}`,
);
expect(await harness.ruleDestinationsSummary("No destinations yet")).toBe(
"No destinations",
);
});
});
@@ -0,0 +1,44 @@
import { ApiKeyLinkCard } from "@/components/integrations/api-key/api-key-link-card";
import { JiraIntegrationCard } from "@/components/integrations/jira/jira-integration-card";
import { S3IntegrationCard } from "@/components/integrations/s3/s3-integration-card";
import { SecurityHubIntegrationCard } from "@/components/integrations/security-hub/security-hub-integration-card";
import { SlackIntegrationCard } from "@/components/integrations/slack/slack-integration-card";
import { SsoLinkCard } from "@/components/integrations/sso/sso-link-card";
import { isCloud } from "@/lib/shared/env";
/**
* Split out of `page.tsx` for the browser-mode tests: `ContentLayout`'s navbar
* streams async server children a client renderer can't resolve.
*/
export function IntegrationsContent() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect external services to enhance your security workflow and
automatically export your scan results.
</p>
</div>
<div className="grid gap-6">
{/* Amazon S3 Integration */}
<S3IntegrationCard />
{/* AWS Security Hub Integration */}
<SecurityHubIntegrationCard />
{/* Jira Integration */}
<JiraIntegrationCard />
{/* Slack Integration - cloud-only API, nothing to manage self-hosted */}
{isCloud() && <SlackIntegrationCard />}
{/* SSO Configuration - redirects to Profile */}
<SsoLinkCard />
{/* API Keys - redirects to Profile */}
<ApiKeyLinkCard />
</div>
</div>
);
}
@@ -0,0 +1,40 @@
/**
* Browser-mode tests for the Slack entry in the integrations catalogue
* (`/integrations`), which is offered in Prowler Cloud only. Driven through
* `SlackIntegrationHarness` against the MSW handlers.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import { slackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
import { SlackIntegrationHarness } from "./slack/slack-integration.harness";
describe("the integrations catalogue", () => {
it("offers Slack in Prowler Cloud, with a way to manage it", async () => {
// Given — a Prowler Cloud deployment (the fixtures' default island).
const harness = new SlackIntegrationHarness(slackFixture());
harness.mountCatalogue();
expect(await harness.listedIntegrations()).toContain("Slack");
expect(harness.offersSlackManagement()).toBe(true);
}, 30000);
it("omits Slack in a deployment that is not Prowler Cloud", async ({
seedRuntimeConfig,
}) => {
seedRuntimeConfig({ cloudEnabled: false });
const harness = new SlackIntegrationHarness(slackFixture());
harness.mountCatalogue();
const listed = await harness.listedIntegrations();
expect(listed).not.toContain("Slack");
expect(harness.offersSlackManagement()).toBe(false);
// Tripwire: the catalogue rendered, so the assertions above are Slack's
// absence rather than the page failing to load.
expect(listed).toContain("Jira");
}, 30000);
});
+3 -32
View File
@@ -1,40 +1,11 @@
import {
ApiKeyLinkCard,
JiraIntegrationCard,
S3IntegrationCard,
SecurityHubIntegrationCard,
SsoLinkCard,
} from "@/components/integrations";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { IntegrationsContent } from "./integrations-content";
export default async function Integrations() {
return (
<ContentLayout title="Integrations" icon="lucide:puzzle">
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect external services to enhance your security workflow and
automatically export your scan results.
</p>
</div>
<div className="grid gap-6">
{/* Amazon S3 Integration */}
<S3IntegrationCard />
{/* AWS Security Hub Integration */}
<SecurityHubIntegrationCard />
{/* Jira Integration */}
<JiraIntegrationCard />
{/* SSO Configuration - redirects to Profile */}
<SsoLinkCard />
{/* API Keys - redirects to Profile */}
<ApiKeyLinkCard />
</div>
</div>
<IntegrationsContent />
</ContentLayout>
);
}
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
export default async function SlackCallbackPage() {
if (!isCloud()) {
redirect("/");
}
return (
<ContentLayout title="Slack">
{/* `SlackCallback` reads the query string, so it needs a boundary. */}
<Suspense fallback={null}>
<SlackCallback />
</Suspense>
</ContentLayout>
);
}
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
import { SlackIntegrationContent } from "./slack-integration-content";
export default async function SlackIntegrationPage() {
// The Slack API is cloud-only, so self-hosted has nothing behind this page.
// Mirrors `/alerts`.
if (!isCloud()) {
redirect("/");
}
return (
<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 the channels you
authorize.
</p>
<SlackIntegrationContent />
</div>
</ContentLayout>
);
}
@@ -0,0 +1,246 @@
/**
* Browser-mode tests for the Slack OAuth callback
* (`/integrations/slack/callback`), driven through `SlackIntegrationHarness`.
* MSW answers from handlers derived from the API contract in `design.md`.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
SLACK_EXCHANGE_OUTCOME,
SLACK_OAUTH_CODE,
SLACK_OAUTH_STATE,
slackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import { SlackIntegrationHarness } from "./slack-integration.harness";
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
/**
* Callback headlines, spelled out rather than imported so a rename fails here.
* `FAILURE_TITLE` is for installs that connected nothing; `UNCONFIRMED_TITLE`
* for answers that arrive after the API already upserted the integration.
*/
const FAILURE_TITLE = "Slack workspace not connected";
const UNCONFIRMED_TITLE = "Slack install not confirmed";
describe("returning from Slack", () => {
it("completes the install and shows the connected workspace", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
expect(await harness.completedInstall()).toBe(true);
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
// The code is single-use and the exchange runs from a render (design D4):
// without the once-guard, a second call burns it and reports a failure.
expect(harness.exchangeCallCount).toBe(1);
// A completed install invalidates the cached "none connected".
expect(harness.revalidatedPaths).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
}, 30000);
it("does not report an install the API completed as failed when it answers no content", async () => {
// Given — a `204`: the API consumed the code and upserted the integration,
// then answered with no body. `response.ok` is true, so this is no refusal.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).toMatch(/Slack integration page/);
expect(reason).not.toMatch(/JSON/i);
expect(harness.offersRetry()).toBe(true);
// The `204` says the workspace is connected; the headline cannot deny it.
expect(await harness.installFailureTitle()).toBe(UNCONFIRMED_TITLE);
// The install exists, so the cached "none connected" has to go with it.
expect(harness.revalidatedPaths).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
}, 30000);
it("shows Prowler's own wording when a proxy answers the completion with an HTML page", async () => {
// Given — a proxy answering `200` with a challenge page instead of JSON.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// V8's parse message truncates before the word `html`, so the shared
// HTML-shaped-error filter cannot catch this one.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).not.toMatch(/DOCTYPE/i);
expect(reason).not.toMatch(/not valid JSON/i);
}, 30000);
it("says the result is unreadable, not that the workspace is unknown, when the answer names no resource", async () => {
// Given — a `200` carrying well-formed JSON:API with no `data` member.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).not.toMatch(/undefined/i);
expect(await harness.completedInstall()).toBe(false);
}, 30000);
it("connects nothing when the user declines in Slack, and offers to retry", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({ error: "access_denied" });
expect(await harness.installFailureReason()).toMatch(
/not approved in Slack/,
);
expect(harness.offersRetry()).toBe(true);
// A declined consent carries no code, so there was nothing to exchange.
expect(harness.exchangeCallCount).toBe(0);
}, 30000);
it("surfaces the reason when Slack refuses to complete the install", async () => {
// Given — Slack rejects the code, and the API's own wording explains it.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// A refusal Prowler has no wording of its own for falls back to the API's
// `detail`, not to a generic failure.
expect(await harness.installFailureReason()).toMatch(
/OAuth code is invalid/,
);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("surfaces a completion the API refuses, and connects nothing", async () => {
// Given — the state was minted for another session, or already consumed.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REFUSED_STATE }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: "state-from-another-session",
});
expect(await harness.installFailureReason()).toMatch(
/state is invalid, expired, or already consumed/,
);
// The API refused before consuming anything, so nothing was created and the
// headline states that plainly.
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
// Refused once, not retried into a second burnt code.
expect(harness.exchangeCallCount).toBe(1);
}, 30000);
it("says how to resolve a workspace conflict, in Prowler's own words", async () => {
// Given — this tenant already has a different workspace connected, which
// the API refuses as a 409 naming the conflict in `code`.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// The copy comes from the error `code`: the API's `detail` states the
// conflict but not the way out of it.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/already connected to a different Slack workspace/);
expect(reason).toMatch(/Disconnect it before connecting another/);
expect(reason).not.toMatch(/tenant/);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("tells the user when to come back if Slack is rate limiting the install", async () => {
// Given — Slack answers 429 with a Retry-After.
const harness = new SlackIntegrationHarness(
slackFixture({ rateLimited: true }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/rate limiting/);
expect(reason).toMatch(/about 30 seconds/);
expect(reason).not.toMatch(/not available in this environment/);
// A 429 refuses the exchange outright, so nothing was connected: the plain
// headline, unlike the unreadable `2xx` that arrives after the upsert.
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("reports Slack being broken upstream, rather than leaving the callback spinning", async () => {
// Given — the completion answers `502`, the contract's status for a Slack
// upstream failure. The shared 5xx handling throws, so the callback only
// renders this if the action answers that rejection itself.
const harness = new SlackIntegrationHarness(
slackFixture({ oauthUpstreamError: true }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// The API refused, so nothing was created: not the "could not confirm" the
// page falls back to when the action never answers at all.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/temporarily unavailable/);
expect(reason).not.toMatch(/could not confirm/);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("does not attempt an exchange when the completion carries no state", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({ code: SLACK_OAUTH_CODE });
// Refused before the API is ever asked, so no code is spent.
expect(await harness.installFailureReason()).toMatch(/incomplete response/);
expect(harness.exchangeCallCount).toBe(0);
}, 30000);
});
@@ -0,0 +1,61 @@
import { getIntegrations } from "@/actions/integrations/integrations";
import { getSlackAuthorizeUrl } from "@/actions/integrations/slack";
import { SlackIntegrationManager } from "@/components/integrations/slack/slack-integration-manager";
import { GENERIC_SERVER_ERROR_MESSAGE } from "@/lib/helper";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
/**
* `getIntegrations` throws a `>= 500` answer past its own catch, which covers
* only transport. Uncaught it trips the route's error boundary and replaces a
* page that could still offer the install, so report it as `{ error }` and take
* the page's one error path.
*/
const readSlackIntegrations = async (searchParams: URLSearchParams) => {
try {
return await getIntegrations(searchParams);
} catch {
// The thrown message can carry the server's own wording; `handleApiResponse`
// already reported it to Sentry.
return { error: GENERIC_SERVER_ERROR_MESSAGE };
}
};
/**
* Split out of `page.tsx` so the browser-mode tests can render it without the
* surrounding `ContentLayout`.
*/
export async function SlackIntegrationContent() {
const searchParams = new URLSearchParams();
searchParams.set("filter[integration_type]", INTEGRATION_TYPE.SLACK);
// One workspace per tenant, so one row is the whole result set.
searchParams.set("page[size]", "1");
const integrations = await readSlackIntegrations(searchParams);
const loadError =
integrations && "error" in integrations
? (integrations.error as string)
: null;
const integration: IntegrationProps | null =
(integrations?.data?.[0] as IntegrationProps | undefined) ?? null;
const authorize = integration ? null : await getSlackAuthorizeUrl();
return (
<SlackIntegrationManager
integration={integration}
authorizeUrl={
authorize && "authorizeUrl" in authorize ? authorize.authorizeUrl : null
}
unavailable={Boolean(authorize && "unavailable" in authorize)}
// Rate limited is not unavailable: the install is still on offer, it just
// cannot be started yet.
rateLimitMessage={
authorize && "rateLimited" in authorize ? authorize.message : null
}
loadError={
loadError ??
(authorize && "error" in authorize ? authorize.error : null)
}
/>
);
}
@@ -0,0 +1,984 @@
/**
* Page-level test harness for the Slack integration (Vitest Browser Mode).
*
* A client renderer cannot render an async server component, so the component is
* called and the element it returns is what gets rendered.
*/
import { revalidatePath } from "next/cache";
import { createElement } from "react";
import { vi } from "vitest";
import { BrowserHarness } from "@/__tests__/browser-harness";
import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import { setSlackAuthorizedChannels } from "@/actions/integrations/slack";
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { IntegrationsContent } from "../integrations-content";
import { SlackIntegrationContent } from "./slack-integration-content";
export const CONNECTION_OUTCOME = {
SUCCESS: "success",
FAILURE: "failure",
} as const;
export type ConnectionOutcome =
(typeof CONNECTION_OUTCOME)[keyof typeof CONNECTION_OUTCOME];
/** 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",
NOT_REVOKED: "not-revoked",
/** The answer said nothing either way, so the page claims neither. */
UNREPORTED: "unreported",
} as const;
export type RevocationOutcome =
(typeof REVOCATION_OUTCOME)[keyof typeof REVOCATION_OUTCOME];
interface CallbackParams {
code?: string;
state?: string;
/** Slack's own refusal code, e.g. `access_denied`. */
error?: string;
}
/** What a picker search leaves on offer. */
interface ChannelSearch {
/** Names still offered once the filter landed, in the order offered. */
offered: string[];
/** The picker's no-match note; null while any channel is still offered. */
emptyNote: string | null;
}
export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
get exchangeCallCount(): number {
return this.countRequests("POST", "/slack/oauth/exchange");
}
get authorizeUrlCallCount(): number {
return this.countRequests("POST", "/slack/oauth/authorize-url");
}
/** Paths the actions asked Next to refresh (`next/cache` is stubbed in this lane). */
get revalidatedPaths(): string[] {
return vi.mocked(revalidatePath).mock.calls.map(([path]) => path);
}
// --- Mounting -----------------------------------------------------------
private wireHandlers(): void {
// The stub is module-level and shared, so clearing it here is what makes
// `revalidatedPaths` mean "since this mount".
vi.mocked(revalidatePath).mockClear();
worker.use(...handlersForSlack(this.fixture));
this.trackRequests(worker);
}
async mount(): Promise<void> {
window.history.replaceState(null, "", "/integrations/slack");
this.wireHandlers();
const readsBefore = this.channelListCallCount;
this.mounted = render(await SlackIntegrationContent());
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
}
private mounted: ReturnType<typeof render> | null = null;
/**
* Open the management page again, the way a later visit does — the handlers in
* place keep serving what the previous visit left behind. Unmounts the previous
* render first: two live copies would make every assertion ambiguous.
*/
async revisit(): Promise<void> {
(await this.mounted)?.unmount();
const readsBefore = this.channelListCallCount;
this.mounted = render(await SlackIntegrationContent());
await this.mounted;
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
}
/**
* Refresh the page's server data under the open card, as `revalidatePath` does
* after an action: new props, no unmount, so React state survives — unlike
* `revisit()`, which re-seeds everything from scratch.
*/
async refreshPageData(): Promise<void> {
const rendered = await this.mounted;
if (!rendered) {
throw new Error("refreshPageData: the page is not mounted");
}
await rendered.rerender(await SlackIntegrationContent());
}
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
// 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);
if (error) params.set("error", error);
window.history.replaceState(
null,
"",
`/integrations/slack/callback?${params.toString()}`,
);
this.wireHandlers();
// 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. */
mountCatalogue(): void {
window.history.replaceState(null, "", "/integrations");
render(createElement(IntegrationsContent));
}
// --- The integrations catalogue ------------------------------------------
async listedIntegrations(): Promise<string[]> {
const headings = await this.waitFor(
() => {
const found = Array.from(
this.container.querySelectorAll<HTMLElement>("h4"),
);
return found.length > 0 ? found : null;
},
5000,
"the integrations catalogue",
);
return headings.map((heading) => (heading.textContent ?? "").trim());
}
offersSlackManagement(): boolean {
return this.q('a[href="/integrations/slack"]') !== null;
}
// --- Starting the install -----------------------------------------------
private connectLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
/Add to Slack/.test(anchor.textContent ?? ""),
) ?? null
);
}
async authorizeUrl(): Promise<string> {
const link = await this.waitFor(
() => this.connectLink(),
5000,
"the Add to Slack link",
);
return link.href;
}
/**
* Clicks the install affordance and reports where it points. The default
* action is cancelled: following the link navigates the test frame off the app.
*/
async connect(): Promise<string> {
const link = await this.waitFor(
() => this.connectLink(),
5000,
"the Add to Slack link",
);
let destination = "";
const intercept = (event: MouseEvent) => {
event.preventDefault();
destination = link.href;
};
link.addEventListener("click", intercept);
try {
await this.clickElement(link, { fallbackToDomClick: true });
} finally {
link.removeEventListener("click", intercept);
}
return destination;
}
offersInstall(): boolean {
return this.connectLink() !== null;
}
async waitForUnavailable(): Promise<void> {
await this.waitForText(/Slack is not available in this environment yet/);
}
saysUnavailable(): boolean {
return this.containsText(/Slack is not available in this environment yet/);
}
saysLoadFailed(): boolean {
return this.containsText(/Could not load your Slack integration/);
}
async rateLimitNotice(): Promise<string> {
await this.waitForText(/Slack is busy right now/, 10000);
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the rate limit notice",
);
return (description.textContent ?? "").trim();
}
async loadErrorNotice(): Promise<string> {
await this.waitForText(/Could not load your Slack integration/, 10000);
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the load error notice",
);
return (description.textContent ?? "").trim();
}
// --- Connected state ----------------------------------------------------
/**
* Read from the heading element, not the page text: in `textContent`
* "Connected to <workspace>" runs straight into the copy that follows it.
*/
async connectedWorkspaceName(): Promise<string> {
const heading = await this.waitFor(
() => this.deepestElementMatching(/^Connected to \S/),
5000,
"the connected workspace name",
);
return (heading.textContent ?? "").trim().replace(/^Connected to /, "");
}
/** Last match in document order: every ancestor of a match matches too. */
private deepestElementMatching(pattern: RegExp): HTMLElement | null {
return (
Array.from(this.container.querySelectorAll<HTMLElement>("*"))
.reverse()
.find((element) => pattern.test((element.textContent ?? "").trim())) ??
null
);
}
/**
* Keyed on the badge's state attribute, not its copy: the heading beside it
* also starts "Connected to …".
*/
async connectionBadge(): Promise<string> {
const badge = await this.waitFor(
() => this.q("[data-connection-status]"),
5000,
"the connection badge",
);
return (badge.textContent ?? "").trim();
}
async offersConnectionTest(): Promise<boolean> {
const button = await this.waitFor(
() => this.buttonByText(/Test connection/),
5000,
"the Test connection button",
);
return !button.disabled;
}
/**
* 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.
*/
/**
* 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;
const reason = this.container.querySelector<HTMLElement>(`#${describedBy}`);
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.
*/
lastCheckedLine(): string | null {
const line = Array.from(
this.container.querySelectorAll<HTMLElement>("p"),
).find((p) => /^Last checked:/.test((p.textContent ?? "").trim()));
return line ? (line.textContent ?? "").trim() : null;
}
get connectionCheckCallCount(): number {
return this.countRequests("POST", "/connection");
}
/** The outcome of a check under way, started by the button or by a save. */
async connectionOutcome(): Promise<ConnectionOutcome> {
return this.waitFor(
() => {
if (this.containsText(/Connection test successful/)) {
return CONNECTION_OUTCOME.SUCCESS;
}
if (this.containsText(/Connection test failed/)) {
return CONNECTION_OUTCOME.FAILURE;
}
return null;
},
15000,
"the connection test outcome",
);
}
async testConnection(): Promise<ConnectionOutcome> {
await this.clickButton(/Test connection/);
return this.connectionOutcome();
}
// --- Returning from Slack -----------------------------------------------
/**
* The one element every non-success outcome renders. Keyed on it rather than
* the alert title, which is not the same claim on every outcome.
*/
private backLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find(
(anchor) =>
anchor.getAttribute("href") === "/integrations/slack" &&
/Back to Slack integration/.test(anchor.textContent ?? ""),
) ?? null
);
}
async completedInstall(): Promise<boolean> {
const outcome = await this.waitFor(
() => this.containsText(/Connected to /) || this.backLink() !== null,
10000,
"the callback outcome",
);
return outcome && this.containsText(/Connected to /);
}
async installFailureReason(): Promise<string> {
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the failure reason",
);
return (description.textContent ?? "").trim();
}
async installFailureTitle(): Promise<string> {
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
const title = await this.waitFor(
() => this.q('[data-slot="alert-title"]'),
5000,
"the failure title",
);
return (title.textContent ?? "").trim();
}
offersRetry(): boolean {
return this.backLink() !== null || this.offersInstall();
}
// --- Choosing a destination channel --------------------------------------
/** Channel reads issued — one per cursor page the UI followed. */
get channelListCallCount(): number {
return this.countRequests("GET", "/slack/channels");
}
/**
* Wait for the channel read every connected mount starts, counting from the
* reads already issued: one still in flight when the test ends lands in the
* middle of the next, against a harness that never asked for it.
*/
private async waitForChannelsRead(readsBefore: number): Promise<void> {
await this.waitFor(
() => {
const refresh = this.buttonByText(/Refresh channels/);
return this.channelListCallCount > readsBefore &&
refresh !== null &&
!refresh.disabled
? true
: null;
},
15000,
"the workspace's channels to be read",
);
}
/**
* 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 alreadyOpen = this.openPickerOptions();
if (alreadyOpen) return alreadyOpen;
const trigger = await this.waitFor<HTMLElement>(
() => this.q("#slack-channels"),
10000,
"the channel picker",
);
await this.clickElement(trigger, { fallbackToDomClick: true });
let options = await this.waitForOrNull(
() => this.openPickerOptions(),
2000,
"the channel options",
);
if (!options) {
await this.user.keyboard("{Enter}");
options = await this.waitForOrNull(
() => this.openPickerOptions(),
8000,
"the channel options",
);
}
if (!options) {
throw new Error("openChannelPicker: the channel picker offered nothing");
}
return options;
}
private async closeChannelPicker(): Promise<void> {
await this.user.keyboard("{Escape}");
await this.waitForTransition();
}
/**
* Re-read the workspace's channels, the way a user does after inviting
* `@Prowler` to one in Slack. Waits for the read to have settled, not for the
* click alone.
*/
async refreshChannels(): Promise<void> {
const readsBefore = this.channelListCallCount;
await this.clickButton(/Refresh channels/);
await this.waitFor(
() => {
const button = this.buttonByText(/Refresh channels/);
return (
this.channelListCallCount > readsBefore &&
button !== null &&
!button.disabled
);
},
15000,
"the workspace's channels to be read again",
);
}
/** The channels the workspace offers, in the order the picker lists them. */
async channelOptions(): Promise<string[]> {
const options = await this.openChannelPicker();
const names = options.map(
(option) => option.getAttribute("data-channel") ?? "",
);
await this.closeChannelPicker();
return names;
}
/**
* Whether the channel offered under `name` is presented as private — read
* from the marker the user sees, not from how the option is wired up.
*/
async isChannelShownAsPrivate(name: string): Promise<boolean> {
const options = await this.openChannelPicker();
const option = options.find(
(element) => element.getAttribute("data-channel") === name,
);
await this.closeChannelPicker();
return /Private/.test(option?.textContent ?? "");
}
/**
* Open the picker, type `query` into its search, and hand back what the
* filter leaves on offer. The search dies with the popover, so each call
* starts from the full list.
*/
async searchChannels(query: string): Promise<ChannelSearch> {
const all = await this.openChannelPicker();
const input = document.querySelector<HTMLInputElement>("[cmdk-input]");
if (!input) {
throw new Error("searchChannels: the open picker has no search field");
}
await this.user.fill(input, query);
// The filter lands a render after the last keystroke: the offered set
// shrinks, or the no-match note shows. A query that matches everything
// would never settle — the tests only narrow.
await this.waitFor(
() =>
(this.openPickerOptions() ?? []).length !== all.length ||
document.querySelector(
'[data-slot="multiselect-content"] [cmdk-empty]',
) !== null ||
null,
5000,
"the search to narrow the channels",
);
const offered = (this.openPickerOptions() ?? []).map(
(option) => option.getAttribute("data-channel") ?? "",
);
const emptyNote =
document.querySelector<HTMLElement>(
'[data-slot="multiselect-content"] [cmdk-empty]',
)?.textContent ?? null;
await this.closeChannelPicker();
return { offered, emptyNote };
}
/** 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(`pickChannels: no channel named "${name}" is offered`);
}
await this.user.click(option);
await this.waitForTransition();
}
await this.closeChannelPicker();
}
/**
* 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(
() => {
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,
`${names.map((name) => `#${name}`).join(", ")} to be de-authorized`,
);
}
/**
* 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 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("channelsRecordedElsewhere: no workspace is connected");
}
const result = await setSlackAuthorizedChannels(
integrationId,
channels.map((channel) => channel.id),
);
if ("error" in result) {
throw new Error(`channelsRecordedElsewhere: ${result.error}`);
}
}
/** 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 channels the API refuses and hand back what the user is told.
* A save that succeeds fails the test rather than timing out.
*/
async refusedChannelsSave(names: string[]): Promise<string> {
await this.pickChannels(names);
await this.saveChannels();
return this.waitFor(
() => {
const authorized = this.authorizedChannelNames() ?? [];
if (names.every((name) => authorized.includes(name))) {
throw new Error(
`refusedChannelsSave: ${names.join(", ")} were recorded, not refused`,
);
}
return this.toastText(/Could not save the destination channels/);
},
15000,
"the refused channels save",
);
}
/**
* The text of the toast matching `pattern` — title and message together. Radix
* portals each toast into its viewport as an `<li>`, outside the page's markup.
*/
private toastText(pattern: RegExp): string | null {
const toast = Array.from(
document.querySelectorAll<HTMLElement>("ol li"),
).find((element) => pattern.test(element.textContent ?? ""));
return toast ? (toast.textContent ?? "").replace(/\s+/g, " ").trim() : null;
}
/** 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 channels recorded as the integration's authorized destinations. */
async authorizedChannels(): Promise<string[]> {
const settled = await this.waitFor<
string[] | typeof NO_AUTHORIZED_CHANNELS
>(
() =>
this.authorizedChannelNames() ??
(this.containsText(/No destination channels authorized yet/)
? NO_AUTHORIZED_CHANNELS
: null),
10000,
"the authorized destination channels",
);
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. */
async channelPickerMessage(): Promise<string> {
const alert = await this.waitFor(
() =>
Array.from(
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((element) =>
/No channels available yet|Could not read the workspace/.test(
element.textContent ?? "",
),
),
10000,
"the channel picker's message",
);
return (alert.textContent ?? "").replace(/\s+/g, " ").trim();
}
/**
* What the user is told about a list short of the workspace, shown beside a
* picker that still works — unlike `channelPickerMessage()`, which replaces it.
*/
partialListNotice(): string | null {
const notice = this.q("[data-channels-notice]");
return notice
? (notice.textContent ?? "").replace(/\s+/g, " ").trim()
: null;
}
/** Whether the picker was replaced by the "could not read them" alert. */
saysChannelsUnreadable(): boolean {
return this.containsText(/Could not read the workspace/);
}
/** The invite copy that says how to make a private channel appear. */
channelInviteHint(): string | null {
const hint = Array.from(
this.container.querySelectorAll<HTMLElement>("p"),
).find((element) => /invites? @Prowler/.test(element.textContent ?? ""));
return hint ? (hint.textContent ?? "").trim() : null;
}
// --- Disconnecting ------------------------------------------------------
get disconnectCallCount(): number {
return this.countRequests("DELETE", "/integrations/");
}
/**
* Disconnects the workspace, confirming the way a user has to, and reports
* what the page says about the revocation. The outcomes are mutually
* exclusive, so asking for one also checks the others are absent.
*
* The revoked and unreported outcomes share a toast title, so each is read
* from its own description: a title match would agree with either.
*/
async disconnect(): Promise<RevocationOutcome> {
// The dialog's own button carries the noun too, hence the exact match on
// the card's action.
await this.clickButton(/^\s*Disconnect\s*$/);
await this.clickButton(/Disconnect workspace/);
return this.waitFor(
() => {
if (this.alertMatching(/revocation/i)) {
return REVOCATION_OUTCOME.NOT_REVOKED;
}
if (this.containsText(/has been revoked/)) {
return REVOCATION_OUTCOME.REVOKED;
}
if (this.containsText(/is no longer connected to Prowler/)) {
return REVOCATION_OUTCOME.UNREPORTED;
}
return null;
},
15000,
"the disconnect outcome",
);
}
/**
* Whether the page is back to offering an install with no workspace
* connected. The consent URL is minted after the disconnect, so the install
* affordance appears a beat after the copy does.
*/
async returnedToUnconnectedState(): Promise<boolean> {
await this.waitForText(/No workspace connected/, 10000);
return (
(await this.waitForOrNull(
() => this.offersInstall(),
5000,
"the install to be offered again",
)) ?? false
);
}
/** Whether the page is asking the user to remove the access in Slack. */
showsRevocationNotice(): boolean {
return this.alertMatching(/revocation/i) !== null;
}
/**
* What the user is told when the row was removed but Slack never confirmed
* the revocation.
*/
async revocationNotice(): Promise<string> {
const notice = await this.waitFor(
() => this.alertMatching(/revocation/i),
10000,
"the revocation notice",
);
return (notice.textContent ?? "").trim();
}
// --- A credential Slack no longer accepts --------------------------------
/** What the user is told when Slack has stopped accepting the credential. */
async revokedCredentialNotice(): Promise<string> {
const notice = await this.waitFor(
() => this.alertMatching(/no longer accepts Prowler's access/),
10000,
"the revoked-credential notice",
);
return (notice.textContent ?? "").replace(/\s+/g, " ").trim();
}
/** Whether the page is saying Slack has stopped accepting the credential. */
showsRevokedCredentialNotice(): boolean {
return this.alertMatching(/no longer accepts Prowler's access/) !== null;
}
private reconnectLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find((anchor) =>
/Reconnect to Slack/.test(anchor.textContent ?? ""),
) ?? null
);
}
/** Whether the page offers to approve Prowler in the workspace again. */
offersReconnect(): boolean {
return this.reconnectLink() !== null;
}
/**
* Waits, unlike `offersReconnect`: the affordance needs a consent URL the
* page mints only once it knows it needs one, so it lands a beat after the
* notice that explains it.
*/
async waitForReconnect(): Promise<boolean> {
await this.waitFor(() => this.reconnectLink(), 10000, "the reconnect link");
return true;
}
/** The consent URL the reconnect affordance points at, once it is offered. */
async reconnectUrl(): Promise<string> {
const link = await this.waitFor(
() => this.reconnectLink(),
10000,
"the reconnect link",
);
return link.href;
}
/** The alert whose text matches, of however many the page is showing. */
private alertMatching(pattern: RegExp): HTMLElement | null {
return (
Array.from(
this.container.querySelectorAll<HTMLElement>('[data-slot="alert"]'),
).find((alert) => pattern.test(alert.textContent ?? "")) ?? null
);
}
}
File diff suppressed because it is too large Load Diff
@@ -32,13 +32,6 @@ export const ProvidersTabContent = async ({
}: {
searchParams: SearchParamsProps;
}) => {
// The React Compiler (`reactCompiler: true`) otherwise instruments this as a
// client component and injects `useMemoCache`, which needs a React dispatcher.
// An async server component renders once per request, so there is nothing to
// memoize — and the injected hook makes it uncallable outside a render, which
// is exactly how the browser-mode tests mount it.
"use no memo";
const isCloudEnvironment = isCloud();
const [providersView, scanConfigsState] = await Promise.all([
loadProvidersAccountsViewData({
@@ -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 @@
Alerts list Recipients column becomes Destinations, summarizing a rule's email recipients and Slack channels at a glance
@@ -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
@@ -0,0 +1 @@
Slack integration: connect a Slack workspace from the Integrations page (Prowler Cloud only)
@@ -752,6 +752,51 @@ export const JiraIcon: React.FC<IconSvgProps> = ({
</svg>
);
export const SlackIcon: React.FC<IconSvgProps> = ({
size = 32,
width,
height,
className = "rounded-md",
...props
}) => (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={height ?? size}
viewBox="0 0 48 48"
width={width ?? size}
className={className}
{...props}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 12C0 5.37258 5.37258 0 12 0H36C42.6274 0 48 5.37258 48 12V36C48 42.6274 42.6274 48 36 48H12C5.37258 48 0 42.6274 0 36V12Z"
fill="#FFFFFF"
/>
{/* Slack mark on its native 122.8 grid, scaled into the 48px tile's 30px
safe area (30 / 122.8 = 0.2443). */}
<g transform="translate(9 9) scale(0.2443)">
<path
d="M25.8 77.6c0 7.1-5.8 12.9-12.9 12.9S0 84.7 0 77.6s5.8-12.9 12.9-12.9h12.9v12.9zm6.5 0c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9v32.3c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V77.6z"
fill="#E01E5A"
/>
<path
d="M45.2 25.8c-7.1 0-12.9-5.8-12.9-12.9S38.1 0 45.2 0s12.9 5.8 12.9 12.9v12.9H45.2zm0 6.5c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H12.9C5.8 58.1 0 52.3 0 45.2s5.8-12.9 12.9-12.9h32.3z"
fill="#36C5F0"
/>
<path
d="M97 45.2c0-7.1 5.8-12.9 12.9-12.9s12.9 5.8 12.9 12.9-5.8 12.9-12.9 12.9H97V45.2zm-6.5 0c0 7.1-5.8 12.9-12.9 12.9s-12.9-5.8-12.9-12.9V12.9C64.7 5.8 70.5 0 77.6 0s12.9 5.8 12.9 12.9v32.3z"
fill="#2EB67D"
/>
<path
d="M77.6 97c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9-12.9-5.8-12.9-12.9V97h12.9zm0-6.5c-7.1 0-12.9-5.8-12.9-12.9s5.8-12.9 12.9-12.9h32.3c7.1 0 12.9 5.8 12.9 12.9s-5.8 12.9-12.9 12.9H77.6z"
fill="#ECB22E"
/>
</g>
</svg>
);
export const AWSSecurityHubIcon: React.FC<IconSvgProps> = ({
size = 32,
width,
@@ -6,18 +6,47 @@ import { ReactNode } from "react";
import { Badge } from "@/components/shadcn";
import { cn } from "@/lib/utils";
// `null` means never checked, not disconnected: it must not get the fail tokens.
const CONNECTION_BADGE = {
connected: {
label: "Connected",
className:
"bg-bg-pass-secondary text-text-success-primary border-transparent",
},
disconnected: {
label: "Disconnected",
className:
"bg-bg-fail-secondary text-text-error-primary border-transparent",
},
unchecked: {
label: "Not checked yet",
className: "border-border-tag bg-bg-tag text-text-neutral-secondary",
},
} as const;
type ConnectionBadgeState = keyof typeof CONNECTION_BADGE;
const connectionBadgeState = (
connected: boolean | null,
): ConnectionBadgeState =>
connected === null ? "unchecked" : connected ? "connected" : "disconnected";
interface IntegrationCardChip {
label: string;
className?: string;
}
interface IntegrationConnectionStatus {
connected: boolean | null;
label?: string;
}
interface IntegrationCardHeaderProps {
icon: ReactNode;
title: string;
subtitle?: string;
chips?: Array<{
label: string;
className?: string;
}>;
connectionStatus?: {
connected: boolean;
label?: string;
};
chips?: IntegrationCardChip[];
connectionStatus?: IntegrationConnectionStatus;
navigationUrl?: string;
}
@@ -29,6 +58,11 @@ export const IntegrationCardHeader = ({
connectionStatus,
navigationUrl,
}: IntegrationCardHeaderProps) => {
const badgeState = connectionStatus
? connectionBadgeState(connectionStatus.connected)
: null;
const badge = badgeState ? CONNECTION_BADGE[badgeState] : null;
return (
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
@@ -55,7 +89,7 @@ export const IntegrationCardHeader = ({
)}
</div>
</div>
{(chips.length > 0 || connectionStatus) && (
{(chips.length > 0 || badge) && (
<div className="flex flex-wrap items-center gap-2">
{chips.map((chip, index) => (
<Badge
@@ -69,18 +103,13 @@ export const IntegrationCardHeader = ({
{chip.label}
</Badge>
))}
{connectionStatus && (
{badge && badgeState && (
<Badge
variant="outline"
className={cn(
"text-xs font-normal",
connectionStatus.connected
? "bg-bg-pass-secondary text-text-success-primary border-transparent"
: "bg-bg-fail-secondary text-text-error-primary border-transparent",
)}
data-connection-status={badgeState}
className={cn("text-xs font-normal", badge.className)}
>
{connectionStatus.label ||
(connectionStatus.connected ? "Connected" : "Disconnected")}
{connectionStatus?.label || badge.label}
</Badge>
)}
</div>
@@ -0,0 +1,156 @@
/**
* The cases `slack-page.integration.test.tsx` cannot express: it runs the Server
* Action as a plain function, so there is no client→server transport to reject,
* and its handler only answers the contract's shapes. React error boundaries
* cannot see a rejection awaited in an effect, so an uncaught one leaves the
* user on the spinner with no error and no way out.
*/
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationProps } from "@/types/integrations";
import { SlackCallback } from "./slack-callback";
const COMPLETED_QUERY = "code=slack-code-1f4a&state=st-2f1c9d7a";
const { exchangeSlackOAuthCode, callbackQuery, routerReplace } = vi.hoisted(
() => ({
exchangeSlackOAuthCode: vi.fn(),
callbackQuery: { value: "" },
routerReplace: vi.fn(),
}),
);
vi.mock("@/actions/integrations/slack", () => ({ exchangeSlackOAuthCode }));
// One router across renders, so the redirect off the spent code is assertable.
const router = { replace: routerReplace };
vi.mock("next/navigation", () => ({
useRouter: () => router,
useSearchParams: () => new URLSearchParams(callbackQuery.value),
}));
beforeEach(() => {
callbackQuery.value = COMPLETED_QUERY;
routerReplace.mockClear();
});
const SPINNER_COPY = /Connecting your Slack workspace/;
/**
* Literals, not imports: a rename on the component's side has to fail here.
* `FAILURE_TITLE` claims nothing was connected, which only holds for outcomes
* that happen before the API consumed the code.
*/
const FAILURE_TITLE = "Slack workspace not connected";
const UNCONFIRMED_TITLE = "Slack install not confirmed";
describe("returning from Slack when the completion answers unexpectedly", () => {
it("reports an unconfirmed result instead of spinning forever when the exchange call never comes back", async () => {
// The client→server POST itself fails (dropped connection, action id
// invalidated by a deploy), so the action's own error handling never runs.
exchangeSlackOAuthCode.mockRejectedValue(new TypeError("Failed to fetch"));
render(<SlackCallback />);
// The API consumes the single-use code before answering, so the workspace
// may well be connected: unknown, not failed.
expect(
await screen.findByText(/could not confirm whether the workspace/i),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Back to Slack integration/ }),
).toHaveAttribute("href", "/integrations/slack");
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
expect(screen.getByText(UNCONFIRMED_TITLE)).toBeInTheDocument();
expect(screen.queryByText(FAILURE_TITLE)).not.toBeInTheDocument();
});
it("still reports the workspace as connected when the created integration carries no configuration", async () => {
// The install already succeeded; `configuration` only goes missing on the
// client, where the callback reads the workspace name off it.
exchangeSlackOAuthCode.mockResolvedValue({
integration: {
type: "integrations",
id: "slack-integration-1",
attributes: {
inserted_at: "2026-08-10T09:00:00Z",
updated_at: "2026-08-10T09:00:00Z",
enabled: true,
connected: null,
connection_last_checked_at: null,
integration_type: "slack",
},
links: { self: "/api/v1/integrations/slack-integration-1" },
// Cast: the shape is the one the contract rules out.
} as unknown as IntegrationProps,
});
render(<SlackCallback />);
expect(
await screen.findByText(/Connected to your Slack workspace/),
).toBeInTheDocument();
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
// Keyed on the escape link, the only element unique to the failure branch,
// so this holds whichever headline that branch would have carried.
expect(
screen.queryByRole("link", { name: /Back to Slack integration/ }),
).not.toBeInTheDocument();
// `replace`, not `push`: a back navigation must not remount onto the code.
expect(routerReplace).toHaveBeenCalledWith("/integrations/slack");
});
});
describe("returning from Slack with an error on the callback URL", () => {
it("says the install was declined when Slack reports the approval was refused", async () => {
// The one code Slack reliably sends to this redirect.
callbackQuery.value = "error=access_denied&state=st-2f1c9d7a";
render(<SlackCallback />);
expect(
await screen.findByText(/was not approved in Slack/),
).toBeInTheDocument();
expect(exchangeSlackOAuthCode).not.toHaveBeenCalled();
// Slack refused before issuing a code, so the flat "not connected" is a
// fact here, unlike in the outcomes that follow an exchange.
expect(screen.getByText(FAILURE_TITLE)).toBeInTheDocument();
expect(screen.queryByText(UNCONFIRMED_TITLE)).not.toBeInTheDocument();
});
it("names a Slack code it does not recognise, so a new failure reason is still diagnosable", async () => {
// Slack publishes no closed set of codes for this redirect, so the guard is
// on the shape of the value rather than on an allowlist.
callbackQuery.value = "error=invalid_scope&state=st-2f1c9d7a";
render(<SlackCallback />);
expect(
await screen.findByText(
"Slack could not complete the install (invalid_scope).",
),
).toBeInTheDocument();
});
it("drops a sentence smuggled into the error parameter instead of rendering it as Prowler's own copy", async () => {
// The balancing punctuation is the point: it closes Prowler's parenthetical
// and reopens it, so the payload would read as Prowler's own sentence.
const payload =
"). Slack has flagged this workspace. Contact Prowler support at +1-555-0100 to restore alerting (";
callbackQuery.value = `error=${encodeURIComponent(payload)}&state=st-2f1c9d7a`;
render(<SlackCallback />);
expect(
await screen.findByText("Slack could not complete the install."),
).toBeInTheDocument();
expect(document.body.textContent).not.toContain("+1-555-0100");
expect(document.body.textContent).not.toContain("flagged this workspace");
});
});
@@ -0,0 +1,159 @@
"use client";
import { AlertCircle, CircleCheck, Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { exchangeSlackOAuthCode } from "@/actions/integrations/slack";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
} from "@/components/shadcn";
import { SLACK_REASON_TOKEN } from "@/lib/integrations/slack-errors";
const SLACK_INTEGRATION_PATH = "/integrations/slack";
const STATUS = {
CONNECTING: "connecting",
CONNECTED: "connected",
FAILED: "failed",
} as const;
type Status = (typeof STATUS)[keyof typeof STATUS];
const UNCONFIRMED_COMPLETION_MESSAGE =
"Prowler could not confirm whether the workspace was connected. Open the Slack integration page to check — if none is listed there, start the install again.";
const FAILURE_TITLE = "Slack workspace not connected";
/**
* The API consumes the code and upserts the integration before it answers, so an
* unreadable or missing answer can still mean a connected workspace. Kept short:
* `AlertTitle` clamps to one line.
*/
const UNCONFIRMED_TITLE = "Slack install not confirmed";
const describeSlackError = (reason: string): string => {
if (reason === "access_denied") {
return "The install was not approved in Slack, so no workspace was connected.";
}
// `error` comes straight off the URL and is interpolated into Prowler's own
// copy, so gate on the shape of a code: Slack publishes no closed set.
return SLACK_REASON_TOKEN.test(reason)
? `Slack could not complete the install (${reason}).`
: "Slack could not complete the install.";
};
/**
* Slack's `code` is single-use: `hasStarted` holds the exchange to one run per
* mount, and `router.replace` (not `push`) keeps a back navigation from
* remounting onto a spent code.
*/
export const SlackCallback = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<Status>(STATUS.CONNECTING);
const [workspaceName, setWorkspaceName] = useState<string | null>(null);
const [failure, setFailure] = useState<string>("");
const [failureTitle, setFailureTitle] = useState<string>(FAILURE_TITLE);
const hasStarted = useRef(false);
useEffect(() => {
if (hasStarted.current) return;
hasStarted.current = true;
const slackError = searchParams.get("error");
const code = searchParams.get("code");
const state = searchParams.get("state");
// Slack answers a declined install with `error` and no code, so there is
// nothing to exchange.
if (slackError) {
setFailure(describeSlackError(slackError));
setStatus(STATUS.FAILED);
return;
}
if (!code || !state) {
setFailure(
"Slack sent an incomplete response back, so the install could not be completed.",
);
setStatus(STATUS.FAILED);
return;
}
const complete = async () => {
const result = await exchangeSlackOAuthCode({ code, state });
if ("integration" in result) {
setWorkspaceName(
result.integration.attributes?.configuration?.team_name ?? null,
);
setStatus(STATUS.CONNECTED);
router.replace(SLACK_INTEGRATION_PATH);
return;
}
if ("unavailable" in result) {
setFailure("Slack is not available in this environment yet.");
} else if ("rateLimited" in result) {
setFailure(result.message);
} else if ("unconfirmed" in result) {
setFailure(result.message);
setFailureTitle(UNCONFIRMED_TITLE);
} else {
setFailure(result.error);
}
setStatus(STATUS.FAILED);
};
// A rejection here means the call never came back (stale action id after a
// deploy, HTML 502): error boundaries cannot see a rejection awaited inside
// an effect, and the once-guard blocks a retry, so the page would spin.
void complete().catch(() => {
setFailure(UNCONFIRMED_COMPLETION_MESSAGE);
setFailureTitle(UNCONFIRMED_TITLE);
setStatus(STATUS.FAILED);
});
}, [router, searchParams]);
if (status === STATUS.CONNECTING) {
return (
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<Loader2 className="animate-spin" size={16} />
Connecting your Slack workspace...
</div>
);
}
if (status === STATUS.CONNECTED) {
return (
<Alert variant="success">
<CircleCheck />
<AlertTitle>
Connected to {workspaceName ?? "your Slack workspace"}
</AlertTitle>
<AlertDescription>
Taking you back to the Slack integration, where you can authorize the
channels Prowler posts to.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col items-start gap-4">
<Alert variant="error">
<AlertCircle />
<AlertTitle>{failureTitle}</AlertTitle>
<AlertDescription>{failure}</AlertDescription>
</Alert>
<Button asChild variant="outline">
<Link href={SLACK_INTEGRATION_PATH}>Back to Slack integration</Link>
</Button>
</div>
);
};
@@ -0,0 +1,167 @@
"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;
}
/**
* 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,
}: 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 ? "slack-channels" : 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="slack-channels"
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>
);
};
@@ -0,0 +1,54 @@
import { SettingsIcon } from "lucide-react";
import Link from "next/link";
import { SlackIcon } from "@/components/icons/services/IconServices";
import { Button, Card, CardContent, CardHeader } from "@/components/shadcn";
import { CustomLink } from "@/components/shadcn/custom/custom-link";
const SLACK_DOCS_URL =
"https://docs.prowler.com/user-guide/tutorials/prowler-app-slack-integration";
export const SlackIntegrationCard = () => {
return (
<Card variant="base" padding="lg">
<CardHeader>
<div className="flex w-full flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<SlackIcon size={40} />
<div className="flex flex-col gap-1">
<h4 className="text-lg font-bold text-gray-900 dark:text-gray-100">
Slack
</h4>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
<p className="text-xs text-nowrap text-gray-500 dark:text-gray-300">
Send Prowler messages to your Slack workspace.
</p>
<CustomLink
href={SLACK_DOCS_URL}
aria-label="Learn more about Slack integration"
size="xs"
>
Learn more
</CustomLink>
</div>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
<Button asChild size="sm">
<Link href="/integrations/slack">
<SettingsIcon size={14} />
Manage
</Link>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-600 dark:text-gray-300">
Connect a Slack workspace and authorize the channels Prowler posts to,
so your team gets security updates where it already works.
</p>
</CardContent>
</Card>
);
};
@@ -0,0 +1,65 @@
/**
* The case `slack-page.integration.test.tsx` cannot express: it asserts against
* a hydrated, settled page, so it never sees the first frame the user is
* served. The effect that reads the channels only runs in the browser, so the
* channel state at render time is what the served HTML says until hydration.
*/
import { renderToString } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
import { SlackIntegrationManager } from "./slack-integration-manager";
vi.mock("@/actions/integrations/slack", () => ({
getSlackChannels: vi.fn(),
setSlackAuthorizedChannels: vi.fn(),
}));
vi.mock("@/actions/integrations/integrations", () => ({
testIntegrationConnection: vi.fn(),
}));
/**
* A connected workspace with no channel recorded, as the contract has it before
* a save: with one, the picker would show that channel instead of the
* placeholder this test reads.
*/
const CONNECTED_WORKSPACE: IntegrationProps = {
type: "integrations",
id: "slack-integration-1",
attributes: {
inserted_at: "2026-08-10T09:00:00Z",
updated_at: "2026-08-10T09:00:00Z",
enabled: true,
connected: true,
connection_last_checked_at: "2026-08-10T09:05:00Z",
integration_type: INTEGRATION_TYPE.SLACK,
configuration: {
team_id: "T024BE7LD",
team_name: "Prowler HQ",
bot_user_id: "U0KRQLJ9H",
},
},
links: { self: "/api/v1/integrations/slack-integration-1" },
};
describe("the first paint of a connected workspace", () => {
it("reads as still reading the channels rather than as a workspace with none", () => {
// When
const serverHtml = renderToString(
<SlackIntegrationManager
integration={CONNECTED_WORKSPACE}
authorizeUrl={null}
unavailable={false}
rateLimitMessage={null}
loadError={null}
/>,
);
// Then
expect(serverHtml).toContain("Reading channels...");
expect(serverHtml).not.toContain("No channels available yet");
});
});
@@ -0,0 +1,781 @@
"use client";
import { format, isValid, parseISO } from "date-fns";
import { TestTube, Unplug } from "lucide-react";
import { useEffect, useState } from "react";
import { testIntegrationConnection } from "@/actions/integrations/integrations";
import {
disconnectSlackIntegration,
getSlackAuthorizeUrl,
getSlackChannels,
setSlackAuthorizedChannels,
} from "@/actions/integrations/slack";
import { SlackIcon } from "@/components/icons/services/IconServices";
import { IntegrationCardHeader } from "@/components/integrations/shared";
import { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardHeader,
useToast,
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import {
isSlackTokenErrorCode,
SLACK_REASON_TOKEN,
slackErrorMessage,
} from "@/lib/integrations/slack-errors";
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
import type {
IntegrationProps,
SlackAuthorizedChannel,
SlackChannelOption,
} from "@/types/integrations";
const CHANNELS_STATUS = {
LOADING: "loading",
ERROR: "error",
LOADED: "loaded",
} as const;
interface ChannelsLoading {
status: typeof CHANNELS_STATUS.LOADING;
}
interface ChannelsFailed {
status: typeof CHANNELS_STATUS.ERROR;
message: string;
}
interface ChannelsLoaded {
status: typeof CHANNELS_STATUS.LOADED;
channels: SlackChannelOption[];
// Rides with the list it qualifies, so it can never outlive it.
notice: string | null;
}
type ChannelsState = ChannelsLoading | ChannelsFailed | ChannelsLoaded;
const 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.
* The workspace name travels with it: the notice exists to name the workspace
* to clean up, and the record is gone by the time revalidation lands.
*/
interface UnconfirmedRevocation {
workspace: string | null;
}
/** 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;
/**
* 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
* reason and its own prose in the same field, and only a reason is an answer
* from Slack about the credential.
*/
const asReasonCode = (reason: string | null): string | null =>
reason && SLACK_REASON_TOKEN.test(reason) ? reason : null;
interface SlackIntegrationManagerProps {
/** At most one exists per tenant (one workspace). */
integration: IntegrationProps | null;
authorizeUrl: string | null;
/** This deployment has no Prowler Slack app, so no install can be started. */
unavailable: boolean;
rateLimitMessage: string | null;
loadError: string | null;
}
export const SlackIntegrationManager = ({
integration,
authorizeUrl,
unavailable,
rateLimitMessage,
loadError,
}: SlackIntegrationManagerProps) => {
const [isTesting, setIsTesting] = useState(false);
const [isDisconnectOpen, setIsDisconnectOpen] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
// The row is gone the moment the API says so; the server component's
// revalidation only catches up on the next navigation.
const [disconnected, setDisconnected] = useState(false);
const [unconfirmedRevocation, setUnconfirmedRevocation] =
useState<UnconfirmedRevocation | null>(null);
/**
* The `code` of the last refusal any Slack-backed call ran into, or `null`
* when the last answer was not a refusal. A dead grant can surface from any
* of them (contract, Cross-cutting), so every call reports here instead of
* deciding on its own.
*/
const [lastRefusalCode, setLastRefusalCode] = useState<string | null>(null);
// A connected workspace arrives with no consent URL, since no install is left
// to start (design D10), so one is minted only if a reconnect turns out to be
// the way out.
const [mintedInstallUrl, setMintedInstallUrl] = useState<string | null>(null);
const { toast } = useToast();
const integrationId = integration?.id ?? null;
const 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
// hydration.
const [channelsState, setChannelsState] = useState<ChannelsState>(
integrationId
? { status: CHANNELS_STATUS.LOADING }
: { status: CHANNELS_STATUS.LOADED, channels: [], notice: null },
);
// Bumped by refresh: a channel invited after load only shows on a re-read.
const [channelReloads, setChannelReloads] = useState(0);
// Local state needed: the 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 [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 [syncedChannels, setSyncedChannels] = useState(recordedChannels);
const [isSavingChannels, setIsSavingChannels] = useState(false);
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 (sameChannelIds(selectedChannelIds, previousSyncedIds)) {
setSelectedChannelIds(recordedChannels.map((channel) => channel.id));
}
}
// Only an answer from Slack moves the bus: a call that never got one proves
// nothing and leaves the last answer standing.
const provedCredentialAlive = () => setLastRefusalCode(null);
const recordRefusal = (code: string | null | undefined) => {
if (code) setLastRefusalCode(code);
};
/**
* Whether the last refusal proves the grant itself is dead, rather than a
* channel unreachable or Slack busy. Derived, not stored, so it self-clears:
* a later call Slack answered at all (even to refuse a channel) is proof the
* credential works again, and the notice goes with it.
*/
const credentialFailure: SlackTokenErrorCode | null = isSlackTokenErrorCode(
lastRefusalCode,
)
? lastRefusalCode
: null;
const needsInstallUrl = disconnected || credentialFailure !== null;
useEffect(() => {
if (!needsInstallUrl) return;
let cancelled = false;
getSlackAuthorizeUrl()
.then((result) => {
if (cancelled || !("authorizeUrl" in result)) return;
setMintedInstallUrl(result.authorizeUrl);
})
.catch(() => {
// Nothing to say: the page loses a shortcut, not a way to reconnect.
});
return () => {
cancelled = true;
};
}, [needsInstallUrl]);
useEffect(() => {
if (!integrationId) return;
let cancelled = false;
setChannelsState({ status: CHANNELS_STATUS.LOADING });
getSlackChannels(integrationId)
.then((result) => {
if (cancelled) return;
setChannelsState(
"error" in result
? { status: CHANNELS_STATUS.ERROR, message: result.error }
: {
status: CHANNELS_STATUS.LOADED,
channels: result.channels,
notice: result.incomplete ?? null,
},
);
// The listing runs on arrival, so it is where a dead credential shows
// up first. A read cut short still names its refusal's code, so a grant
// that died on a later cursor page is heard too; a truncation naming
// none was Slack busy, not refusing.
if ("error" in result || result.code) recordRefusal(result.code);
else provedCredentialAlive();
})
.catch(() => {
if (cancelled) return;
setChannelsState({
status: CHANNELS_STATUS.ERROR,
message: "Could not reach Slack to read the channel list.",
});
});
return () => {
cancelled = true;
};
}, [integrationId, channelReloads]);
const listedChannels =
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.channels
: [];
const channelOptions = mergeChannelOptions(
listedChannels,
authorizedChannels,
);
// 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),
);
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 savedCount = 0;
setIsSavingChannels(true);
try {
// Only ids travel — the API validates them and derives the names.
const result = await setSlackAuthorizedChannels(
integrationId,
selectedChannelIds,
);
if ("error" in result) {
// 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 channels",
description: result.error,
});
return;
}
// 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.
const savedChannels =
result.integration.attributes.configuration.channels ??
recordedFromSelection(
selectedChannelIds,
channelOptions,
authorizedChannels,
);
provedCredentialAlive();
setAuthorizedChannels(savedChannels);
setSelectedChannelIds(savedChannels.map((channel) => channel.id));
savedCount = savedChannels.length;
toast({
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 channels",
description: "Something went wrong. Please try again.",
});
} finally {
setIsSavingChannels(false);
}
// 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 (savedCount > 0) await handleTestConnection(integrationId);
};
const handleTestConnection = async (id: string) => {
setIsTesting(true);
try {
const result = await testIntegrationConnection(id);
if (result.success) {
provedCredentialAlive();
toast({
title: "Connection test successful!",
description:
result.message ||
"Prowler can reach your Slack workspace and every authorized channel.",
});
} else {
// A dead credential named here is not a failure checking again can
// fix, so the reason is recorded and not only reported.
const reason = result.error?.trim() || null;
recordRefusal(asReasonCode(reason));
const explanation = reason
? slackErrorMessage({ code: reason, detail: reason })
: "Failed to reach your Slack workspace.";
toast({
variant: "destructive",
title: "Connection test failed",
// The failure names the channel it is about (design D7): the fix is
// in Slack, on that channel, not on the integration as a whole.
description: result.failedChannel
? `Slack refused #${result.failedChannel}: ${explanation}`
: explanation,
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to test connection. Please try again.",
});
} finally {
setIsTesting(false);
}
};
const handleDisconnect = async (id: string) => {
const recordedWorkspace =
integration?.attributes.configuration.team_name ?? null;
const workspace = recordedWorkspace ?? "your Slack workspace";
setIsDisconnecting(true);
try {
const result = await disconnectSlackIntegration(id);
if ("error" in result) {
toast({
variant: "destructive",
title: "Disconnect failed",
description: result.error,
});
return;
}
const { revoked } = result.revocation;
// The row is gone whatever Slack answered, so the page goes back to its
// unconnected state either way, and a dead credential is moot once the
// row it belonged to is gone.
setDisconnected(true);
setLastRefusalCode(null);
// Only an explicit `false` sends the user to finish the job in Slack: an
// unreported outcome is neither a failed revocation nor a confirmed one,
// so it claims neither.
setUnconfirmedRevocation(
revoked === false ? { workspace: recordedWorkspace } : null,
);
if (revoked !== false) {
toast({
title: "Slack workspace disconnected",
description:
revoked === true
? `Prowler's access to ${workspace} has been revoked.`
: `${workspace} is no longer connected to Prowler.`,
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to disconnect Slack. Please try again.",
});
} finally {
setIsDisconnecting(false);
setIsDisconnectOpen(false);
}
};
const workspaceName = integration?.attributes.configuration.team_name;
const installUrl = mintedInstallUrl ?? authorizeUrl;
const checkedAt = integration?.attributes.connection_last_checked_at;
const checkedOn = checkedAt ? parseISO(checkedAt) : null;
// `format` throws a RangeError on an unreadable value, which would replace
// the page with the route's error boundary: show nothing instead, as for a
// connection that was never checked.
const lastCheckedOn =
checkedOn && isValid(checkedOn) ? format(checkedOn, "yyyy/MM/dd") : null;
return (
<div className="flex flex-col gap-6">
{rateLimitMessage && (
<Alert variant="warning">
<AlertTitle>Slack is busy right now</AlertTitle>
<AlertDescription>{rateLimitMessage}</AlertDescription>
</Alert>
)}
<Modal
open={isDisconnectOpen}
onOpenChange={setIsDisconnectOpen}
title="Disconnect Slack workspace"
description={`Prowler will revoke its access at Slack and stop posting to ${workspaceName ?? "this workspace"}. Connecting again means approving Prowler in Slack.`}
>
<div className="flex w-full justify-end gap-4">
<Button
type="button"
variant="ghost"
size="lg"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(false)}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
size="lg"
disabled={isDisconnecting}
onClick={() => integration && handleDisconnect(integration.id)}
>
{!isDisconnecting && <Unplug size={20} />}
{isDisconnecting ? "Disconnecting..." : "Disconnect workspace"}
</Button>
</div>
</Modal>
{loadError && (
<Alert variant="error">
<AlertTitle>Could not load your Slack integration</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</Alert>
)}
{unconfirmedRevocation && (
<Alert variant="warning">
<AlertTitle>
Slack disconnected remove Prowler&apos;s access in Slack
</AlertTitle>
<AlertDescription>
The integration and the token Prowler had stored are gone from
Prowler, so there is nothing to retry here. Slack did not confirm
the revocation, so the Prowler app may still be installed in{" "}
{unconfirmedRevocation.workspace ?? "the workspace"} remove it
from that workspace&apos;s Slack app settings.
</AlertDescription>
</Alert>
)}
{credentialFailure && (
<Alert variant="error">
<AlertTitle>
Slack no longer accepts Prowler&apos;s access to{" "}
{workspaceName ?? "this workspace"}
</AlertTitle>
{/* Each mapped sentence already ends in the thing that fixes it. */}
<AlertDescription>
{slackErrorMessage({ code: credentialFailure })} Until then, nothing
Prowler sends will reach the workspace.
</AlertDescription>
{installUrl && (
<div className="col-start-2 mt-3">
<Button asChild size="sm">
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Reconnect to Slack
</a>
</Button>
</div>
)}
</Alert>
)}
{/* Replaces the cards, not the whole page: an early return here would
swallow the rate-limit and load-error notices above. */}
{unavailable ? (
<Alert variant="info">
<AlertTitle>
Slack is not available in this environment yet
</AlertTitle>
<AlertDescription>
The Prowler Slack app is not configured here, so no workspace can be
connected. Nothing to do on your side this page starts working as
soon as it is.
</AlertDescription>
</Alert>
) : integration && !disconnected ? (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
icon={<SlackIcon size={32} />}
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
subtitle="Prowler posts to this workspace only."
connectionStatus={{
// A dead token outranks the state the page was loaded with.
connected:
credentialFailure === null
? integration.attributes.connected
: false,
}}
/>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="text-xs text-gray-500 dark:text-gray-300">
{lastCheckedOn && (
<p>
<span className="font-medium">Last checked:</span>{" "}
{lastCheckedOn}
</p>
)}
</div>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
{/* The check reaches the authorized channels: the API
answers 400 while the set is empty. */}
<Button
size="sm"
variant="outline"
disabled={isTesting || 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} />
{isTesting ? "Testing..." : "Test connection"}
</Button>
<Button
size="sm"
variant="destructive"
disabled={isDisconnecting}
onClick={() => setIsDisconnectOpen(true)}
>
<Unplug size={14} />
Disconnect
</Button>
</div>
<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">
<SlackChannelMultiSelect
options={channelOptions}
values={selectedChannelIds}
onChange={setSelectedChannelIds}
isLoading={channelsState.status === CHANNELS_STATUS.LOADING}
error={
channelsState.status === CHANNELS_STATUS.ERROR
? channelsState.message
: null
}
incompleteNotice={
channelsState.status === CHANNELS_STATUS.LOADED
? channelsState.notice
: null
}
onRefresh={() => setChannelReloads((reloads) => reloads + 1)}
disabled={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">
{/* 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={
sameChannelIds(
selectedChannelIds,
authorizedChannels.map((channel) => channel.id),
) ||
isSavingChannels ||
isTesting
}
onClick={handleSaveChannels}
>
{isSavingChannels ? "Saving..." : "Save channels"}
</Button>
</div>
</div>
</CardContent>
</Card>
) : (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
icon={<SlackIcon size={32} />}
title="No workspace connected"
subtitle="Approve Prowler in Slack to connect a workspace. No tokens to copy."
/>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-gray-600 dark:text-gray-300">
Prowler asks for permission to post messages and to read the
workspace&apos;s channel list.
</p>
{installUrl ? (
<Button asChild>
<a href={installUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Add to Slack
</a>
</Button>
) : (
<Button disabled>
<SlackIcon size={16} />
Add to Slack
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
};
+189
View File
@@ -0,0 +1,189 @@
/**
* Unit-tested because the mapping carries copy for codes no page-level flow can
* be driven into — `no_permission`, `invalid_auth`, `account_inactive`.
*/
import { describe, expect, it } from "vitest";
import {
isSlackTokenErrorCode,
SLACK_ERROR_CODE,
SLACK_ERROR_MESSAGES,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_RATE_LIMITED_MESSAGE,
SLACK_REASON_TOKEN,
SLACK_TOKEN_ERROR_CODES,
readSlackFailure,
slackErrorMessage,
slackRateLimitMessage,
} from "./slack-errors";
describe("slackErrorMessage", () => {
it("prefers the code's own copy over the API's wording", () => {
// `detail` states the condition; the code's copy states the fix.
const failure = {
code: SLACK_ERROR_CODE.WORKSPACE_CONFLICT,
detail:
"This tenant is already connected to a different Slack workspace.",
};
expect(slackErrorMessage(failure)).toBe(
SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.WORKSPACE_CONFLICT],
);
expect(slackErrorMessage(failure)).toMatch(/Disconnect it/);
});
it("tells the user how to grant a scope Prowler is missing", () => {
// A missing scope is fixable by the reader, so the copy names the fix.
const message = slackErrorMessage({
code: SLACK_ERROR_CODE.MISSING_SCOPE,
detail: "missing_scope",
});
expect(message).toMatch(/Connect the workspace again/);
expect(message).not.toMatch(/missing_scope/);
});
it("says what to do about each channel refusal", () => {
expect(
slackErrorMessage({ code: SLACK_ERROR_CODE.CHANNEL_NOT_FOUND }),
).toMatch(/Choose another one/);
expect(
slackErrorMessage({ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL }),
).toMatch(/Invite @Prowler/);
expect(slackErrorMessage({ code: SLACK_ERROR_CODE.NO_PERMISSION })).toMatch(
/Choose another channel/,
);
});
it("points every dead-credential code at reconnecting, not at retrying", () => {
for (const code of SLACK_TOKEN_ERROR_CODES) {
// Revoked, invalid, inactive or expired: no retry helps for any of them.
expect(slackErrorMessage({ code })).toMatch(
/Connect the workspace again to restore access/,
);
expect(isSlackTokenErrorCode(code)).toBe(true);
}
});
it("does not treat an actionable refusal as a dead credential", () => {
expect(isSlackTokenErrorCode(SLACK_ERROR_CODE.MISSING_SCOPE)).toBe(false);
expect(isSlackTokenErrorCode(null)).toBe(false);
expect(isSlackTokenErrorCode(undefined)).toBe(false);
});
it("falls back to the API's detail for a code it does not know", () => {
expect(
slackErrorMessage({
code: "some_future_slack_reason",
detail: "Slack said no.",
}),
).toBe("Slack said no.");
});
it("falls back to the generic line when there is neither", () => {
expect(slackErrorMessage({ code: null, detail: null })).toBe(
SLACK_GENERIC_ERROR_MESSAGE,
);
expect(slackErrorMessage(null)).toBe(SLACK_GENERIC_ERROR_MESSAGE);
expect(
slackErrorMessage({ detail: " " }, "Could not read channels."),
).toBe("Could not read channels.");
});
it("falls back only for a code the mapping does not cover", () => {
const FALLBACK = "Slack refused it (is_archived).";
expect(
slackErrorMessage({ code: SLACK_ERROR_CODE.NOT_IN_CHANNEL }, FALLBACK),
).toBe(SLACK_ERROR_MESSAGES[SLACK_ERROR_CODE.NOT_IN_CHANNEL]);
// No `detail`: one holding the same token would make the raw token the
// whole message again.
expect(slackErrorMessage({ code: "is_archived" }, FALLBACK)).toBe(FALLBACK);
});
});
describe("SLACK_REASON_TOKEN", () => {
it("recognises a reason code and refuses anything that reads as a sentence", () => {
// Slack publishes no closed set of reasons, so the guard is on shape rather
// than an allowlist.
for (const reason of [
"is_archived",
"restricted_action",
"team_access_not_granted",
"ekm_access_denied",
"messages_tab_disabled",
]) {
expect(SLACK_REASON_TOKEN.test(reason)).toBe(true);
}
for (const prose of [
"Slack rejected the message: the channel is archived.",
"). Contact support at +1-555-0100 (",
"",
"a".repeat(49),
]) {
expect(SLACK_REASON_TOKEN.test(prose)).toBe(false);
}
});
});
describe("slackRateLimitMessage", () => {
it("names the wait Slack asked for", () => {
expect(slackRateLimitMessage(30)).toMatch(/about 30 seconds/);
expect(slackRateLimitMessage(1)).toMatch(/about 1 second\b/);
expect(slackRateLimitMessage(90)).toMatch(/about 2 minutes/);
});
it("still says to come back when Slack named no wait", () => {
expect(slackRateLimitMessage(null)).toBe(SLACK_RATE_LIMITED_MESSAGE);
expect(slackRateLimitMessage(0)).toBe(SLACK_RATE_LIMITED_MESSAGE);
});
});
describe("readSlackFailure", () => {
it("reads the code, the detail and the wait off a JSON:API refusal", async () => {
const response = new Response(
JSON.stringify({
errors: [
{
status: "429",
detail: "Slack is rate limiting requests from Prowler.",
source: { pointer: "/data" },
},
],
}),
{ status: 429, headers: { "Retry-After": "30" } },
);
const failure = await readSlackFailure(response);
expect(failure).toEqual({
status: 429,
code: null,
detail: "Slack is rate limiting requests from Prowler.",
retryAfterSeconds: 30,
});
});
it("survives a body that is not JSON:API at all", async () => {
const failure = await readSlackFailure(
new Response("<html>Bad gateway</html>", { status: 502 }),
);
expect(failure).toEqual({
status: 502,
code: null,
detail: null,
retryAfterSeconds: null,
});
});
it("ignores a Retry-After it cannot use", async () => {
const failure = await readSlackFailure(
new Response("{}", { status: 429, headers: { "Retry-After": "soon" } }),
);
expect(failure.retryAfterSeconds).toBeNull();
});
});
+148
View File
@@ -0,0 +1,148 @@
export const SLACK_ERROR_CODE = {
MISSING_SCOPE: "missing_scope",
CHANNEL_NOT_FOUND: "channel_not_found",
NOT_IN_CHANNEL: "not_in_channel",
NO_PERMISSION: "no_permission",
TOKEN_REVOKED: "token_revoked",
INVALID_AUTH: "invalid_auth",
ACCOUNT_INACTIVE: "account_inactive",
TOKEN_EXPIRED: "token_expired",
/** One workspace per tenant. */
WORKSPACE_CONFLICT: "slack_workspace_conflict",
} as const;
export type SlackErrorCode =
(typeof SLACK_ERROR_CODE)[keyof typeof SLACK_ERROR_CODE];
/**
* The grant itself is dead: reconnecting is the only way out, not retrying. The
* API answers these with `400`, not `401`, so they are not mistaken for an
* expired Prowler session.
*/
export const SLACK_TOKEN_ERROR_CODES = [
SLACK_ERROR_CODE.TOKEN_REVOKED,
SLACK_ERROR_CODE.INVALID_AUTH,
SLACK_ERROR_CODE.ACCOUNT_INACTIVE,
SLACK_ERROR_CODE.TOKEN_EXPIRED,
] as const;
export type SlackTokenErrorCode = (typeof SLACK_TOKEN_ERROR_CODES)[number];
export const isSlackTokenErrorCode = (
code: string | null | undefined,
): code is SlackTokenErrorCode =>
SLACK_TOKEN_ERROR_CODES.includes(code as SlackTokenErrorCode);
export const SLACK_GENERIC_ERROR_MESSAGE =
"Slack could not complete that request. Try again in a moment.";
export const SLACK_RATE_LIMITED_MESSAGE =
"Slack is rate limiting Prowler right now. Try again in a few moments.";
/**
* For a channel list that stopped short of the workspace: the page budget ran
* out, or `links.next` left the API's origin.
*/
export const SLACK_PARTIAL_CHANNEL_LIST_MESSAGE =
"This workspace has more channels than Prowler reads in one go, so this list is not all of them. A channel missing from it is not necessarily one @Prowler has to be invited to.";
/**
* For a `2xx` the UI could not read. Not phrased as a failure: the install
* happened, only the workspace cannot be named.
*/
export const SLACK_UNREADABLE_RESULT_MESSAGE =
"Prowler could not read the result of the install. Open the Slack integration page to see the workspace — if none is listed there, start the install again.";
/**
* The shape of a Slack reason code, as opposed to a sentence: the set is
* open-ended, so a reason is gated on its shape before being interpolated.
*/
export const SLACK_REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
const RECONNECT = "Connect the workspace again to restore access.";
export const SLACK_ERROR_MESSAGES = {
[SLACK_ERROR_CODE.MISSING_SCOPE]:
"Prowler is missing a permission it needs in Slack. Connect the workspace again and approve the access Prowler asks for.",
[SLACK_ERROR_CODE.CHANNEL_NOT_FOUND]:
"That channel no longer exists in the workspace. Choose another one.",
[SLACK_ERROR_CODE.NOT_IN_CHANNEL]:
"Prowler is not in that channel. Invite @Prowler to it in Slack, or choose a channel it can already post to.",
[SLACK_ERROR_CODE.NO_PERMISSION]:
"Slack did not allow Prowler to post there. Choose another channel, or ask a workspace admin to allow it.",
[SLACK_ERROR_CODE.TOKEN_REVOKED]: `Prowler's access to Slack was revoked. ${RECONNECT}`,
[SLACK_ERROR_CODE.INVALID_AUTH]: `Slack no longer accepts Prowler's credential. ${RECONNECT}`,
[SLACK_ERROR_CODE.ACCOUNT_INACTIVE]: `The Slack account Prowler was installed with is no longer active. ${RECONNECT}`,
[SLACK_ERROR_CODE.TOKEN_EXPIRED]: `Prowler's Slack credential has expired. ${RECONNECT}`,
[SLACK_ERROR_CODE.WORKSPACE_CONFLICT]:
"Prowler is already connected to a different Slack workspace. Disconnect it before connecting another one.",
} as const satisfies Record<SlackErrorCode, string>;
/** The parts of a JSON:API error this mapping reads. */
export interface SlackErrorSource {
code?: string | null;
detail?: string | null;
}
export interface SlackApiFailure extends SlackErrorSource {
status: number;
retryAfterSeconds: number | null;
}
const isKnownCode = (code: string | null | undefined): code is SlackErrorCode =>
typeof code === "string" &&
Object.prototype.hasOwnProperty.call(SLACK_ERROR_MESSAGES, code);
/**
* Copy for a refusal: Prowler's wording for a known `code`, else the API's
* `detail`, else `fallback`.
*/
export const slackErrorMessage = (
error: SlackErrorSource | null | undefined,
fallback: string = SLACK_GENERIC_ERROR_MESSAGE,
): string => {
if (isKnownCode(error?.code)) return SLACK_ERROR_MESSAGES[error.code];
return error?.detail?.trim() || fallback;
};
const describeWait = (seconds: number): string => {
if (seconds < 60) return `${seconds} second${seconds === 1 ? "" : "s"}`;
const minutes = Math.ceil(seconds / 60);
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
};
export const slackRateLimitMessage = (
retryAfterSeconds: number | null,
): string => {
if (retryAfterSeconds === null || retryAfterSeconds <= 0) {
return SLACK_RATE_LIMITED_MESSAGE;
}
return `Slack is rate limiting Prowler right now. Try again in about ${describeWait(
Math.ceil(retryAfterSeconds),
)}.`;
};
const retryAfterFrom = (response: Response): number | null => {
const header = response.headers.get("retry-after");
if (!header) return null;
const seconds = Number(header.trim());
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
};
/**
* Read a non-OK Slack response into the failure it describes. Never throws: a
* body that is not JSON:API still yields a failure carrying the status.
*/
export const readSlackFailure = async (
response: Response,
): Promise<SlackApiFailure> => {
const body = await response.json().catch(() => null);
const error = Array.isArray(body?.errors) ? body.errors[0] : null;
return {
status: response.status,
code: typeof error?.code === "string" ? error.code : null,
detail: typeof error?.detail === "string" ? error.detail : null,
retryAfterSeconds: retryAfterFrom(response),
};
};
+58 -2
View File
@@ -2,7 +2,15 @@ import { z } from "zod";
import type { TaskState } from "@/types/tasks";
export type IntegrationType = "amazon_s3" | "aws_security_hub" | "jira";
export const INTEGRATION_TYPE = {
AMAZON_S3: "amazon_s3",
AWS_SECURITY_HUB: "aws_security_hub",
JIRA: "jira",
SLACK: "slack",
} as const;
export type IntegrationType =
(typeof INTEGRATION_TYPE)[keyof typeof INTEGRATION_TYPE];
export const JIRA_DISPATCH_MODE = {
INDIVIDUAL: "individual",
@@ -68,7 +76,10 @@ export interface IntegrationProps {
inserted_at: string;
updated_at: string;
enabled: boolean;
connected: boolean;
// `null` until a connection check has run: never verified, neither working
// nor broken. A Slack install starts here, and returns here on a channel
// change.
connected: boolean | null;
connection_last_checked_at: string | null;
integration_type: IntegrationType;
configuration: {
@@ -87,6 +98,15 @@ export interface IntegrationProps {
domain?: string;
projects?: { [key: string]: string };
issue_types?: { [key: string]: string[] };
// 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;
channels?: SlackAuthorizedChannel[];
verification?: SlackVerification;
[key: string]: unknown;
};
url?: string;
@@ -95,6 +115,42 @@ export interface IntegrationProps {
links: { self: string };
}
/**
* A channel Prowler can post to: every active public channel, plus the private
* ones `@Prowler` was invited to. `is_private` keeps the API's own naming.
*
* 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;
name: string;
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: {
+30 -8
View File
@@ -1,9 +1,34 @@
import react from "@vitejs/plugin-react";
import react, { type BabelOptions } from "@vitejs/plugin-react";
import { playwright } from "@vitest/browser-playwright";
import fs from "fs";
import path from "path";
import type { TestProjectConfiguration } from "vitest/config";
import { defineConfig } from "vitest/config";
/**
* Next runs the React Compiler on the client compilation only — its
* `getReactCompilerPlugins` returns nothing when `isServer` — so a Server
* Component ships uncompiled. Mirror that: compiled, it calls `useMemoCache`
* on the active dispatcher, which a harness invoking the component as a
* function has none of, and `react/compiler-runtime` reads the client
* internals the `react-server` build does not export anyway.
*/
const isServerModule = (id: string): boolean => {
const file = id.split("?")[0];
if (!file.includes("/app/")) return false;
try {
return !/^\s*["']use client["']/.test(fs.readFileSync(file, "utf8"));
} catch {
return false;
}
};
const reactCompilerBabel = (id: string): BabelOptions => ({
plugins: isServerModule(id)
? []
: [["babel-plugin-react-compiler", { target: "19" }]],
});
export default defineConfig(() => {
const apiBaseUrl = process.env.UI_API_BASE_URL ?? "http://localhost/api/v1";
@@ -57,13 +82,7 @@ export default defineConfig(() => {
},
{
extends: true,
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler", { target: "19" }]],
},
}),
],
plugins: [react({ babel: reactCompilerBabel })],
test: {
name: "integration",
setupFiles: ["./vitest.integration.setup.ts"],
@@ -109,6 +128,9 @@ export default defineConfig(() => {
// React runtime (pre-bundle so a cold run doesn't re-optimize and
// reload mid-test — see the on-demand-reload note above).
"react-dom/client",
// What the compiler's output imports. `@vitejs/plugin-react` adds it
// itself only when `babel` is a plain object, and ours is a function.
"react/compiler-runtime",
// Next runtime
"next/headers",