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
285 lines
8.8 KiB
TypeScript
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()
|
|
}
|
|
)
|
|
})
|