Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,281 changes: 1,172 additions & 109 deletions apps/docs/openapi-v2-files-audit.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions apps/sim/app/api/v1/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ export type ApiEndpoint =
| 'table-columns'
| 'files'
| 'file-detail'
| 'file-share'
| 'file-content'
| 'file-move'
| 'file-bulk-archive'
| 'knowledge'
| 'knowledge-detail'
| 'knowledge-search'
Expand Down
186 changes: 186 additions & 0 deletions apps/sim/app/api/v2/files/[fileId]/content/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformUpdateContent } = vi.hoisted(
() => ({
mockCheckRateLimit: vi.fn(),
mockResolveWorkspaceAccess: vi.fn(),
mockPerformUpdateContent: vi.fn(),
})
)

vi.mock('@/app/api/v1/middleware', () => ({
checkRateLimit: mockCheckRateLimit,
resolveWorkspaceAccess: mockResolveWorkspaceAccess,
}))

vi.mock('@/app/api/v2/lib/gate', () => ({
v2ApiGateError: vi.fn().mockResolvedValue(null),
}))

vi.mock('@/lib/workspace-files/orchestration', () => ({
performUpdateWorkspaceFileContent: mockPerformUpdateContent,
}))

import { PUT } from '@/app/api/v2/files/[fileId]/content/route'

const WS = 'workspace-1'
const FILE_ID = 'wf_1'

const RATE_LIMIT_OK = {
allowed: true,
userId: 'user-1',
keyType: 'workspace',
limit: 100,
remaining: 99,
resetAt: new Date('2024-01-01T01:00:00Z'),
}

const RATE_LIMIT_DENIED = {
allowed: false,
limit: 100,
remaining: 0,
resetAt: new Date('2024-01-01T01:00:00Z'),
retryAfterMs: 1000,
}

const RECORD = {
id: FILE_ID,
workspaceId: WS,
name: 'data.csv',
key: 'workspace/ws/1-x-data.csv',
path: '/api/files/serve/x',
size: 8,
type: 'text/csv',
uploadedBy: 'user-1',
folderId: null,
folderPath: null,
uploadedAt: new Date('2024-01-01T00:00:00Z'),
updatedAt: new Date('2024-01-03T00:00:00Z'),
}

const callPut = (body: unknown) =>
PUT(
new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
{ params: Promise.resolve({ fileId: FILE_ID }) }
)

describe('PUT /api/v2/files/[fileId]/content', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
mockResolveWorkspaceAccess.mockResolvedValue(null)
mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD })
})

it('returns 404 when the v2 API surface flag is off', async () => {
const { v2ApiGateError } = await import('@/app/api/v2/lib/gate')
const { v2Error } = await import('@/app/api/v2/lib/response')
vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found'))

const res = await callPut({ workspaceId: WS, content: 'id,name\n' })

expect(res.status).toBe(404)
expect(mockPerformUpdateContent).not.toHaveBeenCalled()
})

it('400s when content is missing', async () => {
const res = await callPut({ workspaceId: WS })
expect(res.status).toBe(400)
expect((await res.json()).error.code).toBe('BAD_REQUEST')
expect(mockPerformUpdateContent).not.toHaveBeenCalled()
})

it('400s on an encoding outside the enum', async () => {
const res = await callPut({ workspaceId: WS, content: 'x', encoding: 'latin1' })
expect(res.status).toBe(400)
expect(mockPerformUpdateContent).not.toHaveBeenCalled()
})

it('surfaces an access-denied failure in the v2 error envelope', async () => {
mockResolveWorkspaceAccess.mockResolvedValue({
status: 403,
code: 'FORBIDDEN',
message: 'Access denied',
})
const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
expect(res.status).toBe(403)
expect(mockPerformUpdateContent).not.toHaveBeenCalled()
})

it('returns the rate-limit response when denied', async () => {
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED)
const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
expect(res.status).toBe(429)
expect((await res.json()).error.code).toBe('RATE_LIMITED')
})

it('replaces the content and returns the updated file', async () => {
const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
const body = await res.json()

expect(res.status).toBe(200)
expect(body.data).toEqual({
id: FILE_ID,
name: 'data.csv',
size: 8,
type: 'text/csv',
key: 'workspace/ws/1-x-data.csv',
folderId: null,
folderPath: null,
uploadedBy: 'user-1',
uploadedAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-03T00:00:00.000Z',
})
expect(mockPerformUpdateContent).toHaveBeenCalledWith({
workspaceId: WS,
fileId: FILE_ID,
userId: 'user-1',
content: 'id,name\n',
encoding: 'utf-8',
request: expect.anything(),
})
})

it('forwards base64 encoding through to the orchestration', async () => {
await callPut({ workspaceId: WS, content: 'aWQsbmFtZQo=', encoding: 'base64' })
expect(mockPerformUpdateContent).toHaveBeenCalledWith(
expect.objectContaining({ encoding: 'base64' })
)
})

it('maps a payload_too_large errorCode to 413 rather than string-sniffing', async () => {
mockPerformUpdateContent.mockResolvedValue({
success: false,
error: 'Storage limit exceeded. Used: 5.10GB, Limit: 5GB',
errorCode: 'payload_too_large',
})

const res = await callPut({ workspaceId: WS, content: 'id,name\n' })
const body = await res.json()

expect(res.status).toBe(413)
expect(body.error.code).toBe('PAYLOAD_TOO_LARGE')
expect(body.error.message).toContain('Storage limit exceeded')
})

it('maps a not_found errorCode to 404', async () => {
mockPerformUpdateContent.mockResolvedValue({
success: false,
error: 'File not found',
errorCode: 'not_found',
})

const res = await callPut({ workspaceId: WS, content: 'id,name\n' })

expect(res.status).toBe(404)
expect((await res.json()).error.code).toBe('NOT_FOUND')
})
})
80 changes: 80 additions & 0 deletions apps/sim/app/api/v2/files/[fileId]/content/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files'
import { parseRequest } from '@/lib/api/server'
import { messageForOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { performUpdateWorkspaceFileContent } from '@/lib/workspace-files/orchestration'
import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware'
import { toV2File } from '@/app/api/v2/files/utils'
import { v2ApiGateError } from '@/app/api/v2/lib/gate'
import {
v2Data,
v2Error,
v2ErrorForOrchestration,
v2RateLimitError,
v2ValidationError,
v2WorkspaceAccessError,
} from '@/app/api/v2/lib/response'

const logger = createLogger('V2FileContentAPI')

export const dynamic = 'force-dynamic'
export const revalidate = 0

interface FileRouteParams {
params: Promise<{ fileId: string }>
}

/**
* PUT /api/v2/files/[fileId]/content — Replace a file's bytes.
*
* A full replace, not an append: `content` becomes the entire body of the file.
* `encoding: 'base64'` carries non-UTF-8 bytes. The decoded body is capped at
* 50 MB and still debits the workspace storage quota, so a write that would push
* the payer past its limit fails with 413.
*/
export const PUT = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => {
try {
const rateLimit = await checkRateLimit(request, 'file-content')
if (!rateLimit.allowed) return v2RateLimitError(rateLimit)

const userId = rateLimit.userId!

const gate = await v2ApiGateError(userId)
if (gate) return gate

const parsed = await parseRequest(v2UpdateFileContentContract, request, context, {
validationErrorResponse: v2ValidationError,
})
if (!parsed.success) return parsed.response

const { fileId } = parsed.data.params
const { workspaceId, content, encoding } = parsed.data.body

const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
if (access) return v2WorkspaceAccessError(access)

const result = await performUpdateWorkspaceFileContent({
workspaceId,
fileId,
userId,
content,
encoding,
request,
})

if (!result.success || !result.file) {
return v2ErrorForOrchestration(
result.errorCode,
messageForOrchestrationError(result, 'Failed to update file content')
)
}

return v2Data(toV2File(result.file), { rateLimit })
} catch (error) {
logger.error('Error updating file content', { error: getErrorMessage(error, 'Unknown error') })
return v2Error('INTERNAL_ERROR', 'Internal server error')
}
})
Loading
Loading