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
This commit is contained in:
Pablo F.G
2026-08-18 09:23:40 +02:00
parent 8481a43fe3
commit ed1fce420e
5 changed files with 30 additions and 12 deletions
+14 -4
View File
@@ -1,10 +1,12 @@
"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,
@@ -50,6 +52,11 @@ interface SlackExchangeInput {
state: string;
}
const slackExchangeInputSchema = z.object({
code: z.string().min(1),
state: z.string().min(1),
});
interface SlackExchangeSuccess {
integration: IntegrationProps;
}
@@ -134,10 +141,13 @@ export const getSlackAuthorizeUrl =
* consumes the `state`, exchanges the single-use `code`, and upserts the
* tenant's Slack integration.
*/
export const exchangeSlackOAuthCode = async ({
code,
state,
}: SlackExchangeInput): Promise<SlackExchangeResult> => {
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`);
@@ -41,6 +41,10 @@ describe("returning from Slack", () => {
// 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 () => {
@@ -2,7 +2,7 @@ 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 type { IntegrationProps } from "@/types/integrations";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
/**
* `getIntegrations` throws a `>= 500` answer past its own catch, which covers
@@ -26,7 +26,7 @@ const readSlackIntegrations = async (searchParams: URLSearchParams) => {
*/
export async function SlackIntegrationContent() {
const searchParams = new URLSearchParams();
searchParams.set("filter[integration_type]", "slack");
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");
@@ -763,10 +763,10 @@ export const SlackIcon: React.FC<IconSvgProps> = ({
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
height={height ?? size}
role="presentation"
viewBox="0 0 48 48"
width={size || width}
width={width ?? size}
className={className}
{...props}
xmlns="http://www.w3.org/2000/svg"
+8 -4
View File
@@ -2,11 +2,15 @@ import { z } from "zod";
import type { TaskState } from "@/types/tasks";
export const INTEGRATION_TYPE = {
AMAZON_S3: "amazon_s3",
AWS_SECURITY_HUB: "aws_security_hub",
JIRA: "jira",
SLACK: "slack",
} as const;
export type IntegrationType =
| "amazon_s3"
| "aws_security_hub"
| "jira"
| "slack";
(typeof INTEGRATION_TYPE)[keyof typeof INTEGRATION_TYPE];
export const JIRA_DISPATCH_MODE = {
INDIVIDUAL: "individual",