Files
simstudioai--sim/apps/sim/app/api/files/multipart/route.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

285 lines
8.8 KiB
TypeScript

/**
* @vitest-environment node
*/
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockIsUsingCloudStorage,
mockGetStorageProvider,
mockGetStorageConfig,
mockCompleteS3MultipartUpload,
mockCompleteBlobMultipartUpload,
mockDeriveBlobBlockId,
mockVerifyUploadToken,
mockSignUploadToken,
} = vi.hoisted(() => ({
mockIsUsingCloudStorage: vi.fn(),
mockGetStorageProvider: vi.fn(),
mockGetStorageConfig: vi.fn(),
mockCompleteS3MultipartUpload: vi.fn(),
mockCompleteBlobMultipartUpload: vi.fn(),
mockDeriveBlobBlockId: vi.fn(),
mockVerifyUploadToken: vi.fn(),
mockSignUploadToken: vi.fn(),
}))
vi.mock('@/lib/uploads', () => ({
isUsingCloudStorage: mockIsUsingCloudStorage,
getStorageProvider: mockGetStorageProvider,
getStorageConfig: mockGetStorageConfig,
}))
vi.mock('@/lib/uploads/core/upload-token', () => ({
signUploadToken: mockSignUploadToken,
verifyUploadToken: mockVerifyUploadToken,
}))
vi.mock('@/lib/uploads/providers/s3/client', () => ({
completeS3MultipartUpload: mockCompleteS3MultipartUpload,
initiateS3MultipartUpload: mockInitiateS3MultipartUpload,
getS3MultipartPartUrls: vi.fn(),
abortS3MultipartUpload: vi.fn(),
}))
vi.mock('@/lib/uploads/providers/blob/client', () => ({
completeMultipartUpload: mockCompleteBlobMultipartUpload,
deriveBlobBlockId: mockDeriveBlobBlockId,
initiateMultipartUpload: vi.fn(),
getMultipartPartUrls: vi.fn(),
abortMultipartUpload: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
const { mockCheckStorageQuota, mockInitiateS3MultipartUpload } = vi.hoisted(() => ({
mockCheckStorageQuota: vi.fn(),
mockInitiateS3MultipartUpload: vi.fn(),
}))
vi.mock('@/lib/billing/storage', () => ({
checkStorageQuota: mockCheckStorageQuota,
}))
import { POST } from '@/app/api/files/multipart/route'
const tokenPayload = {
uploadId: 'upload-1',
key: 'workspace/ws-1/123-abc-file.bin',
userId: 'user-1',
workspaceId: 'ws-1',
context: 'workspace' as const,
}
const makeRequest = (action: string, body: unknown) =>
new NextRequest(`http://localhost/api/files/multipart?action=${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
describe('POST /api/files/multipart action=complete', () => {
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockIsUsingCloudStorage.mockReturnValue(true)
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
mockVerifyUploadToken.mockReturnValue({ valid: true, payload: tokenPayload })
mockSignUploadToken.mockReturnValue('signed-token')
mockCompleteS3MultipartUpload.mockResolvedValue({
location: 'loc',
path: '/api/files/serve/...',
key: tokenPayload.key,
})
mockCompleteBlobMultipartUpload.mockResolvedValue({
location: 'loc',
path: '/api/files/serve/...',
key: tokenPayload.key,
})
mockDeriveBlobBlockId.mockImplementation(
(n: number) => `block-${n.toString().padStart(6, '0')}`
)
})
it('rejects parts without partNumber', async () => {
mockGetStorageProvider.mockReturnValue('s3')
const res = await POST(
makeRequest('complete', {
uploadToken: 'tok',
parts: [{ etag: 'abc' }],
})
)
expect(res.status).toBe(400)
expect(mockCompleteS3MultipartUpload).not.toHaveBeenCalled()
})
it('S3 path requires etag and forwards { ETag, PartNumber }', async () => {
mockGetStorageProvider.mockReturnValue('s3')
const missingEtag = await POST(
makeRequest('complete', {
uploadToken: 'tok',
parts: [{ partNumber: 1 }],
})
)
expect(missingEtag.status).toBe(500)
mockCompleteS3MultipartUpload.mockClear()
const ok = await POST(
makeRequest('complete', {
uploadToken: 'tok',
parts: [
{ partNumber: 1, etag: 'aaa' },
{ partNumber: 2, etag: 'bbb' },
],
})
)
expect(ok.status).toBe(200)
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledWith(
tokenPayload.key,
tokenPayload.uploadId,
[
{ ETag: 'aaa', PartNumber: 1 },
{ ETag: 'bbb', PartNumber: 2 },
],
expect.any(Object)
)
})
it('Blob path derives blockId from partNumber and ignores etag', async () => {
mockGetStorageProvider.mockReturnValue('blob')
mockGetStorageConfig.mockReturnValue({
containerName: 'c',
accountName: 'a',
accountKey: 'k',
})
const res = await POST(
makeRequest('complete', {
uploadToken: 'tok',
parts: [{ partNumber: 1, etag: 'irrelevant' }, { partNumber: 2 }],
})
)
expect(res.status).toBe(200)
expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(1)
expect(mockDeriveBlobBlockId).toHaveBeenCalledWith(2)
expect(mockCompleteBlobMultipartUpload).toHaveBeenCalledWith(
tokenPayload.key,
[
{ partNumber: 1, blockId: 'block-000001' },
{ partNumber: 2, blockId: 'block-000002' },
],
expect.objectContaining({ containerName: 'c' })
)
})
it('returns 403 when token is invalid', async () => {
mockGetStorageProvider.mockReturnValue('s3')
mockVerifyUploadToken.mockReturnValueOnce({ valid: false })
const res = await POST(
makeRequest('complete', {
uploadToken: 'bad',
parts: [{ partNumber: 1, etag: 'a' }],
})
)
expect(res.status).toBe(403)
})
it('batch complete normalizes per upload', async () => {
mockGetStorageProvider.mockReturnValue('s3')
const res = await POST(
makeRequest('complete', {
uploads: [
{
uploadToken: 'tok-a',
parts: [{ partNumber: 1, etag: 'aaa' }],
},
{
uploadToken: 'tok-b',
parts: [{ partNumber: 1, etag: 'bbb' }],
},
],
})
)
expect(res.status).toBe(200)
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2)
})
})
describe('POST /api/files/multipart action=initiate quota enforcement', () => {
const makeInitiateRequest = (body: unknown) =>
new NextRequest('http://localhost/api/files/multipart?action=initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockIsUsingCloudStorage.mockReturnValue(true)
mockGetStorageProvider.mockReturnValue('s3')
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
mockSignUploadToken.mockReturnValue('signed-token')
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
})
it('blocks upload when fileSize: 0 exceeds quota', async () => {
mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })
const res = await makeInitiateRequest({
fileName: 'file.bin',
contentType: 'application/octet-stream',
fileSize: 0,
workspaceId: 'ws-1',
context: 'knowledge-base',
})
const response = await POST(res)
expect(response.status).toBe(413)
const body = await response.json()
expect(body.error).toContain('Storage limit exceeded')
})
it('allows quota-enforced contexts that pass the quota check', async () => {
const res = await makeInitiateRequest({
fileName: 'doc.pdf',
contentType: 'application/pdf',
fileSize: 99999,
workspaceId: 'ws-1',
context: 'knowledge-base',
})
const response = await POST(res)
expect(response.status).toBe(200)
expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 99999)
expect(mockInitiateS3MultipartUpload).toHaveBeenCalled()
})
it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])(
'rejects quota-exempt context %s — not allowed via the multipart endpoint',
async (context) => {
const res = await makeInitiateRequest({
fileName: 'asset.png',
contentType: 'image/png',
fileSize: 100 * 1024 * 1024 * 1024,
workspaceId: 'ws-1',
context,
})
const response = await POST(res)
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toMatch(/invalid storage context/i)
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
expect(mockInitiateS3MultipartUpload).not.toHaveBeenCalled()
}
)
})