Skip to content

Commit 09c27a4

Browse files
fix(uploads): prevent multipart cleanup races
1 parent b528ad0 commit 09c27a4

9 files changed

Lines changed: 175 additions & 150 deletions

File tree

apps/sim/app/api/knowledge/[id]/documents/uploads/route.test.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,25 @@ import { NextRequest, NextResponse } from 'next/server'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
8-
mockCreateUploadSession,
8+
mockCreateKnowledgeDocumentUploadSession,
99
mockRequireKnowledgeDocumentUploadAccess,
1010
mockRequireKnowledgeDocumentUploadActor,
1111
mockRequireKnowledgeDocumentUploadBilling,
1212
} = vi.hoisted(() => ({
13-
mockCreateUploadSession: vi.fn(),
13+
mockCreateKnowledgeDocumentUploadSession: vi.fn(),
1414
mockRequireKnowledgeDocumentUploadAccess: vi.fn(),
1515
mockRequireKnowledgeDocumentUploadActor: vi.fn(),
1616
mockRequireKnowledgeDocumentUploadBilling: vi.fn(),
1717
}))
1818

19-
vi.mock('@/lib/uploads/multipart-session/service', () => ({
20-
createUploadSession: mockCreateUploadSession,
21-
}))
2219
vi.mock('@/app/api/knowledge/[id]/documents/uploads/utils', () => ({
2320
requireKnowledgeDocumentUploadAccess: mockRequireKnowledgeDocumentUploadAccess,
2421
requireKnowledgeDocumentUploadActor: mockRequireKnowledgeDocumentUploadActor,
2522
requireKnowledgeDocumentUploadBilling: mockRequireKnowledgeDocumentUploadBilling,
2623
}))
2724
vi.mock('@/app/api/files/uploads/utils', () => ({ uploadSessionErrorResponse: vi.fn() }))
2825
vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({
26+
createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession,
2927
toV2KnowledgeDocumentUpload: (session: Record<string, unknown>) => ({
3028
...session,
3129
name: session.fileName,
@@ -66,7 +64,7 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => {
6664
knowledgeBase: { id: 'kb-1', name: 'Docs', workspaceId: WORKSPACE_ID },
6765
})
6866
mockRequireKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' })
69-
mockCreateUploadSession.mockResolvedValue({
67+
mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({
7068
id: 'upload-1',
7169
knowledgeBaseId: 'kb-1',
7270
status: 'uploading',
@@ -89,11 +87,10 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => {
8987
workspaceId: WORKSPACE_ID,
9088
userId: 'user-1',
9189
})
92-
expect(mockCreateUploadSession).toHaveBeenCalledWith({
90+
expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({
9391
workspaceId: WORKSPACE_ID,
9492
userId: 'user-1',
9593
knowledgeBaseId: 'kb-1',
96-
purpose: 'knowledge_document',
9794
fileName: 'guide.pdf',
9895
contentType: 'application/pdf',
9996
fileSize: 1024,
@@ -103,7 +100,7 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => {
103100
},
104101
})
105102
expect(mockRequireKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan(
106-
mockCreateUploadSession.mock.invocationCallOrder[0]
103+
mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0]
107104
)
108105
})
109106

@@ -116,6 +113,6 @@ describe('POST /api/knowledge/[id]/documents/uploads', () => {
116113

117114
expect(response.status).toBe(403)
118115
expect(mockRequireKnowledgeDocumentUploadBilling).not.toHaveBeenCalled()
119-
expect(mockCreateUploadSession).not.toHaveBeenCalled()
116+
expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled()
120117
})
121118
})

apps/sim/app/api/knowledge/[id]/documents/uploads/route.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,17 @@ import { type NextRequest, NextResponse } from 'next/server'
22
import { createKnowledgeDocumentUploadContract } from '@/lib/api/contracts/knowledge/upload-sessions'
33
import { parseRequest } from '@/lib/api/server'
44
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
5-
import { createUploadSession } from '@/lib/uploads/multipart-session/service'
65
import { validateFileType } from '@/lib/uploads/utils/validation'
76
import { uploadSessionErrorResponse } from '@/app/api/files/uploads/utils'
87
import {
98
requireKnowledgeDocumentUploadAccess,
109
requireKnowledgeDocumentUploadActor,
1110
requireKnowledgeDocumentUploadBilling,
1211
} from '@/app/api/knowledge/[id]/documents/uploads/utils'
13-
import { toV2KnowledgeDocumentUpload } from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
12+
import {
13+
createKnowledgeDocumentUploadSession,
14+
toV2KnowledgeDocumentUpload,
15+
} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
1416

1517
interface KnowledgeDocumentUploadsRouteParams {
1618
params: Promise<{ id: string }>
@@ -40,11 +42,10 @@ export const POST = withRouteHandler(
4042
return NextResponse.json({ error: fileTypeError.message }, { status: 415 })
4143
}
4244
try {
43-
const upload = await createUploadSession({
45+
const upload = await createKnowledgeDocumentUploadSession({
4446
workspaceId,
4547
userId: actor.id,
4648
knowledgeBaseId,
47-
purpose: 'knowledge_document',
4849
fileName: name,
4950
contentType,
5051
fileSize: size,

apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.test.ts

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

77
const {
88
mockCheckRateLimit,
9-
mockCreateUploadSession,
9+
mockCreateKnowledgeDocumentUploadSession,
1010
mockResolveKnowledgeDocumentUploadAccess,
1111
mockResolveKnowledgeDocumentUploadBilling,
1212
} = vi.hoisted(() => ({
1313
mockCheckRateLimit: vi.fn(),
14-
mockCreateUploadSession: vi.fn(),
14+
mockCreateKnowledgeDocumentUploadSession: vi.fn(),
1515
mockResolveKnowledgeDocumentUploadAccess: vi.fn(),
1616
mockResolveKnowledgeDocumentUploadBilling: vi.fn(),
1717
}))
@@ -20,10 +20,8 @@ vi.mock('@/app/api/v1/middleware', () => ({ checkRateLimit: mockCheckRateLimit }
2020
vi.mock('@/app/api/v2/lib/gate', () => ({
2121
v2ApiGateError: vi.fn().mockResolvedValue(null),
2222
}))
23-
vi.mock('@/lib/uploads/multipart-session/service', () => ({
24-
createUploadSession: mockCreateUploadSession,
25-
}))
2623
vi.mock('@/app/api/v2/knowledge/[id]/documents/uploads/utils', () => ({
24+
createKnowledgeDocumentUploadSession: mockCreateKnowledgeDocumentUploadSession,
2725
resolveKnowledgeDocumentUploadAccess: mockResolveKnowledgeDocumentUploadAccess,
2826
resolveKnowledgeDocumentUploadBilling: mockResolveKnowledgeDocumentUploadBilling,
2927
toV2KnowledgeDocumentUpload: (session: Record<string, unknown>) => ({
@@ -74,7 +72,7 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => {
7472
kb: { id: 'kb-1', name: 'Docs' },
7573
})
7674
mockResolveKnowledgeDocumentUploadBilling.mockResolvedValue({ actorUserId: 'user-1' })
77-
mockCreateUploadSession.mockResolvedValue({
75+
mockCreateKnowledgeDocumentUploadSession.mockResolvedValue({
7876
id: 'upload-1',
7977
knowledgeBaseId: 'kb-1',
8078
status: 'uploading',
@@ -100,11 +98,10 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => {
10098
})
10199
)
102100
expect(mockResolveKnowledgeDocumentUploadBilling).toHaveBeenCalled()
103-
expect(mockCreateUploadSession).toHaveBeenCalledWith({
101+
expect(mockCreateKnowledgeDocumentUploadSession).toHaveBeenCalledWith({
104102
workspaceId: WORKSPACE_ID,
105103
userId: 'user-1',
106104
knowledgeBaseId: 'kb-1',
107-
purpose: 'knowledge_document',
108105
fileName: 'guide.pdf',
109106
contentType: 'application/pdf',
110107
fileSize: 1024,
@@ -114,7 +111,7 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => {
114111
},
115112
})
116113
expect(mockResolveKnowledgeDocumentUploadBilling.mock.invocationCallOrder[0]).toBeLessThan(
117-
mockCreateUploadSession.mock.invocationCallOrder[0]
114+
mockCreateKnowledgeDocumentUploadSession.mock.invocationCallOrder[0]
118115
)
119116
})
120117

@@ -127,6 +124,6 @@ describe('POST /api/v2/knowledge/[id]/documents/uploads', () => {
127124

128125
expect(response.status).toBe(403)
129126
expect(mockResolveKnowledgeDocumentUploadBilling).not.toHaveBeenCalled()
130-
expect(mockCreateUploadSession).not.toHaveBeenCalled()
127+
expect(mockCreateKnowledgeDocumentUploadSession).not.toHaveBeenCalled()
131128
})
132129
})

apps/sim/app/api/v2/knowledge/[id]/documents/uploads/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ import { NextResponse } from 'next/server'
55
import { v2CreateKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge'
66
import { parseRequest } from '@/lib/api/server'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { createUploadSession } from '@/lib/uploads/multipart-session/service'
98
import { validateFileType } from '@/lib/uploads/utils/validation'
109
import { checkRateLimit } from '@/app/api/v1/middleware'
1110
import {
11+
createKnowledgeDocumentUploadSession,
1212
resolveKnowledgeDocumentUploadAccess,
1313
resolveKnowledgeDocumentUploadBilling,
1414
toV2KnowledgeDocumentUpload,
@@ -64,11 +64,10 @@ export const POST = withRouteHandler(
6464
return v2Error('UNSUPPORTED_MEDIA_TYPE', fileTypeError.message)
6565
}
6666

67-
const session = await createUploadSession({
67+
const session = await createKnowledgeDocumentUploadSession({
6868
workspaceId,
6969
userId,
7070
knowledgeBaseId,
71-
purpose: 'knowledge_document',
7271
fileName: name,
7372
contentType,
7473
fileSize: size,

apps/sim/app/api/v2/knowledge/[id]/documents/uploads/utils.test.ts

Lines changed: 69 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,17 @@
33
*/
44
import { NextRequest } from 'next/server'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import type { UploadSessionRecord } from '@/lib/uploads/multipart-session/service'
67

78
const {
89
mockAbortUploadSession,
9-
mockDeleteFile,
10-
mockDeleteFileMetadata,
10+
mockCreateUploadSession,
1111
mockFindBoundKnowledgeDocument,
1212
mockPerformUploadKnowledgeDocument,
1313
mockRecordKnowledgeBaseFileOwnership,
1414
} = vi.hoisted(() => ({
1515
mockAbortUploadSession: vi.fn(),
16-
mockDeleteFile: vi.fn(),
17-
mockDeleteFileMetadata: vi.fn(),
16+
mockCreateUploadSession: vi.fn(),
1817
mockFindBoundKnowledgeDocument: vi.fn(),
1918
mockPerformUploadKnowledgeDocument: vi.fn(),
2019
mockRecordKnowledgeBaseFileOwnership: vi.fn(),
@@ -28,21 +27,21 @@ vi.mock('@/lib/knowledge/orchestration/documents', () => ({
2827
}))
2928
vi.mock('@/lib/uploads/multipart-session/service', () => ({
3029
abortUploadSession: mockAbortUploadSession,
30+
createUploadSession: mockCreateUploadSession,
3131
getOwnedUploadSession: vi.fn(),
3232
}))
33-
vi.mock('@/lib/uploads/core/storage-service', () => ({ deleteFile: mockDeleteFile }))
3433
vi.mock('@/lib/uploads/server/metadata', () => ({
35-
deleteFileMetadata: mockDeleteFileMetadata,
3634
recordKnowledgeBaseFileOwnership: mockRecordKnowledgeBaseFileOwnership,
3735
}))
3836

3937
import {
4038
abortKnowledgeDocumentUpload,
39+
createKnowledgeDocumentUploadSession,
4140
finalizeKnowledgeDocumentUpload,
4241
} from '@/app/api/v2/knowledge/[id]/documents/uploads/utils'
4342

4443
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
45-
const CLAIMED = {
44+
const CLAIMED: UploadSessionRecord = {
4645
id: 'upload-1',
4746
workspaceId: WORKSPACE_ID,
4847
userId: 'user-1',
@@ -66,8 +65,7 @@ const CLAIMED = {
6665
error: null,
6766
completedAt: null,
6867
updatedAt: new Date('2026-08-03T21:00:00.000Z'),
69-
// biome-ignore lint/suspicious/noExplicitAny: partial session shape for the test
70-
} as any
68+
}
7169
const DOCUMENT = { id: 'upload-1', knowledgeBaseId: 'kb-1', filename: 'guide.pdf' }
7270

7371
function finalize(resolveAttribution = vi.fn().mockResolvedValue({ actorUserId: 'payer-1' })) {
@@ -84,6 +82,60 @@ function finalize(resolveAttribution = vi.fn().mockResolvedValue({ actorUserId:
8482
})
8583
}
8684

85+
function createSession() {
86+
return createKnowledgeDocumentUploadSession({
87+
workspaceId: WORKSPACE_ID,
88+
userId: 'user-1',
89+
knowledgeBaseId: 'kb-1',
90+
fileName: 'guide.pdf',
91+
contentType: 'application/pdf',
92+
fileSize: 1024,
93+
metadata: { tag1: 'product' },
94+
})
95+
}
96+
97+
describe('createKnowledgeDocumentUploadSession', () => {
98+
beforeEach(() => {
99+
vi.clearAllMocks()
100+
mockCreateUploadSession.mockResolvedValue(CLAIMED)
101+
mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined)
102+
mockAbortUploadSession.mockResolvedValue({ ...CLAIMED, status: 'aborted' })
103+
})
104+
105+
it('records the ownership binding before returning the upload token', async () => {
106+
await expect(createSession()).resolves.toBe(CLAIMED)
107+
108+
expect(mockCreateUploadSession).toHaveBeenCalledWith({
109+
workspaceId: WORKSPACE_ID,
110+
userId: 'user-1',
111+
knowledgeBaseId: 'kb-1',
112+
purpose: 'knowledge_document',
113+
fileName: 'guide.pdf',
114+
contentType: 'application/pdf',
115+
fileSize: 1024,
116+
metadata: { tag1: 'product' },
117+
})
118+
expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({
119+
key: 'kb/guide.pdf',
120+
userId: 'user-1',
121+
workspaceId: WORKSPACE_ID,
122+
originalName: 'guide.pdf',
123+
contentType: 'application/pdf',
124+
size: 1024,
125+
})
126+
expect(mockCreateUploadSession.mock.invocationCallOrder[0]).toBeLessThan(
127+
mockRecordKnowledgeBaseFileOwnership.mock.invocationCallOrder[0]
128+
)
129+
})
130+
131+
it('aborts provider state when the ownership binding cannot be recorded', async () => {
132+
mockRecordKnowledgeBaseFileOwnership.mockRejectedValue(new Error('database unavailable'))
133+
134+
await expect(createSession()).rejects.toThrow('database unavailable')
135+
expect(mockAbortUploadSession).toHaveBeenCalledWith(CLAIMED)
136+
})
137+
})
138+
87139
describe('abortKnowledgeDocumentUpload', () => {
88140
beforeEach(() => {
89141
vi.clearAllMocks()
@@ -113,9 +165,6 @@ describe('finalizeKnowledgeDocumentUpload', () => {
113165
beforeEach(() => {
114166
vi.clearAllMocks()
115167
mockFindBoundKnowledgeDocument.mockResolvedValue({ status: 'absent' })
116-
mockRecordKnowledgeBaseFileOwnership.mockResolvedValue(undefined)
117-
mockDeleteFile.mockResolvedValue(undefined)
118-
mockDeleteFileMetadata.mockResolvedValue(true)
119168
mockPerformUploadKnowledgeDocument.mockResolvedValue({
120169
success: true,
121170
document: DOCUMENT,
@@ -127,14 +176,6 @@ describe('finalizeKnowledgeDocumentUpload', () => {
127176
const result = await finalize()
128177

129178
expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' })
130-
expect(mockRecordKnowledgeBaseFileOwnership).toHaveBeenCalledWith({
131-
key: 'kb/guide.pdf',
132-
userId: 'user-1',
133-
workspaceId: WORKSPACE_ID,
134-
originalName: 'guide.pdf',
135-
contentType: 'application/pdf',
136-
size: 1024,
137-
})
138179
expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledWith(
139180
expect.objectContaining({
140181
documentId: 'upload-1',
@@ -144,7 +185,6 @@ describe('finalizeKnowledgeDocumentUpload', () => {
144185
document: expect.objectContaining({ filename: 'guide.pdf', tag1: 'product' }),
145186
})
146187
)
147-
expect(mockDeleteFile).not.toHaveBeenCalled()
148188
})
149189

150190
it('answers a retry from the bound document without resolving a payer', async () => {
@@ -155,32 +195,32 @@ describe('finalizeKnowledgeDocumentUpload', () => {
155195

156196
expect(result).toEqual({ value: DOCUMENT, completedFileId: 'upload-1' })
157197
expect(resolveAttribution).not.toHaveBeenCalled()
158-
expect(mockRecordKnowledgeBaseFileOwnership).not.toHaveBeenCalled()
159198
expect(mockPerformUploadKnowledgeDocument).not.toHaveBeenCalled()
160-
expect(mockDeleteFile).not.toHaveBeenCalled()
161199
})
162200

163-
it('deletes the uploaded object when creation fails and nothing is bound', async () => {
201+
it('retains completed bytes for retry when document creation fails', async () => {
164202
mockPerformUploadKnowledgeDocument.mockResolvedValue({
165203
success: false,
166204
errorCode: 'payload_too_large',
167205
error: 'Storage limit exceeded',
168206
})
169207

170208
await expect(finalize()).rejects.toThrow('Storage limit exceeded')
171-
expect(mockDeleteFile).toHaveBeenCalledWith({ key: 'kb/guide.pdf', context: 'knowledge-base' })
172-
expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/guide.pdf')
209+
expect(mockFindBoundKnowledgeDocument).toHaveBeenCalledTimes(1)
173210
})
174211

175-
it('keeps the uploaded object when a document is bound despite the failure', async () => {
212+
it('lets a retry converge when the first response fails after the document binds', async () => {
176213
mockFindBoundKnowledgeDocument
177214
.mockResolvedValueOnce({ status: 'absent' })
178215
.mockResolvedValueOnce({ status: 'bound', document: DOCUMENT })
179216
mockPerformUploadKnowledgeDocument.mockRejectedValue(new Error('audit sink exploded'))
180217

181218
await expect(finalize()).rejects.toThrow('audit sink exploded')
182-
expect(mockDeleteFile).not.toHaveBeenCalled()
183-
expect(mockDeleteFileMetadata).not.toHaveBeenCalled()
219+
await expect(finalize()).resolves.toEqual({
220+
value: DOCUMENT,
221+
completedFileId: 'upload-1',
222+
})
223+
expect(mockPerformUploadKnowledgeDocument).toHaveBeenCalledTimes(1)
184224
})
185225

186226
it('rejects an upload id already bound to a different document without deleting anything', async () => {
@@ -191,6 +231,5 @@ describe('finalizeKnowledgeDocumentUpload', () => {
191231
'Upload id is already bound to a different document'
192232
)
193233
expect(resolveAttribution).not.toHaveBeenCalled()
194-
expect(mockDeleteFile).not.toHaveBeenCalled()
195234
})
196235
})

0 commit comments

Comments
 (0)