Skip to content

Commit cd24443

Browse files
author
test
committed
fix(workflows): preserve auth ordering and harden async tests
1 parent 818e477 commit cd24443

File tree

3 files changed

+341
-46
lines changed

3 files changed

+341
-46
lines changed

apps/sim/app/api/workflows/[id]/execute/route.async.test.ts

Lines changed: 128 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,21 @@ const {
1212
mockGetJobQueue,
1313
mockShouldExecuteInline,
1414
mockEnqueue,
15+
mockLoggerInfo,
16+
mockLoggerWarn,
17+
mockLoggerError,
18+
mockLoggerDebug,
1519
} = vi.hoisted(() => ({
1620
mockCheckHybridAuth: vi.fn(),
1721
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
1822
mockPreprocessExecution: vi.fn(),
1923
mockGetJobQueue: vi.fn(),
2024
mockShouldExecuteInline: vi.fn(),
2125
mockEnqueue: vi.fn().mockResolvedValue('job-123'),
26+
mockLoggerInfo: vi.fn(),
27+
mockLoggerWarn: vi.fn(),
28+
mockLoggerError: vi.fn(),
29+
mockLoggerDebug: vi.fn(),
2230
}))
2331

2432
vi.mock('@/lib/auth/hybrid', () => ({
@@ -70,10 +78,10 @@ vi.mock('@/background/workflow-execution', () => ({
7078

7179
vi.mock('@sim/logger', () => ({
7280
createLogger: vi.fn().mockReturnValue({
73-
info: vi.fn(),
74-
warn: vi.fn(),
75-
error: vi.fn(),
76-
debug: vi.fn(),
81+
info: mockLoggerInfo,
82+
warn: mockLoggerWarn,
83+
error: mockLoggerError,
84+
debug: mockLoggerDebug,
7785
}),
7886
}))
7987

@@ -84,10 +92,27 @@ vi.mock('uuid', () => ({
8492

8593
import { POST } from './route'
8694

95+
async function readJsonBody(response: Response) {
96+
const text = await response.text()
97+
98+
return {
99+
text,
100+
json: text ? JSON.parse(text) : null,
101+
}
102+
}
103+
104+
function expectCheckpointOrder(checkpoints: string[], expected: string[]) {
105+
expect(checkpoints).toEqual(expected)
106+
}
107+
87108
describe('workflow execute async route', () => {
109+
const asyncCheckpoints: string[] = []
110+
88111
beforeEach(() => {
89112
vi.clearAllMocks()
90113

114+
asyncCheckpoints.length = 0
115+
91116
mockCheckHybridAuth.mockReset()
92117
mockAuthorizeWorkflowByWorkspacePermission.mockReset()
93118
mockPreprocessExecution.mockReset()
@@ -102,31 +127,58 @@ describe('workflow execute async route', () => {
102127
completeJob: vi.fn(),
103128
markJobFailed: vi.fn(),
104129
})
105-
mockShouldExecuteInline.mockReturnValue(false)
130+
mockShouldExecuteInline.mockImplementation(() => {
131+
asyncCheckpoints.push('shouldExecuteInline')
132+
return false
133+
})
134+
135+
mockCheckHybridAuth.mockImplementation(async () => {
136+
asyncCheckpoints.push('authorization')
137+
return {
138+
success: true,
139+
userId: 'session-user-1',
140+
authType: 'session',
141+
}
142+
})
143+
144+
mockAuthorizeWorkflowByWorkspacePermission.mockImplementation(async () => {
145+
asyncCheckpoints.push('authorizeWorkflowByWorkspacePermission')
146+
return {
147+
allowed: true,
148+
workflow: {
149+
id: 'workflow-1',
150+
userId: 'owner-1',
151+
workspaceId: 'workspace-1',
152+
},
153+
}
154+
})
106155

107-
mockCheckHybridAuth.mockResolvedValue({
108-
success: true,
109-
userId: 'session-user-1',
110-
authType: 'session',
156+
mockPreprocessExecution.mockImplementation(async () => {
157+
asyncCheckpoints.push('preprocessing')
158+
return {
159+
success: true,
160+
actorUserId: 'actor-1',
161+
workflowRecord: {
162+
id: 'workflow-1',
163+
userId: 'owner-1',
164+
workspaceId: 'workspace-1',
165+
},
166+
}
111167
})
112168

113-
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
114-
allowed: true,
115-
workflow: {
116-
id: 'workflow-1',
117-
userId: 'owner-1',
118-
workspaceId: 'workspace-1',
119-
},
169+
mockGetJobQueue.mockImplementation(async () => {
170+
asyncCheckpoints.push('getJobQueue')
171+
return {
172+
enqueue: mockEnqueue,
173+
startJob: vi.fn(),
174+
completeJob: vi.fn(),
175+
markJobFailed: vi.fn(),
176+
}
120177
})
121178

122-
mockPreprocessExecution.mockResolvedValue({
123-
success: true,
124-
actorUserId: 'actor-1',
125-
workflowRecord: {
126-
id: 'workflow-1',
127-
userId: 'owner-1',
128-
workspaceId: 'workspace-1',
129-
},
179+
mockEnqueue.mockImplementation(async () => {
180+
asyncCheckpoints.push('enqueue')
181+
return 'job-123'
130182
})
131183
})
132184

@@ -142,11 +194,23 @@ describe('workflow execute async route', () => {
142194
const params = Promise.resolve({ id: 'workflow-1' })
143195

144196
const response = await POST(req as any, { params })
145-
const body = await response.json()
197+
const { json: body, text: bodyText } = await readJsonBody(response)
146198

147-
expect(response.status).toBe(202)
199+
expect(
200+
response.status,
201+
`Expected async execute route to return 202, got ${response.status} with body: ${bodyText}`
202+
).toBe(202)
203+
expectCheckpointOrder(asyncCheckpoints, [
204+
'authorization',
205+
'authorizeWorkflowByWorkspacePermission',
206+
'preprocessing',
207+
'getJobQueue',
208+
'enqueue',
209+
'shouldExecuteInline',
210+
])
148211
expect(body.executionId).toBe('execution-123')
149212
expect(body.jobId).toBe('job-123')
213+
expect(mockLoggerError).not.toHaveBeenCalled()
150214
expect(mockEnqueue).toHaveBeenCalledWith(
151215
'workflow-execution',
152216
expect.objectContaining({
@@ -177,4 +241,42 @@ describe('workflow execute async route', () => {
177241
}
178242
)
179243
})
244+
245+
it('returns queue failure payload with checkpoint trail and logs the queue error', async () => {
246+
const queueError = new Error('queue unavailable')
247+
mockEnqueue.mockImplementationOnce(async () => {
248+
asyncCheckpoints.push('enqueue')
249+
throw queueError
250+
})
251+
252+
const req = createMockRequest(
253+
'POST',
254+
{ input: { hello: 'world' } },
255+
{
256+
'Content-Type': 'application/json',
257+
'X-Execution-Mode': 'async',
258+
}
259+
)
260+
261+
const response = await POST(req as any, { params: Promise.resolve({ id: 'workflow-1' }) })
262+
const { json: body, text: bodyText } = await readJsonBody(response)
263+
264+
expectCheckpointOrder(asyncCheckpoints, [
265+
'authorization',
266+
'authorizeWorkflowByWorkspacePermission',
267+
'preprocessing',
268+
'getJobQueue',
269+
'enqueue',
270+
])
271+
expect(
272+
response.status,
273+
`Expected async execute route to return 500, got ${response.status} with body: ${bodyText}`
274+
).toBe(500)
275+
expect(body).toEqual({ error: 'Failed to queue async execution: queue unavailable' })
276+
expect(mockShouldExecuteInline).not.toHaveBeenCalled()
277+
expect(mockLoggerError).toHaveBeenCalledWith(
278+
'[req-12345678] Failed to queue async execution',
279+
queueError
280+
)
281+
})
180282
})
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import { validateWorkflowAccess } from './middleware'
8+
9+
const { mockAuthenticateApiKeyFromHeader, mockUpdateApiKeyLastUsed, mockCheckHybridAuth } =
10+
vi.hoisted(() => ({
11+
mockAuthenticateApiKeyFromHeader: vi.fn(),
12+
mockUpdateApiKeyLastUsed: vi.fn(),
13+
mockCheckHybridAuth: vi.fn(),
14+
}))
15+
16+
const { mockAuthorizeWorkflowByWorkspacePermission, mockGetWorkflowById, mockLoggerError } =
17+
vi.hoisted(() => ({
18+
mockAuthorizeWorkflowByWorkspacePermission: vi.fn(),
19+
mockGetWorkflowById: vi.fn(),
20+
mockLoggerError: vi.fn(),
21+
}))
22+
23+
vi.mock('@sim/logger', () => ({
24+
createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: mockLoggerError }),
25+
}))
26+
27+
vi.mock('@/lib/api-key/service', () => ({
28+
authenticateApiKeyFromHeader: (...args: unknown[]) => mockAuthenticateApiKeyFromHeader(...args),
29+
updateApiKeyLastUsed: (...args: unknown[]) => mockUpdateApiKeyLastUsed(...args),
30+
}))
31+
32+
vi.mock('@/lib/auth/hybrid', () => ({
33+
checkHybridAuth: (...args: unknown[]) => mockCheckHybridAuth(...args),
34+
}))
35+
36+
vi.mock('@/lib/core/config/env', () => ({
37+
env: {},
38+
}))
39+
40+
vi.mock('@/lib/workflows/utils', () => ({
41+
authorizeWorkflowByWorkspacePermission: (...args: unknown[]) =>
42+
mockAuthorizeWorkflowByWorkspacePermission(...args),
43+
getWorkflowById: (...args: unknown[]) => mockGetWorkflowById(...args),
44+
}))
45+
46+
const WORKFLOW_ID = 'wf-1'
47+
const WORKSPACE_ID = 'ws-1'
48+
49+
function createRequest() {
50+
return new NextRequest(`http://localhost:3000/api/workflows/${WORKFLOW_ID}/status`)
51+
}
52+
53+
function createWorkflow(overrides: Record<string, unknown> = {}) {
54+
return {
55+
id: WORKFLOW_ID,
56+
workspaceId: WORKSPACE_ID,
57+
isDeployed: false,
58+
...overrides,
59+
}
60+
}
61+
62+
describe('validateWorkflowAccess', () => {
63+
beforeEach(() => {
64+
vi.clearAllMocks()
65+
mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-1', authType: 'session' })
66+
mockGetWorkflowById.mockResolvedValue(createWorkflow())
67+
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
68+
allowed: true,
69+
status: 200,
70+
workflow: createWorkflow(),
71+
workspacePermission: 'admin',
72+
})
73+
})
74+
75+
it('returns 401 before workflow lookup when unauthenticated', async () => {
76+
const request = createRequest()
77+
78+
mockCheckHybridAuth.mockResolvedValue({ success: false, error: 'Unauthorized' })
79+
80+
const result = await validateWorkflowAccess(request, WORKFLOW_ID, {
81+
requireDeployment: false,
82+
action: 'read',
83+
})
84+
85+
expect(result).toEqual({
86+
error: {
87+
message: 'Unauthorized',
88+
status: 401,
89+
},
90+
})
91+
expect(mockCheckHybridAuth).toHaveBeenCalledWith(request, { requireWorkflowId: false })
92+
expect(mockGetWorkflowById).not.toHaveBeenCalled()
93+
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
94+
})
95+
96+
it('returns 404 for authenticated missing workflow', async () => {
97+
mockGetWorkflowById.mockResolvedValue(null)
98+
99+
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
100+
requireDeployment: false,
101+
action: 'read',
102+
})
103+
104+
expect(result).toEqual({
105+
error: {
106+
message: 'Workflow not found',
107+
status: 404,
108+
},
109+
})
110+
expect(mockGetWorkflowById).toHaveBeenCalledWith(WORKFLOW_ID)
111+
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
112+
})
113+
114+
it('returns 403 for authenticated workflow without workspace', async () => {
115+
mockGetWorkflowById.mockResolvedValue(createWorkflow({ workspaceId: null }))
116+
117+
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
118+
requireDeployment: false,
119+
action: 'read',
120+
})
121+
122+
expect(result).toEqual({
123+
error: {
124+
message:
125+
'This workflow is not attached to a workspace. Personal workflows are deprecated and cannot be accessed.',
126+
status: 403,
127+
},
128+
})
129+
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
130+
})
131+
132+
it('returns authorization denial status and message', async () => {
133+
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
134+
allowed: false,
135+
status: 403,
136+
message: 'Unauthorized: Access denied to admin this workflow',
137+
workflow: createWorkflow(),
138+
workspacePermission: 'write',
139+
})
140+
141+
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
142+
requireDeployment: false,
143+
action: 'admin',
144+
})
145+
146+
expect(result).toEqual({
147+
error: {
148+
message: 'Unauthorized: Access denied to admin this workflow',
149+
status: 403,
150+
},
151+
})
152+
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
153+
workflowId: WORKFLOW_ID,
154+
userId: 'user-1',
155+
action: 'admin',
156+
})
157+
})
158+
159+
it('returns workflow and auth on success', async () => {
160+
const workflow = createWorkflow({ name: 'Test Workflow' })
161+
const auth = { success: true, userId: 'user-1', authType: 'session' as const }
162+
163+
mockCheckHybridAuth.mockResolvedValue(auth)
164+
mockGetWorkflowById.mockResolvedValue(workflow)
165+
166+
const result = await validateWorkflowAccess(createRequest(), WORKFLOW_ID, {
167+
requireDeployment: false,
168+
action: 'read',
169+
})
170+
171+
expect(result).toEqual({ workflow, auth })
172+
})
173+
})

0 commit comments

Comments
 (0)