Files
gbrain/test/storage.test.ts
Garry Tan f82978d38d security: fix wave 2 — 5 vulns + typed health check DSL (v0.9.3) (#95)
* security: path traversal, query bounds, marker injection fixes

LocalStorage: contained() method validates all paths stay within storage root.
file-resolver: resolveFile validates filePath within brainRoot, marker prefix
rejects ../, absolute paths, bare '..'. file_list: LIMIT 100 on slug-filtered
branch + FILE_LIST_LIMIT constant for both branches.

Co-Authored-By: Gus <garagon@users.noreply.github.com>

* security: symlink hardening in all file walkers

All 4 walkers in files.ts (collectFiles, findRedirects, findAndClean, scan)
plus init.ts counter now use lstatSync + isSymbolicLink skip. Tests import
production collectFiles instead of reimplementing it. node_modules skipped.
CLI file list and verify queries bounded with LIMIT.

Co-Authored-By: Gus <garagon@users.noreply.github.com>

* feat: typed health check DSL + recipe migration

4 DSL types: http, env_exists, command, any_of. Replaces raw execSync
on recipe YAML. All 7 first-party recipes migrated from shell strings
to typed objects. String health_checks still accepted with deprecation
warning + metachar validation for non-embedded recipes. isUnsafeHealthCheck
blocks shell injection for user-created recipes.

Co-Authored-By: Gus <garagon@users.noreply.github.com>

* chore: bump version and changelog (v0.9.3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: E2E test for file_list LIMIT enforcement against real Postgres

Inserts 150 file rows for one slug, verifies file_list returns at most
100 (both slug-filtered and unfiltered branches). Proves the LIMIT
works at the database level, not just in unit tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Gus <garagon@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 07:49:13 -10:00

169 lines
6.0 KiB
TypeScript

import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { LocalStorage } from '../src/core/storage/local.ts';
import { createStorage } from '../src/core/storage.ts';
describe('LocalStorage', () => {
let storage: LocalStorage;
let tmpDir: string;
beforeAll(() => {
tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-storage-test-'));
storage = new LocalStorage(tmpDir);
});
afterAll(() => {
rmSync(tmpDir, { recursive: true });
});
test('upload creates file', async () => {
await storage.upload('test/file.txt', Buffer.from('hello'));
expect(existsSync(join(tmpDir, 'test/file.txt'))).toBe(true);
});
test('download returns uploaded data', async () => {
await storage.upload('test/roundtrip.bin', Buffer.from('binary data'));
const data = await storage.download('test/roundtrip.bin');
expect(data.toString()).toBe('binary data');
});
test('download throws for missing file', async () => {
expect(storage.download('nonexistent.txt')).rejects.toThrow('not found');
});
test('exists returns true for uploaded file', async () => {
await storage.upload('test/exists.txt', Buffer.from('x'));
expect(await storage.exists('test/exists.txt')).toBe(true);
});
test('exists returns false for missing file', async () => {
expect(await storage.exists('nope.txt')).toBe(false);
});
test('delete removes file', async () => {
await storage.upload('test/deleteme.txt', Buffer.from('x'));
await storage.delete('test/deleteme.txt');
expect(await storage.exists('test/deleteme.txt')).toBe(false);
});
test('delete is idempotent (missing file is ok)', async () => {
await storage.delete('already-gone.txt');
// No throw
});
test('list returns uploaded files', async () => {
await storage.upload('listdir/a.txt', Buffer.from('a'));
await storage.upload('listdir/b.txt', Buffer.from('b'));
await storage.upload('listdir/sub/c.txt', Buffer.from('c'));
const files = await storage.list('listdir');
expect(files.length).toBe(3);
expect(files).toContain('listdir/a.txt');
expect(files).toContain('listdir/b.txt');
expect(files).toContain('listdir/sub/c.txt');
});
test('list returns empty for missing prefix', async () => {
const files = await storage.list('nonexistent-prefix');
expect(files.length).toBe(0);
});
test('getUrl returns file:// URL', async () => {
const url = await storage.getUrl('test/file.txt');
expect(url.startsWith('file://')).toBe(true);
});
});
// --- Path traversal containment ---
describe('LocalStorage path traversal', () => {
test('blocks upload path traversal via ../', async () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
try {
const storage = new LocalStorage(tmpDir);
await expect(storage.upload('../../etc/evil', Buffer.from('pwned'))).rejects.toThrow('Path traversal blocked');
await expect(storage.upload('../sibling/file', Buffer.from('x'))).rejects.toThrow('Path traversal blocked');
} finally {
rmSync(tmpDir, { recursive: true });
}
});
test('blocks download path traversal via ../', async () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
try {
const storage = new LocalStorage(tmpDir);
await expect(storage.download('../../etc/passwd')).rejects.toThrow('Path traversal blocked');
} finally {
rmSync(tmpDir, { recursive: true });
}
});
test('blocks delete path traversal via ../', async () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
try {
const storage = new LocalStorage(tmpDir);
await expect(storage.delete('../../../tmp/important')).rejects.toThrow('Path traversal blocked');
} finally {
rmSync(tmpDir, { recursive: true });
}
});
test('blocks list path traversal via ../', async () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
try {
const storage = new LocalStorage(tmpDir);
await expect(storage.list('../../etc')).rejects.toThrow('Path traversal blocked');
} finally {
rmSync(tmpDir, { recursive: true });
}
});
test('blocks getUrl path traversal via ../', async () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
try {
const storage = new LocalStorage(tmpDir);
await expect(storage.getUrl('../../etc/passwd')).rejects.toThrow('Path traversal blocked');
} finally {
rmSync(tmpDir, { recursive: true });
}
});
test('allows legitimate nested paths', async () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-traversal-'));
try {
const storage = new LocalStorage(tmpDir);
await storage.upload('pages/people/elon/avatar.png', Buffer.from('img'));
const data = await storage.download('pages/people/elon/avatar.png');
expect(data.toString()).toBe('img');
} finally {
rmSync(tmpDir, { recursive: true });
}
});
});
describe('createStorage', () => {
test('creates LocalStorage for backend: local', async () => {
const tmpDir = mkdtempSync(join(tmpdir(), 'gbrain-factory-test-'));
try {
const storage = await createStorage({ backend: 'local', bucket: 'test', localPath: tmpDir });
await storage.upload('test.txt', Buffer.from('hello'));
expect(await storage.exists('test.txt')).toBe(true);
} finally {
rmSync(tmpDir, { recursive: true });
}
});
test('throws for unknown backend', async () => {
expect(createStorage({ backend: 'unknown' as any, bucket: 'test' })).rejects.toThrow('Unknown storage backend');
});
test('S3Storage requires credentials', async () => {
expect(createStorage({ backend: 's3', bucket: 'test' })).rejects.toThrow('accessKeyId');
});
test('SupabaseStorage requires projectUrl', async () => {
expect(createStorage({ backend: 'supabase', bucket: 'test' })).rejects.toThrow('projectUrl');
});
});