import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from 'vitest'; import { S3StorageProvider } from '../../src/providers/storage/s3.provider.ts'; import { CopyObjectCommand, DeleteObjectsCommand, GetObjectCommand, S3Client, } from '@aws-sdk/client-s3'; import { Readable } from 'stream'; import crypto from 'crypto'; vi.mock('@/infra/config/app.config.js', () => { const c = { cloud: { get cloudFrontUrl() { return process.env.AWS_CLOUDFRONT_URL; }, get cloudFrontKeyPairId() { return process.env.AWS_CLOUDFRONT_KEY_PAIR_ID; }, get cloudFrontPrivateKey() { return process.env.AWS_CLOUDFRONT_PRIVATE_KEY; }, }, storage: { get s3EndpointUrl() { return process.env.S3_ENDPOINT_URL; }, }, server: { logsDir: 'logs', }, app: { logLevel: 'info', }, }; return { config: c, appConfig: c }; }); function asyncIterableFromString(s: string): AsyncIterable { return { [Symbol.asyncIterator]: async function* () { yield Buffer.from(s); }, }; } function notFoundError(name = 'NoSuchKey') { return Object.assign(new Error(name), { name, $metadata: { httpStatusCode: 404 } }); } describe('S3StorageProvider — branch fallback', () => { let sendMock: ReturnType; beforeEach(() => { sendMock = vi.fn(); vi.spyOn(S3Client.prototype, 'send').mockImplementation( sendMock as unknown as typeof S3Client.prototype.send ); }); function makeProvider(parentAppKey?: string): S3StorageProvider { const p = new S3StorageProvider('bucket', 'branchkey', 'us-east-2', parentAppKey); // Inject a real client without going through env-driven initialize(). // Dummy credentials so getSignedUrl can sign locally without hitting the // SDK credential provider chain (which fails on CI runners with no creds). (p as unknown as { s3Client: S3Client }).s3Client = new S3Client({ region: 'us-east-2', credentials: { accessKeyId: 'test', secretAccessKey: 'test' }, }); return p; } describe('getObject', () => { it('returns branch object on first hit (no parent call)', async () => { sendMock.mockResolvedValueOnce({ Body: asyncIterableFromString('hello') }); const p = makeProvider('parentkey'); const out = await p.getObject('photos', 'a.txt'); expect(out?.toString()).toBe('hello'); expect(sendMock).toHaveBeenCalledTimes(1); const cmd = sendMock.mock.calls[0][0] as GetObjectCommand; expect(cmd.input.Key).toBe('branchkey/photos/a.txt'); }); it('falls back to parent on NoSuchKey', async () => { sendMock .mockRejectedValueOnce(notFoundError('NoSuchKey')) .mockResolvedValueOnce({ Body: asyncIterableFromString('parent-data') }); const p = makeProvider('parentkey'); const out = await p.getObject('photos', 'a.txt'); expect(out?.toString()).toBe('parent-data'); const k1 = (sendMock.mock.calls[0][0] as GetObjectCommand).input.Key; const k2 = (sendMock.mock.calls[1][0] as GetObjectCommand).input.Key; expect(k1).toBe('branchkey/photos/a.txt'); expect(k2).toBe('parentkey/photos/a.txt'); }); it('returns null when both branch and parent miss', async () => { sendMock.mockRejectedValueOnce(notFoundError()).mockRejectedValueOnce(notFoundError()); const p = makeProvider('parentkey'); expect(await p.getObject('photos', 'a.txt')).toBeNull(); }); it('does NOT fall back when no parent configured', async () => { sendMock.mockRejectedValueOnce(notFoundError()); const p = makeProvider(); // no parentAppKey expect(await p.getObject('photos', 'a.txt')).toBeNull(); expect(sendMock).toHaveBeenCalledTimes(1); }); it('returns null on non-404 error WITHOUT calling parent', async () => { // Transient/IAM/etc errors must not silently masquerade as parent reads. sendMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { name: 'AccessDenied' })); const p = makeProvider('parentkey'); expect(await p.getObject('photos', 'a.txt')).toBeNull(); expect(sendMock).toHaveBeenCalledTimes(1); }); }); describe('headObject', () => { it('returns branch metadata on first hit', async () => { sendMock.mockResolvedValueOnce({ ContentLength: 42, ETag: '"abc"', ContentType: 'text/plain', LastModified: new Date('2026-04-29'), }); const p = makeProvider('parentkey'); const meta = await p.headObject('photos', 'a.txt'); expect(meta?.size).toBe(42); expect(sendMock).toHaveBeenCalledTimes(1); }); it('falls back to parent on NotFound', async () => { sendMock .mockRejectedValueOnce(notFoundError('NotFound')) .mockResolvedValueOnce({ ContentLength: 7, ETag: '"p"', LastModified: new Date() }); const p = makeProvider('parentkey'); const meta = await p.headObject('photos', 'a.txt'); expect(meta?.size).toBe(7); expect(sendMock).toHaveBeenCalledTimes(2); }); it('rethrows non-404 errors instead of falling back', async () => { sendMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { name: 'AccessDenied' })); const p = makeProvider('parentkey'); await expect(p.headObject('photos', 'a.txt')).rejects.toThrow(); expect(sendMock).toHaveBeenCalledTimes(1); }); }); describe('deleteObjects', () => { it('sends one DeleteObjectsCommand and maps S3 errors back to original keys', async () => { sendMock.mockResolvedValueOnce({ Errors: [ { Key: 'branchkey/photos/b.txt', Code: 'AccessDenied', Message: 'denied', }, ], }); const p = makeProvider('parentkey'); const result = await p.deleteObjects('photos', ['a.txt', 'b.txt']); expect(result).toEqual({ deleted: ['a.txt'], failed: [{ key: 'b.txt', message: 'Failed to delete object' }], }); expect(sendMock).toHaveBeenCalledTimes(1); const command = sendMock.mock.calls[0][0] as DeleteObjectsCommand; expect(command.input).toMatchObject({ Bucket: 'bucket', Delete: { Objects: [{ Key: 'branchkey/photos/a.txt' }, { Key: 'branchkey/photos/b.txt' }], Quiet: true, }, }); }); it('chunks deletes into S3-compatible batches of 1000 objects', async () => { sendMock.mockResolvedValue({}); const p = makeProvider('parentkey'); const keys = Array.from({ length: 1001 }, (_, index) => `file-${index}.txt`); const result = await p.deleteObjects('photos', keys); expect(result).toEqual({ deleted: keys, failed: [] }); expect(sendMock).toHaveBeenCalledTimes(2); const firstCommand = sendMock.mock.calls[0][0] as DeleteObjectsCommand; const secondCommand = sendMock.mock.calls[1][0] as DeleteObjectsCommand; expect(firstCommand.input.Delete?.Objects).toHaveLength(1000); expect(firstCommand.input.Delete?.Quiet).toBe(true); expect(secondCommand.input.Delete?.Objects).toEqual([ { Key: 'branchkey/photos/file-1000.txt' }, ]); expect(secondCommand.input.Delete?.Quiet).toBe(true); }); it('reports keys absent from keyed S3 errors as deleted when S3 returns an error without a key', async () => { sendMock.mockResolvedValueOnce({ Errors: [ { Code: 'InternalError', Message: 'missing key', }, ], }); const p = makeProvider('parentkey'); const result = await p.deleteObjects('photos', ['a.txt', 'b.txt']); expect(result).toEqual({ deleted: ['a.txt', 'b.txt'], failed: [], }); }); }); describe('getObjectStream', () => { function fakeStreamResponse(body: string) { return { Body: Readable.from([Buffer.from(body)]), ContentLength: body.length, ETag: '"x"', ContentType: 'text/plain', LastModified: new Date(), }; } it('streams from parent when branch is 404', async () => { sendMock .mockRejectedValueOnce(notFoundError()) .mockResolvedValueOnce(fakeStreamResponse('parent-stream')); const p = makeProvider('parentkey'); const out = await p.getObjectStream('photos', 'a.txt'); const chunks: Buffer[] = []; for await (const c of out.body) chunks.push(c as Buffer); expect(Buffer.concat(chunks).toString()).toBe('parent-stream'); }); it('throws when both branch and parent miss', async () => { sendMock.mockRejectedValueOnce(notFoundError()).mockRejectedValueOnce(notFoundError()); const p = makeProvider('parentkey'); await expect(p.getObjectStream('photos', 'a.txt')).rejects.toThrow(); }); }); describe('getDownloadStrategy presigned URL', () => { beforeEach(() => { delete process.env.AWS_CLOUDFRONT_URL; }); it('signs branch key when branch HEAD succeeds', async () => { sendMock.mockResolvedValueOnce({ ContentLength: 5, LastModified: new Date(), ETag: '"x"' }); const p = makeProvider('parentkey'); const strategy = await p.getDownloadStrategy('photos', 'a.txt'); expect(strategy.method).toBe('presigned'); expect(strategy.url).toContain('branchkey/photos/a.txt'); }); it('signs parent key when branch HEAD returns 404', async () => { // First call = branch HEAD → 404. sendMock.mockRejectedValueOnce(notFoundError('NotFound')); const p = makeProvider('parentkey'); const strategy = await p.getDownloadStrategy('photos', 'a.txt'); expect(strategy.url).toContain('parentkey/photos/a.txt'); }); it('non-branch project: no HEAD round-trip, signs branch key directly', async () => { const p = makeProvider(); // no parentAppKey const strategy = await p.getDownloadStrategy('photos', 'a.txt'); expect(strategy.url).toContain('branchkey/photos/a.txt'); // No HEAD call should have happened. expect(sendMock).not.toHaveBeenCalled(); }); it('defaults to branch key (no throw) when HEAD fails with non-404', async () => { // HEAD failures (network/IAM/throttling) shouldn't break URL gen. sendMock.mockRejectedValueOnce(Object.assign(new Error('throttled'), { name: 'SlowDown' })); const p = makeProvider('parentkey'); const strategy = await p.getDownloadStrategy('photos', 'a.txt'); expect(strategy.url).toContain('branchkey/photos/a.txt'); }); }); describe('getDownloadStrategy — CloudFront signed URL with cache-bust version', () => { // CloudFront canned-policy verification reconstructs `Resource` from the // request URL minus the three CF params (Expires/Signature/Key-Pair-Id). // Any *other* query — like our `?v=` — must be in the URL BEFORE // signing or CloudFront returns 403 SignatureDoesNotMatch on download. let testPrivateKey: string; const savedEnv: Record = {}; beforeAll(() => { const { privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs1', format: 'pem' }, publicKeyEncoding: { type: 'pkcs1', format: 'pem' }, }); testPrivateKey = privateKey; }); beforeEach(() => { for (const k of [ 'AWS_CLOUDFRONT_URL', 'AWS_CLOUDFRONT_KEY_PAIR_ID', 'AWS_CLOUDFRONT_PRIVATE_KEY', 'S3_ENDPOINT_URL', ]) { savedEnv[k] = process.env[k]; } process.env.AWS_CLOUDFRONT_URL = 'https://cdn.example.test'; process.env.AWS_CLOUDFRONT_KEY_PAIR_ID = 'K123TEST'; process.env.AWS_CLOUDFRONT_PRIVATE_KEY = testPrivateKey; delete process.env.S3_ENDPOINT_URL; }); afterEach(() => { for (const [k, v] of Object.entries(savedEnv)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } }); it('places v= before the CF signing params (i.e. v is part of the signed Resource)', async () => { const p = makeProvider(); // no parent — skip the HEAD round-trip const strategy = await p.getDownloadStrategy('photos', 'a.txt', 3600, false, 'etag-abc'); expect(strategy.method).toBe('presigned'); const url = new URL(strategy.url); expect(url.searchParams.get('v')).toBe('etag-abc'); // Order matters: getCloudFrontSignedUrl appends Expires/Signature/Key-Pair-Id // *after* the URL it signs. So v= sitting before Expires= proves v was // present at signing time and is therefore covered by the signature. const params = url.search.replace(/^\?/, '').split('&'); const vIdx = params.findIndex((p) => p.startsWith('v=')); const expiresIdx = params.findIndex((p) => p.startsWith('Expires=')); expect(vIdx).toBeGreaterThanOrEqual(0); expect(expiresIdx).toBeGreaterThanOrEqual(0); expect(vIdx).toBeLessThan(expiresIdx); }); it('omits v= when no version is supplied', async () => { const p = makeProvider(); const strategy = await p.getDownloadStrategy('photos', 'a.txt'); const url = new URL(strategy.url); expect(url.searchParams.get('v')).toBeNull(); expect(url.searchParams.get('Signature')).not.toBeNull(); }); it('URL-encodes version values with reserved characters', async () => { const p = makeProvider(); const strategy = await p.getDownloadStrategy('photos', 'a.txt', 3600, false, 'a&b c'); const url = new URL(strategy.url); expect(url.searchParams.get('v')).toBe('a&b c'); // Raw-form check: the literal `&` from the version must be percent-encoded // so it doesn't break the query into a separate param. expect(url.search).toContain('v=a%26b%20c'); }); }); describe('copyObject', () => { function copyOk(etag = 'cp') { return { CopyObjectResult: { ETag: `"${etag}"`, LastModified: new Date('2026-04-29') }, }; } it('copies from branch source on first hit', async () => { sendMock.mockResolvedValueOnce(copyOk()); const p = makeProvider('parentkey'); const out = await p.copyObject('photos', 'a.txt', 'photos', 'b.txt'); expect(out.etag).toBe('cp'); expect(sendMock).toHaveBeenCalledTimes(1); const cmd = sendMock.mock.calls[0][0] as CopyObjectCommand; expect(cmd.input.Key).toBe('branchkey/photos/b.txt'); expect(cmd.input.CopySource).toBe('bucket/branchkey/photos/a.txt'); }); it('falls back to parent source when branch returns NoSuchKey', async () => { sendMock.mockRejectedValueOnce(notFoundError('NoSuchKey')).mockResolvedValueOnce(copyOk()); const p = makeProvider('parentkey'); const out = await p.copyObject('photos', 'a.txt', 'photos', 'b.txt'); expect(out.etag).toBe('cp'); expect(sendMock).toHaveBeenCalledTimes(2); const branchCmd = sendMock.mock.calls[0][0] as CopyObjectCommand; const parentCmd = sendMock.mock.calls[1][0] as CopyObjectCommand; expect(branchCmd.input.CopySource).toBe('bucket/branchkey/photos/a.txt'); expect(parentCmd.input.CopySource).toBe('bucket/parentkey/photos/a.txt'); // Destination always stays on branch. expect(parentCmd.input.Key).toBe('branchkey/photos/b.txt'); }); it('rethrows non-404 errors instead of falling back', async () => { sendMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { name: 'AccessDenied' })); const p = makeProvider('parentkey'); await expect(p.copyObject('photos', 'a.txt', 'photos', 'b.txt')).rejects.toThrow(); expect(sendMock).toHaveBeenCalledTimes(1); }); it('does NOT fall back when no parent configured', async () => { sendMock.mockRejectedValueOnce(notFoundError('NoSuchKey')); const p = makeProvider(); await expect(p.copyObject('photos', 'a.txt', 'photos', 'b.txt')).rejects.toThrow(); expect(sendMock).toHaveBeenCalledTimes(1); }); }); describe('getDownloadStrategy expiry', () => { // Uses the real presigner (signs locally with the dummy creds), so the // returned URL actually carries `X-Amz-Expires` — proving the caller's // expiresIn reaches the signature, not just the function arguments. it('private bucket: caller expiresIn flows into the S3 presigned signature', async () => { const p = makeProvider(); // no parent -> no HEAD round-trip, pure S3 presign const strat = await p.getDownloadStrategy('photos', 'a.txt', 120, false); expect(strat.method).toBe('presigned'); expect(new URL(strat.url).searchParams.get('X-Amz-Expires')).toBe('120'); }); it('public bucket: expiry is overridden to 7 days regardless of caller value', async () => { const p = makeProvider(); const strat = await p.getDownloadStrategy('photos', 'a.txt', 120, true); expect(strat.method).toBe('presigned'); expect(new URL(strat.url).searchParams.get('X-Amz-Expires')).toBe('604800'); }); }); });