Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @vitest-environment node
*/

import { authMockFns, createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
list: vi.fn(),
create: vi.fn(),
bulk: vi.fn(),
}))

vi.mock('@/lib/knowledge/application/chunks', () => ({
listKnowledgeChunks: {
operation: { id: 'knowledge.chunks.list' },
execute: mocks.list,
},
createKnowledgeChunk: {
operation: { id: 'knowledge.chunks.create' },
execute: mocks.create,
},
bulkUpdateKnowledgeChunks: {
operation: { id: 'knowledge.chunks.bulk' },
execute: mocks.bulk,
},
}))

vi.mock('@/app/api/knowledge/secret-provenance', () => ({
finalizeKnowledgePersistedResponse: vi.fn(),
finalizeKnowledgeProvenanceResponse: vi.fn(),
resolveKnowledgeWriteSecretProvenance: vi.fn(),
}))

import { KnowledgeDocumentNotReadyError } from '@/lib/knowledge/application/chunk-errors'
import { GET } from '@/app/api/knowledge/[id]/documents/[documentId]/chunks/route'

const params = () => ({
params: Promise.resolve({ id: 'knowledge-1', documentId: 'document-1' }),
})

describe('/api/knowledge/[id]/documents/[documentId]/chunks internal route composition', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({
user: { id: 'user-1' },
session: { id: 'session-1' },
})
})

it('preserves retry metadata when a document is still processing', async () => {
mocks.list.mockRejectedValueOnce(new KnowledgeDocumentNotReadyError('processing'))

const response = await GET(createMockRequest('GET'), params())

expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
error: 'Document is not ready for access',
details: 'Document status: processing',
retryAfter: 5,
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const GET = defineInternalJsonRoute({
auth: internalKnowledgeSessionOrExecutorAuth,
operation: knowledgeOperations.listChunks,
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal chunk-list behavior' }),
errorPolicy: internalKnowledgeErrorPolicies.chunks,
errorPolicy: internalKnowledgeErrorPolicies.chunkList,
mapInput: ({ params, query }) => ({
knowledgeBaseId: params.id,
documentId: params.documentId,
Expand Down
19 changes: 19 additions & 0 deletions apps/sim/app/api/v2/files/[fileId]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,25 @@ describe('v2 single-file routes', () => {
})
})

it('encodes special characters in the extended download filename', async () => {
mocks.download.mockResolvedValueOnce({
file: fileRecord({ name: "it's (final)* café.pdf" }),
stream: new Blob(['pdf']).stream(),
contentType: 'application/pdf',
contentLength: 3,
})

const response = await GET(
new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`),
context
)

expect(response.status).toBe(200)
expect(response.headers.get('Content-Disposition')).toBe(
`attachment; filename="it's (final)* caf_.pdf"; filename*=UTF-8''it%27s%20%28final%29%2A%20caf%C3%A9.pdf`
)
})

it('conceals cross-workspace download authorization', async () => {
mocks.download.mockRejectedValue(new NoWorkspaceAccessError())

Expand Down
3 changes: 2 additions & 1 deletion apps/sim/app/api/v2/files/[fileId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/
import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file'
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file'
import { encodeFilenameForHeader } from '@/app/api/files/utils'
import { toV2File } from '@/app/api/v2/files/utils'

export const dynamic = 'force-dynamic'
Expand Down Expand Up @@ -42,7 +43,7 @@ export const GET = defineV2BinaryRoute({
present: ({ file, stream, contentType, contentLength }) => ({
body: stream,
contentType,
contentDisposition: `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`,
contentDisposition: `attachment; ${encodeFilenameForHeader(file.name)}`,
contentLength,
}),
})
Expand Down
44 changes: 13 additions & 31 deletions apps/sim/app/api/v2/knowledge/[id]/documents/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ const {
mockUploadDocument,
mockReadFormData,
mockReadFile,
mockUploadWorkspaceFile,
mockPlatformUploaded,
mockCapture,
mockIsPayloadSizeLimitError,
Expand All @@ -26,7 +25,6 @@ const {
mockUploadDocument: vi.fn(),
mockReadFormData: vi.fn(),
mockReadFile: vi.fn(),
mockUploadWorkspaceFile: vi.fn(),
mockPlatformUploaded: vi.fn(),
mockCapture: vi.fn(),
mockIsPayloadSizeLimitError: vi.fn(),
Expand Down Expand Up @@ -58,10 +56,6 @@ vi.mock('@/lib/core/utils/stream-limits', () => ({
readFileToBufferWithLimit: mockReadFile,
}))

vi.mock('@/lib/uploads/contexts/workspace', () => ({
uploadWorkspaceFile: mockUploadWorkspaceFile,
}))

vi.mock('@/lib/core/telemetry', () => ({
PlatformEvents: { knowledgeBaseDocumentsUploaded: mockPlatformUploaded },
}))
Expand Down Expand Up @@ -102,13 +96,11 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
knowledgeBaseId: 'kb-1',
knowledgeBaseName: 'Support docs',
workspaceId: WORKSPACE_ID,
storageActorUserId: 'user-1',
})
const formData = new FormData()
formData.set('file', new File(['hello'], 'support.txt', { type: 'text/plain' }))
mockReadFormData.mockResolvedValue(formData)
mockReadFile.mockResolvedValue(Buffer.from('hello'))
mockUploadWorkspaceFile.mockResolvedValue({ url: 's3://workspace/support.txt' })
mockUploadDocument.mockResolvedValue({
created: true,
document: {
Expand Down Expand Up @@ -141,21 +133,14 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
input: { knowledgeBaseId: 'kb-1', assertedWorkspaceId: WORKSPACE_ID },
request,
})
expect(mockUploadWorkspaceFile).toHaveBeenCalledWith(
WORKSPACE_ID,
'user-1',
Buffer.from('hello'),
'support.txt',
'text/plain'
)
expect(mockUploadDocument).toHaveBeenCalledWith({
principal: PRINCIPAL,
input: {
knowledgeBaseId: 'kb-1',
assertedWorkspaceId: WORKSPACE_ID,
document: {
file: {
buffer: Buffer.from('hello'),
filename: 'support.txt',
fileUrl: 's3://workspace/support.txt',
fileSize: 5,
mimeType: 'text/plain',
},
Expand Down Expand Up @@ -194,7 +179,6 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
error: { code: 'USAGE_LIMIT_EXCEEDED', message: 'Upgrade required' },
})
expect(mockReadFormData).not.toHaveBeenCalled()
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
expect(mockUploadDocument).not.toHaveBeenCalled()
})

Expand All @@ -214,7 +198,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
expect(mockCapture).not.toHaveBeenCalled()
})

it('preserves the malformed multipart envelope without transferring storage', async () => {
it('preserves the malformed multipart envelope without entering the upload operation', async () => {
mockReadFormData.mockRejectedValueOnce(new Error('multipart boundary missing'))

const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })
Expand All @@ -223,12 +207,11 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
expect(await response.json()).toEqual({
error: { code: 'BAD_REQUEST', message: 'Request body must be valid multipart form data' },
})
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
expect(mockUploadDocument).not.toHaveBeenCalled()
expect(mockPlatformUploaded).not.toHaveBeenCalled()
})

it('preserves bounded multipart rejection and stops before storage transfer', async () => {
it('preserves bounded multipart rejection and stops before the upload operation', async () => {
const error = new Error('knowledge document upload body exceeds maximum size')
mockReadFormData.mockRejectedValueOnce(error)
mockIsPayloadSizeLimitError.mockImplementation((candidate: unknown) => candidate === error)
Expand All @@ -239,11 +222,10 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
expect(await response.json()).toEqual({
error: { code: 'PAYLOAD_TOO_LARGE', message: error.message },
})
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
expect(mockUploadDocument).not.toHaveBeenCalled()
})

it('requires a file form field before storage transfer', async () => {
it('requires a file form field before the upload operation', async () => {
mockReadFormData.mockResolvedValueOnce(new FormData())

const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })
Expand All @@ -252,7 +234,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
expect(await response.json()).toEqual({
error: { code: 'BAD_REQUEST', message: 'file form field is required' },
})
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
expect(mockUploadDocument).not.toHaveBeenCalled()
})

it('preserves the exact file-size rejection before reading file bytes', async () => {
Expand All @@ -269,7 +251,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
error: { code: 'PAYLOAD_TOO_LARGE', message: 'File size exceeds 100MB limit (100.00MB)' },
})
expect(mockReadFile).not.toHaveBeenCalled()
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
expect(mockUploadDocument).not.toHaveBeenCalled()
})

it('preserves unsupported file-type validation before reading file bytes', async () => {
Expand All @@ -286,24 +268,24 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
error: { code: 'UNSUPPORTED_MEDIA_TYPE', message: expectedMessage },
})
expect(mockReadFile).not.toHaveBeenCalled()
expect(mockUploadWorkspaceFile).not.toHaveBeenCalled()
expect(mockUploadDocument).not.toHaveBeenCalled()
})

it('does not register or emit effects when storage transfer fails', async () => {
mockUploadWorkspaceFile.mockRejectedValueOnce(new Error('storage unavailable'))
it('does not emit effects when the upload operation fails', async () => {
mockUploadDocument.mockRejectedValueOnce(new Error('storage unavailable'))

const response = await POST(buildRequest(), { params: Promise.resolve({ id: 'kb-1' }) })

expect(response.status).toBe(500)
expect(await response.json()).toEqual({
error: { code: 'INTERNAL_ERROR', message: 'Internal server error' },
})
expect(mockUploadDocument).not.toHaveBeenCalled()
expect(mockUploadDocument).toHaveBeenCalledOnce()
expect(mockPlatformUploaded).not.toHaveBeenCalled()
expect(mockCapture).not.toHaveBeenCalled()
})

it('preserves application authorization errors after storage transfer', async () => {
it('preserves final application authorization errors', async () => {
mockUploadDocument.mockRejectedValueOnce(
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
)
Expand All @@ -314,7 +296,7 @@ describe('POST /api/v2/knowledge/[id]/documents', () => {
expect(await response.json()).toEqual({
error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' },
})
expect(mockUploadWorkspaceFile).toHaveBeenCalledOnce()
expect(mockUploadDocument).toHaveBeenCalledOnce()
expect(mockPlatformUploaded).not.toHaveBeenCalled()
expect(mockCapture).not.toHaveBeenCalled()
})
Expand Down
15 changes: 3 additions & 12 deletions apps/sim/app/api/v2/knowledge/[id]/documents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
import { KnowledgeDocumentUnsupportedMediaTypeError } from '@/lib/knowledge/application/upload-sessions'
import { captureServerEvent } from '@/lib/posthog/server'
import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types'
import { validateFileType } from '@/lib/uploads/utils/validation'
import { serializeDate } from '@/app/api/v1/knowledge/utils'
Expand Down Expand Up @@ -138,20 +137,12 @@ export const POST = defineV2BodyLifecycleRoute({
})
return { file: rawFile, buffer, contentType }
},
transfer: ({ admission, body }) =>
uploadWorkspaceFile(
admission.workspaceId,
admission.storageActorUserId,
body.buffer,
body.file.name,
body.contentType
),
mapInput: ({ parsed, body, transfer }) => ({
mapInput: ({ parsed, body }) => ({
knowledgeBaseId: parsed.params.id,
assertedWorkspaceId: parsed.query.workspaceId,
document: {
file: {
buffer: body.buffer,
filename: body.file.name,
fileUrl: transfer.url,
fileSize: body.file.size,
mimeType: body.contentType,
},
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/api/webhooks/outbox/process/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { billingOutboxHandlers } from '@/lib/billing/webhooks/outbox-handlers'
import { processOutboxEvents } from '@/lib/core/outbox/service'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler'
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
import { invitationMigrationOutboxHandlers } from '@/lib/workspaces/admin-move'
import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
Expand All @@ -23,6 +24,7 @@ const handlers = {
...membershipBillingOutboxHandlers,
...enterpriseIssuanceOutboxHandlers,
...invitationMigrationOutboxHandlers,
...knowledgeDocumentProcessingOutboxHandlers,
...workflowDeploymentOutboxHandlers,
} as const

Expand Down
Loading
Loading