Files
simstudioai--sim/apps/sim/lib/uploads/providers/s3/client.test.ts
T
wehub-resource-sync d25d482dc2
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

472 lines
14 KiB
TypeScript

/**
* Tests for S3 client functionality
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockSend,
mockS3Client,
mockS3ClientConstructor,
mockPutObjectCommand,
mockGetObjectCommand,
mockDeleteObjectCommand,
mockCompleteMultipartUploadCommand,
mockGetSignedUrl,
mockEnv,
mockS3Config,
} = vi.hoisted(() => {
const mockSend = vi.fn()
const mockS3Client = { send: mockSend }
const mockEnv: Record<string, string | undefined> = {
NEXT_PUBLIC_APP_URL: 'https://test.sim.ai',
S3_BUCKET_NAME: 'test-bucket',
AWS_REGION: 'test-region',
AWS_ACCESS_KEY_ID: 'test-access-key',
AWS_SECRET_ACCESS_KEY: 'test-secret-key',
}
const mockS3Config: {
bucket: string
region: string
endpoint: string | undefined
forcePathStyle: boolean
} = {
bucket: 'test-bucket',
region: 'test-region',
endpoint: undefined,
forcePathStyle: false,
}
return {
mockSend,
mockS3Client,
mockS3Config,
mockS3ClientConstructor: vi.fn().mockImplementation(
class {
constructor() {
// biome-ignore lint/correctness/noConstructorReturn: vitest 4 constructs mocks via Reflect.construct; returning the object overrides the instance so `new S3Client()` yields the shared mock the tests assert on
return mockS3Client
}
}
),
mockPutObjectCommand: vi.fn().mockImplementation(class {}),
mockGetObjectCommand: vi.fn().mockImplementation(class {}),
mockDeleteObjectCommand: vi.fn().mockImplementation(class {}),
mockCompleteMultipartUploadCommand: vi.fn().mockImplementation(class {}),
mockGetSignedUrl: vi.fn(),
mockEnv,
}
})
vi.mock('@aws-sdk/client-s3', () => ({
S3Client: mockS3ClientConstructor,
PutObjectCommand: mockPutObjectCommand,
GetObjectCommand: mockGetObjectCommand,
DeleteObjectCommand: mockDeleteObjectCommand,
CompleteMultipartUploadCommand: mockCompleteMultipartUploadCommand,
}))
vi.mock('@aws-sdk/s3-request-presigner', () => ({
getSignedUrl: mockGetSignedUrl,
}))
vi.mock('@/lib/core/config/env', () => ({
env: mockEnv,
getEnv: (key: string) => mockEnv[key],
isTruthy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
isFalsy: (value: string | boolean | number | undefined) =>
typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false,
}))
vi.mock('@/lib/uploads/setup', () => ({
S3_CONFIG: {
bucket: 'test-bucket',
region: 'test-region',
},
}))
vi.mock('@/lib/uploads/config', () => ({
S3_CONFIG: mockS3Config,
S3_KB_CONFIG: {
bucket: 'test-kb-bucket',
region: 'test-region',
},
}))
import {
completeS3MultipartUpload,
deleteFromS3,
downloadFromS3,
getPresignedUrl,
getS3Client,
resetS3ClientForTesting,
uploadToS3,
} from '@/lib/uploads/providers/s3/client'
describe('S3 Client', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(Date, 'now').mockReturnValue(1672603200000)
vi.spyOn(Date.prototype, 'toISOString').mockReturnValue('2025-06-16T01:13:10.765Z')
mockEnv.AWS_ACCESS_KEY_ID = 'test-access-key'
mockEnv.AWS_SECRET_ACCESS_KEY = 'test-secret-key'
mockS3Config.endpoint = undefined
mockS3Config.forcePathStyle = false
resetS3ClientForTesting()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('uploadToS3', () => {
it('should upload a file to S3 and return file info', async () => {
mockSend.mockResolvedValueOnce({})
const file = Buffer.from('test content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
const result = await uploadToS3(file, fileName, contentType)
expect(mockPutObjectCommand).toHaveBeenCalledWith({
Bucket: 'test-bucket',
Key: expect.stringContaining('test-file.txt'),
Body: file,
ContentType: 'text/plain',
Metadata: {
originalName: 'test-file.txt',
uploadedAt: expect.any(String),
},
})
expect(mockSend).toHaveBeenCalledWith(expect.any(Object))
expect(result).toEqual({
path: expect.stringContaining('/api/files/serve/'),
key: expect.stringContaining('test-file.txt'),
name: 'test-file.txt',
size: file.length,
type: 'text/plain',
})
})
it('should handle spaces in filenames', async () => {
mockSend.mockResolvedValueOnce({})
const testFile = Buffer.from('test file content')
const fileName = 'test file with spaces.txt'
const contentType = 'text/plain'
const result = await uploadToS3(testFile, fileName, contentType)
expect(mockPutObjectCommand).toHaveBeenCalledWith(
expect.objectContaining({
Key: expect.stringContaining('test-file-with-spaces.txt'),
})
)
expect(result.name).toBe(fileName)
})
it('should use provided size if available', async () => {
mockSend.mockResolvedValueOnce({})
const testFile = Buffer.from('test file content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
const providedSize = 1000
const result = await uploadToS3(testFile, fileName, contentType, providedSize)
expect(result.size).toBe(providedSize)
})
it('should handle upload errors', async () => {
const error = new Error('Upload failed')
mockSend.mockRejectedValueOnce(error)
const testFile = Buffer.from('test file content')
const fileName = 'test-file.txt'
const contentType = 'text/plain'
await expect(uploadToS3(testFile, fileName, contentType)).rejects.toThrow('Upload failed')
})
})
describe('getPresignedUrl', () => {
it('should generate a presigned URL for a file', async () => {
mockGetSignedUrl.mockResolvedValueOnce('https://example.com/presigned-url')
const key = 'test-file.txt'
const expiresIn = 1800
const url = await getPresignedUrl(key, expiresIn)
expect(mockGetObjectCommand).toHaveBeenCalledWith({
Bucket: 'test-bucket',
Key: key,
})
expect(mockGetSignedUrl).toHaveBeenCalledWith(mockS3Client, expect.any(Object), { expiresIn })
expect(url).toBe('https://example.com/presigned-url')
})
it('should use default expiration if not provided', async () => {
mockGetSignedUrl.mockResolvedValueOnce('https://example.com/presigned-url')
const key = 'test-file.txt'
await getPresignedUrl(key)
expect(mockGetSignedUrl).toHaveBeenCalledWith(mockS3Client, expect.any(Object), {
expiresIn: 3600,
})
})
it('should handle errors when generating presigned URL', async () => {
const error = new Error('Presigned URL generation failed')
mockGetSignedUrl.mockRejectedValueOnce(error)
const key = 'test-file.txt'
await expect(getPresignedUrl(key)).rejects.toThrow('Presigned URL generation failed')
})
})
describe('downloadFromS3', () => {
it('should download a file from S3', async () => {
const mockStream = {
on: vi.fn((event, callback) => {
if (event === 'data') {
callback(Buffer.from('chunk1'))
callback(Buffer.from('chunk2'))
}
if (event === 'end') {
callback()
}
return mockStream
}),
off: vi.fn(() => mockStream),
}
mockSend.mockResolvedValueOnce({
Body: mockStream,
$metadata: { httpStatusCode: 200 },
})
const key = 'test-file.txt'
const result = await downloadFromS3(key)
expect(mockGetObjectCommand).toHaveBeenCalledWith({
Bucket: 'test-bucket',
Key: key,
})
expect(mockSend).toHaveBeenCalledTimes(1)
expect(result).toBeInstanceOf(Buffer)
expect(result.toString()).toBe('chunk1chunk2')
})
it('should handle stream errors', async () => {
const mockStream = {
on: vi.fn((event, callback) => {
if (event === 'error') {
callback(new Error('Stream error'))
}
return mockStream
}),
off: vi.fn(() => mockStream),
}
mockSend.mockResolvedValueOnce({
Body: mockStream,
$metadata: { httpStatusCode: 200 },
})
const key = 'test-file.txt'
await expect(downloadFromS3(key)).rejects.toThrow('Stream error')
})
it('should destroy the opened stream when content length exceeds the limit', async () => {
const mockDestroy = vi.fn()
const mockStream = {
destroy: mockDestroy,
on: vi.fn(() => mockStream),
}
mockSend.mockResolvedValueOnce({
Body: mockStream,
ContentLength: 1024,
$metadata: { httpStatusCode: 200 },
})
await expect(
downloadFromS3('large-file.txt', { bucket: 'test-bucket', region: 'test-region' }, 10)
).rejects.toThrow('storage download exceeds maximum size')
expect(mockDestroy).toHaveBeenCalledWith(expect.any(Error))
})
it('should handle S3 client errors', async () => {
const error = new Error('Download failed')
mockSend.mockRejectedValueOnce(error)
const key = 'test-file.txt'
await expect(downloadFromS3(key)).rejects.toThrow('Download failed')
})
})
describe('deleteFromS3', () => {
it('should delete a file from S3', async () => {
mockSend.mockResolvedValueOnce({})
const key = 'test-file.txt'
await deleteFromS3(key)
expect(mockDeleteObjectCommand).toHaveBeenCalledWith({
Bucket: 'test-bucket',
Key: key,
})
expect(mockSend).toHaveBeenCalledTimes(1)
})
it('should handle delete errors', async () => {
const error = new Error('Delete failed')
mockSend.mockRejectedValueOnce(error)
const key = 'test-file.txt'
await expect(deleteFromS3(key)).rejects.toThrow('Delete failed')
})
})
describe('s3Client initialization', () => {
it('should initialize with correct configuration when credentials are available', () => {
mockEnv.AWS_ACCESS_KEY_ID = 'test-access-key'
mockEnv.AWS_SECRET_ACCESS_KEY = 'test-secret-key'
resetS3ClientForTesting()
const client = getS3Client()
expect(client).toBeDefined()
expect(mockS3ClientConstructor).toHaveBeenCalledWith({
region: 'test-region',
endpoint: undefined,
forcePathStyle: false,
credentials: {
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
},
})
})
it('should initialize without credentials when env vars are not available', () => {
mockEnv.AWS_ACCESS_KEY_ID = undefined
mockEnv.AWS_SECRET_ACCESS_KEY = undefined
resetS3ClientForTesting()
const client = getS3Client()
expect(client).toBeDefined()
expect(mockS3ClientConstructor).toHaveBeenCalledWith({
region: 'test-region',
endpoint: undefined,
forcePathStyle: false,
credentials: undefined,
})
})
it('should pass a custom endpoint and path-style flag for S3-compatible providers', () => {
mockS3Config.endpoint = 'https://account.r2.cloudflarestorage.com'
mockS3Config.forcePathStyle = true
resetS3ClientForTesting()
const client = getS3Client()
expect(client).toBeDefined()
expect(mockS3ClientConstructor).toHaveBeenCalledWith({
region: 'test-region',
endpoint: 'https://account.r2.cloudflarestorage.com',
forcePathStyle: true,
credentials: {
accessKeyId: 'test-access-key',
secretAccessKey: 'test-secret-key',
},
})
})
})
describe('completeS3MultipartUpload fallback location', () => {
const parts = [{ ETag: 'etag-1', PartNumber: 1 }]
it('uses the SDK-provided Location when present', async () => {
mockSend.mockResolvedValueOnce({ Location: 'https://provided.example.com/object' })
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe('https://provided.example.com/object')
expect(result.key).toBe('kb/uuid-file.txt')
expect(result.path).toBe('/api/files/serve/kb%2Fuuid-file.txt')
})
it('falls back to an AWS virtual-hosted URL when Location is absent', async () => {
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe(
'https://test-kb-bucket.s3.test-region.amazonaws.com/kb/uuid-file.txt'
)
})
it('builds a path-style fallback URL for a custom endpoint with forcePathStyle', async () => {
mockS3Config.endpoint = 'https://minio.example.com'
mockS3Config.forcePathStyle = true
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe('https://minio.example.com/test-kb-bucket/kb/uuid-file.txt')
})
it('builds a virtual-hosted fallback URL for a custom endpoint without forcePathStyle', async () => {
mockS3Config.endpoint = 'https://account.r2.cloudflarestorage.com'
mockS3Config.forcePathStyle = false
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe(
'https://test-kb-bucket.account.r2.cloudflarestorage.com/kb/uuid-file.txt'
)
})
it('strips a trailing slash from the custom endpoint before appending the key', async () => {
mockS3Config.endpoint = 'https://minio.example.com/'
mockS3Config.forcePathStyle = true
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-file.txt', 'upload-1', parts)
expect(result.location).toBe('https://minio.example.com/test-kb-bucket/kb/uuid-file.txt')
})
it('percent-encodes special characters per path segment, preserving slashes', async () => {
mockSend.mockResolvedValueOnce({})
const result = await completeS3MultipartUpload('kb/uuid-my file.txt', 'upload-1', parts)
expect(result.location).toBe(
'https://test-kb-bucket.s3.test-region.amazonaws.com/kb/uuid-my%20file.txt'
)
})
})
})