mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
fix(jira): fix connection check timeout (#12742)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
co-authored by
alejandrobailo
parent
8bdb597921
commit
1edcf6e5de
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user