267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { S3FileStore, type S3Config } from '../src/file-store.js';
|
|
|
|
// ── Mock @aws-sdk/client-s3 ────────────────────────────────────────
|
|
|
|
const mockSend = vi.fn();
|
|
|
|
type CommandInput = Record<string, unknown>;
|
|
|
|
class MockS3Client {
|
|
send = mockSend;
|
|
constructor(_config: unknown) {}
|
|
}
|
|
|
|
vi.mock('@aws-sdk/client-s3', () => {
|
|
return {
|
|
S3Client: MockS3Client,
|
|
GetObjectCommand: class { [k: string]: unknown; _type = 'GetObject'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
|
PutObjectCommand: class { [k: string]: unknown; _type = 'PutObject'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
|
DeleteObjectCommand: class { [k: string]: unknown; _type = 'DeleteObject'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
|
ListObjectsV2Command: class { [k: string]: unknown; _type = 'ListObjects'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
|
CopyObjectCommand: class { [k: string]: unknown; _type = 'CopyObject'; constructor(input: CommandInput) { Object.assign(this, input); } },
|
|
};
|
|
});
|
|
|
|
// ── Test setup ──────────────────────────────────────────────────────
|
|
|
|
const testConfig: S3Config = {
|
|
endpoint: 'minio:9000',
|
|
bucket: 'waggle-files',
|
|
accessKey: 'waggle',
|
|
secretKey: 'waggle_s3_prod',
|
|
prefix: 'workspaces/ws-123/',
|
|
};
|
|
|
|
describe('S3FileStore', () => {
|
|
let store: S3FileStore;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
store = new S3FileStore(testConfig);
|
|
});
|
|
|
|
// ── Meta ────────────────────────────────────────────────────────
|
|
|
|
it('getStorageType returns virtual', () => {
|
|
expect(store.getStorageType()).toBe('virtual');
|
|
});
|
|
|
|
it('getRootPath returns s3:// URL', () => {
|
|
expect(store.getRootPath()).toBe('s3://waggle-files/workspaces/ws-123/');
|
|
});
|
|
|
|
// ── writeFile ───────────────────────────────────────────────────
|
|
|
|
it('writeFile sends PutObjectCommand with correct bucket and key', async () => {
|
|
mockSend.mockResolvedValueOnce({});
|
|
|
|
await store.writeFile('docs/notes.md', 'hello world');
|
|
|
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
const cmd = mockSend.mock.calls[0][0];
|
|
expect(cmd._type).toBe('PutObject');
|
|
expect(cmd.Bucket).toBe('waggle-files');
|
|
expect(cmd.Key).toBe('workspaces/ws-123/docs/notes.md');
|
|
expect(cmd.Body).toEqual(Buffer.from('hello world'));
|
|
});
|
|
|
|
it('writeFile accepts Buffer content', async () => {
|
|
mockSend.mockResolvedValueOnce({});
|
|
const buf = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
|
|
|
|
await store.writeFile('image.png', buf);
|
|
|
|
const cmd = mockSend.mock.calls[0][0];
|
|
expect(cmd.Body).toBe(buf);
|
|
});
|
|
|
|
// ── readFile ────────────────────────────────────────────────────
|
|
|
|
it('readFile sends GetObjectCommand and returns Buffer', async () => {
|
|
const chunks = [Buffer.from('chunk1'), Buffer.from('chunk2')];
|
|
const asyncIterable = {
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const c of chunks) yield c;
|
|
},
|
|
};
|
|
mockSend.mockResolvedValueOnce({ Body: asyncIterable });
|
|
|
|
const result = await store.readFile('docs/notes.md');
|
|
|
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
const cmd = mockSend.mock.calls[0][0];
|
|
expect(cmd._type).toBe('GetObject');
|
|
expect(cmd.Bucket).toBe('waggle-files');
|
|
expect(cmd.Key).toBe('workspaces/ws-123/docs/notes.md');
|
|
expect(Buffer.isBuffer(result)).toBe(true);
|
|
expect(result.toString()).toBe('chunk1chunk2');
|
|
});
|
|
|
|
// ── deleteFile ──────────────────────────────────────────────────
|
|
|
|
it('deleteFile sends DeleteObjectCommand', async () => {
|
|
mockSend.mockResolvedValueOnce({});
|
|
|
|
await store.deleteFile('old-file.txt');
|
|
|
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
const cmd = mockSend.mock.calls[0][0];
|
|
expect(cmd._type).toBe('DeleteObject');
|
|
expect(cmd.Bucket).toBe('waggle-files');
|
|
expect(cmd.Key).toBe('workspaces/ws-123/old-file.txt');
|
|
});
|
|
|
|
// ── listFiles ───────────────────────────────────────────────────
|
|
|
|
it('listFiles sends ListObjectsV2Command and returns FileEntry[]', async () => {
|
|
mockSend.mockResolvedValueOnce({
|
|
Contents: [
|
|
{ Key: 'workspaces/ws-123/readme.md', Size: 1024, LastModified: new Date('2025-06-01T00:00:00Z') },
|
|
{ Key: 'workspaces/ws-123/src/index.ts', Size: 512, LastModified: new Date('2025-06-02T00:00:00Z') },
|
|
],
|
|
CommonPrefixes: [
|
|
{ Prefix: 'workspaces/ws-123/docs/' },
|
|
],
|
|
});
|
|
|
|
const entries = await store.listFiles();
|
|
|
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
const cmd = mockSend.mock.calls[0][0];
|
|
expect(cmd._type).toBe('ListObjects');
|
|
expect(cmd.Bucket).toBe('waggle-files');
|
|
expect(cmd.Prefix).toBe('workspaces/ws-123/');
|
|
expect(cmd.Delimiter).toBe('/');
|
|
|
|
expect(entries).toHaveLength(3);
|
|
// File entries
|
|
expect(entries[0]).toEqual({
|
|
name: 'readme.md',
|
|
path: 'readme.md',
|
|
size: 1024,
|
|
modified: '2025-06-01T00:00:00.000Z',
|
|
isDirectory: false,
|
|
});
|
|
expect(entries[1]).toEqual({
|
|
name: 'index.ts',
|
|
path: 'src/index.ts',
|
|
size: 512,
|
|
modified: '2025-06-02T00:00:00.000Z',
|
|
isDirectory: false,
|
|
});
|
|
// Directory entry
|
|
expect(entries[2]).toEqual({
|
|
name: 'docs',
|
|
path: 'docs',
|
|
size: 0,
|
|
modified: '',
|
|
isDirectory: true,
|
|
});
|
|
});
|
|
|
|
it('listFiles with directory argument appends to prefix', async () => {
|
|
mockSend.mockResolvedValueOnce({ Contents: [], CommonPrefixes: [] });
|
|
|
|
await store.listFiles('src');
|
|
|
|
const cmd = mockSend.mock.calls[0][0];
|
|
expect(cmd.Prefix).toBe('workspaces/ws-123/src/');
|
|
});
|
|
|
|
it('listFiles handles empty response', async () => {
|
|
mockSend.mockResolvedValueOnce({});
|
|
|
|
const entries = await store.listFiles();
|
|
expect(entries).toEqual([]);
|
|
});
|
|
|
|
// ── moveFile ────────────────────────────────────────────────────
|
|
|
|
it('moveFile copies then deletes', async () => {
|
|
mockSend.mockResolvedValueOnce({}); // CopyObject
|
|
mockSend.mockResolvedValueOnce({}); // DeleteObject
|
|
|
|
await store.moveFile('old.txt', 'new.txt');
|
|
|
|
expect(mockSend).toHaveBeenCalledTimes(2);
|
|
const copyCmd = mockSend.mock.calls[0][0];
|
|
expect(copyCmd._type).toBe('CopyObject');
|
|
expect(copyCmd.CopySource).toBe('waggle-files/workspaces/ws-123/old.txt');
|
|
expect(copyCmd.Key).toBe('workspaces/ws-123/new.txt');
|
|
|
|
const deleteCmd = mockSend.mock.calls[1][0];
|
|
expect(deleteCmd._type).toBe('DeleteObject');
|
|
expect(deleteCmd.Key).toBe('workspaces/ws-123/old.txt');
|
|
});
|
|
|
|
// ── searchFiles ─────────────────────────────────────────────────
|
|
|
|
it('searchFiles filters listFiles results by pattern', async () => {
|
|
mockSend.mockResolvedValueOnce({
|
|
Contents: [
|
|
{ Key: 'workspaces/ws-123/readme.md', Size: 100, LastModified: new Date() },
|
|
{ Key: 'workspaces/ws-123/notes.txt', Size: 200, LastModified: new Date() },
|
|
{ Key: 'workspaces/ws-123/data.md', Size: 50, LastModified: new Date() },
|
|
],
|
|
CommonPrefixes: [],
|
|
});
|
|
|
|
const results = await store.searchFiles('*.md');
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0].name).toBe('readme.md');
|
|
expect(results[1].name).toBe('data.md');
|
|
});
|
|
|
|
it('searchFiles is ReDoS-safe with a hostile regex pattern', async () => {
|
|
mockSend.mockResolvedValueOnce({
|
|
Contents: [{ Key: 'workspaces/ws-123/' + 'a'.repeat(40) + 'X', Size: 1, LastModified: new Date() }],
|
|
CommonPrefixes: [],
|
|
});
|
|
const start = Date.now();
|
|
// Under the old `new RegExp(pattern…)` this built /(a+)+$/i and catastrophically
|
|
// backtracked on the key. Now the metachars are escaped → a literal match → instant.
|
|
const results = await store.searchFiles('(a+)+$');
|
|
expect(Date.now() - start).toBeLessThan(1000);
|
|
expect(results).toHaveLength(0); // matched literally; no key contains "(a+)+$"
|
|
});
|
|
|
|
// ── key traversal protection ────────────────────────────────────
|
|
|
|
it('rejects ../ traversal in every op BEFORE any S3 call', async () => {
|
|
await expect(store.readFile('../ws-456/secret')).rejects.toThrow(/traversal denied/i);
|
|
await expect(store.writeFile('../ws-456/x', 'y')).rejects.toThrow(/traversal denied/i);
|
|
await expect(store.deleteFile('../../etc/passwd')).rejects.toThrow(/traversal denied/i);
|
|
await expect(store.moveFile('ok', '../ws-456/b')).rejects.toThrow(/traversal denied/i);
|
|
await expect(store.listFiles('../ws-456')).rejects.toThrow(/traversal denied/i);
|
|
expect(mockSend).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('allows a normal nested key', async () => {
|
|
mockSend.mockResolvedValueOnce({});
|
|
await store.writeFile('docs/sub/file.md', 'x');
|
|
expect(mockSend.mock.calls[0][0].Key).toBe('workspaces/ws-123/docs/sub/file.md');
|
|
});
|
|
|
|
// ── getStorageInfo ──────────────────────────────────────────────
|
|
|
|
it('getStorageInfo sums sizes from S3 listing', async () => {
|
|
mockSend.mockResolvedValueOnce({
|
|
Contents: [
|
|
{ Key: 'workspaces/ws-123/a.txt', Size: 100 },
|
|
{ Key: 'workspaces/ws-123/b.txt', Size: 250 },
|
|
{ Key: 'workspaces/ws-123/c.txt', Size: 50 },
|
|
],
|
|
});
|
|
|
|
const info = await store.getStorageInfo();
|
|
|
|
expect(info).toEqual({
|
|
usedBytes: 400,
|
|
fileCount: 3,
|
|
storageType: 'virtual',
|
|
});
|
|
});
|
|
});
|