add local ciphertext storage module to backend.
Bind-mount backend/uploads in dev; path helpers with traversal guards and Jest coverage for StorageService.
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StorageService } from './services/StorageService';
|
||||
|
||||
@Module({
|
||||
providers: [StorageService],
|
||||
exports: [StorageService]
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { access, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { getUploadsDir, resolveStoragePath } from '../../../config/uploadPaths';
|
||||
import { StorageService } from './StorageService';
|
||||
|
||||
describe('StorageService', () => {
|
||||
let service: StorageService;
|
||||
let previousCwd: string;
|
||||
let tempRoot: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
service = new StorageService();
|
||||
previousCwd = process.cwd();
|
||||
tempRoot = await mkdtemp(join(tmpdir(), 'storage-service-'));
|
||||
process.chdir(tempRoot);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.chdir(previousCwd);
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes and reads ciphertext bytes', async () => {
|
||||
const storageKey = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
const data = Buffer.from('ciphertext-bytes');
|
||||
|
||||
await service.write(storageKey, data);
|
||||
|
||||
expect(await service.read(storageKey)).toEqual(data);
|
||||
});
|
||||
|
||||
it('creates the uploads directory on write', async () => {
|
||||
await service.write('file-id', Buffer.from('x'));
|
||||
|
||||
await expect(access(getUploadsDir())).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('writes nested storage keys under uploads', async () => {
|
||||
const storageKey = 'nested/file-id';
|
||||
const data = Buffer.from('nested');
|
||||
|
||||
await service.write(storageKey, data);
|
||||
|
||||
expect(await service.read(storageKey)).toEqual(data);
|
||||
});
|
||||
|
||||
it('unlinks a stored file', async () => {
|
||||
const storageKey = 'to-delete';
|
||||
|
||||
await service.write(storageKey, Buffer.from('delete-me'));
|
||||
await service.unlink(storageKey);
|
||||
|
||||
await expect(access(resolveStoragePath(storageKey))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it.each(['../../escape', '../outside', '/etc/passwd', ''])(
|
||||
'rejects invalid storage key on write: %s',
|
||||
async storageKey => {
|
||||
await expect(service.write(storageKey, Buffer.from('x'))).rejects.toThrow('Invalid storage key');
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['../../escape', '../outside', '/etc/passwd', ''])(
|
||||
'rejects invalid storage key on read: %s',
|
||||
storageKey => {
|
||||
expect(() => service.read(storageKey)).toThrow('Invalid storage key');
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['../../escape', '../outside', '/etc/passwd', ''])(
|
||||
'rejects invalid storage key on unlink: %s',
|
||||
storageKey => {
|
||||
expect(() => service.unlink(storageKey)).toThrow('Invalid storage key');
|
||||
}
|
||||
);
|
||||
|
||||
it('throws when reading a missing file', async () => {
|
||||
await expect(service.read('missing-id')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('throws when unlinking a missing file', async () => {
|
||||
await expect(service.unlink('missing-id')).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
||||
it('overwrites an existing storage key on second write', async () => {
|
||||
const storageKey = 'overwrite-me';
|
||||
|
||||
await service.write(storageKey, Buffer.from('first'));
|
||||
await service.write(storageKey, Buffer.from('second'));
|
||||
|
||||
expect(await service.read(storageKey)).toEqual(Buffer.from('second'));
|
||||
});
|
||||
|
||||
it('keeps separate storage keys isolated', async () => {
|
||||
await service.write('key-a', Buffer.from('a'));
|
||||
await service.write('key-b', Buffer.from('b'));
|
||||
|
||||
expect(await service.read('key-a')).toEqual(Buffer.from('a'));
|
||||
expect(await service.read('key-b')).toEqual(Buffer.from('b'));
|
||||
});
|
||||
|
||||
it('round-trips an empty buffer', async () => {
|
||||
const storageKey = 'empty';
|
||||
const data = Buffer.alloc(0);
|
||||
|
||||
await service.write(storageKey, data);
|
||||
|
||||
expect(await service.read(storageKey)).toEqual(data);
|
||||
});
|
||||
|
||||
it('round-trips binary ciphertext bytes', async () => {
|
||||
const storageKey = 'binary';
|
||||
const data = Buffer.from([0x00, 0xff, 0x10, 0x89, 0xab]);
|
||||
|
||||
await service.write(storageKey, data);
|
||||
|
||||
expect(await service.read(storageKey)).toEqual(data);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { mkdir, readFile, unlink as fsUnlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { resolveStoragePath } from '../../../config/uploadPaths';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
async write(storageKey: string, data: Buffer): Promise<void> {
|
||||
const absolutePath = resolveStoragePath(storageKey);
|
||||
const dir = dirname(absolutePath);
|
||||
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
return writeFile(absolutePath, data);
|
||||
}
|
||||
|
||||
read(storageKey: string): Promise<Buffer> {
|
||||
const absolutePath = resolveStoragePath(storageKey);
|
||||
|
||||
return readFile(absolutePath);
|
||||
}
|
||||
|
||||
unlink(storageKey: string): Promise<void> {
|
||||
const absolutePath = resolveStoragePath(storageKey);
|
||||
|
||||
return fsUnlink(absolutePath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user