import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { LocalFsStore } from '../../../src/lib/server/storage/local-fs-store'; import { PrefixedStore } from '../../../src/lib/server/storage/prefixed-store'; import { decodeText, encodeText } from '../../../src/lib/server/storage/store'; describe('PrefixedStore', () => { let dir: string; let inner: LocalFsStore; let a: PrefixedStore; let b: PrefixedStore; beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'splitwise-prefixed-')); inner = new LocalFsStore(dir); a = new PrefixedStore(inner, 'tenants/a'); b = new PrefixedStore(inner, '/tenants/b/'); }); afterEach(() => rm(dir, { recursive: true, force: true })); it('stores objects under the prefix and reads them back', async () => { await a.put('users.json', encodeText('A'), 'application/json'); expect(decodeText((await a.get('users.json'))!)).toBe('A'); expect(decodeText((await inner.get('tenants/a/users.json'))!)).toBe('A'); expect(await b.get('users.json')).toBeNull(); }); it('lists with the prefix stripped and never leaks other prefixes', async () => { await a.put('events/1.json', encodeText('1')); await a.put('events/2.json', encodeText('2')); await b.put('events/9.json', encodeText('9')); expect((await a.list('events/')).map((e) => e.path)).toEqual(['events/1.json', 'events/2.json']); expect((await a.list('')).map((e) => e.path)).toEqual(['events/1.json', 'events/2.json']); expect((await b.list('events/')).map((e) => e.path)).toEqual(['events/9.json']); }); it('rejects a list prefix without a trailing slash', async () => { await expect(a.list('events')).rejects.toThrow(/Invalid prefix/); }); it('appends, copies and deletes inside the prefix', async () => { await a.append('ledger.jsonl', encodeText('x\n')); await a.append('ledger.jsonl', encodeText('y\n')); await a.copy('ledger.jsonl', 'backups/l.jsonl'); expect(decodeText((await inner.get('tenants/a/backups/l.jsonl'))!)).toBe('x\ny\n'); await a.delete(['ledger.jsonl', 'missing.txt']); expect(await a.get('ledger.jsonl')).toBeNull(); }); it('wipe removes only its own subtree', async () => { await a.put('x.txt', encodeText('x')); await a.put('deep/y.txt', encodeText('y')); await b.put('z.txt', encodeText('z')); expect(await a.wipe()).toBe(2); expect(await a.list('')).toEqual([]); expect(await b.get('z.txt')).not.toBeNull(); }); it('rejects invalid prefixes', () => { expect(() => new PrefixedStore(inner, '')).toThrow(); expect(() => new PrefixedStore(inner, '../x')).toThrow(); }); it('rejects paths that escape the prefix', async () => { await expect(a.get('../b/z.txt')).rejects.toThrow(); await expect(a.put('../../x', encodeText('x'))).rejects.toThrow(); }); });