import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { contentTypeForPath, createLocalStorage, isSafeObjectPath, resolveObjectPath, StoragePathError, type Storage, } from '~/lib/storage' let root: string let storage: Storage beforeAll(async () => { root = await mkdtemp(join(tmpdir(), 'logbuch-storage-')) storage = createLocalStorage(root) }) afterAll(async () => { await rm(root, { recursive: true, force: true }) }) const escapes = [ '../secret.txt', '..', 'a/../../secret.txt', '/etc/passwd', './a.webp', 'a//b.webp', 'a/./b.webp', '..\\secret.txt', 'a\\b.webp', '', 'C:/windows/win.ini', ] describe('isSafeObjectPath', () => { it('nimmt gewöhnliche Objektpfade an', () => { expect(isSafeObjectPath('project/media/w960.webp')).toBe(true) expect(isSafeObjectPath('a.webp')).toBe(true) }) it('weist jeden Ausbruch aus dem Speicher ab', () => { for (const path of escapes) { expect(isSafeObjectPath(path), path).toBe(false) } }) it('weist Nullbytes und Steuerzeichen ab', () => { expect(isSafeObjectPath('a\u0000b.webp')).toBe(false) expect(isSafeObjectPath('a\nb.webp')).toBe(false) }) }) describe('resolveObjectPath', () => { it('bleibt innerhalb des Speicherverzeichnisses', () => { expect(resolveObjectPath(root, 'a/b.webp')).toBe(resolve(root, 'a/b.webp')) }) it('wirft bei jedem Ausbruchsversuch', () => { for (const path of escapes) { expect(() => resolveObjectPath(root, path), path).toThrow(StoragePathError) } }) }) describe('lokaler Speicher', () => { it('legt ab, liest zurück und meldet den Inhaltstyp', async () => { await storage.put('projekt/bild/w480.webp', Buffer.from('inhalt'), 'image/webp') const object = await storage.read('projekt/bild/w480.webp') expect(object?.body.toString()).toBe('inhalt') expect(object?.contentType).toBe('image/webp') expect(object?.byteSize).toBe(6) }) it('meldet fehlende Objekte als undefined', async () => { expect(await storage.read('projekt/bild/gibtsnicht.webp')).toBeUndefined() }) it('legt keine Datei ausserhalb des Verzeichnisses an', async () => { await expect(storage.put('../ausbruch.webp', Buffer.from('nein'), 'image/webp')).rejects.toThrow(StoragePathError) }) it('liest keine Datei ausserhalb des Verzeichnisses', async () => { const outside = resolve(root, '..', 'logbuch-outside.txt') await writeFile(outside, 'geheim') try { await expect(storage.read('../logbuch-outside.txt')).rejects.toThrow(StoragePathError) expect(await readFile(outside, 'utf8')).toBe('geheim') } finally { await rm(outside, { force: true }) } }) it('entfernt Objekte und meldet ihre Abwesenheit', async () => { await storage.put('projekt/bild/weg.webp', Buffer.from('x'), 'image/webp') expect(await storage.exists('projekt/bild/weg.webp')).toBe(true) await storage.remove('projekt/bild/weg.webp') expect(await storage.exists('projekt/bild/weg.webp')).toBe(false) }) it('baut eine Auslieferungs-URL unter dem Medienpfad', () => { expect(storage.url('projekt/bild/w960.webp')).toBe('/api/v1/media/file/projekt/bild/w960.webp') }) }) describe('contentTypeForPath', () => { it('kennt die Bildformate', () => { expect(contentTypeForPath('a/b.webp')).toBe('image/webp') expect(contentTypeForPath('a/b.PNG')).toBe('image/png') expect(contentTypeForPath('a/b.jpg')).toBe('image/jpeg') }) it('fällt bei Unbekanntem auf Bytes zurück', () => { expect(contentTypeForPath('a/b.bin')).toBe('application/octet-stream') expect(contentTypeForPath('ohnepunkt')).toBe('application/octet-stream') }) })