@opensaas/stack-storage-s3 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +171 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +145 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/index.ts +211 -0
- package/tests/s3-storage-provider.test.ts +780 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,780 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
import { S3StorageProvider, s3Storage } from '../src/index.js'
|
|
3
|
+
import type { S3StorageConfig } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
// Mock AWS SDK
|
|
6
|
+
vi.mock('@aws-sdk/client-s3', () => {
|
|
7
|
+
const mockSend = vi.fn()
|
|
8
|
+
return {
|
|
9
|
+
S3Client: class {
|
|
10
|
+
send = mockSend
|
|
11
|
+
},
|
|
12
|
+
PutObjectCommand: vi.fn(),
|
|
13
|
+
GetObjectCommand: vi.fn(),
|
|
14
|
+
DeleteObjectCommand: vi.fn(),
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
vi.mock('@aws-sdk/s3-request-presigner', () => ({
|
|
19
|
+
getSignedUrl: vi.fn(),
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
// Mock crypto for deterministic filename testing
|
|
23
|
+
vi.mock('node:crypto', () => ({
|
|
24
|
+
randomBytes: vi.fn((size: number) => ({
|
|
25
|
+
toString: () => 'a'.repeat(size * 2), // Hex string is 2x the byte length
|
|
26
|
+
})),
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
// Mock Date.now for predictable timestamps
|
|
30
|
+
const MOCK_TIMESTAMP = 1234567890000
|
|
31
|
+
vi.spyOn(Date, 'now').mockReturnValue(MOCK_TIMESTAMP)
|
|
32
|
+
|
|
33
|
+
describe('S3StorageProvider', () => {
|
|
34
|
+
let PutObjectCommand: ReturnType<typeof vi.fn>
|
|
35
|
+
let GetObjectCommand: ReturnType<typeof vi.fn>
|
|
36
|
+
let DeleteObjectCommand: ReturnType<typeof vi.fn>
|
|
37
|
+
let getSignedUrl: ReturnType<typeof vi.fn>
|
|
38
|
+
let mockSend: ReturnType<typeof vi.fn>
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
vi.clearAllMocks()
|
|
42
|
+
|
|
43
|
+
// Import mocked modules
|
|
44
|
+
const s3Module = await import('@aws-sdk/client-s3')
|
|
45
|
+
const presignerModule = await import('@aws-sdk/s3-request-presigner')
|
|
46
|
+
|
|
47
|
+
PutObjectCommand = s3Module.PutObjectCommand as unknown as ReturnType<typeof vi.fn>
|
|
48
|
+
GetObjectCommand = s3Module.GetObjectCommand as unknown as ReturnType<typeof vi.fn>
|
|
49
|
+
DeleteObjectCommand = s3Module.DeleteObjectCommand as unknown as ReturnType<typeof vi.fn>
|
|
50
|
+
getSignedUrl = presignerModule.getSignedUrl as ReturnType<typeof vi.fn>
|
|
51
|
+
|
|
52
|
+
// Get the shared mockSend function
|
|
53
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
54
|
+
const S3ClientClass = s3Module.S3Client as any
|
|
55
|
+
mockSend = new S3ClientClass().send
|
|
56
|
+
|
|
57
|
+
// Default mock implementations
|
|
58
|
+
mockSend.mockResolvedValue({})
|
|
59
|
+
getSignedUrl.mockResolvedValue('https://signed-url.example.com')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('constructor', () => {
|
|
63
|
+
it('should create instance with accessKeyId and secretAccessKey', () => {
|
|
64
|
+
const config: S3StorageConfig = {
|
|
65
|
+
type: 's3',
|
|
66
|
+
bucket: 'test-bucket',
|
|
67
|
+
region: 'us-east-1',
|
|
68
|
+
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
69
|
+
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const provider = new S3StorageProvider(config)
|
|
73
|
+
|
|
74
|
+
expect(provider).toBeInstanceOf(S3StorageProvider)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should create instance without credentials (IAM role)', () => {
|
|
78
|
+
const config: S3StorageConfig = {
|
|
79
|
+
type: 's3',
|
|
80
|
+
bucket: 'test-bucket',
|
|
81
|
+
region: 'us-west-2',
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const provider = new S3StorageProvider(config)
|
|
85
|
+
|
|
86
|
+
expect(provider).toBeInstanceOf(S3StorageProvider)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('should create instance with custom endpoint for S3-compatible services', () => {
|
|
90
|
+
const config: S3StorageConfig = {
|
|
91
|
+
type: 's3',
|
|
92
|
+
bucket: 'my-bucket',
|
|
93
|
+
region: 'us-east-1',
|
|
94
|
+
endpoint: 'https://minio.example.com',
|
|
95
|
+
forcePathStyle: true,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const provider = new S3StorageProvider(config)
|
|
99
|
+
|
|
100
|
+
expect(provider).toBeInstanceOf(S3StorageProvider)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('should create instance with forcePathStyle option', () => {
|
|
104
|
+
const config: S3StorageConfig = {
|
|
105
|
+
type: 's3',
|
|
106
|
+
bucket: 'test-bucket',
|
|
107
|
+
region: 'us-east-1',
|
|
108
|
+
forcePathStyle: true,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const provider = new S3StorageProvider(config)
|
|
112
|
+
|
|
113
|
+
expect(provider).toBeInstanceOf(S3StorageProvider)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe('upload', () => {
|
|
118
|
+
it('should upload file successfully with Buffer', async () => {
|
|
119
|
+
const config: S3StorageConfig = {
|
|
120
|
+
type: 's3',
|
|
121
|
+
bucket: 'test-bucket',
|
|
122
|
+
region: 'us-east-1',
|
|
123
|
+
}
|
|
124
|
+
const provider = new S3StorageProvider(config)
|
|
125
|
+
const fileBuffer = Buffer.from('test file content')
|
|
126
|
+
|
|
127
|
+
const result = await provider.upload(fileBuffer, 'test.txt')
|
|
128
|
+
|
|
129
|
+
expect(PutObjectCommand).toHaveBeenCalledWith({
|
|
130
|
+
Bucket: 'test-bucket',
|
|
131
|
+
Key: `${MOCK_TIMESTAMP}-${'a'.repeat(32)}.txt`,
|
|
132
|
+
Body: fileBuffer,
|
|
133
|
+
ContentType: undefined,
|
|
134
|
+
Metadata: undefined,
|
|
135
|
+
ACL: 'private',
|
|
136
|
+
CacheControl: undefined,
|
|
137
|
+
})
|
|
138
|
+
expect(mockSend).toHaveBeenCalled()
|
|
139
|
+
expect(result.filename).toMatch(/\d+-[a-f0-9]+\.txt/)
|
|
140
|
+
expect(result.url).toBe(
|
|
141
|
+
`https://test-bucket.s3.us-east-1.amazonaws.com/${MOCK_TIMESTAMP}-${'a'.repeat(32)}.txt`,
|
|
142
|
+
)
|
|
143
|
+
expect(result.size).toBe(fileBuffer.length)
|
|
144
|
+
expect(result.contentType).toBe('application/octet-stream')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('should upload file successfully with Uint8Array', async () => {
|
|
148
|
+
const config: S3StorageConfig = {
|
|
149
|
+
type: 's3',
|
|
150
|
+
bucket: 'test-bucket',
|
|
151
|
+
region: 'us-east-1',
|
|
152
|
+
}
|
|
153
|
+
const provider = new S3StorageProvider(config)
|
|
154
|
+
const uint8Array = new Uint8Array([1, 2, 3, 4, 5])
|
|
155
|
+
|
|
156
|
+
const result = await provider.upload(uint8Array, 'binary.dat')
|
|
157
|
+
|
|
158
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
159
|
+
expect.objectContaining({
|
|
160
|
+
Body: uint8Array,
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
expect(result.size).toBe(uint8Array.length)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('should generate unique filenames by default', async () => {
|
|
167
|
+
const config: S3StorageConfig = {
|
|
168
|
+
type: 's3',
|
|
169
|
+
bucket: 'test-bucket',
|
|
170
|
+
region: 'us-east-1',
|
|
171
|
+
}
|
|
172
|
+
const provider = new S3StorageProvider(config)
|
|
173
|
+
|
|
174
|
+
const result = await provider.upload(Buffer.from('test'), 'test.txt')
|
|
175
|
+
|
|
176
|
+
expect(result.filename).not.toBe('test.txt')
|
|
177
|
+
expect(result.filename).toMatch(/\d+-[a-f0-9]+\.txt/)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('should preserve original filename when generateUniqueFilenames is false', async () => {
|
|
181
|
+
const config: S3StorageConfig = {
|
|
182
|
+
type: 's3',
|
|
183
|
+
bucket: 'test-bucket',
|
|
184
|
+
region: 'us-east-1',
|
|
185
|
+
generateUniqueFilenames: false,
|
|
186
|
+
}
|
|
187
|
+
const provider = new S3StorageProvider(config)
|
|
188
|
+
|
|
189
|
+
const result = await provider.upload(Buffer.from('test'), 'original.txt')
|
|
190
|
+
|
|
191
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
192
|
+
expect.objectContaining({
|
|
193
|
+
Key: 'original.txt',
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
expect(result.filename).toBe('original.txt')
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('should preserve file extension when generating unique names', async () => {
|
|
200
|
+
const config: S3StorageConfig = {
|
|
201
|
+
type: 's3',
|
|
202
|
+
bucket: 'test-bucket',
|
|
203
|
+
region: 'us-east-1',
|
|
204
|
+
}
|
|
205
|
+
const provider = new S3StorageProvider(config)
|
|
206
|
+
|
|
207
|
+
const resultTxt = await provider.upload(Buffer.from('test'), 'file.txt')
|
|
208
|
+
const resultJpg = await provider.upload(Buffer.from('test'), 'photo.jpg')
|
|
209
|
+
const resultNoExt = await provider.upload(Buffer.from('test'), 'noext')
|
|
210
|
+
|
|
211
|
+
expect(resultTxt.filename).toMatch(/\.txt$/)
|
|
212
|
+
expect(resultJpg.filename).toMatch(/\.jpg$/)
|
|
213
|
+
expect(resultNoExt.filename).not.toMatch(/\./)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('should apply pathPrefix to uploaded files', async () => {
|
|
217
|
+
const config: S3StorageConfig = {
|
|
218
|
+
type: 's3',
|
|
219
|
+
bucket: 'test-bucket',
|
|
220
|
+
region: 'us-east-1',
|
|
221
|
+
pathPrefix: 'avatars',
|
|
222
|
+
generateUniqueFilenames: false,
|
|
223
|
+
}
|
|
224
|
+
const provider = new S3StorageProvider(config)
|
|
225
|
+
|
|
226
|
+
await provider.upload(Buffer.from('test'), 'profile.jpg')
|
|
227
|
+
|
|
228
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
229
|
+
expect.objectContaining({
|
|
230
|
+
Key: 'avatars/profile.jpg',
|
|
231
|
+
}),
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should use provided contentType', async () => {
|
|
236
|
+
const config: S3StorageConfig = {
|
|
237
|
+
type: 's3',
|
|
238
|
+
bucket: 'test-bucket',
|
|
239
|
+
region: 'us-east-1',
|
|
240
|
+
}
|
|
241
|
+
const provider = new S3StorageProvider(config)
|
|
242
|
+
|
|
243
|
+
const result = await provider.upload(Buffer.from('test'), 'test.txt', {
|
|
244
|
+
contentType: 'text/plain',
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
248
|
+
expect.objectContaining({
|
|
249
|
+
ContentType: 'text/plain',
|
|
250
|
+
}),
|
|
251
|
+
)
|
|
252
|
+
expect(result.contentType).toBe('text/plain')
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it('should use provided metadata', async () => {
|
|
256
|
+
const config: S3StorageConfig = {
|
|
257
|
+
type: 's3',
|
|
258
|
+
bucket: 'test-bucket',
|
|
259
|
+
region: 'us-east-1',
|
|
260
|
+
}
|
|
261
|
+
const provider = new S3StorageProvider(config)
|
|
262
|
+
const metadata = { uploadedBy: 'user123', category: 'documents' }
|
|
263
|
+
|
|
264
|
+
const result = await provider.upload(Buffer.from('test'), 'test.txt', { metadata })
|
|
265
|
+
|
|
266
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
267
|
+
expect.objectContaining({
|
|
268
|
+
Metadata: metadata,
|
|
269
|
+
}),
|
|
270
|
+
)
|
|
271
|
+
expect(result.metadata).toEqual(metadata)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('should apply ACL setting - private (default)', async () => {
|
|
275
|
+
const config: S3StorageConfig = {
|
|
276
|
+
type: 's3',
|
|
277
|
+
bucket: 'test-bucket',
|
|
278
|
+
region: 'us-east-1',
|
|
279
|
+
}
|
|
280
|
+
const provider = new S3StorageProvider(config)
|
|
281
|
+
|
|
282
|
+
await provider.upload(Buffer.from('test'), 'test.txt')
|
|
283
|
+
|
|
284
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
285
|
+
expect.objectContaining({
|
|
286
|
+
ACL: 'private',
|
|
287
|
+
}),
|
|
288
|
+
)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('should apply ACL setting - public-read', async () => {
|
|
292
|
+
const config: S3StorageConfig = {
|
|
293
|
+
type: 's3',
|
|
294
|
+
bucket: 'test-bucket',
|
|
295
|
+
region: 'us-east-1',
|
|
296
|
+
acl: 'public-read',
|
|
297
|
+
}
|
|
298
|
+
const provider = new S3StorageProvider(config)
|
|
299
|
+
|
|
300
|
+
await provider.upload(Buffer.from('test'), 'test.txt')
|
|
301
|
+
|
|
302
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
303
|
+
expect.objectContaining({
|
|
304
|
+
ACL: 'public-read',
|
|
305
|
+
}),
|
|
306
|
+
)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('should apply cacheControl header', async () => {
|
|
310
|
+
const config: S3StorageConfig = {
|
|
311
|
+
type: 's3',
|
|
312
|
+
bucket: 'test-bucket',
|
|
313
|
+
region: 'us-east-1',
|
|
314
|
+
}
|
|
315
|
+
const provider = new S3StorageProvider(config)
|
|
316
|
+
|
|
317
|
+
await provider.upload(Buffer.from('test'), 'test.txt', {
|
|
318
|
+
cacheControl: 'public, max-age=31536000',
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
expect(PutObjectCommand).toHaveBeenCalledWith(
|
|
322
|
+
expect.objectContaining({
|
|
323
|
+
CacheControl: 'public, max-age=31536000',
|
|
324
|
+
}),
|
|
325
|
+
)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('should handle upload errors', async () => {
|
|
329
|
+
const config: S3StorageConfig = {
|
|
330
|
+
type: 's3',
|
|
331
|
+
bucket: 'test-bucket',
|
|
332
|
+
region: 'us-east-1',
|
|
333
|
+
}
|
|
334
|
+
const provider = new S3StorageProvider(config)
|
|
335
|
+
mockSend.mockRejectedValueOnce(new Error('Upload failed'))
|
|
336
|
+
|
|
337
|
+
await expect(provider.upload(Buffer.from('test'), 'test.txt')).rejects.toThrow(
|
|
338
|
+
'Upload failed',
|
|
339
|
+
)
|
|
340
|
+
})
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
describe('download', () => {
|
|
344
|
+
it('should download file successfully and convert stream to Buffer', async () => {
|
|
345
|
+
const config: S3StorageConfig = {
|
|
346
|
+
type: 's3',
|
|
347
|
+
bucket: 'test-bucket',
|
|
348
|
+
region: 'us-east-1',
|
|
349
|
+
}
|
|
350
|
+
const provider = new S3StorageProvider(config)
|
|
351
|
+
|
|
352
|
+
// Mock async iterable stream
|
|
353
|
+
const mockChunks = [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5])]
|
|
354
|
+
const mockStream = {
|
|
355
|
+
async *[Symbol.asyncIterator]() {
|
|
356
|
+
for (const chunk of mockChunks) {
|
|
357
|
+
yield chunk
|
|
358
|
+
}
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
mockSend.mockResolvedValueOnce({
|
|
363
|
+
Body: mockStream,
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
const result = await provider.download('test.txt')
|
|
367
|
+
|
|
368
|
+
expect(GetObjectCommand).toHaveBeenCalledWith({
|
|
369
|
+
Bucket: 'test-bucket',
|
|
370
|
+
Key: 'test.txt',
|
|
371
|
+
})
|
|
372
|
+
expect(mockSend).toHaveBeenCalled()
|
|
373
|
+
expect(result).toBeInstanceOf(Buffer)
|
|
374
|
+
expect(result).toEqual(Buffer.concat([Buffer.from([1, 2, 3]), Buffer.from([4, 5])]))
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('should apply pathPrefix when downloading', async () => {
|
|
378
|
+
const config: S3StorageConfig = {
|
|
379
|
+
type: 's3',
|
|
380
|
+
bucket: 'test-bucket',
|
|
381
|
+
region: 'us-east-1',
|
|
382
|
+
pathPrefix: 'documents',
|
|
383
|
+
}
|
|
384
|
+
const provider = new S3StorageProvider(config)
|
|
385
|
+
|
|
386
|
+
const mockStream = {
|
|
387
|
+
async *[Symbol.asyncIterator]() {
|
|
388
|
+
yield new Uint8Array([1, 2, 3])
|
|
389
|
+
},
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
mockSend.mockResolvedValueOnce({
|
|
393
|
+
Body: mockStream,
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
await provider.download('report.pdf')
|
|
397
|
+
|
|
398
|
+
expect(GetObjectCommand).toHaveBeenCalledWith({
|
|
399
|
+
Bucket: 'test-bucket',
|
|
400
|
+
Key: 'documents/report.pdf',
|
|
401
|
+
})
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('should throw error when file not found (empty body)', async () => {
|
|
405
|
+
const config: S3StorageConfig = {
|
|
406
|
+
type: 's3',
|
|
407
|
+
bucket: 'test-bucket',
|
|
408
|
+
region: 'us-east-1',
|
|
409
|
+
}
|
|
410
|
+
const provider = new S3StorageProvider(config)
|
|
411
|
+
|
|
412
|
+
mockSend.mockResolvedValueOnce({
|
|
413
|
+
Body: null,
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
await expect(provider.download('nonexistent.txt')).rejects.toThrow(
|
|
417
|
+
'File not found: nonexistent.txt',
|
|
418
|
+
)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it('should throw error when file not found (undefined body)', async () => {
|
|
422
|
+
const config: S3StorageConfig = {
|
|
423
|
+
type: 's3',
|
|
424
|
+
bucket: 'test-bucket',
|
|
425
|
+
region: 'us-east-1',
|
|
426
|
+
}
|
|
427
|
+
const provider = new S3StorageProvider(config)
|
|
428
|
+
|
|
429
|
+
mockSend.mockResolvedValueOnce({})
|
|
430
|
+
|
|
431
|
+
await expect(provider.download('nonexistent.txt')).rejects.toThrow(
|
|
432
|
+
'File not found: nonexistent.txt',
|
|
433
|
+
)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('should handle stream reading errors', async () => {
|
|
437
|
+
const config: S3StorageConfig = {
|
|
438
|
+
type: 's3',
|
|
439
|
+
bucket: 'test-bucket',
|
|
440
|
+
region: 'us-east-1',
|
|
441
|
+
}
|
|
442
|
+
const provider = new S3StorageProvider(config)
|
|
443
|
+
|
|
444
|
+
mockSend.mockRejectedValueOnce(new Error('Network error'))
|
|
445
|
+
|
|
446
|
+
await expect(provider.download('test.txt')).rejects.toThrow('Network error')
|
|
447
|
+
})
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
describe('delete', () => {
|
|
451
|
+
it('should delete file successfully', async () => {
|
|
452
|
+
const config: S3StorageConfig = {
|
|
453
|
+
type: 's3',
|
|
454
|
+
bucket: 'test-bucket',
|
|
455
|
+
region: 'us-east-1',
|
|
456
|
+
}
|
|
457
|
+
const provider = new S3StorageProvider(config)
|
|
458
|
+
|
|
459
|
+
await provider.delete('test.txt')
|
|
460
|
+
|
|
461
|
+
expect(DeleteObjectCommand).toHaveBeenCalledWith({
|
|
462
|
+
Bucket: 'test-bucket',
|
|
463
|
+
Key: 'test.txt',
|
|
464
|
+
})
|
|
465
|
+
expect(mockSend).toHaveBeenCalled()
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('should apply pathPrefix when deleting', async () => {
|
|
469
|
+
const config: S3StorageConfig = {
|
|
470
|
+
type: 's3',
|
|
471
|
+
bucket: 'test-bucket',
|
|
472
|
+
region: 'us-east-1',
|
|
473
|
+
pathPrefix: 'temp',
|
|
474
|
+
}
|
|
475
|
+
const provider = new S3StorageProvider(config)
|
|
476
|
+
|
|
477
|
+
await provider.delete('old-file.txt')
|
|
478
|
+
|
|
479
|
+
expect(DeleteObjectCommand).toHaveBeenCalledWith({
|
|
480
|
+
Bucket: 'test-bucket',
|
|
481
|
+
Key: 'temp/old-file.txt',
|
|
482
|
+
})
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('should handle deletion errors', async () => {
|
|
486
|
+
const config: S3StorageConfig = {
|
|
487
|
+
type: 's3',
|
|
488
|
+
bucket: 'test-bucket',
|
|
489
|
+
region: 'us-east-1',
|
|
490
|
+
}
|
|
491
|
+
const provider = new S3StorageProvider(config)
|
|
492
|
+
|
|
493
|
+
mockSend.mockRejectedValueOnce(new Error('Deletion failed'))
|
|
494
|
+
|
|
495
|
+
await expect(provider.delete('test.txt')).rejects.toThrow('Deletion failed')
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('should not throw on non-existent file deletion', async () => {
|
|
499
|
+
const config: S3StorageConfig = {
|
|
500
|
+
type: 's3',
|
|
501
|
+
bucket: 'test-bucket',
|
|
502
|
+
region: 'us-east-1',
|
|
503
|
+
}
|
|
504
|
+
const provider = new S3StorageProvider(config)
|
|
505
|
+
|
|
506
|
+
// S3 DeleteObject succeeds even if file doesn't exist
|
|
507
|
+
mockSend.mockResolvedValueOnce({})
|
|
508
|
+
|
|
509
|
+
await expect(provider.delete('nonexistent.txt')).resolves.not.toThrow()
|
|
510
|
+
})
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
describe('getUrl', () => {
|
|
514
|
+
it('should return standard AWS S3 URL format', () => {
|
|
515
|
+
const config: S3StorageConfig = {
|
|
516
|
+
type: 's3',
|
|
517
|
+
bucket: 'my-bucket',
|
|
518
|
+
region: 'us-east-1',
|
|
519
|
+
}
|
|
520
|
+
const provider = new S3StorageProvider(config)
|
|
521
|
+
|
|
522
|
+
const url = provider.getUrl('photo.jpg')
|
|
523
|
+
|
|
524
|
+
expect(url).toBe('https://my-bucket.s3.us-east-1.amazonaws.com/photo.jpg')
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('should apply pathPrefix to URL', () => {
|
|
528
|
+
const config: S3StorageConfig = {
|
|
529
|
+
type: 's3',
|
|
530
|
+
bucket: 'my-bucket',
|
|
531
|
+
region: 'us-west-2',
|
|
532
|
+
pathPrefix: 'images',
|
|
533
|
+
}
|
|
534
|
+
const provider = new S3StorageProvider(config)
|
|
535
|
+
|
|
536
|
+
const url = provider.getUrl('photo.jpg')
|
|
537
|
+
|
|
538
|
+
expect(url).toBe('https://my-bucket.s3.us-west-2.amazonaws.com/images/photo.jpg')
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('should use customDomain when configured', () => {
|
|
542
|
+
const config: S3StorageConfig = {
|
|
543
|
+
type: 's3',
|
|
544
|
+
bucket: 'my-bucket',
|
|
545
|
+
region: 'us-east-1',
|
|
546
|
+
customDomain: 'https://cdn.example.com',
|
|
547
|
+
}
|
|
548
|
+
const provider = new S3StorageProvider(config)
|
|
549
|
+
|
|
550
|
+
const url = provider.getUrl('photo.jpg')
|
|
551
|
+
|
|
552
|
+
expect(url).toBe('https://cdn.example.com/photo.jpg')
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('should use customDomain with pathPrefix', () => {
|
|
556
|
+
const config: S3StorageConfig = {
|
|
557
|
+
type: 's3',
|
|
558
|
+
bucket: 'my-bucket',
|
|
559
|
+
region: 'us-east-1',
|
|
560
|
+
customDomain: 'https://cdn.example.com',
|
|
561
|
+
pathPrefix: 'assets',
|
|
562
|
+
}
|
|
563
|
+
const provider = new S3StorageProvider(config)
|
|
564
|
+
|
|
565
|
+
const url = provider.getUrl('photo.jpg')
|
|
566
|
+
|
|
567
|
+
expect(url).toBe('https://cdn.example.com/assets/photo.jpg')
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
it('should use custom endpoint for S3-compatible services', () => {
|
|
571
|
+
const config: S3StorageConfig = {
|
|
572
|
+
type: 's3',
|
|
573
|
+
bucket: 'my-bucket',
|
|
574
|
+
region: 'us-east-1',
|
|
575
|
+
endpoint: 'https://minio.example.com',
|
|
576
|
+
}
|
|
577
|
+
const provider = new S3StorageProvider(config)
|
|
578
|
+
|
|
579
|
+
const url = provider.getUrl('file.pdf')
|
|
580
|
+
|
|
581
|
+
expect(url).toBe('https://minio.example.com/my-bucket/file.pdf')
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it('should handle forcePathStyle URL format with custom endpoint', () => {
|
|
585
|
+
const config: S3StorageConfig = {
|
|
586
|
+
type: 's3',
|
|
587
|
+
bucket: 'my-bucket',
|
|
588
|
+
region: 'us-east-1',
|
|
589
|
+
endpoint: 'https://s3.backblazeb2.com',
|
|
590
|
+
forcePathStyle: true,
|
|
591
|
+
}
|
|
592
|
+
const provider = new S3StorageProvider(config)
|
|
593
|
+
|
|
594
|
+
const url = provider.getUrl('file.pdf')
|
|
595
|
+
|
|
596
|
+
expect(url).toBe('https://s3.backblazeb2.com/my-bucket/file.pdf')
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
it('should handle filenames with special characters', () => {
|
|
600
|
+
const config: S3StorageConfig = {
|
|
601
|
+
type: 's3',
|
|
602
|
+
bucket: 'my-bucket',
|
|
603
|
+
region: 'us-east-1',
|
|
604
|
+
}
|
|
605
|
+
const provider = new S3StorageProvider(config)
|
|
606
|
+
|
|
607
|
+
const url = provider.getUrl('my file (1).txt')
|
|
608
|
+
|
|
609
|
+
expect(url).toBe('https://my-bucket.s3.us-east-1.amazonaws.com/my file (1).txt')
|
|
610
|
+
})
|
|
611
|
+
})
|
|
612
|
+
|
|
613
|
+
describe('getSignedUrl', () => {
|
|
614
|
+
it('should generate signed URL with default expiration', async () => {
|
|
615
|
+
const config: S3StorageConfig = {
|
|
616
|
+
type: 's3',
|
|
617
|
+
bucket: 'test-bucket',
|
|
618
|
+
region: 'us-east-1',
|
|
619
|
+
}
|
|
620
|
+
const provider = new S3StorageProvider(config)
|
|
621
|
+
|
|
622
|
+
getSignedUrl.mockResolvedValueOnce('https://signed.example.com/test.txt?signature=abc123')
|
|
623
|
+
|
|
624
|
+
const url = await provider.getSignedUrl('test.txt')
|
|
625
|
+
|
|
626
|
+
expect(GetObjectCommand).toHaveBeenCalledWith({
|
|
627
|
+
Bucket: 'test-bucket',
|
|
628
|
+
Key: 'test.txt',
|
|
629
|
+
})
|
|
630
|
+
expect(getSignedUrl).toHaveBeenCalledWith(expect.any(Object), expect.anything(), {
|
|
631
|
+
expiresIn: 3600,
|
|
632
|
+
})
|
|
633
|
+
expect(url).toBe('https://signed.example.com/test.txt?signature=abc123')
|
|
634
|
+
})
|
|
635
|
+
|
|
636
|
+
it('should generate signed URL with custom expiration', async () => {
|
|
637
|
+
const config: S3StorageConfig = {
|
|
638
|
+
type: 's3',
|
|
639
|
+
bucket: 'test-bucket',
|
|
640
|
+
region: 'us-east-1',
|
|
641
|
+
}
|
|
642
|
+
const provider = new S3StorageProvider(config)
|
|
643
|
+
|
|
644
|
+
await provider.getSignedUrl('test.txt', 7200)
|
|
645
|
+
|
|
646
|
+
expect(getSignedUrl).toHaveBeenCalledWith(expect.any(Object), expect.anything(), {
|
|
647
|
+
expiresIn: 7200,
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
it('should apply pathPrefix to signed URLs', async () => {
|
|
652
|
+
const config: S3StorageConfig = {
|
|
653
|
+
type: 's3',
|
|
654
|
+
bucket: 'test-bucket',
|
|
655
|
+
region: 'us-east-1',
|
|
656
|
+
pathPrefix: 'private',
|
|
657
|
+
}
|
|
658
|
+
const provider = new S3StorageProvider(config)
|
|
659
|
+
|
|
660
|
+
await provider.getSignedUrl('document.pdf')
|
|
661
|
+
|
|
662
|
+
expect(GetObjectCommand).toHaveBeenCalledWith({
|
|
663
|
+
Bucket: 'test-bucket',
|
|
664
|
+
Key: 'private/document.pdf',
|
|
665
|
+
})
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('should handle signing errors', async () => {
|
|
669
|
+
const config: S3StorageConfig = {
|
|
670
|
+
type: 's3',
|
|
671
|
+
bucket: 'test-bucket',
|
|
672
|
+
region: 'us-east-1',
|
|
673
|
+
}
|
|
674
|
+
const provider = new S3StorageProvider(config)
|
|
675
|
+
|
|
676
|
+
getSignedUrl.mockRejectedValueOnce(new Error('Signing failed'))
|
|
677
|
+
|
|
678
|
+
await expect(provider.getSignedUrl('test.txt')).rejects.toThrow('Signing failed')
|
|
679
|
+
})
|
|
680
|
+
|
|
681
|
+
it('should generate different signed URLs for different files', async () => {
|
|
682
|
+
const config: S3StorageConfig = {
|
|
683
|
+
type: 's3',
|
|
684
|
+
bucket: 'test-bucket',
|
|
685
|
+
region: 'us-east-1',
|
|
686
|
+
}
|
|
687
|
+
const provider = new S3StorageProvider(config)
|
|
688
|
+
|
|
689
|
+
getSignedUrl
|
|
690
|
+
.mockResolvedValueOnce('https://signed.example.com/file1.txt?sig=abc')
|
|
691
|
+
.mockResolvedValueOnce('https://signed.example.com/file2.txt?sig=def')
|
|
692
|
+
|
|
693
|
+
const url1 = await provider.getSignedUrl('file1.txt')
|
|
694
|
+
const url2 = await provider.getSignedUrl('file2.txt')
|
|
695
|
+
|
|
696
|
+
expect(url1).not.toBe(url2)
|
|
697
|
+
expect(GetObjectCommand).toHaveBeenCalledTimes(2)
|
|
698
|
+
})
|
|
699
|
+
})
|
|
700
|
+
|
|
701
|
+
describe('s3Storage factory', () => {
|
|
702
|
+
it('should create config with correct defaults', () => {
|
|
703
|
+
const config = s3Storage({
|
|
704
|
+
bucket: 'my-bucket',
|
|
705
|
+
region: 'us-east-1',
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
expect(config).toEqual({
|
|
709
|
+
type: 's3',
|
|
710
|
+
bucket: 'my-bucket',
|
|
711
|
+
region: 'us-east-1',
|
|
712
|
+
generateUniqueFilenames: true,
|
|
713
|
+
acl: 'private',
|
|
714
|
+
})
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
it('should merge provided options with defaults', () => {
|
|
718
|
+
const config = s3Storage({
|
|
719
|
+
bucket: 'my-bucket',
|
|
720
|
+
region: 'us-west-2',
|
|
721
|
+
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
722
|
+
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
723
|
+
pathPrefix: 'uploads',
|
|
724
|
+
generateUniqueFilenames: false,
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
expect(config).toEqual({
|
|
728
|
+
type: 's3',
|
|
729
|
+
bucket: 'my-bucket',
|
|
730
|
+
region: 'us-west-2',
|
|
731
|
+
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
|
|
732
|
+
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY',
|
|
733
|
+
pathPrefix: 'uploads',
|
|
734
|
+
generateUniqueFilenames: false,
|
|
735
|
+
acl: 'private',
|
|
736
|
+
})
|
|
737
|
+
})
|
|
738
|
+
|
|
739
|
+
it('should set type to s3', () => {
|
|
740
|
+
const config = s3Storage({
|
|
741
|
+
bucket: 'my-bucket',
|
|
742
|
+
region: 'us-east-1',
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
expect(config.type).toBe('s3')
|
|
746
|
+
})
|
|
747
|
+
|
|
748
|
+
it('should override default ACL', () => {
|
|
749
|
+
const config = s3Storage({
|
|
750
|
+
bucket: 'my-bucket',
|
|
751
|
+
region: 'us-east-1',
|
|
752
|
+
acl: 'public-read',
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
expect(config.acl).toBe('public-read')
|
|
756
|
+
})
|
|
757
|
+
|
|
758
|
+
it('should support custom endpoint configuration', () => {
|
|
759
|
+
const config = s3Storage({
|
|
760
|
+
bucket: 'my-bucket',
|
|
761
|
+
region: 'us-east-1',
|
|
762
|
+
endpoint: 'https://minio.example.com',
|
|
763
|
+
forcePathStyle: true,
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
expect(config.endpoint).toBe('https://minio.example.com')
|
|
767
|
+
expect(config.forcePathStyle).toBe(true)
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it('should support customDomain configuration', () => {
|
|
771
|
+
const config = s3Storage({
|
|
772
|
+
bucket: 'my-bucket',
|
|
773
|
+
region: 'us-east-1',
|
|
774
|
+
customDomain: 'https://cdn.example.com',
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
expect(config.customDomain).toBe('https://cdn.example.com')
|
|
778
|
+
})
|
|
779
|
+
})
|
|
780
|
+
})
|