Files
waggle-os/packages/core/tests/file-store-s3.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

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',
});
});
});