chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,509 @@
|
||||
import type { Readable } from 'node:stream'
|
||||
import {
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
UploadPartCommand,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { getAwsCredentialsFromEnv } from '@/lib/core/config/aws'
|
||||
import {
|
||||
assertKnownSizeWithinLimit,
|
||||
readNodeStreamToBufferWithLimit,
|
||||
} from '@/lib/core/utils/stream-limits'
|
||||
import { S3_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/config'
|
||||
import type {
|
||||
S3Config,
|
||||
S3MultipartPart,
|
||||
S3MultipartUploadInit,
|
||||
S3PartUploadUrl,
|
||||
} from '@/lib/uploads/providers/s3/types'
|
||||
import type { FileInfo } from '@/lib/uploads/shared/types'
|
||||
import {
|
||||
sanitizeFilenameForMetadata,
|
||||
sanitizeStorageMetadata,
|
||||
} from '@/lib/uploads/utils/file-utils'
|
||||
import { sanitizeFileName } from '@/executor/constants'
|
||||
|
||||
let _s3Client: S3Client | null = null
|
||||
|
||||
/**
|
||||
* Reset the cached S3 client. Only intended for use in tests.
|
||||
*/
|
||||
export function resetS3ClientForTesting(): void {
|
||||
_s3Client = null
|
||||
}
|
||||
|
||||
export function getS3Client(): S3Client {
|
||||
if (_s3Client) return _s3Client
|
||||
|
||||
const { region } = S3_CONFIG
|
||||
|
||||
if (!region) {
|
||||
throw new Error(
|
||||
'AWS region is missing – set AWS_REGION in your environment or disable S3 uploads.'
|
||||
)
|
||||
}
|
||||
|
||||
_s3Client = new S3Client({
|
||||
region,
|
||||
endpoint: S3_CONFIG.endpoint,
|
||||
forcePathStyle: S3_CONFIG.forcePathStyle,
|
||||
credentials: getAwsCredentialsFromEnv(),
|
||||
})
|
||||
|
||||
return _s3Client
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to S3
|
||||
* @param file Buffer containing file data
|
||||
* @param fileName Original file name
|
||||
* @param contentType MIME type of the file
|
||||
* @param configOrSize Custom S3 configuration OR file size in bytes (optional)
|
||||
* @param size File size in bytes (required if configOrSize is S3Config, optional otherwise)
|
||||
* @param skipTimestampPrefix Skip adding timestamp prefix to filename (default: false)
|
||||
* @param metadata Optional metadata to store with the file
|
||||
* @returns Object with file information
|
||||
*/
|
||||
export async function uploadToS3(
|
||||
file: Buffer,
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
configOrSize?: S3Config | number,
|
||||
size?: number,
|
||||
skipTimestampPrefix?: boolean,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<FileInfo> {
|
||||
let config: S3Config
|
||||
let fileSize: number
|
||||
let shouldSkipTimestamp: boolean
|
||||
|
||||
if (typeof configOrSize === 'object') {
|
||||
config = configOrSize
|
||||
fileSize = size ?? file.length
|
||||
shouldSkipTimestamp = skipTimestampPrefix ?? false
|
||||
} else {
|
||||
config = { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
|
||||
fileSize = configOrSize ?? file.length
|
||||
shouldSkipTimestamp = skipTimestampPrefix ?? false
|
||||
}
|
||||
|
||||
const safeFileName = sanitizeFileName(fileName)
|
||||
const uniqueKey = shouldSkipTimestamp ? fileName : `${Date.now()}-${safeFileName}`
|
||||
|
||||
const s3Client = getS3Client()
|
||||
|
||||
const s3Metadata: Record<string, string> = {
|
||||
originalName: sanitizeFilenameForMetadata(fileName),
|
||||
uploadedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (metadata) {
|
||||
Object.assign(s3Metadata, sanitizeStorageMetadata(metadata, 2000))
|
||||
}
|
||||
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: uniqueKey,
|
||||
Body: file,
|
||||
ContentType: contentType,
|
||||
Metadata: s3Metadata,
|
||||
})
|
||||
)
|
||||
|
||||
const servePath = `/api/files/serve/${encodeURIComponent(uniqueKey)}`
|
||||
|
||||
return {
|
||||
path: servePath,
|
||||
key: uniqueKey,
|
||||
name: fileName,
|
||||
size: fileSize,
|
||||
type: contentType,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for direct file access
|
||||
* @param key S3 object key
|
||||
* @param expiresIn Time in seconds until URL expires
|
||||
* @returns Presigned URL
|
||||
*/
|
||||
export async function getPresignedUrl(key: string, expiresIn = 3600) {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: S3_CONFIG.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
return getSignedUrl(getS3Client(), command, { expiresIn })
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for direct file access with custom bucket
|
||||
* @param key S3 object key
|
||||
* @param customConfig Custom S3 configuration
|
||||
* @param expiresIn Time in seconds until URL expires
|
||||
* @returns Presigned URL
|
||||
*/
|
||||
export async function getPresignedUrlWithConfig(
|
||||
key: string,
|
||||
customConfig: S3Config,
|
||||
expiresIn = 3600
|
||||
) {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: customConfig.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
return getSignedUrl(getS3Client(), command, { expiresIn })
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from S3
|
||||
* @param key S3 object key
|
||||
* @returns File buffer
|
||||
*/
|
||||
export async function downloadFromS3(key: string): Promise<Buffer>
|
||||
|
||||
/**
|
||||
* Download a file from S3 with custom bucket configuration
|
||||
* @param key S3 object key
|
||||
* @param customConfig Custom S3 configuration
|
||||
* @returns File buffer
|
||||
*/
|
||||
export async function downloadFromS3(key: string, customConfig: S3Config): Promise<Buffer>
|
||||
|
||||
export async function downloadFromS3(
|
||||
key: string,
|
||||
customConfig: S3Config,
|
||||
maxBytes: number
|
||||
): Promise<Buffer>
|
||||
|
||||
export async function downloadFromS3(
|
||||
key: string,
|
||||
customConfig?: S3Config,
|
||||
maxBytes?: number
|
||||
): Promise<Buffer> {
|
||||
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
})
|
||||
|
||||
const response = await getS3Client().send(command)
|
||||
if (maxBytes !== undefined && response.ContentLength !== undefined) {
|
||||
try {
|
||||
assertKnownSizeWithinLimit(response.ContentLength, maxBytes, 'storage download')
|
||||
} catch (error) {
|
||||
const body = response.Body as { destroy?: (error?: Error) => void } | undefined
|
||||
body?.destroy?.(error instanceof Error ? error : undefined)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const stream = response.Body as NodeJS.ReadableStream
|
||||
return readNodeStreamToBufferWithLimit(stream, {
|
||||
maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER,
|
||||
label: 'storage download',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream an object out of S3 without buffering it. The caller MUST fully consume or
|
||||
* `destroy()` the returned stream. Used by the large-CSV import worker so a 1M-row file is
|
||||
* never resident in memory.
|
||||
*/
|
||||
export async function downloadFromS3Stream(
|
||||
key: string,
|
||||
customConfig?: S3Config
|
||||
): Promise<Readable> {
|
||||
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
|
||||
const command = new GetObjectCommand({ Bucket: config.bucket, Key: key })
|
||||
const response = await getS3Client().send(command)
|
||||
if (!response.Body) {
|
||||
throw new Error(`S3 object has no body: ${key}`)
|
||||
}
|
||||
return response.Body as Readable
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether an object exists in S3 (and return its size when it does).
|
||||
* Returns null when the object is missing.
|
||||
*/
|
||||
export async function headS3Object(
|
||||
key: string,
|
||||
customConfig?: S3Config
|
||||
): Promise<{ size: number; contentType?: string } | null> {
|
||||
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
|
||||
|
||||
try {
|
||||
const response = await getS3Client().send(
|
||||
new HeadObjectCommand({ Bucket: config.bucket, Key: key })
|
||||
)
|
||||
return {
|
||||
size: response.ContentLength ?? 0,
|
||||
contentType: response.ContentType,
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as { name?: string; $metadata?: { httpStatusCode?: number } } | null)?.name
|
||||
const status = (error as { $metadata?: { httpStatusCode?: number } } | null)?.$metadata
|
||||
?.httpStatusCode
|
||||
if (code === 'NotFound' || code === 'NoSuchKey' || status === 404) {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from S3
|
||||
* @param key S3 object key
|
||||
*/
|
||||
export async function deleteFromS3(key: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Delete a file from S3 with custom bucket configuration
|
||||
* @param key S3 object key
|
||||
* @param customConfig Custom S3 configuration
|
||||
*/
|
||||
export async function deleteFromS3(key: string, customConfig: S3Config): Promise<void>
|
||||
|
||||
export async function deleteFromS3(key: string, customConfig?: S3Config): Promise<void> {
|
||||
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
|
||||
|
||||
await getS3Client().send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** S3 `DeleteObjects` hard cap. */
|
||||
const S3_DELETE_OBJECTS_MAX_KEYS = 1000
|
||||
|
||||
/**
|
||||
* Multi-object delete. One HTTP call per 1000 keys; each key still counts
|
||||
* against the per-prefix DELETE rate limit (3500/sec).
|
||||
*/
|
||||
export async function deleteManyFromS3(
|
||||
keys: string[],
|
||||
customConfig?: S3Config
|
||||
): Promise<{ failed: Array<{ key: string; error: string }> }> {
|
||||
const failed: Array<{ key: string; error: string }> = []
|
||||
if (keys.length === 0) return { failed }
|
||||
|
||||
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
|
||||
const s3Client = getS3Client()
|
||||
|
||||
for (let i = 0; i < keys.length; i += S3_DELETE_OBJECTS_MAX_KEYS) {
|
||||
const chunk = keys.slice(i, i + S3_DELETE_OBJECTS_MAX_KEYS)
|
||||
try {
|
||||
const response = await s3Client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: config.bucket,
|
||||
Delete: {
|
||||
Objects: chunk.map((Key) => ({ Key })),
|
||||
Quiet: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
for (const error of response.Errors ?? []) {
|
||||
if (error.Key) {
|
||||
failed.push({
|
||||
key: error.Key,
|
||||
error: error.Message ?? error.Code ?? 'unknown',
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error)
|
||||
for (const Key of chunk) failed.push({ key: Key, error: message })
|
||||
}
|
||||
}
|
||||
|
||||
return { failed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a multipart upload for S3
|
||||
*/
|
||||
export async function initiateS3MultipartUpload(
|
||||
options: S3MultipartUploadInit
|
||||
): Promise<{ uploadId: string; key: string }> {
|
||||
const { fileName, contentType, customConfig, customKey, purpose } = options
|
||||
|
||||
const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region }
|
||||
const s3Client = getS3Client()
|
||||
|
||||
const safeFileName = sanitizeFileName(fileName)
|
||||
const uniqueKey = customKey || `kb/${generateId()}-${safeFileName}`
|
||||
|
||||
const command = new CreateMultipartUploadCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: uniqueKey,
|
||||
ContentType: contentType,
|
||||
Metadata: {
|
||||
originalName: sanitizeFilenameForMetadata(fileName),
|
||||
uploadedAt: new Date().toISOString(),
|
||||
purpose: purpose || 'knowledge-base',
|
||||
},
|
||||
})
|
||||
|
||||
const response = await s3Client.send(command)
|
||||
|
||||
if (!response.UploadId) {
|
||||
throw new Error('Failed to initiate S3 multipart upload')
|
||||
}
|
||||
|
||||
return {
|
||||
uploadId: response.UploadId,
|
||||
key: uniqueKey,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a single multipart part from the server (Body in hand), returning its
|
||||
* `{ PartNumber, ETag }`. The presigned variant ({@link getS3MultipartPartUrls})
|
||||
* is for browser uploads; this is the server-side streaming path.
|
||||
*/
|
||||
export async function uploadS3Part(
|
||||
key: string,
|
||||
uploadId: string,
|
||||
partNumber: number,
|
||||
body: Buffer,
|
||||
customConfig?: S3Config
|
||||
): Promise<S3MultipartPart> {
|
||||
const config = customConfig || { bucket: S3_CONFIG.bucket, region: S3_CONFIG.region }
|
||||
const response = await getS3Client().send(
|
||||
new UploadPartCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
PartNumber: partNumber,
|
||||
UploadId: uploadId,
|
||||
Body: body,
|
||||
})
|
||||
)
|
||||
if (!response.ETag) {
|
||||
throw new Error(`S3 UploadPart returned no ETag for part ${partNumber} of ${key}`)
|
||||
}
|
||||
return { PartNumber: partNumber, ETag: response.ETag }
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate presigned URLs for uploading parts to S3
|
||||
*/
|
||||
export async function getS3MultipartPartUrls(
|
||||
key: string,
|
||||
uploadId: string,
|
||||
partNumbers: number[],
|
||||
customConfig?: S3Config
|
||||
): Promise<S3PartUploadUrl[]> {
|
||||
const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region }
|
||||
const s3Client = getS3Client()
|
||||
|
||||
const presignedUrls = await Promise.all(
|
||||
partNumbers.map(async (partNumber) => {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
PartNumber: partNumber,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
|
||||
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 })
|
||||
return { partNumber, url }
|
||||
})
|
||||
)
|
||||
|
||||
return presignedUrls
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a fallback object URL for when the SDK omits `Location` on multipart
|
||||
* completion. For a custom `S3_CONFIG.endpoint` it matches the configured
|
||||
* addressing mode — path-style for MinIO/Ceph (`forcePathStyle`), virtual-hosted
|
||||
* (bucket as a subdomain) for R2 and friends. Falls back to the AWS
|
||||
* virtual-hosted host when no custom endpoint is set.
|
||||
*
|
||||
* The key is percent-encoded per path segment (preserving `/` separators) so
|
||||
* keys containing spaces or reserved characters still yield a valid URL.
|
||||
*/
|
||||
function buildObjectFallbackUrl(bucket: string, region: string, key: string): string {
|
||||
const encodedKey = key
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/')
|
||||
if (S3_CONFIG.endpoint) {
|
||||
const base = S3_CONFIG.endpoint.replace(/\/+$/, '')
|
||||
if (S3_CONFIG.forcePathStyle) {
|
||||
return `${base}/${bucket}/${encodedKey}`
|
||||
}
|
||||
const url = new URL(base)
|
||||
url.hostname = `${bucket}.${url.hostname}`
|
||||
return `${url.origin}/${encodedKey}`
|
||||
}
|
||||
return `https://${bucket}.s3.${region}.amazonaws.com/${encodedKey}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete multipart upload for S3
|
||||
*/
|
||||
export async function completeS3MultipartUpload(
|
||||
key: string,
|
||||
uploadId: string,
|
||||
parts: S3MultipartPart[],
|
||||
customConfig?: S3Config
|
||||
): Promise<{ location: string; path: string; key: string }> {
|
||||
const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region }
|
||||
const s3Client = getS3Client()
|
||||
|
||||
const command = new CompleteMultipartUploadCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
|
||||
},
|
||||
})
|
||||
|
||||
const response = await s3Client.send(command)
|
||||
const location = response.Location || buildObjectFallbackUrl(config.bucket, config.region, key)
|
||||
const path = `/api/files/serve/${encodeURIComponent(key)}`
|
||||
|
||||
return {
|
||||
location,
|
||||
path,
|
||||
key,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort multipart upload for S3
|
||||
*/
|
||||
export async function abortS3MultipartUpload(
|
||||
key: string,
|
||||
uploadId: string,
|
||||
customConfig?: S3Config
|
||||
): Promise<void> {
|
||||
const config = customConfig || { bucket: S3_KB_CONFIG.bucket, region: S3_KB_CONFIG.region }
|
||||
const s3Client = getS3Client()
|
||||
|
||||
const command = new AbortMultipartUploadCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
|
||||
await s3Client.send(command)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface S3Config {
|
||||
bucket: string
|
||||
region: string
|
||||
}
|
||||
|
||||
export interface S3MultipartUploadInit {
|
||||
fileName: string
|
||||
contentType: string
|
||||
fileSize: number
|
||||
customConfig?: S3Config
|
||||
/**
|
||||
* When provided, overrides the default `kb/${id}-${name}` key derivation.
|
||||
* Caller is responsible for uniqueness and prefix conventions.
|
||||
*/
|
||||
customKey?: string
|
||||
/**
|
||||
* Storage purpose tag persisted as object metadata. Defaults to `knowledge-base`
|
||||
* for backwards compatibility.
|
||||
*/
|
||||
purpose?: string
|
||||
}
|
||||
|
||||
export interface S3PartUploadUrl {
|
||||
partNumber: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface S3MultipartPart {
|
||||
ETag: string
|
||||
PartNumber: number
|
||||
}
|
||||
Reference in New Issue
Block a user