Skip to content

Commit 16a774a

Browse files
committed
fix(files): use explicit archive extraction route
1 parent 0d255a2 commit 16a774a

5 files changed

Lines changed: 111 additions & 85 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const mocks = vi.hoisted(() => ({
8+
getSession: vi.fn(),
9+
extract: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
13+
14+
vi.mock('@/lib/workspace-files/application/extract-workspace-file', () => ({
15+
extractWorkspaceFile: {
16+
operation: { id: 'files.extract_archive', minimumRole: 'write', workspaceApiKey: 'deny' },
17+
execute: mocks.extract,
18+
},
19+
}))
20+
21+
import { ArchiveError } from '@/lib/uploads/archive'
22+
import { POST } from '@/app/api/workspaces/[id]/files/[fileId]/extract/route'
23+
24+
const WORKSPACE_ID = 'workspace-1'
25+
const FILE_ID = 'wf_1'
26+
const context = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) }
27+
28+
function callExtract() {
29+
return POST(
30+
new NextRequest(
31+
`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/extract`,
32+
{ method: 'POST' }
33+
),
34+
context
35+
)
36+
}
37+
38+
describe('POST /api/workspaces/[id]/files/[fileId]/extract', () => {
39+
beforeEach(() => {
40+
vi.clearAllMocks()
41+
mocks.getSession.mockResolvedValue({
42+
user: { id: 'user-1' },
43+
session: { id: 'session-1' },
44+
})
45+
mocks.extract.mockResolvedValue({ folderName: 'bundle', extractedCount: 2, skippedCount: 0 })
46+
})
47+
48+
it('passes a session principal and canonical assertion to the extraction use case', async () => {
49+
const response = await callExtract()
50+
51+
expect(response.status).toBe(200)
52+
expect(await response.json()).toEqual({
53+
success: true,
54+
folderName: 'bundle',
55+
extractedCount: 2,
56+
skippedCount: 0,
57+
})
58+
expect(mocks.extract).toHaveBeenCalledWith({
59+
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
60+
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID },
61+
request: expect.anything(),
62+
})
63+
})
64+
65+
it('authenticates before invoking extraction', async () => {
66+
mocks.getSession.mockResolvedValue(null)
67+
68+
const response = await callExtract()
69+
70+
expect(response.status).toBe(401)
71+
expect(mocks.extract).not.toHaveBeenCalled()
72+
})
73+
74+
it('returns a caller-safe error for an invalid zip', async () => {
75+
mocks.extract.mockRejectedValue(new ArchiveError('invalid', 'Not a valid .zip archive.'))
76+
77+
const response = await callExtract()
78+
79+
expect(response.status).toBe(400)
80+
expect(await response.json()).toEqual({ error: 'Not a valid .zip archive.' })
81+
})
82+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { extractWorkspaceFileContract } from '@/lib/api/contracts/workspace-files'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { internalFileErrorPolicies } from '@/lib/workspace-files/api'
8+
import { extractWorkspaceFile } from '@/lib/workspace-files/application/extract-workspace-file'
9+
import { fileOperations } from '@/lib/workspace-files/application/operations'
10+
11+
export const dynamic = 'force-dynamic'
12+
export const maxDuration = 300
13+
14+
/**
15+
* POST /api/workspaces/[id]/files/[fileId]/extract
16+
* Unzip an archive file into a new folder beside it (requires write permission)
17+
*/
18+
export const POST = defineInternalJsonRoute({
19+
contract: extractWorkspaceFileContract,
20+
auth: internalSessionAuth,
21+
operation: fileOperations.extractArchive,
22+
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file behavior' }),
23+
errorPolicy: internalFileErrorPolicies.extractArchive,
24+
mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }),
25+
useCase: extractWorkspaceFile,
26+
present: (result) => ({ success: true, ...result }),
27+
})

apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts

Lines changed: 1 addition & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const mocks = vi.hoisted(() => ({
88
getSession: vi.fn(),
9-
extract: vi.fn(),
109
rename: vi.fn(),
1110
deleteItems: vi.fn(),
1211
getUserEntityPermissions: vi.fn(),
@@ -15,13 +14,6 @@ const mocks = vi.hoisted(() => ({
1514

1615
vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession }))
1716

18-
vi.mock('@/lib/workspace-files/application/extract-workspace-file', () => ({
19-
extractWorkspaceFile: {
20-
operation: { id: 'files.extract_archive', minimumRole: 'write', workspaceApiKey: 'deny' },
21-
execute: mocks.extract,
22-
},
23-
}))
24-
2517
vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({
2618
renameWorkspaceFile: {
2719
operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' },
@@ -46,8 +38,7 @@ import {
4638
WorkspaceApiKeyScopeAuthorizationError,
4739
} from '@/lib/core/application'
4840
import { OrchestrationError } from '@/lib/core/orchestration/types'
49-
import { ArchiveError } from '@/lib/uploads/archive'
50-
import { PATCH, POST } from '@/app/api/workspaces/[id]/files/[fileId]/route'
41+
import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/route'
5142

5243
const WORKSPACE_ID = 'workspace-1'
5344
const FILE_ID = 'wf_1'
@@ -64,15 +55,6 @@ function callRename(body: unknown) {
6455
)
6556
}
6657

67-
function callExtract() {
68-
return POST(
69-
new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}`, {
70-
method: 'POST',
71-
}),
72-
context
73-
)
74-
}
75-
7658
function fileRecord() {
7759
return {
7860
id: FILE_ID,
@@ -97,7 +79,6 @@ describe('PATCH /api/workspaces/[id]/files/[fileId]', () => {
9779
session: { id: 'session-1' },
9880
})
9981
mocks.rename.mockResolvedValue({ file: fileRecord() })
100-
mocks.extract.mockResolvedValue({ folderName: 'bundle', extractedCount: 2, skippedCount: 0 })
10182
})
10283

10384
it('authenticates before parsing the request', async () => {
@@ -189,49 +170,3 @@ describe('PATCH /api/workspaces/[id]/files/[fileId]', () => {
189170
})
190171
})
191172
})
192-
193-
describe('POST /api/workspaces/[id]/files/[fileId]', () => {
194-
beforeEach(() => {
195-
vi.clearAllMocks()
196-
mocks.getSession.mockResolvedValue({
197-
user: { id: 'user-1' },
198-
session: { id: 'session-1' },
199-
})
200-
mocks.extract.mockResolvedValue({ folderName: 'bundle', extractedCount: 2, skippedCount: 0 })
201-
})
202-
203-
it('passes a session principal and canonical assertion to the extraction use case', async () => {
204-
const response = await callExtract()
205-
206-
expect(response.status).toBe(200)
207-
expect(await response.json()).toEqual({
208-
success: true,
209-
folderName: 'bundle',
210-
extractedCount: 2,
211-
skippedCount: 0,
212-
})
213-
expect(mocks.extract).toHaveBeenCalledWith({
214-
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
215-
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID },
216-
request: expect.anything(),
217-
})
218-
})
219-
220-
it('authenticates before invoking extraction', async () => {
221-
mocks.getSession.mockResolvedValue(null)
222-
223-
const response = await callExtract()
224-
225-
expect(response.status).toBe(401)
226-
expect(mocks.extract).not.toHaveBeenCalled()
227-
})
228-
229-
it('returns a caller-safe error for an invalid zip', async () => {
230-
mocks.extract.mockRejectedValue(new ArchiveError('invalid', 'Not a valid .zip archive.'))
231-
232-
const response = await callExtract()
233-
234-
expect(response.status).toBe(400)
235-
expect(await response.json()).toEqual({ error: 'Not a valid .zip archive.' })
236-
})
237-
})

apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {
22
deleteWorkspaceFileContract,
3-
extractWorkspaceFileContract,
43
renameWorkspaceFileContract,
54
} from '@/lib/api/contracts/workspace-files'
65
import {
@@ -15,27 +14,10 @@ import {
1514
internalFilePresenters,
1615
} from '@/lib/workspace-files/api'
1716
import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file'
18-
import { extractWorkspaceFile } from '@/lib/workspace-files/application/extract-workspace-file'
1917
import { fileOperations } from '@/lib/workspace-files/application/operations'
2018
import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file'
2119

2220
export const dynamic = 'force-dynamic'
23-
export const maxDuration = 300
24-
25-
/**
26-
* POST /api/workspaces/[id]/files/[fileId]
27-
* Unzip an archive file into a new folder beside it (requires write permission)
28-
*/
29-
export const POST = defineInternalJsonRoute({
30-
contract: extractWorkspaceFileContract,
31-
auth: internalSessionAuth,
32-
operation: fileOperations.extractArchive,
33-
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file behavior' }),
34-
errorPolicy: internalFileErrorPolicies.extractArchive,
35-
mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }),
36-
useCase: extractWorkspaceFile,
37-
present: (result) => ({ success: true, ...result }),
38-
})
3921

4022
/**
4123
* PATCH /api/workspaces/[id]/files/[fileId]

apps/sim/lib/api/contracts/workspace-files.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export const renameWorkspaceFileContract = defineRouteContract({
194194

195195
export const extractWorkspaceFileContract = defineRouteContract({
196196
method: 'POST',
197-
path: '/api/workspaces/[id]/files/[fileId]',
197+
path: '/api/workspaces/[id]/files/[fileId]/extract',
198198
params: workspaceFileParamsSchema,
199199
response: {
200200
mode: 'json',

0 commit comments

Comments
 (0)