433 lines
17 KiB
TypeScript
433 lines
17 KiB
TypeScript
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<Uint8Array> {
|
|
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<typeof vi.fn>;
|
|
|
|
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=<etag>` — must be in the URL BEFORE
|
|
// signing or CloudFront returns 403 SignatureDoesNotMatch on download.
|
|
let testPrivateKey: string;
|
|
const savedEnv: Record<string, string | undefined> = {};
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|