fix(jira): fix connection check timeout (#12742)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pedro Martín
2026-09-04 15:51:09 +02:00
committed by GitHub
co-authored by alejandrobailo
parent 8bdb597921
commit 1edcf6e5de
20 changed files with 681 additions and 206 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ export {
createIntegration,
deleteIntegration,
getIntegrations,
pollConnectionTestStatus,
revalidateIntegrationConnectionPages,
testIntegrationConnection,
updateIntegration,
} from "./integrations";
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { fetchMock, revalidatePathMock } = vi.hoisted(() => ({
fetchMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
vi.mock("@/lib", () => ({
apiBaseUrl: "https://api.example.com/api/v1",
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
parseStringify: (value: unknown) => JSON.parse(JSON.stringify(value)),
}));
vi.mock("@/lib/server-actions-helper", () => ({
handleApiError: () => ({ error: "An error occurred" }),
handleApiResponse: vi.fn(),
}));
import {
revalidateIntegrationConnectionPages,
testIntegrationConnection,
} from "./integrations";
describe("testIntegrationConnection", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("fetch", fetchMock);
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: { id: "task-1", type: "tasks" } }), {
status: 202,
}),
);
});
it("returns the task immediately for shared background tracking", async () => {
// When
const response = await testIntegrationConnection("jira-1");
// Then
expect(response).toEqual({
success: true,
message: "Connection test started. It may take some time to complete.",
taskId: "task-1",
data: { data: { id: "task-1", type: "tasks" } },
});
});
it("revalidates every integration page through one shared action", async () => {
// When
await revalidateIntegrationConnectionPages();
// Then
expect(revalidatePathMock.mock.calls).toEqual([
["/integrations/amazon-s3"],
["/integrations/aws-security-hub"],
["/integrations/jira"],
["/integrations/slack"],
]);
});
});
+21 -134
View File
@@ -2,25 +2,19 @@
import { revalidatePath } from "next/cache";
import { pollTaskUntilSettled } from "@/actions/task/poll";
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
import { IntegrationType } from "@/types/integrations";
import type { TaskState } from "@/types/tasks";
import type {
IntegrationConnectionTestResponse,
IntegrationType,
} from "@/types/integrations";
type TaskStartResponse = {
data: { id: string; type: "tasks" };
};
type TestConnectionResponse = {
success: boolean;
message?: string;
taskId?: string;
data?: TaskStartResponse;
error?: string;
/** The id of the channel a channel-level failure named, when it named one. */
failedChannelId?: string | null;
};
const INTEGRATION_CONNECTION_PATHS = [
"/integrations/amazon-s3",
"/integrations/aws-security-hub",
"/integrations/jira",
"/integrations/slack",
] as const;
export const getIntegrations = async (searchParams?: URLSearchParams) => {
const headers = await getAuthHeaders({ contentType: false });
@@ -266,59 +260,9 @@ export const deleteIntegration = async (
}
};
type ConnectionTaskResult = {
connected?: boolean;
error?: string | null;
// The failing channel's id, or null when the failure names no channel.
channel?: string | null;
};
type PollConnectionResult =
| {
success: true;
message: string;
taskState: TaskState;
result: ConnectionTaskResult | undefined;
}
| {
success: false;
message: string;
taskState?: TaskState;
result?: ConnectionTaskResult;
}
| { error: string };
const pollTaskUntilComplete = async (
taskId: string,
): Promise<PollConnectionResult> => {
const settled = await pollTaskUntilSettled<ConnectionTaskResult>(taskId, {
maxAttempts: 20,
delayMs: 3000,
});
if (!settled.ok) {
return { error: settled.error };
}
const taskState = settled.state;
const result = settled.result;
const isSuccessful =
taskState === "completed" &&
result?.connected === true &&
result?.error === null;
const message = isSuccessful
? "Connection test completed successfully."
: result?.error || "Connection test failed.";
return { success: isSuccessful, message, taskState, result };
};
export const testIntegrationConnection = async (
id: string,
waitForCompletion = true,
): Promise<TestConnectionResponse> => {
): Promise<IntegrationConnectionTestResponse> => {
const headers = await getAuthHeaders({ contentType: true });
const url = new URL(`${apiBaseUrl}/integrations/${id}/connection`);
@@ -330,43 +274,13 @@ export const testIntegrationConnection = async (
const taskId = data?.data?.id;
if (taskId) {
// If waitForCompletion is false, return immediately with task started status
if (!waitForCompletion) {
return {
success: true,
message:
"Connection test started. It may take some time to complete.",
taskId,
data: parseStringify(data),
};
}
// Poll the task until completion
const pollResult = await pollTaskUntilComplete(taskId);
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 };
}
if (pollResult.success) {
return {
success: true,
message:
pollResult.message || "Connection test completed successfully!",
data: parseStringify(data),
};
} else {
return {
success: false,
error: pollResult.message || "Connection test failed.",
failedChannelId: pollResult.result?.channel ?? null,
};
}
return {
success: true,
message:
"Connection test started. It may take some time to complete.",
taskId,
data: parseStringify(data),
};
} else {
return {
success: false,
@@ -386,35 +300,8 @@ export const testIntegrationConnection = async (
}
};
export const pollConnectionTestStatus = async (
taskId: string,
): Promise<TestConnectionResponse> => {
try {
const pollResult = await pollTaskUntilComplete(taskId);
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 };
}
if (pollResult.success) {
return {
success: true,
message:
pollResult.message || "Connection test completed successfully!",
};
} else {
return {
success: false,
error: pollResult.message || "Connection test failed.",
failedChannelId: pollResult.result?.channel ?? null,
};
}
} catch (_error) {
return { success: false, error: "Failed to check connection test status." };
export const revalidateIntegrationConnectionPages = async (): Promise<void> => {
for (const path of INTEGRATION_CONNECTION_PATHS) {
revalidatePath(path);
}
};
@@ -0,0 +1 @@
Integration connection test polling now waits up to ~3 minutes instead of ~57 seconds before giving up, so it no longer reports a false failure on slower checks (e.g. Jira accounts with many projects) that were still going to succeed
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { revalidateIntegrationConnectionPagesMock, toastMock } = vi.hoisted(
() => ({
revalidateIntegrationConnectionPagesMock: vi.fn(),
toastMock: vi.fn(),
}),
);
vi.mock("@/actions/integrations", () => ({
revalidateIntegrationConnectionPages:
revalidateIntegrationConnectionPagesMock,
}));
vi.mock("@/components/shadcn/toast", () => ({
toast: toastMock,
}));
import { integrationConnectionTaskHandler } from "./integration-connection-task-handler";
const task = {
taskId: "task-1",
kind: "integration-connection-test",
status: "ready" as const,
meta: { integrationId: "jira-1" },
startedAt: 1,
};
describe("integration connection task handler", () => {
beforeEach(() => {
vi.clearAllMocks();
revalidateIntegrationConnectionPagesMock.mockResolvedValue(undefined);
});
it("revalidates integrations and reports a resumed success", () => {
// When
integrationConnectionTaskHandler.onReady({
...task,
result: { connected: true, error: null },
});
// Then
expect(revalidateIntegrationConnectionPagesMock).toHaveBeenCalledOnce();
expect(toastMock).toHaveBeenCalledWith({
title: "Connection test successful!",
description: "Connection test completed successfully.",
});
});
it("reports a resumed backend failure", () => {
// When
integrationConnectionTaskHandler.onReady({
...task,
result: { connected: false, error: "Missing permission" },
});
// Then
expect(toastMock).toHaveBeenCalledWith({
variant: "destructive",
title: "Connection test failed",
description: "Missing permission",
});
});
});
@@ -0,0 +1,42 @@
"use client";
import { revalidateIntegrationConnectionPages } from "@/actions/integrations";
import { toast } from "@/components/shadcn/toast";
import { evaluateIntegrationConnectionTask } from "@/lib/integrations/test-connection-result";
import type { TaskKindHandler } from "@/store/task-watcher/store";
import type { IntegrationConnectionTaskResult } from "@/types/integrations";
const refreshIntegrationPages = (): void => {
void revalidateIntegrationConnectionPages().catch(() => undefined);
};
export const integrationConnectionTaskHandler: TaskKindHandler = {
onReady: (task) => {
refreshIntegrationPages();
const result = evaluateIntegrationConnectionTask(
task.result as IntegrationConnectionTaskResult | undefined,
);
if (result.success) {
toast({
title: "Connection test successful!",
description: result.message,
});
return;
}
toast({
variant: "destructive",
title: "Connection test failed",
description: result.error,
});
},
onError: (task) => {
refreshIntegrationPages();
toast({
variant: "destructive",
title: "Connection test failed",
description: task.error || "The connection test failed unexpectedly.",
});
},
};
@@ -4,11 +4,7 @@ import { format } from "date-fns";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import {
deleteIntegration,
testIntegrationConnection,
updateIntegration,
} from "@/actions/integrations";
import { deleteIntegration, updateIntegration } from "@/actions/integrations";
import { JiraIcon } from "@/components/icons/services/IconServices";
import {
IntegrationActionButtons,
@@ -24,7 +20,10 @@ import {
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import {
executeIntegrationConnectionTest,
triggerTestConnectionWithDelay,
} from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations";
@@ -98,7 +97,7 @@ export const JiraIntegrationsManager = ({
const handleTestConnection = async (id: string) => {
setIsTesting(id);
try {
const result = await testIntegrationConnection(id);
const result = await executeIntegrationConnectionTest(id);
if (result.success) {
toast({
@@ -4,11 +4,7 @@ import { format } from "date-fns";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import {
deleteIntegration,
testIntegrationConnection,
updateIntegration,
} from "@/actions/integrations";
import { deleteIntegration, updateIntegration } from "@/actions/integrations";
import { AmazonS3Icon } from "@/components/icons/services/IconServices";
import {
IntegrationActionButtons,
@@ -24,7 +20,10 @@ import {
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import {
executeIntegrationConnectionTest,
triggerTestConnectionWithDelay,
} from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
@@ -112,7 +111,7 @@ export const S3IntegrationsManager = ({
const handleTestConnection = async (id: string) => {
setIsTesting(id);
try {
const result = await testIntegrationConnection(id);
const result = await executeIntegrationConnectionTest(id);
if (result.success) {
toast({
@@ -4,11 +4,7 @@ import { format } from "date-fns";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useState } from "react";
import {
deleteIntegration,
testIntegrationConnection,
updateIntegration,
} from "@/actions/integrations";
import { deleteIntegration, updateIntegration } from "@/actions/integrations";
import { AWSSecurityHubIcon } from "@/components/icons/services/IconServices";
import {
IntegrationActionButtons,
@@ -25,7 +21,10 @@ import {
} from "@/components/shadcn";
import { Modal } from "@/components/shadcn/modal";
import { DataTablePagination } from "@/components/shadcn/table/data-table-pagination";
import { triggerTestConnectionWithDelay } from "@/lib/integrations/test-connection-helper";
import {
executeIntegrationConnectionTest,
triggerTestConnectionWithDelay,
} from "@/lib/integrations/test-connection-helper";
import { MetaDataProps } from "@/types";
import { IntegrationProps } from "@/types/integrations";
import { ProviderProps } from "@/types/providers";
@@ -114,7 +113,7 @@ export const SecurityHubIntegrationsManager = ({
const handleTestConnection = async (id: string) => {
setIsTesting(id);
try {
const result = await testIntegrationConnection(id);
const result = await executeIntegrationConnectionTest(id);
if (result.success) {
toast({
@@ -17,8 +17,8 @@ vi.mock("@/actions/integrations/slack", () => ({
setSlackAuthorizedChannels: vi.fn(),
}));
vi.mock("@/actions/integrations/integrations", () => ({
testIntegrationConnection: vi.fn(),
vi.mock("@/lib/integrations/test-connection-helper", () => ({
executeIntegrationConnectionTest: vi.fn(),
}));
/**
@@ -4,7 +4,6 @@ import { format, isValid, parseISO } from "date-fns";
import { TestTube, Unplug } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { testIntegrationConnection } from "@/actions/integrations/integrations";
import {
disconnectSlackIntegration,
getSlackAuthorizeUrl,
@@ -37,6 +36,7 @@ import {
slackErrorMessage,
} from "@/lib/integrations/slack-errors";
import type { SlackTokenErrorCode } from "@/lib/integrations/slack-errors";
import { executeIntegrationConnectionTest } from "@/lib/integrations/test-connection-helper";
import type {
IntegrationProps,
SlackAuthorizedChannel,
@@ -492,7 +492,7 @@ export const SlackIntegrationManager = ({
setIsTesting(true);
try {
const result = await testIntegrationConnection(id);
const result = await executeIntegrationConnectionTest(id);
if (result.success) {
provedCredentialAlive();
@@ -9,12 +9,16 @@ import {
crossProviderPdfHandler,
} from "@/app/(prowler)/compliance/_lib/cross-provider-pdf";
import { jiraDispatchTaskHandler } from "@/components/findings/jira-dispatch-task-handler";
import { integrationConnectionTaskHandler } from "@/components/integrations/integration-connection-task-handler";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
registerTaskKindHandler,
resumePendingTasks,
} from "@/store/task-watcher/store";
import { JIRA_DISPATCH_TASK_KIND } from "@/types/integrations";
import {
INTEGRATION_CONNECTION_TASK_KIND,
JIRA_DISPATCH_TASK_KIND,
} from "@/types/integrations";
// Kind registrations happen at module scope, before any task can settle in
// this tab. Adding a new watched task kind (integration tests, scan exports,
@@ -22,6 +26,10 @@ import { JIRA_DISPATCH_TASK_KIND } from "@/types/integrations";
registerTaskKindHandler(CROSS_PROVIDER_PDF_TASK_KIND, crossProviderPdfHandler);
registerTaskKindHandler(CROSS_ACCOUNT_PDF_TASK_KIND, crossAccountPdfHandler);
registerTaskKindHandler(JIRA_DISPATCH_TASK_KIND, jiraDispatchTaskHandler);
registerTaskKindHandler(
INTEGRATION_CONNECTION_TASK_KIND,
integrationConnectionTaskHandler,
);
/**
* Mounted once in the app layout (next to `Toaster`): resumes polling any
@@ -0,0 +1,125 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const {
revalidateIntegrationConnectionPagesMock,
testIntegrationConnectionMock,
trackAndPollTaskMock,
} = vi.hoisted(() => ({
revalidateIntegrationConnectionPagesMock: vi.fn(),
testIntegrationConnectionMock: vi.fn(),
trackAndPollTaskMock: vi.fn(),
}));
vi.mock("@/actions/integrations", () => ({
revalidateIntegrationConnectionPages:
revalidateIntegrationConnectionPagesMock,
testIntegrationConnection: testIntegrationConnectionMock,
}));
vi.mock("@/store/task-watcher/store", () => ({
TASK_WATCHER_STATUS: { READY: "ready", ERROR: "error" },
trackAndPollTask: trackAndPollTaskMock,
}));
import {
executeIntegrationConnectionTest,
runTestConnection,
} from "./test-connection-helper";
describe("integration connection test helper", () => {
beforeEach(() => {
vi.clearAllMocks();
revalidateIntegrationConnectionPagesMock.mockResolvedValue(undefined);
testIntegrationConnectionMock.mockResolvedValue({
success: true,
taskId: "task-1",
});
trackAndPollTaskMock.mockResolvedValue({
status: "ready",
result: { connected: true, error: null },
});
});
it("tracks a started test through the shared task watcher", async () => {
// Given
const onStarted = vi.fn();
// When
const result = await executeIntegrationConnectionTest("jira-1", onStarted);
// Then
expect(onStarted).toHaveBeenCalledOnce();
expect(trackAndPollTaskMock).toHaveBeenCalledWith({
taskId: "task-1",
kind: "integration-connection-test",
meta: { integrationId: "jira-1" },
notifyHandler: false,
});
expect(revalidateIntegrationConnectionPagesMock).toHaveBeenCalledOnce();
expect(result).toEqual({
success: true,
message: "Connection test completed successfully.",
});
});
it("preserves the backend connection failure details", async () => {
// Given
trackAndPollTaskMock.mockResolvedValue({
status: "ready",
result: {
connected: false,
error: "Channel unavailable",
channel: "C123",
},
});
// When
const result = await executeIntegrationConnectionTest("slack-1");
// Then
expect(result).toEqual({
success: false,
error: "Channel unavailable",
failedChannelId: "C123",
});
});
it("surfaces task watcher failures", async () => {
// Given
trackAndPollTaskMock.mockResolvedValue({
status: "error",
error: "The task is taking too long. Try again later.",
});
// When
const result = await executeIntegrationConnectionTest("jira-1");
// Then
expect(result).toEqual({
success: false,
error: "The task is taking too long. Try again later.",
});
});
it("completes once when the task cannot be started", async () => {
// Given
const onComplete = vi.fn();
const onError = vi.fn();
testIntegrationConnectionMock.mockResolvedValue({
success: false,
error: "Could not start",
});
// When
await runTestConnection({
integrationId: "jira-1",
integrationType: "jira",
onComplete,
onError,
});
// Then
expect(onError).toHaveBeenCalledWith("Could not start");
expect(onComplete).toHaveBeenCalledOnce();
});
});
+61 -31
View File
@@ -1,7 +1,18 @@
import {
pollConnectionTestStatus,
revalidateIntegrationConnectionPages,
testIntegrationConnection,
} from "@/actions/integrations";
import type { useToast } from "@/components/shadcn/toast/use-toast";
import { evaluateIntegrationConnectionTask } from "@/lib/integrations/test-connection-result";
import {
TASK_WATCHER_STATUS,
trackAndPollTask,
} from "@/store/task-watcher/store";
import {
INTEGRATION_CONNECTION_TASK_KIND,
type IntegrationConnectionTaskResult,
type IntegrationConnectionTestResponse,
} from "@/types/integrations";
// Integration configuration type
export interface IntegrationMessages {
@@ -58,6 +69,47 @@ interface TestConnectionOptions {
onComplete?: () => void;
}
export const executeIntegrationConnectionTest = async (
integrationId: string,
onStarted?: () => void,
): Promise<IntegrationConnectionTestResponse> => {
const started = await testIntegrationConnection(integrationId);
if (!started.success) {
return {
success: false,
error: started.error || "Connection test could not be started.",
};
}
if (!started.taskId) {
return {
success: false,
error: "Failed to start connection test. No task ID received.",
};
}
onStarted?.();
const tracked = await trackAndPollTask<IntegrationConnectionTaskResult>({
taskId: started.taskId,
kind: INTEGRATION_CONNECTION_TASK_KIND,
meta: { integrationId },
notifyHandler: false,
});
await revalidateIntegrationConnectionPages();
if (tracked.status !== TASK_WATCHER_STATUS.READY) {
return {
success: false,
error: tracked.error || "Failed to track the connection test.",
};
}
return evaluateIntegrationConnectionTask(tracked.result);
};
export const runTestConnection = async ({
integrationId,
integrationType,
@@ -67,44 +119,22 @@ export const runTestConnection = async ({
onComplete,
}: TestConnectionOptions) => {
try {
// Start the test without waiting for completion
const result = await testIntegrationConnection(integrationId, false);
const result = await executeIntegrationConnectionTest(
integrationId,
onStart,
);
if (!result || (!result.success && !result.error)) {
onError?.("Connection test could not be started. Please try again.");
onComplete?.();
return;
}
if (result.error) {
onError?.(result.error);
onComplete?.();
return;
}
if (!result.taskId) {
onError?.("Failed to start connection test. No task ID received.");
onComplete?.();
return;
}
// Notify that test has started
onStart?.();
// Poll for the test completion
const pollResult = await pollConnectionTestStatus(result.taskId);
if (pollResult.success) {
if (result.success) {
const config = INTEGRATION_CONFIG[integrationType];
const defaultMessage =
config?.successMessage ||
`Successfully connected to ${integrationType}.`;
onSuccess?.(pollResult.message || defaultMessage);
onSuccess?.(result.message || defaultMessage);
} else {
const config = INTEGRATION_CONFIG[integrationType];
const defaultError =
config?.errorMessage || `Failed to connect to ${integrationType}.`;
onError?.(pollResult.error || defaultError);
onError?.(result.error || defaultError);
}
} catch (_error) {
onError?.(
@@ -119,7 +149,7 @@ export const triggerTestConnectionWithDelay = (
integrationId: string | undefined,
shouldTestConnection: boolean | undefined,
integrationType: string,
toast: any,
toast: ReturnType<typeof useToast>["toast"],
delay = 200,
onComplete?: () => void,
) => {
@@ -0,0 +1,23 @@
import type {
IntegrationConnectionTaskResult,
IntegrationConnectionTestResponse,
} from "@/types/integrations";
export const evaluateIntegrationConnectionTask = (
result: IntegrationConnectionTaskResult | undefined,
): IntegrationConnectionTestResponse => {
const isSuccessful = result?.connected === true && result.error === null;
if (isSuccessful) {
return {
success: true,
message: "Connection test completed successfully.",
};
}
return {
success: false,
error: result?.error || "Connection test failed.",
failedChannelId: result?.channel ?? null,
};
};
+28
View File
@@ -12,6 +12,34 @@ export const INTEGRATION_TYPE = {
export type IntegrationType =
(typeof INTEGRATION_TYPE)[keyof typeof INTEGRATION_TYPE];
export const INTEGRATION_CONNECTION_TASK_KIND = "integration-connection-test";
export interface IntegrationConnectionTaskResource {
id: string;
type: "tasks";
}
export interface IntegrationConnectionTaskDocument {
data: IntegrationConnectionTaskResource;
}
export interface IntegrationConnectionTaskResult {
connected?: boolean;
error?: string | null;
/** The failing channel id, or null when the failure names no channel. */
channel?: string | null;
}
export interface IntegrationConnectionTestResponse {
success: boolean;
message?: string;
taskId?: string;
data?: IntegrationConnectionTaskDocument;
error?: string;
/** The failing channel id, or null when the failure names no channel. */
failedChannelId?: string | null;
}
export const JIRA_DISPATCH_MODE = {
INDIVIDUAL: "individual",
GROUPED: "grouped",