Compare commits

...
26 Commits
Author SHA1 Message Date
Pablo F.G 434e5aaf03 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-18 09:23:40 +02:00
Pablo F.G 68f1092f56 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-18 09:23:40 +02:00
Pablo F.G 5ed235088c 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-18 09:23:40 +02:00
Pablo F.G ed1fce420e 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-18 09:23:40 +02:00
Pablo F.G 8481a43fe3 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-18 09:23:40 +02:00
Pablo F.G 37b3ae7d25 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-18 09:23:40 +02:00
Pablo F.G 553c0429e6 docs(ui): focus the Slack changelog entry on what the user can do 2026-08-18 09:23:40 +02:00
Pablo F.G 2f59b89b49 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-18 09:23:40 +02:00
Pablo F.G 4cf3d7af73 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-18 09:23:40 +02:00
Pablo F.G d60bb6ee62 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-18 09:23:40 +02:00
Pablo F.G 327d0ed0c9 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-18 09:23:40 +02:00
Pablo F.G ffa593dff9 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-18 09:23:40 +02:00
Pablo F.G dfc66e43a8 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-18 09:23:40 +02:00
Pablo F.G bdb2e52261 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-18 09:23:40 +02:00
Pablo F.G e15a68c6d1 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-18 09:23:40 +02:00
Pablo F.G 866cb6077f 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-18 09:23:40 +02:00
Pablo F.G fce28e364a 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-18 09:23:40 +02:00
Pablo F.G 7cf3d4d486 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-18 09:23:40 +02:00
Pablo F.G 8089a7576e 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-18 09:23:40 +02:00
Pablo F.G 4fa4354796 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-18 09:23:40 +02:00
Pablo F.G 0aa6457ef1 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-18 09:23:40 +02:00
Pablo F.G baa0d03c06 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-18 09:23:40 +02:00
Pablo F.G 19dadeca05 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-18 09:23:40 +02:00
Pablo F.G 513b76d68d 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-18 09:23:40 +02:00
Pablo F.G 34f752cf41 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-18 09:23:40 +02:00
ye11oc4tandHugo P.Brito f3224d0988 fix(ses): evaluate all identity authorization policies (#12464)
Co-authored-by: Hugo P.Brito <hugopbrit@gmail.com>
2026-08-17 14:19:45 +01:00
31 changed files with 3244 additions and 80 deletions
@@ -0,0 +1 @@
`ses_identity_not_publicly_accessible` now evaluates every SES identity authorization policy and marks mixed public Allow and Deny statements for manual review
@@ -1,25 +1,58 @@
from copy import deepcopy
from prowler.lib.check.models import Check, Check_Report_AWS
from prowler.providers.aws.services.iam.lib.policy import is_policy_public
from prowler.providers.aws.services.ses.ses_client import ses_client
def _normalize_policy_statements(policy: dict) -> dict:
statements = policy.get("Statement", [])
if isinstance(statements, dict):
return {**policy, "Statement": [statements]}
return policy
def _has_explicit_deny(policy: dict) -> bool:
return any(
isinstance(statement, dict) and statement.get("Effect") == "Deny"
for statement in _normalize_policy_statements(policy).get("Statement", [])
)
class ses_identity_not_publicly_accessible(Check):
def execute(self):
"""Ensure SES identities are not publicly accessible through authorization policies."""
def execute(self) -> list[Check_Report_AWS]:
"""Evaluate every authorization policy attached to each SES identity.
Returns:
A list of reports containing the public-access result for each identity.
"""
findings = []
for identity in ses_client.email_identities.values():
if identity.policy is None:
if not identity.policies:
continue
report = Check_Report_AWS(metadata=self.metadata(), resource=identity)
report.status = "PASS"
report.status_extended = (
f"SES identity {identity.name} is not publicly accessible."
)
if is_policy_public(
identity.policy,
ses_client.audited_account,
):
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policy."
has_public_allow = any(
is_policy_public(
_normalize_policy_statements(deepcopy(policy)),
ses_client.audited_account,
)
for policy in identity.policies.values()
)
if has_public_allow:
if any(
_has_explicit_deny(policy) for policy in identity.policies.values()
):
report.status = "MANUAL"
report.status_extended = f"SES identity {identity.name} has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
else:
report.status = "FAIL"
report.status_extended = f"SES identity {identity.name} is publicly accessible due to its resource policies."
findings.append(report)
@@ -1,7 +1,7 @@
from json import loads
from typing import Optional
from pydantic.v1 import BaseModel
from pydantic.v1 import BaseModel, Field
from prowler.lib.logger import logger
from prowler.lib.scan_filters.scan_filters import is_resource_filtered
@@ -46,8 +46,11 @@ class SES(AWSService):
identity_attributes = regional_client.get_email_identity(
EmailIdentity=identity.name
)
for _, content in identity_attributes.get("Policies", {}).items():
identity.policy = loads(content)
identity.policies = {
name: loads(content)
for name, content in identity_attributes.get("Policies", {}).items()
}
identity.policy = next(reversed(identity.policies.values()), None)
identity.tags = identity_attributes.get("Tags", [])
dkim_attrs = identity_attributes.get("DkimAttributes", {}) or {}
identity.dkim_status = dkim_attrs.get("Status")
@@ -72,6 +75,7 @@ class Identity(BaseModel):
region: str
type: Optional[str]
policy: Optional[dict] = None
policies: dict[str, dict] = Field(default_factory=dict)
tags: Optional[list] = []
dkim_status: Optional[str] = None
dkim_signing_attributes_origin: Optional[str] = None
@@ -1,6 +1,8 @@
from copy import deepcopy
from unittest import mock
import botocore
import pytest
from boto3 import client
from moto import mock_aws
@@ -54,6 +56,113 @@ def mock_make_api_call_v2(self, operation_name, kwarg):
return make_api_call(self, operation_name, kwarg)
PUBLIC_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
PRIVATE_ALLOW_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}]}'
MATCHING_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
UNRELATED_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Principal":"*","Action":"ses:SendRawEmail","Resource":"*"}]}'
PUBLIC_ALLOW_AND_DENY_POLICY = '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"},{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}]}'
PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":{"AWS":"arn:aws:iam::123456789012:root"},"Action":"ses:SendEmail","Resource":"*"}}'
MATCHING_DENY_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Deny","Principal":"*","Action":"ses:SendEmail","Resource":"*"}}'
CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY = '{"Version":"2012-10-17","Statement":{"Effect":"Allow","Principal":"*","Action":"ses:SendEmail","Resource":"*","Condition":{"StringEquals":{"AWS:SourceAccount":"123456789012"}}}}'
def make_multiple_policies_api_mock(policies):
def mock_api_call(self, operation_name, kwarg):
if operation_name == "ListEmailIdentities":
return {
"EmailIdentities": [
{
"IdentityType": "DOMAIN",
"IdentityName": "test-email-identity-multiple-policies",
}
],
}
elif operation_name == "GetEmailIdentity":
return {"Policies": policies, "Tags": {}}
return make_api_call(self, operation_name, kwarg)
return mock_api_call
mock_make_api_call_multiple_policies = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_POLICY,
"private-policy": PRIVATE_ALLOW_POLICY,
}
)
mock_make_api_call_multiple_policies_reversed = make_multiple_policies_api_mock(
{
"private-policy": PRIVATE_ALLOW_POLICY,
"public-policy": PUBLIC_ALLOW_POLICY,
}
)
mock_make_api_call_public_allow_and_matching_deny = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_POLICY,
"deny-policy": MATCHING_DENY_POLICY,
}
)
mock_make_api_call_matching_deny_and_public_allow = make_multiple_policies_api_mock(
{
"deny-policy": MATCHING_DENY_POLICY,
"public-policy": PUBLIC_ALLOW_POLICY,
}
)
mock_make_api_call_public_allow_and_unrelated_deny = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_POLICY,
"deny-policy": UNRELATED_DENY_POLICY,
}
)
mock_make_api_call_same_policy_allow_and_deny = make_multiple_policies_api_mock(
{"combined-policy": PUBLIC_ALLOW_AND_DENY_POLICY}
)
mock_make_api_call_multiple_private_policies = make_multiple_policies_api_mock(
{
"private-policy-1": PRIVATE_ALLOW_POLICY,
"private-policy-2": PRIVATE_ALLOW_POLICY,
}
)
mock_make_api_call_public_single_statement = make_multiple_policies_api_mock(
{"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY}
)
mock_make_api_call_private_single_statement = make_multiple_policies_api_mock(
{"private-policy": PRIVATE_ALLOW_SINGLE_STATEMENT_POLICY}
)
mock_make_api_call_public_and_deny_single_statements = make_multiple_policies_api_mock(
{
"public-policy": PUBLIC_ALLOW_SINGLE_STATEMENT_POLICY,
"deny-policy": MATCHING_DENY_SINGLE_STATEMENT_POLICY,
}
)
mock_make_api_call_conditional_single_statement = make_multiple_policies_api_mock(
{"conditional-policy": CONDITIONAL_ALLOW_SINGLE_STATEMENT_POLICY}
)
def execute_check_with_api_mock(api_call_mock):
with mock.patch("botocore.client.BaseClient._make_api_call", new=api_call_mock):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
new=SES(aws_provider),
),
):
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
ses_identity_not_publicly_accessible,
)
return ses_identity_not_publicly_accessible().execute()
class Test_ses_identities_not_publicly_accessible:
@mock_aws
def test_no_identities(self):
@@ -114,6 +223,114 @@ class Test_ses_identities_not_publicly_accessible:
assert result[0].resource_tags == {"tag1": "value1", "tag2": "value2"}
assert result[0].region == AWS_REGION_EU_WEST_1
@mock_aws
@pytest.mark.parametrize(
"api_call_mock",
[
mock_make_api_call_multiple_policies,
mock_make_api_call_multiple_policies_reversed,
],
ids=["public-policy-first", "public-policy-last"],
)
def test_email_identity_public_when_any_policy_is_public(self, api_call_mock):
result = execute_check_with_api_mock(api_call_mock)
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-email-identity-multiple-policies is publicly accessible due to its resource policies."
)
@mock_aws
@pytest.mark.parametrize(
"api_call_mock",
[
mock_make_api_call_public_allow_and_matching_deny,
mock_make_api_call_matching_deny_and_public_allow,
mock_make_api_call_public_allow_and_unrelated_deny,
mock_make_api_call_same_policy_allow_and_deny,
],
ids=[
"matching-deny-last",
"matching-deny-first",
"unrelated-deny",
"same-policy-deny",
],
)
def test_email_identity_public_allow_with_explicit_deny_is_manual(
self, api_call_mock
):
result = execute_check_with_api_mock(api_call_mock)
assert len(result) == 1
assert result[0].status == "MANUAL"
assert (
result[0].status_extended
== "SES identity test-email-identity-multiple-policies has public Allow and explicit Deny statements in its resource policies. Effective public access requires manual review."
)
@mock_aws
def test_email_identity_multiple_private_policies(self):
result = execute_check_with_api_mock(
mock_make_api_call_multiple_private_policies
)
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "SES identity test-email-identity-multiple-policies is not publicly accessible."
)
@mock_aws
@pytest.mark.parametrize(
("api_call_mock", "expected_status"),
[
(mock_make_api_call_public_single_statement, "FAIL"),
(mock_make_api_call_private_single_statement, "PASS"),
(mock_make_api_call_public_and_deny_single_statements, "MANUAL"),
],
ids=["public", "private", "public-with-deny"],
)
def test_email_identity_single_statement_policy(
self, api_call_mock, expected_status
):
result = execute_check_with_api_mock(api_call_mock)
assert len(result) == 1
assert result[0].status == expected_status
@mock_aws
def test_check_preserves_nested_policy_condition_keys(self):
with mock.patch(
"botocore.client.BaseClient._make_api_call",
new=mock_make_api_call_conditional_single_statement,
):
client("sesv2", region_name=AWS_REGION_EU_WEST_1)
aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1])
ses_client = SES(aws_provider)
identity = next(iter(ses_client.email_identities.values()))
policies_before_check = deepcopy(identity.policies)
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=aws_provider,
),
mock.patch(
"prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible.ses_client",
new=ses_client,
),
):
from prowler.providers.aws.services.ses.ses_identity_not_publicly_accessible.ses_identity_not_publicly_accessible import (
ses_identity_not_publicly_accessible,
)
ses_identity_not_publicly_accessible().execute()
assert identity.policies == policies_before_check
@mock_aws
@mock.patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call_v2)
def test_email_identity_public(self):
@@ -140,7 +357,7 @@ class Test_ses_identities_not_publicly_accessible:
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "SES identity test-email-identity-public is publicly accessible due to its resource policy."
== "SES identity test-email-identity-public is publicly accessible due to its resource policies."
)
assert result[0].resource_id == "test-email-identity-public"
assert (
@@ -27,6 +27,7 @@ def mock_make_api_call(self, operation_name, kwarg):
return {
"Policies": {
"policy1": '{"policy1": "value1"}',
"policy2": '{"policy2": "value2"}',
},
"Tags": {"tag1": "value1", "tag2": "value2"},
"DkimAttributes": {
@@ -81,7 +82,11 @@ class Test_SES_Service:
assert ses.email_identities[arn].type == "EMAIL_ADDRESS"
assert ses.email_identities[arn].arn == arn
assert ses.email_identities[arn].region == AWS_REGION_EU_WEST_1
assert ses.email_identities[arn].policy == {"policy1": "value1"}
assert ses.email_identities[arn].policy == {"policy2": "value2"}
assert ses.email_identities[arn].policies == {
"policy1": {"policy1": "value1"},
"policy2": {"policy2": "value2"},
}
assert ses.email_identities[arn].tags == {"tag1": "value1", "tag2": "value2"}
assert ses.email_identities[arn].dkim_status == "SUCCESS"
assert ses.email_identities[arn].dkim_signing_attributes_origin == "AWS_SES"
+227
View File
@@ -0,0 +1,227 @@
/**
* Fixture data for the Slack handlers. Shapes follow the API contract in
* `openspec/changes/add-slack-integration/design.md`.
*/
export interface SlackWorkspaceFixture {
teamId: string;
teamName: string;
botUserId: string;
/**
* Absent from the serialized configuration until a channel is chosen: the API
* omits the keys rather than sending nulls.
*/
channelId?: string;
channelName?: string;
}
export interface SlackInstallFixture {
id: string;
/** `null` until the first connection check runs. */
connected: boolean | null;
connectionLastCheckedAt: string | null;
workspace: SlackWorkspaceFixture;
}
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;
}
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;
}
export const SLACK_INTEGRATION_ID = "slack-integration-1";
/** 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.";
/**
* 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");
/**
* A wire value, spelled out rather than imported from the UI's own mapping, so
* a rename on our side fails these tests instead of agreeing with itself.
*/
export const SLACK_WORKSPACE_CONFLICT_CODE = "slack_workspace_conflict";
export const SLACK_RETRY_AFTER_SECONDS = 30;
export const SLACK_DEFAULT_CHANNEL = {
id: "C0123AB",
name: "security",
} as const;
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,
...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 },
},
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REINSTALLED,
...overrides,
});
const configuredInstall = (): SlackInstallFixture => ({
id: SLACK_INTEGRATION_ID,
connected: true,
connectionLastCheckedAt: "2026-08-10T09:30:00Z",
workspace: {
...PROWLER_HQ,
channelId: SLACK_DEFAULT_CHANNEL.id,
channelName: SLACK_DEFAULT_CHANNEL.name,
},
});
/**
* A workspace connected *and* a channel on record. Anything the API refuses
* until a channel exists (the connection check) needs this fixture.
*/
export const configuredSlackFixture = (
overrides: Partial<SlackFixture> = {},
): SlackFixture =>
connectedSlackFixture({ install: configuredInstall(), ...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",
},
});
+254
View File
@@ -0,0 +1,254 @@
/**
* MSW handlers for the Slack integration, derived from the API contract in
* `openspec/changes/add-slack-integration/design.md` (the API itself lives in
* the cloud repository). State is per-call: an exchange creates the install the
* subsequent `GET /integrations` returns.
*
* Wire them per test via `worker.use(...handlersForSlack(fx))`.
*/
import { http, HttpResponse } from "msw";
import {
INTEGRATIONS_SERVER_ERROR_DETAIL,
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_DETAIL,
SLACK_REFUSED_STATE_DETAIL,
SLACK_RETRY_AFTER_SECONDS,
SLACK_UNCONFIGURED_DETAIL,
SLACK_UPSTREAM_DETAIL,
SLACK_UPSTREAM_ERROR_CODE,
SLACK_WORKSPACE_CONFLICT_CODE,
} from "./slack.fixtures";
import type {
SlackExchangeOutcome,
SlackFixture,
SlackInstallFixture,
} from "./slack.fixtures";
const API = process.env.UI_API_BASE_URL;
const TS = "2026-08-10T09:00:00Z";
const CONNECTION_TASK_PREFIX = "slack-conn-task-";
/**
* `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" },
},
],
});
const configuration = (workspace: SlackInstallFixture["workspace"]) => ({
team_id: workspace.teamId,
team_name: workspace.teamName,
bot_user_id: workspace.botUserId,
// The API omits these keys until a channel is chosen, never sending nulls.
...(workspace.channelId ? { channel_id: workspace.channelId } : {}),
...(workspace.channelName ? { channel_name: workspace.channelName } : {}),
});
const 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.workspace),
},
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 } },
});
/**
* All three are `2xx`: the first two make `response.json()` throw, the third
* parses into a body that names no resource.
*/
const unreadableExchange = (outcome: SlackExchangeOutcome): Response => {
switch (outcome) {
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT:
return new HttpResponse(null, { status: 204 });
case SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML:
return HttpResponse.html(PROXY_CHALLENGE_PAGE);
default:
return HttpResponse.json({ meta: { version: "v1" } });
}
};
export const handlersForSlack = (fx: SlackFixture) => {
// Mutable copy: the exchange must not write through to the caller's fixture.
let install: SlackInstallFixture | null = fx.install
? { ...fx.install, workspace: { ...fx.install.workspace } }
: null;
const unconfigured = () =>
HttpResponse.json(errorBody(SLACK_UNCONFIGURED_DETAIL, 503), {
status: 503,
});
const rateLimited = () =>
HttpResponse.json(errorBody(SLACK_RATE_LIMITED_DETAIL, 429), {
status: 429,
headers: { "Retry-After": String(SLACK_RETRY_AFTER_SECONDS) },
});
/** 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 = {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
return unreadableExchange(fx.exchangeOutcome);
case SLACK_EXCHANGE_OUTCOME.REINSTALLED:
install = {
id: install?.id ?? SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
return HttpResponse.json({ data: integrationResource(install) });
default:
install = {
id: SLACK_INTEGRATION_ID,
connected: null,
connectionLastCheckedAt: null,
workspace: { ...fx.exchangeWorkspace },
};
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 posts to the channel, so the API refuses until one exists.
if (!install?.workspace.channelId) {
return HttpResponse.json(errorBody(SLACK_NO_CHANNEL_DETAIL, 400), {
status: 400,
});
}
return HttpResponse.json(
taskResource(
`${CONNECTION_TASK_PREFIX}${params.id}`,
"executing",
null,
),
{ status: 202 },
);
},
),
http.get<{ taskId: string }>(`${API}/tasks/:taskId`, ({ params }) => {
const { connected, error } = fx.connection;
if (install && params.taskId.startsWith(CONNECTION_TASK_PREFIX)) {
install.connected = connected;
install.connectionLastCheckedAt = TS;
}
return HttpResponse.json(
taskResource(params.taskId, "completed", { connected, error }),
);
}),
];
};
+1
View File
@@ -341,6 +341,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 };
+277
View File
@@ -0,0 +1,277 @@
/**
* Sentry reporting for the Slack OAuth actions. A capture leaves no mark on the
* DOM, so it cannot be covered from `slack-page.integration.test.tsx`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { 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,
}));
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 { exchangeSlackOAuthCode, getSlackAuthorizeUrl } 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 no attributes",
{ id: INTEGRATION.id, type: "integrations" },
],
])("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 });
});
});
+236
View File
@@ -0,0 +1,236 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import {
readSlackFailure,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_UNREADABLE_RESULT_MESSAGE,
slackErrorMessage,
slackRateLimitMessage,
} from "@/lib/integrations/slack-errors";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import type { IntegrationProps } 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;
}
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),
});
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;
}
};
/**
* 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.
*/
const isIntegrationResource = (value: unknown): boolean => {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return false;
}
const { id, attributes } = value as Record<string, unknown>;
return (
typeof id === "string" &&
typeof attributes === "object" &&
attributes !== null &&
!Array.isArray(attributes)
);
};
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) };
};
/** 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);
}
};
@@ -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,26 @@
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 one of its channels.
</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,320 @@
/**
* 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 { 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];
interface CallbackParams {
code?: string;
state?: string;
/** Slack's own refusal code, e.g. `access_denied`. */
error?: string;
}
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();
render(await SlackIntegrationContent());
}
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
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();
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;
}
saysChannelIsNextStep(): boolean {
return this.containsText(/Choosing a destination channel is the next step/);
}
/**
* 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;
}
async testConnection(): Promise<ConnectionOutcome> {
await this.clickButton(/Test connection/);
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",
);
}
// --- 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();
}
}
@@ -0,0 +1,222 @@
/**
* Browser-mode tests for the Slack integration page (`/integrations/slack`),
* driven through `SlackIntegrationHarness`. MSW answers from handlers derived
* from the API contract in `design.md`. The OAuth callback is its own route,
* covered in `slack-callback-page.integration.test.tsx`.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
configuredSlackFixture,
connectedSlackFixture,
INTEGRATIONS_SERVER_ERROR_DETAIL,
slackFixture,
unreadableCheckTimeSlackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import {
CONNECTION_OUTCOME,
SlackIntegrationHarness,
} from "./slack-integration.harness";
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
/** The only scopes Prowler asks a workspace for (design D2). */
const REQUIRED_SCOPES = [
"chat:write",
"chat:write.public",
"channels:read",
"groups:read",
];
describe("starting the install", () => {
it("sends the user to Slack's consent screen for the access Prowler needs", async () => {
// Given — a tenant with no workspace connected yet.
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mount();
const consentScreen = new URL(await harness.connect());
expect(`${consentScreen.origin}${consentScreen.pathname}`).toBe(
"https://slack.com/oauth/v2/authorize",
);
expect((consentScreen.searchParams.get("scope") ?? "").split(",")).toEqual(
expect.arrayContaining(REQUIRED_SCOPES),
);
// The state is server-minted, binding this install to the session
// (design D5).
expect(consentScreen.searchParams.get("state")).toBeTruthy();
}, 30000);
it("says so when the deployment has no Slack app, instead of offering an install", async () => {
// Given — no SLACK_CLIENT_ID/SECRET/REDIRECT_URI, which the API answers
// with a 503.
const harness = new SlackIntegrationHarness(
slackFixture({ appConfigured: false }),
);
await harness.mount();
// The read itself succeeded: an empty collection is what a deployment with
// no Slack app has, so nothing claims it failed.
await harness.waitForUnavailable();
expect(harness.offersInstall()).toBe(false);
expect(harness.saysLoadFailed()).toBe(false);
}, 30000);
it("still says the read failed when the deployment also has no Slack app", async () => {
// Given — both states, which coincide during rollout and rollback
// (design.md, Migration Plan §2-3 and §5).
const harness = new SlackIntegrationHarness(
slackFixture({ appConfigured: false, listServerError: true }),
);
await harness.mount();
// Both notices: the read's is the actionable half (a retry may still show a
// workspace this tenant has connected).
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/temporarily unavailable/);
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
expect(harness.saysUnavailable()).toBe(true);
// The install is still not on offer: there is no Slack app to install into.
expect(harness.offersInstall()).toBe(false);
}, 30000);
it("says Slack is busy, not that the deployment has no Slack app, when it is rate limiting", async () => {
// Given — the app is configured; Slack rate limits (429) the call that
// mints the consent URL.
const harness = new SlackIntegrationHarness(
slackFixture({ rateLimited: true }),
);
await harness.mount();
expect(await harness.rateLimitNotice()).toMatch(/about 30 seconds/);
expect(harness.saysUnavailable()).toBe(false);
}, 30000);
it("keeps the page usable when reading the install fails on the server", async () => {
// Given — the shared `GET /integrations` read answers 500. The action
// throws instead of returning a result, so the page has to catch it:
// uncaught, the route's error boundary replaces the Slack page.
const harness = new SlackIntegrationHarness(
slackFixture({ listServerError: true }),
);
await harness.mount();
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/temporarily unavailable/);
expect(notice).not.toMatch(INTEGRATIONS_SERVER_ERROR_DETAIL);
// The install stays on offer: one read failed, the Slack app is fine.
expect(harness.offersInstall()).toBe(true);
}, 30000);
it("keeps the page usable when Slack's own side is broken upstream", async () => {
// Given — the `502` the contract reserves for a Slack upstream failure.
// The UI's shared 5xx handling throws, so this is the page's other
// rejection path.
const harness = new SlackIntegrationHarness(
slackFixture({ oauthUpstreamError: true }),
);
await harness.mount();
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/temporarily unavailable/);
// 502 is not 503: the app is configured, Slack is down.
expect(harness.saysUnavailable()).toBe(false);
}, 30000);
it("names the missing consent URL when a proxy answers that call with an HTML page", async () => {
// Given — a 200 carrying a challenge page instead of JSON. Nothing refused
// the call, so the action reaches its success path with no URL.
const harness = new SlackIntegrationHarness(
slackFixture({ authorizeUrlUnreadable: true }),
);
await harness.mount();
// V8 truncates the parse message to `"<!DOCTYPE "`, before the word `html`,
// so the UI's HTML-shaped-error filter can never match it.
const notice = await harness.loadErrorNotice();
expect(notice).toMatch(/did not return an authorization URL/);
expect(notice).not.toMatch(/DOCTYPE/i);
expect(notice).not.toMatch(/not valid JSON/i);
}, 30000);
});
describe("a connected workspace", () => {
it("identifies the workspace and reports the connection as healthy", async () => {
// Given — a finished setup: workspace approved and a destination channel
// recorded, which the API requires before it will check a connection.
const harness = new SlackIntegrationHarness(configuredSlackFixture());
await harness.mount();
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(await harness.connectionBadge()).toBe("Connected");
expect(await harness.offersConnectionTest()).toBe(true);
expect(await harness.testConnection()).toBe(CONNECTION_OUTCOME.SUCCESS);
// One workspace per tenant (design D10): no second install on offer, and no
// consent URL minted for a page that would never use it.
expect(harness.offersInstall()).toBe(false);
expect(harness.authorizeUrlCallCount).toBe(0);
}, 30000);
it("still identifies the workspace before a destination channel is chosen", async () => {
// Given — the state the OAuth exchange leaves behind.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// The configuration carries no channel keys at all, which is "nothing
// chosen yet", not a broken install.
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(harness.offersInstall()).toBe(false);
}, 30000);
it("reports the connection as never checked, not as broken, before the first check", async () => {
// Given — the state the OAuth exchange leaves behind: `connected` is null,
// neither true nor false (design.md, "Connection state, in order").
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
const badge = await harness.connectionBadge();
expect(badge).toBe("Not checked yet");
expect(badge).not.toMatch(/Disconnected/);
}, 30000);
it("keeps the page usable when the recorded check time is one no parser can read", async () => {
// Given — a finished setup whose `connection_last_checked_at` is a zero
// date. `date-fns` throws a RangeError on it, which would replace the whole
// page with the route's error boundary.
const harness = new SlackIntegrationHarness(
unreadableCheckTimeSlackFixture(),
);
await harness.mount();
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
expect(await harness.connectionBadge()).toBe("Connected");
// Nothing to show, so nothing is shown: the same line a workspace that was
// never checked renders.
expect(harness.lastCheckedLine()).toBeNull();
}, 30000);
it("does not offer a connection check the API is bound to refuse", async () => {
// Given — a workspace connected and no destination channel recorded.
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mount();
// The check posts to the destination channel, so with none recorded the API
// answers 400 rather than `connected: false`.
expect(await harness.offersConnectionTest()).toBe(false);
expect(harness.saysChannelIsNextStep()).toBe(true);
}, 30000);
});
@@ -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 integration: connect a Slack workspace from the Integrations page (Prowler Cloud only)
@@ -752,6 +752,52 @@ 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}
role="presentation"
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,160 @@
"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";
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";
// `error` comes straight off the URL and is interpolated into Prowler's own copy,
// so gate on the shape of a code: Slack publishes no closed set of values.
const REASON_TOKEN = /^[a-z0-9_]{1,48}$/;
const describeSlackError = (reason: string): string => {
if (reason === "access_denied") {
return "The install was not approved in Slack, so no workspace was connected.";
}
return REASON_TOKEN.test(reason)
? `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 choose the
channel 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,55 @@
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";
// Placeholder slug: the docs slice writes the page and confirms it.
const SLACK_DOCS_URL =
"https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/prowler-app-slack-integration/";
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 pick the channel Prowler posts to, so
your team gets security updates where it already works.
</p>
</CardContent>
</Card>
);
};
@@ -0,0 +1,191 @@
"use client";
import { format, isValid, parseISO } from "date-fns";
import { TestTube } from "lucide-react";
import { useState } from "react";
import { testIntegrationConnection } from "@/actions/integrations/integrations";
import { SlackIcon } from "@/components/icons/services/IconServices";
import { IntegrationCardHeader } from "@/components/integrations/shared";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
Card,
CardContent,
CardHeader,
useToast,
} from "@/components/shadcn";
import type { IntegrationProps } from "@/types/integrations";
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 { toast } = useToast();
const handleTestConnection = async (id: string) => {
setIsTesting(true);
try {
const result = await testIntegrationConnection(id);
if (result.success) {
toast({
title: "Connection test successful!",
description:
result.message || "Prowler can reach your Slack workspace.",
});
} else {
toast({
variant: "destructive",
title: "Connection test failed",
description: result.error || "Failed to reach your Slack workspace.",
});
}
} catch (_error) {
toast({
variant: "destructive",
title: "Error",
description: "Failed to test connection. Please try again.",
});
} finally {
setIsTesting(false);
}
};
const configuration = integration?.attributes.configuration;
const workspaceName = configuration?.team_name;
// Absent until a channel is chosen, never present-and-null.
const channelId = configuration?.channel_id ?? null;
const 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>
)}
{loadError && (
<Alert variant="error">
<AlertTitle>Could not load your Slack integration</AlertTitle>
<AlertDescription>{loadError}</AlertDescription>
</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 ? (
<Card variant="base">
<CardHeader>
<IntegrationCardHeader
icon={<SlackIcon size={32} />}
title={`Connected to ${workspaceName ?? "your Slack workspace"}`}
subtitle="Prowler posts to this workspace only."
connectionStatus={{
connected: integration.attributes.connected,
}}
/>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center 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>
)}
{!channelId && (
<p>
Choosing a destination channel is the next step the
connection is checked against it.
</p>
)}
</div>
{/* The check posts to the destination channel: the API answers
400 when none is recorded yet. */}
<Button
size="sm"
variant="outline"
disabled={isTesting || !channelId}
onClick={() => handleTestConnection(integration.id)}
>
<TestTube size={14} />
{isTesting ? "Testing..." : "Test connection"}
</Button>
</div>
</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>
{authorizeUrl ? (
<Button asChild>
<a href={authorizeUrl} rel="noopener noreferrer">
<SlackIcon size={16} />
Add to Slack
</a>
</Button>
) : (
<Button disabled>
<SlackIcon size={16} />
Add to Slack
</Button>
)}
</div>
</CardContent>
</Card>
)}
</div>
);
};
+152
View File
@@ -0,0 +1,152 @@
/**
* Unit-tested because most of the codes in the mapping belong to flows this
* layer does not have yet: the channel picker, the test message, the disconnect.
*/
import { describe, expect, it } from "vitest";
import {
isSlackTokenErrorCode,
SLACK_ERROR_CODE,
SLACK_ERROR_MESSAGES,
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_RATE_LIMITED_MESSAGE,
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.");
});
});
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();
});
});
+135
View File
@@ -0,0 +1,135 @@
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 `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.";
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),
};
};
+20 -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,13 @@ export interface IntegrationProps {
domain?: string;
projects?: { [key: string]: string };
issue_types?: { [key: string]: string[] };
// Slack specific configuration, server-owned. The channel keys are absent
// until one is chosen, not present and null: read them with `?? null`.
team_id?: string;
team_name?: string;
bot_user_id?: string;
channel_id?: string;
channel_name?: string;
[key: string]: unknown;
};
url?: string;
+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",