From 1ecaae818a9d97678b2acc7e3eb643536b845d71 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 31 Aug 2026 13:51:03 -0700 Subject: [PATCH] fix(workflows): authorize paused execution reads --- .../[workflowId]/[executionId]/page.test.tsx | 109 +++++++++ .../[workflowId]/[executionId]/page.tsx | 37 ++- .../resume-execution-unavailable.tsx | 17 ++ .../[executionId]/resume-page-client.test.tsx | 180 +++++++++++++++ .../[executionId]/resume-page-client.tsx | 112 ++++++--- .../[workflowId]/[executionId]/route.test.ts | 182 +++++++++++++++ .../[workflowId]/[executionId]/route.ts | 70 ++---- .../[id]/paused/[executionId]/route.ts | 55 ++--- .../hooks/queries/resume-execution.test.ts | 26 +++ apps/sim/hooks/queries/resume-execution.ts | 13 +- .../workflows/application/operations.test.ts | 10 + .../lib/workflows/application/operations.ts | 6 + .../read-paused-workflow-execution.test.ts | 218 ++++++++++++++++++ .../read-paused-workflow-execution.ts | 24 ++ 14 files changed, 943 insertions(+), 116 deletions(-) create mode 100644 apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.test.tsx create mode 100644 apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable.tsx create mode 100644 apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.test.tsx create mode 100644 apps/sim/app/api/resume/[workflowId]/[executionId]/route.test.ts create mode 100644 apps/sim/hooks/queries/resume-execution.test.ts create mode 100644 apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts create mode 100644 apps/sim/lib/workflows/application/read-paused-workflow-execution.ts diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.test.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.test.tsx new file mode 100644 index 00000000000..873da140c5c --- /dev/null +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.test.tsx @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + authorize: vi.fn(), + getSession: vi.fn(), + resumePage: vi.fn(() => null), + unavailablePage: vi.fn(() => null), + redirect: vi.fn((url: string) => { + throw new Error(`NEXT_REDIRECT:${url}`) + }), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mocks.getSession, +})) + +vi.mock('next/navigation', () => ({ + redirect: mocks.redirect, +})) + +vi.mock('@/lib/workflows/application/read-paused-workflow-execution', () => ({ + readPausedWorkflowExecution: { authorize: mocks.authorize }, +})) + +vi.mock('@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client', () => ({ + default: mocks.resumePage, +})) + +vi.mock( + '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable', + () => ({ + ResumeExecutionUnavailable: mocks.unavailablePage, + }) +) + +import ResumeExecutionPageWrapper from '@/app/(interfaces)/resume/[workflowId]/[executionId]/page' + +const PAGE_PARAMS = { workflowId: 'workflow-1', executionId: 'execution-1' } + +function pageProps(contextId?: string) { + return { + params: Promise.resolve(PAGE_PARAMS), + searchParams: Promise.resolve(contextId ? { contextId } : {}), + } +} + +describe('ResumeExecutionPageWrapper', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.authorize.mockResolvedValue(undefined) + }) + + it('redirects an unauthenticated visitor before any protected lookup', async () => { + mocks.getSession.mockResolvedValueOnce(null) + const callbackPath = '/resume/workflow-1/execution-1?contextId=context-1' + + await expect(ResumeExecutionPageWrapper(pageProps('context-1'))).rejects.toThrow( + `NEXT_REDIRECT:/login?callbackUrl=${encodeURIComponent(callbackPath)}` + ) + expect(mocks.authorize).not.toHaveBeenCalled() + }) + + it('authorizes the session without serializing paused execution detail into the page', async () => { + const result = await ResumeExecutionPageWrapper(pageProps('context-1')) + + expect(mocks.authorize).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: PAGE_PARAMS, + }) + expect(result.props).toMatchObject({ + params: PAGE_PARAMS, + initialContextId: 'context-1', + }) + expect(result.type).toBe(mocks.resumePage) + expect(result.key).toBe('workflow-1:execution-1:context-1') + expect(result.props).not.toHaveProperty('initialExecutionDetail') + expect(result.props).not.toHaveProperty('canLoadExecution') + }) + + it.each([ + new OrchestrationError('forbidden', 'Insufficient workspace permissions'), + new OrchestrationError('not_found', 'Workflow not found'), + ])('renders a data-free concealed state after authorization refusal: %s', async (error) => { + mocks.authorize.mockRejectedValueOnce(error) + + const result = await ResumeExecutionPageWrapper(pageProps()) + + expect(result.type).toBe(mocks.unavailablePage) + expect(result.type).not.toBe(mocks.resumePage) + expect(result.props).toEqual({}) + }) + + it('propagates authorization infrastructure failures', async () => { + const infrastructureError = new Error('database unavailable') + mocks.authorize.mockRejectedValueOnce(infrastructureError) + + await expect(ResumeExecutionPageWrapper(pageProps())).rejects.toBe(infrastructureError) + }) +}) diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx index 7a965893e1f..edb0e262df2 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/page.tsx @@ -1,5 +1,9 @@ import type { Metadata } from 'next' -import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { readPausedWorkflowExecution } from '@/lib/workflows/application/read-paused-workflow-execution' +import { ResumeExecutionUnavailable } from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable' import ResumeExecutionPage from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client' export const metadata: Metadata = { @@ -30,16 +34,37 @@ export default async function ResumeExecutionPageWrapper({ const initialContextId = Array.isArray(initialContextIdParam) ? initialContextIdParam[0] : initialContextIdParam + const resumePath = `/resume/${encodeURIComponent(workflowId)}/${encodeURIComponent(executionId)}${ + initialContextId ? `?${new URLSearchParams({ contextId: initialContextId })}` : '' + }` + const session = await getSession() + if (!session?.user?.id) { + redirect(`/login?callbackUrl=${encodeURIComponent(resumePath)}`) + } + if (!session.session?.id) throw new Error('Authenticated session is missing its session ID') - const detail = await PauseResumeManager.getPausedExecutionDetail({ - workflowId, - executionId, - }) + try { + if (!readPausedWorkflowExecution.authorize) { + throw new Error('Paused execution read use case does not expose authorization') + } + await readPausedWorkflowExecution.authorize({ + principal: { + kind: 'session', + userId: session.user.id, + sessionId: session.session.id, + }, + input: { workflowId, executionId }, + }) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'forbidden' && classified?.code !== 'not_found') throw error + return + } return ( ) diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable.tsx new file mode 100644 index 00000000000..52c9d065127 --- /dev/null +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable.tsx @@ -0,0 +1,17 @@ +import { ChipLink } from '@sim/emcn' + +export function ResumeExecutionUnavailable() { + return ( +
+
+

Execution Not Found

+

+ This execution could not be located or has already completed. +

+ + Return Home + +
+
+ ) +} diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.test.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.test.tsx new file mode 100644 index 00000000000..47993d91f1b --- /dev/null +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.test.tsx @@ -0,0 +1,180 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiClientError } from '@/lib/api/client/errors' +import type { PausePointWithQueue } from '@/hooks/queries/resume-execution' + +const mocks = vi.hoisted(() => ({ + pauseContextDetail: vi.fn(), + refetch: vi.fn(), + replace: vi.fn(), + resumeContext: vi.fn(), + resumeExecutionDetail: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: mocks.replace }), +})) + +vi.mock('@/hooks/queries/resume-execution', () => ({ + resumeKeys: { + execution: (workflowId: string, executionId: string) => [ + 'resume-execution', + 'execution', + workflowId, + executionId, + ], + context: (workflowId: string, executionId: string, contextId: string) => [ + 'resume-execution', + 'context', + workflowId, + executionId, + contextId, + ], + }, + usePauseContextDetail: mocks.pauseContextDetail, + useResumeContext: mocks.resumeContext, + useResumeExecutionDetail: mocks.resumeExecutionDetail, +})) + +import ResumeExecutionPage, { + selectInitialResumeContextId, +} from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client' + +const params = { workflowId: 'workflow-1', executionId: 'execution-1' } + +let container: HTMLDivElement +let queryClient: QueryClient +let root: Root + +function apiError(status: number): ApiClientError { + return new ApiClientError({ + status, + message: status === 404 ? 'Workflow not found' : 'Request failed', + body: { error: 'Request failed' }, + }) +} + +function renderPage(initialContextId?: string) { + act(() => { + root.render( + + + + ) + }) +} + +describe('ResumeExecutionPage', () => { + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + mocks.pauseContextDetail.mockReturnValue({ data: undefined, isLoading: false }) + mocks.resumeContext.mockReturnValue({ mutateAsync: vi.fn() }) + mocks.resumeExecutionDetail.mockReturnValue({ + data: undefined, + error: null, + isError: false, + isFetching: true, + isLoading: true, + refetch: mocks.refetch, + }) + }) + + afterEach(() => { + act(() => root.unmount()) + queryClient.clear() + container.remove() + vi.clearAllMocks() + }) + + it('renders a concealed state for an absent or newly inaccessible execution', () => { + mocks.resumeExecutionDetail.mockReturnValue({ + data: undefined, + error: apiError(404), + isError: true, + isFetching: false, + isLoading: false, + refetch: mocks.refetch, + }) + + renderPage('context-1') + + expect(container.textContent).toContain('Execution Not Found') + expect(container.textContent).not.toContain('Could Not Load Execution') + expect(mocks.pauseContextDetail).toHaveBeenLastCalledWith( + params.workflowId, + params.executionId, + undefined + ) + }) + + it('redirects an expired session back through login', () => { + mocks.resumeExecutionDetail.mockReturnValue({ + data: undefined, + error: apiError(401), + isError: true, + isFetching: false, + isLoading: false, + refetch: mocks.refetch, + }) + + renderPage('context-1') + + const callbackPath = '/resume/workflow-1/execution-1?contextId=context-1' + expect(mocks.replace).toHaveBeenCalledWith( + `/login?callbackUrl=${encodeURIComponent(callbackPath)}` + ) + expect(container.textContent).toContain('Redirecting to sign in') + }) + + it('shows a retryable error instead of mislabeling infrastructure failure', () => { + mocks.resumeExecutionDetail.mockReturnValue({ + data: undefined, + error: apiError(500), + isError: true, + isFetching: false, + isLoading: false, + refetch: mocks.refetch, + }) + + renderPage() + + expect(container.textContent).toContain('Could Not Load Execution') + expect(container.textContent).not.toContain('Execution Not Found') + const retryButton = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Try again' + ) + expect(retryButton).toBeDefined() + act(() => retryButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + expect(mocks.refetch).toHaveBeenCalledOnce() + }) +}) + +describe('selectInitialResumeContextId', () => { + const pausePoints = [ + { contextId: 'resumed-context', resumeStatus: 'resumed' }, + { contextId: 'paused-context', resumeStatus: 'paused' }, + ] as PausePointWithQueue[] + + it('uses a requested context only when the authorized execution contains it', () => { + expect(selectInitialResumeContextId(pausePoints, 'paused-context')).toBe('paused-context') + expect(selectInitialResumeContextId(pausePoints, 'unknown-context')).toBe('paused-context') + }) + + it('falls back to the first context when none is paused', () => { + expect( + selectInitialResumeContextId( + [{ contextId: 'first-context', resumeStatus: 'resumed' }] as PausePointWithQueue[], + null + ) + ).toBe('first-context') + }) +}) diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx index 5576c035050..2cc1050d9b0 100644 --- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx +++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Badge, Button, + Chip, ChipInput, ChipSelect, ChipTextarea, @@ -22,6 +23,8 @@ import { RefreshCw } from '@sim/emcn/icons' import { formatDateTime } from '@sim/utils/formatting' import { useQueryClient } from '@tanstack/react-query' import { useRouter } from 'next/navigation' +import { isApiClientError } from '@/lib/api/client/errors' +import { ResumeExecutionUnavailable } from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-execution-unavailable' import { type PauseContextDetail, type PausedExecutionDetail, @@ -54,7 +57,6 @@ interface ResponseStructureRow { interface ResumeExecutionPageProps { params: { workflowId: string; executionId: string } - initialExecutionDetail: PausedExecutionDetail | null initialContextId?: string | null } @@ -66,6 +68,22 @@ const STATUS_BADGE_VARIANT: Record pausePoint.contextId === requestedContextId) + ) { + return requestedContextId + } + return ( + pausePoints.find((pausePoint) => pausePoint.resumeStatus === 'paused')?.contextId ?? + pausePoints[0]?.contextId + ) +} + function formatDate(value: string | null): string { if (!value) return '—' try { @@ -155,7 +173,6 @@ function renderStructuredValuePreview(value: unknown) { export default function ResumeExecutionPage({ params, - initialExecutionDetail, initialContextId, }: ResumeExecutionPageProps) { const { workflowId, executionId } = params @@ -164,22 +181,23 @@ export default function ResumeExecutionPage({ const { data: executionDetail, + error: executionLoadError, + isError: executionLoadFailed, + isLoading: loadingExecution, isFetching: refreshingExecution, refetch: refetchExecutionDetail, - } = useResumeExecutionDetail(workflowId, executionId, initialExecutionDetail ?? undefined) + } = useResumeExecutionDetail(workflowId, executionId) const pausePoints = executionDetail?.pausePoints ?? [] - const defaultContextId = useMemo(() => { - if (initialContextId) return initialContextId - return ( - pausePoints.find((point) => point.resumeStatus === 'paused')?.contextId ?? - pausePoints[0]?.contextId - ) - }, [initialContextId, pausePoints]) + const defaultContextId = executionDetail + ? selectInitialResumeContextId(pausePoints, initialContextId) + : undefined + const [selectedContextIdOverride, setSelectedContextIdOverride] = useState< + string | null | undefined + >(undefined) + const selectedContextId = + selectedContextIdOverride === undefined ? (defaultContextId ?? null) : selectedContextIdOverride - const [selectedContextId, setSelectedContextId] = useState( - defaultContextId ?? null - ) const { data: selectedDetail, isLoading: loadingDetail } = usePauseContextDetail( workflowId, executionId, @@ -201,6 +219,18 @@ export default function ResumeExecutionPage({ const resumeMutation = useResumeContext() + const executionErrorStatus = isApiClientError(executionLoadError) + ? executionLoadError.status + : null + + useEffect(() => { + if (executionErrorStatus !== 401) return + const resumePath = `/resume/${encodeURIComponent(workflowId)}/${encodeURIComponent(executionId)}${ + initialContextId ? `?${new URLSearchParams({ contextId: initialContextId })}` : '' + }` + router.replace(`/login?callbackUrl=${encodeURIComponent(resumePath)}`) + }, [executionErrorStatus, executionId, initialContextId, router, workflowId]) + const normalizeInputFormatFields = useCallback((raw: any): NormalizedInputField[] => { if (!Array.isArray(raw)) return [] return raw @@ -529,7 +559,7 @@ export default function ResumeExecutionPage({ if (!selectedContextId) { const firstPaused = data?.pausePoints.find((point) => point.resumeStatus === 'paused')?.contextId ?? null - setSelectedContextId(firstPaused) + setSelectedContextIdOverride(firstPaused) } }, [refetchExecutionDetail, selectedContextId]) @@ -635,7 +665,10 @@ export default function ResumeExecutionPage({ } } ) - setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId)) + setSelectedContextIdOverride((override) => { + const currentContextId = override === undefined ? (defaultContextId ?? null) : override + return currentContextId !== selectedContextId ? override : fallbackContextId + }) setMessage( payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.' ) @@ -691,25 +724,50 @@ export default function ResumeExecutionPage({ ) } - // Not found state - if (!executionDetail) { + if (loadingExecution) { return (
-
-

Execution Not Found

-

- This execution could not be located or has already completed. -

- -
+ Loading…
) } + if (executionLoadFailed) { + if (executionErrorStatus === 401) { + return ( +
+ Redirecting to sign in… +
+ ) + } + if (executionErrorStatus === 403 || executionErrorStatus === 404) { + return + } + return ( +
+
+

Could Not Load Execution

+

+ An unexpected error occurred while loading this execution. Please try again. +

+ void refetchExecutionDetail()} + > + {refreshingExecution ? 'Trying again…' : 'Try again'} + +
+
+ ) + } + + if (!executionDetail) { + return + } + return (
@@ -757,7 +815,7 @@ export default function ResumeExecutionPage({ key={pause.contextId} variant={pause.contextId === selectedContextId ? 'active' : 'ghost'} onClick={() => { - setSelectedContextId(pause.contextId) + setSelectedContextIdOverride(pause.contextId) setError(null) setMessage(null) }} diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.test.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.test.ts new file mode 100644 index 00000000000..9f9310bc17a --- /dev/null +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.test.ts @@ -0,0 +1,182 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + InsufficientWorkspacePermissionsError, + NoWorkspaceAccessError, +} from '@/lib/core/application' + +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + getSession: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mocks.getSession, +})) + +vi.mock('@/lib/workflows/application/read-paused-workflow-execution', () => ({ + readPausedWorkflowExecution: { + operation: { id: 'workflows.paused_executions.read' }, + execute: mocks.execute, + }, +})) + +import { GET } from '@/app/api/resume/[workflowId]/[executionId]/route' +import { GET as GET_PAUSED_EXECUTION } from '@/app/api/workflows/[id]/paused/[executionId]/route' + +const params = { workflowId: 'workflow-1', executionId: 'execution-1' } +const detail = { + id: 'paused-1', + workflowId: params.workflowId, + executionId: params.executionId, + status: 'paused', + totalPauseCount: 1, + resumedCount: 0, + pausedAt: '2026-08-31T12:00:00.000Z', + updatedAt: '2026-08-31T12:00:00.000Z', + expiresAt: null, + metadata: { source: 'human-in-the-loop' }, + triggerIds: ['trigger-1'], + pausePoints: [ + { + contextId: 'context-1', + resumeStatus: 'paused', + registeredAt: '2026-08-31T12:00:00.000Z', + snapshotReady: true, + response: { data: { approved: false } }, + queuePosition: 1, + }, + ], + executionSnapshot: { snapshot: '{}', triggerIds: [] }, + queue: [ + { + id: 'queue-1', + pausedExecutionId: 'paused-1', + parentExecutionId: params.executionId, + newExecutionId: 'execution-2', + contextId: 'context-1', + resumeInput: { approved: true }, + status: 'queued', + queuedAt: '2026-08-31T12:01:00.000Z', + claimedAt: null, + completedAt: null, + failureReason: null, + }, + ], +} + +function request() { + return createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/resume/workflow-1/execution-1' + ) +} + +function pausedExecutionRequest() { + return createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost/api/workflows/workflow-1/paused/execution-1' + ) +} + +const routeCases = [ + { + name: 'resume detail route', + call: () => GET(request(), { params: Promise.resolve(params) }), + }, + { + name: 'workflow paused-detail route', + call: () => + GET_PAUSED_EXECUTION(pausedExecutionRequest(), { + params: Promise.resolve({ id: params.workflowId, executionId: params.executionId }), + }), + }, +] + +describe('GET /api/resume/[workflowId]/[executionId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.execute.mockResolvedValue(detail) + }) + + it('rejects an unauthenticated request before the application use case', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await GET(request(), { params: Promise.resolve(params) }) + + expect(response.status).toBe(401) + expect(await response.json()).toMatchObject({ error: 'Unauthorized' }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('loads detail through the authorized application use case', async () => { + const response = await GET(request(), { params: Promise.resolve(params) }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(detail) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: params, + }) + ) + }) + + it('maps the sibling route parameter to the same semantic input', async () => { + const response = await GET_PAUSED_EXECUTION(pausedExecutionRequest(), { + params: Promise.resolve({ id: params.workflowId, executionId: params.executionId }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual(detail) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: params, + }) + ) + }) + + it.each(routeCases)('$name conceals cross-workspace denial', async ({ call }) => { + mocks.execute.mockRejectedValueOnce(new NoWorkspaceAccessError()) + + const response = await call() + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Workflow not found' }) + }) + + it.each(routeCases)('$name preserves actionable same-workspace denial', async ({ call }) => { + mocks.execute.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError()) + + const response = await call() + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Insufficient workspace permissions' }) + }) + + it.each(routeCases)('$name sanitizes unexpected failures', async ({ call }) => { + mocks.execute.mockRejectedValueOnce(new Error('database password=secret')) + + const response = await call() + const body = await response.json() + + expect(response.status).toBe(500) + expect(body).toMatchObject({ error: 'Internal server error' }) + expect(JSON.stringify(body)).not.toContain('password=secret') + }) +}) diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts index 244da8805c4..950c27ea42c 100644 --- a/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts +++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/route.ts @@ -1,51 +1,29 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { resumeWorkflowExecutionContract } from '@/lib/api/contracts/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' -import { validateWorkflowAccess } from '@/app/api/workflows/middleware' - -const logger = createLogger('WorkflowResumeExecutionAPI') +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalWorkflowErrorPolicies, internalWorkflowReadAuth } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readPausedWorkflowExecution } from '@/lib/workflows/application/read-paused-workflow-execution' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler( - async ( - request: NextRequest, - context: { params: Promise<{ workflowId: string; executionId: string }> } - ) => { - const parsed = await parseRequest(resumeWorkflowExecutionContract, request, context) - if (!parsed.success) return parsed.response - const { workflowId, executionId } = parsed.data.params - - const access = await validateWorkflowAccess(request, workflowId, false) - if (access.error) { - return NextResponse.json({ error: access.error.message }, { status: access.error.status }) - } - - try { - const detail = await PauseResumeManager.getPausedExecutionDetail({ - workflowId, - executionId, - }) - - if (!detail) { - return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) - } - - return NextResponse.json(detail) - } catch (error: any) { - logger.error('Failed to load paused execution detail', { - workflowId, - executionId, - error, - }) - return NextResponse.json( - { error: error?.message || 'Failed to load paused execution detail' }, - { status: 500 } - ) - } - } -) +export const GET = defineInternalJsonRoute({ + contract: resumeWorkflowExecutionContract, + auth: internalWorkflowReadAuth, + operation: workflowOperations.readPausedExecution, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing authenticated resume-detail behavior', + }), + errorPolicy: internalWorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ + workflowId: params.workflowId, + executionId: params.executionId, + }), + useCase: readPausedWorkflowExecution, + responseHeaders: () => ({ 'Cache-Control': 'private, no-store' }), + present: (executionDetail) => ({ + ...executionDetail, + pausePoints: executionDetail.pausePoints.map((pausePoint) => ({ ...pausePoint })), + queue: executionDetail.queue.map((queueEntry) => ({ ...queueEntry })), + }), +}) diff --git a/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts b/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts index 04d835bba12..916e7c22e15 100644 --- a/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts +++ b/apps/sim/app/api/workflows/[id]/paused/[executionId]/route.ts @@ -1,36 +1,29 @@ -import { type NextRequest, NextResponse } from 'next/server' import { pausedWorkflowExecutionByIdContract } from '@/lib/api/contracts/workflows' -import { parseRequest } from '@/lib/api/server' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' -import { validateWorkflowAccess } from '@/app/api/workflows/middleware' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' +import { internalWorkflowErrorPolicies, internalWorkflowReadAuth } from '@/lib/workflows/api' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { readPausedWorkflowExecution } from '@/lib/workflows/application/read-paused-workflow-execution' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler( - async ( - request: NextRequest, - context: { params: Promise<{ id: string; executionId: string }> } - ) => { - const parsed = await parseRequest(pausedWorkflowExecutionByIdContract, request, context) - if (!parsed.success) return parsed.response - const { id: workflowId, executionId } = parsed.data.params - - const access = await validateWorkflowAccess(request, workflowId, false) - if (access.error) { - return NextResponse.json({ error: access.error.message }, { status: access.error.status }) - } - - const detail = await PauseResumeManager.getPausedExecutionDetail({ - workflowId, - executionId, - }) - - if (!detail) { - return NextResponse.json({ error: 'Paused execution not found' }, { status: 404 }) - } - - return NextResponse.json(detail) - } -) +export const GET = defineInternalJsonRoute({ + contract: pausedWorkflowExecutionByIdContract, + auth: internalWorkflowReadAuth, + operation: workflowOperations.readPausedExecution, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing authenticated paused-execution detail behavior', + }), + errorPolicy: internalWorkflowErrorPolicies.concealWorkflowAuthorization, + mapInput: ({ params }) => ({ + workflowId: params.id, + executionId: params.executionId, + }), + useCase: readPausedWorkflowExecution, + responseHeaders: () => ({ 'Cache-Control': 'private, no-store' }), + present: (executionDetail) => ({ + ...executionDetail, + pausePoints: executionDetail.pausePoints.map((pausePoint) => ({ ...pausePoint })), + queue: executionDetail.queue.map((queueEntry) => ({ ...queueEntry })), + }), +}) diff --git a/apps/sim/hooks/queries/resume-execution.test.ts b/apps/sim/hooks/queries/resume-execution.test.ts new file mode 100644 index 00000000000..71768777862 --- /dev/null +++ b/apps/sim/hooks/queries/resume-execution.test.ts @@ -0,0 +1,26 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { ApiClientError } from '@/lib/api/client/errors' +import { shouldRetryResumeExecutionDetail } from '@/hooks/queries/resume-execution' + +function apiError(status: number): ApiClientError { + return new ApiClientError({ + status, + message: 'Request failed', + body: { error: 'Request failed' }, + }) +} + +describe('shouldRetryResumeExecutionDetail', () => { + it.each([401, 403, 404])('does not retry terminal HTTP %s responses', (status) => { + expect(shouldRetryResumeExecutionDetail(0, apiError(status))).toBe(false) + }) + + it('retries an infrastructure failure once', () => { + expect(shouldRetryResumeExecutionDetail(0, apiError(500))).toBe(true) + expect(shouldRetryResumeExecutionDetail(1, apiError(500))).toBe(false) + expect(shouldRetryResumeExecutionDetail(0, new TypeError('network unavailable'))).toBe(true) + }) +}) diff --git a/apps/sim/hooks/queries/resume-execution.ts b/apps/sim/hooks/queries/resume-execution.ts index 9ee3fddd042..2930d9e5636 100644 --- a/apps/sim/hooks/queries/resume-execution.ts +++ b/apps/sim/hooks/queries/resume-execution.ts @@ -102,16 +102,17 @@ interface ResumeContextVariables { input?: unknown } +export function shouldRetryResumeExecutionDetail(failureCount: number, error: unknown): boolean { + if (isApiClientError(error) && error.status >= 400 && error.status < 500) return false + return failureCount < 1 +} + /** * Loads the paused execution detail (all pause points for an execution). The * contract models pause points loosely (`z.record`); the resume UI works against * the richer `PausedExecutionDetail` interface, hence the bridging cast. */ -export function useResumeExecutionDetail( - workflowId: string, - executionId: string, - initialData?: PausedExecutionDetail -) { +export function useResumeExecutionDetail(workflowId: string, executionId: string) { return useQuery({ queryKey: resumeKeys.execution(workflowId, executionId), queryFn: async ({ signal }): Promise => { @@ -124,7 +125,7 @@ export function useResumeExecutionDetail( }, enabled: Boolean(workflowId && executionId), staleTime: RESUME_EXECUTION_DETAIL_STALE_TIME, - initialData, + retry: shouldRetryResumeExecutionDetail, }) } diff --git a/apps/sim/lib/workflows/application/operations.test.ts b/apps/sim/lib/workflows/application/operations.test.ts index c79db22ca80..b978a3e3bcd 100644 --- a/apps/sim/lib/workflows/application/operations.test.ts +++ b/apps/sim/lib/workflows/application/operations.test.ts @@ -134,4 +134,14 @@ describe('workflow operation registry', () => { expect(operation.id).toMatch(/^workflows\.manual\.execute/) } }) + + it('protects paused execution detail as a workflow read', () => { + expect(workflowOperations.readPausedExecution).toMatchObject({ + id: 'workflows.paused_executions.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + }) }) diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 87591f517bc..897c626b961 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -348,6 +348,12 @@ export const workflowOperations = { workspaceApiKey: 'allow', ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), + readPausedExecution: defineWorkspaceOperation({ + id: 'workflows.paused_executions.read', + minimumRole: 'read', + workspaceApiKey: 'allow', + ...ALL_WORKFLOW_PRINCIPAL_POLICY, + }), /** * Downloading one file a run produced. Separate from `readRun` because it * hands out bytes and records a `FILE_DOWNLOADED` audit event, which reading diff --git a/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts b/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts new file mode 100644 index 00000000000..1786ba37513 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts @@ -0,0 +1,218 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getPausedExecutionDetail: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkflowContext: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/workflows/application/context', () => ({ + resolveActiveWorkflowApplicationContext: mocks.resolveWorkflowContext, +})) + +vi.mock('@/lib/workflows/executor/human-in-the-loop-manager', () => ({ + PauseResumeManager: { + getPausedExecutionDetail: mocks.getPausedExecutionDetail, + }, +})) + +import { readPausedWorkflowExecution } from '@/lib/workflows/application/read-paused-workflow-execution' + +const workflowContext = { + workflowId: 'workflow-1', + workflow: { id: 'workflow-1' }, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const detail = { + id: 'paused-1', + workflowId: 'workflow-1', + executionId: 'execution-1', +} + +const allowedPrincipals: Principal[] = [ + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + { kind: 'personal_api_key', userId: 'user-1', keyId: 'personal-key-1' }, + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'workspace-key-1' }, + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date('2999-01-01T00:00:00.000Z'), + }, +] + +describe('readPausedWorkflowExecution', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.resolveWorkflowContext.mockResolvedValue(workflowContext) + mocks.getPausedExecutionDetail.mockResolvedValue(detail) + }) + + it.each(allowedPrincipals)( + 'authorizes $kind before loading paused execution detail', + async (principal) => { + const result = await readPausedWorkflowExecution.execute({ + principal, + input: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + + expect(result).toBe(detail) + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) + expect(mocks.getPausedExecutionDetail).toHaveBeenCalledWith({ + workflowId: 'workflow-1', + executionId: 'execution-1', + }) + } + ) + + it('finishes session authorization before loading paused execution detail', async () => { + await readPausedWorkflowExecution.execute({ + principal: allowedPrincipals[0], + input: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getPausedExecutionDetail.mock.invocationCallOrder[0] + ) + }) + + it('supports an authorization-only preflight without loading paused execution detail', async () => { + expect(readPausedWorkflowExecution.authorize).toBeTypeOf('function') + + await readPausedWorkflowExecution.authorize?.({ + principal: allowedPrincipals[0], + input: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + + expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({ workflowId: 'workflow-1' }) + expect(mocks.resolvePermission).toHaveBeenCalled() + expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled() + }) + + it('rejects executor delegation before canonical lookup', async () => { + const principal: Principal = { + kind: 'delegated', + serviceId: 'executor', + workspaceId: 'workspace-1', + delegationId: 'execution-delegation-1', + audience: 'sim:workflows', + issuedAt: new Date('2026-01-01T00:00:00.000Z'), + expiresAt: new Date('2999-01-01T00:00:00.000Z'), + delegationContext: { kind: 'workflow_execution', workflowId: 'workflow-1' }, + } + + await expect( + readPausedWorkflowExecution.execute({ + principal, + input: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + ).rejects.toMatchObject({ name: 'DelegatedServiceAuthorizationError' }) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled() + }) + + it('rejects a disallowed principal before canonical lookup', async () => { + const principal: Principal = { + kind: 'system', + serviceId: 'internal', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + } + + await expect( + readPausedWorkflowExecution.execute({ + principal, + input: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + ).rejects.toMatchObject({ name: 'PrincipalKindAuthorizationError' }) + expect(mocks.resolveWorkflowContext).not.toHaveBeenCalled() + expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled() + }) + + it('rejects a workspace key outside the canonical workspace before loading detail', async () => { + await expect( + readPausedWorkflowExecution.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-2', + keyId: 'workspace-key-2', + }, + input: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled() + }) + + it('rejects a session without current workspace access before loading detail', async () => { + mocks.resolvePermission.mockResolvedValueOnce(null) + + await expect( + readPausedWorkflowExecution.execute({ + principal: allowedPrincipals[0], + input: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + ).rejects.toMatchObject({ name: 'NoWorkspaceAccessError' }) + expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled() + }) + + it('enforces the workspace personal-key policy before loading detail', async () => { + mocks.resolveWorkflowContext.mockResolvedValueOnce({ + ...workflowContext, + allowPersonalApiKeys: false, + }) + + await expect( + readPausedWorkflowExecution.execute({ + principal: allowedPrincipals[1], + input: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + ).rejects.toMatchObject({ name: 'PersonalApiKeysDisabledError' }) + expect(mocks.getPausedExecutionDetail).not.toHaveBeenCalled() + }) + + it('returns a semantic not-found error when no paused execution matches', async () => { + mocks.getPausedExecutionDetail.mockResolvedValueOnce(null) + + await expect( + readPausedWorkflowExecution.execute({ + principal: allowedPrincipals[0], + input: { workflowId: 'workflow-1', executionId: 'missing-execution' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'Paused execution not found' }) + }) + + it('propagates manager infrastructure failures', async () => { + const infrastructureError = new Error('database unavailable') + mocks.getPausedExecutionDetail.mockRejectedValueOnce(infrastructureError) + + await expect( + readPausedWorkflowExecution.execute({ + principal: allowedPrincipals[0], + input: { workflowId: 'workflow-1', executionId: 'execution-1' }, + }) + ).rejects.toBe(infrastructureError) + }) +}) diff --git a/apps/sim/lib/workflows/application/read-paused-workflow-execution.ts b/apps/sim/lib/workflows/application/read-paused-workflow-execution.ts new file mode 100644 index 00000000000..958bfdea234 --- /dev/null +++ b/apps/sim/lib/workflows/application/read-paused-workflow-execution.ts @@ -0,0 +1,24 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case' +import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' + +export interface ReadPausedWorkflowExecutionInput { + workflowId: string + executionId: string +} + +export const readPausedWorkflowExecution = defineAuthorizedWorkflowUseCase({ + operation: workflowOperations.readPausedExecution, + resolveContext: ({ input }: { input: ReadPausedWorkflowExecutionInput }) => + resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }), + async execute({ context, input }) { + const detail = await PauseResumeManager.getPausedExecutionDetail({ + workflowId: context.workflowId, + executionId: input.executionId, + }) + if (!detail) throw new OrchestrationError('not_found', 'Paused execution not found') + return detail + }, +})