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
472 lines
14 KiB
TypeScript
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'
|
|
)
|
|
})
|
|
})
|
|
})
|