mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
139 lines
5.2 KiB
TypeScript
139 lines
5.2 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { Readable } from 'node:stream';
|
|
import { LocalDriver } from '../../../../src/nest/storage/drivers/local.driver';
|
|
import { StorageInvalidKeyError } from '../../../../src/nest/storage/storage.types';
|
|
import { describeStorageDriver, type DriverHarness } from './storage-driver.contract';
|
|
|
|
function makeTmpDir(): string {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'trek-storage-'));
|
|
}
|
|
|
|
async function makeHarness(): Promise<DriverHarness> {
|
|
const root = makeTmpDir();
|
|
const tmpFiles = makeTmpDir();
|
|
const driver = new LocalDriver({ id: 'test-local', root });
|
|
driver.init({ cleanSpool: true });
|
|
let counter = 0;
|
|
return {
|
|
driver,
|
|
makeTempFile: async (contents) => {
|
|
const p = path.join(tmpFiles, `src-${counter++}.tmp`);
|
|
fs.writeFileSync(p, contents);
|
|
return p;
|
|
},
|
|
cleanup: async () => {
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
fs.rmSync(tmpFiles, { recursive: true, force: true });
|
|
},
|
|
};
|
|
}
|
|
|
|
describeStorageDriver('LocalDriver', makeHarness);
|
|
|
|
describe('LocalDriver specifics', () => {
|
|
const dirs: string[] = [];
|
|
const tmp = (): string => {
|
|
const d = makeTmpDir();
|
|
dirs.push(d);
|
|
return d;
|
|
};
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true });
|
|
});
|
|
|
|
it('realpaths a symlinked root and confines keys to the real directory', async () => {
|
|
// Docker symlinks BOTH storage anchors (/app/server/uploads → /app/uploads,
|
|
// /app/server/data → /app/data), so this is the deployed shape, not an edge.
|
|
const realdir = tmp();
|
|
const link = path.join(tmp(), 'uploads-link');
|
|
fs.symlinkSync(realdir, link);
|
|
|
|
const driver = new LocalDriver({ id: 'sym', root: link });
|
|
driver.init();
|
|
await driver.put('files/x.bin', Readable.from('sym'));
|
|
|
|
const local = driver.getLocalPath('files/x.bin');
|
|
expect(local.startsWith(fs.realpathSync(realdir) + path.sep)).toBe(true);
|
|
expect(fs.readFileSync(local, 'utf8')).toBe('sym');
|
|
expect(() => driver.getLocalPath('../escape')).toThrow(StorageInvalidKeyError);
|
|
});
|
|
|
|
it('cleans aged spool leftovers only at boot, never on reload-style init', () => {
|
|
const driver = new LocalDriver({ id: 'spool', root: tmp() });
|
|
driver.init();
|
|
const stray = path.join(driver.getSpoolDir(), 'leftover.part');
|
|
fs.writeFileSync(stray, 'in-flight upload');
|
|
// Crash leftover: old enough to clear the reap age gate.
|
|
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
|
fs.utimesSync(stray, old, old);
|
|
|
|
driver.init(); // reload(): must NOT delete an in-flight upload's spool file
|
|
expect(fs.existsSync(stray)).toBe(true);
|
|
|
|
driver.init({ cleanSpool: true }); // boot: crash leftovers are reclaimed
|
|
expect(fs.existsSync(stray)).toBe(false);
|
|
});
|
|
|
|
it('the boot spool sweep spares fresh entries (another process may be mid-upload)', () => {
|
|
const driver = new LocalDriver({ id: 'spool-fresh', root: tmp() });
|
|
driver.init();
|
|
const inflight = path.join(driver.getSpoolDir(), 'inflight.part');
|
|
fs.writeFileSync(inflight, 'spooling right now');
|
|
|
|
driver.init({ cleanSpool: true });
|
|
expect(fs.existsSync(inflight)).toBe(true);
|
|
});
|
|
|
|
it('creates its root, spool, and category prefix dirs at init', () => {
|
|
const root = path.join(tmp(), 'nested', 'uploads');
|
|
const driver = new LocalDriver({ id: 'pfx', root });
|
|
driver.init({ ensurePrefixes: ['files', 'photos/trek'] });
|
|
|
|
expect(fs.statSync(path.join(root, '.tmp')).isDirectory()).toBe(true);
|
|
expect(fs.statSync(path.join(root, 'files')).isDirectory()).toBe(true);
|
|
expect(fs.statSync(path.join(root, 'photos/trek')).isDirectory()).toBe(true);
|
|
});
|
|
|
|
it('falls back to copy+unlink when a temp-file rename crosses volumes (EXDEV)', async () => {
|
|
const root = tmp();
|
|
const driver = new LocalDriver({ id: 'exdev', root });
|
|
driver.init();
|
|
const src = path.join(tmp(), 'other-volume.bin');
|
|
fs.writeFileSync(src, 'exdev payload');
|
|
|
|
vi.spyOn(fs.promises, 'rename').mockRejectedValueOnce(
|
|
Object.assign(new Error('EXDEV: cross-device link not permitted'), { code: 'EXDEV' }),
|
|
);
|
|
|
|
await driver.put('files/moved.bin', { tmpPath: src });
|
|
expect(fs.readFileSync(path.join(root, 'files/moved.bin'), 'utf8')).toBe('exdev payload');
|
|
expect(fs.existsSync(src)).toBe(false);
|
|
});
|
|
|
|
it('commits stream puts via a same-volume spool rename', async () => {
|
|
const root = tmp();
|
|
const driver = new LocalDriver({ id: 'samevol', root });
|
|
driver.init();
|
|
|
|
const renameSpy = vi.spyOn(fs.promises, 'rename');
|
|
await driver.put('files/spooled.bin', Readable.from('data'));
|
|
|
|
const [from, to] = renameSpy.mock.calls.at(-1)!;
|
|
expect(String(from).startsWith(path.join(fs.realpathSync(root), '.tmp') + path.sep)).toBe(true);
|
|
expect(String(to)).toBe(path.join(fs.realpathSync(root), 'files/spooled.bin'));
|
|
});
|
|
|
|
it('lists a missing prefix as empty', async () => {
|
|
const driver = new LocalDriver({ id: 'empty', root: tmp() });
|
|
driver.init();
|
|
const seen: unknown[] = [];
|
|
for await (const stat of driver.list('nope/')) seen.push(stat);
|
|
expect(seen).toEqual([]);
|
|
});
|
|
});
|