mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
feat(ui): add Slack integration connect flow (#12435)
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* 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 an empty id", { ...INTEGRATION, id: "" }],
|
||||
["a resource of another type", { ...INTEGRATION, type: "tasks" }],
|
||||
[
|
||||
"a resource with no attributes",
|
||||
{ id: INTEGRATION.id, type: "integrations" },
|
||||
],
|
||||
[
|
||||
"another kind of integration",
|
||||
{
|
||||
...INTEGRATION,
|
||||
attributes: { ...INTEGRATION.attributes, integration_type: "jira" },
|
||||
},
|
||||
],
|
||||
])("cannot confirm the install from %s", async (_label, data) => {
|
||||
// Given — a 2xx whose `data` is truthy but is not an integration resource.
|
||||
fetchMock.mockResolvedValue(exchangeResponse(data));
|
||||
|
||||
// When
|
||||
const result = await exchange();
|
||||
|
||||
// Then — the answer for a body with no `data`: the install happened, only
|
||||
// its result is unknown.
|
||||
expect(result).toEqual({
|
||||
unconfirmed: true,
|
||||
message: SLACK_UNREADABLE_RESULT_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
it("hands over the workspace the API upserted", async () => {
|
||||
// Given
|
||||
fetchMock.mockResolvedValue(exchangeResponse(INTEGRATION));
|
||||
|
||||
// When / Then
|
||||
expect(await exchange()).toEqual({ integration: INTEGRATION });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
"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 { INTEGRATION_TYPE, 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;
|
||||
}
|
||||
};
|
||||
|
||||
const INTEGRATIONS_RESOURCE_TYPE = "integrations";
|
||||
|
||||
/**
|
||||
* The callback names the workspace and redirects on this value alone, so a `2xx`
|
||||
* payload that is not a JSON:API resource (`{}`, `[]`, `"invalid"`) must read as
|
||||
* unreadable rather than as a connected workspace. Identity too: a resource
|
||||
* that is not a linkable Slack integration would be shown as the workspace
|
||||
* just installed.
|
||||
*/
|
||||
const isIntegrationResource = (value: unknown): boolean => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { id, type, attributes } = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
typeof id !== "string" ||
|
||||
id === "" ||
|
||||
type !== INTEGRATIONS_RESOURCE_TYPE ||
|
||||
typeof attributes !== "object" ||
|
||||
attributes === null ||
|
||||
Array.isArray(attributes)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
(attributes as Record<string, unknown>).integration_type ===
|
||||
INTEGRATION_TYPE.SLACK
|
||||
);
|
||||
};
|
||||
|
||||
const failureFrom = async (
|
||||
response: Response,
|
||||
fallback: string,
|
||||
): Promise<SlackUnavailable | SlackRateLimited | SlackActionError> => {
|
||||
if (isUnavailableStatus(response.status)) return { unavailable: true };
|
||||
|
||||
// A 5xx (including the `502` the contract reserves for "Slack upstream
|
||||
// broke") goes through the repo's 5xx handling, which reports to Sentry and
|
||||
// throws, so the caller's catch answers the user. Must run before
|
||||
// `readSlackFailure`: a body can only be read once.
|
||||
if (response.status >= 500) await handleApiResponse(response);
|
||||
|
||||
const failure = await readSlackFailure(response);
|
||||
|
||||
if (failure.status === RATE_LIMITED_STATUS) {
|
||||
return {
|
||||
rateLimited: true,
|
||||
retryAfterSeconds: failure.retryAfterSeconds,
|
||||
message: slackRateLimitMessage(failure.retryAfterSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
return { error: slackErrorMessage(failure, fallback) };
|
||||
};
|
||||
|
||||
/** 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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user