import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import sharp from 'sharp' import { db } from '~/data/db' import { apiClients, brands, media, projects } from '~/data/schema' import { hashToken } from '~/lib/api-auth' import { sanitiseFilename } from '~/lib/media-upload' import { POST as postMedia } from '~/app/api/v1/media/route' import { GET as getFile } from '~/app/api/v1/media/file/[...path]/route' let root: string let previous: string | undefined beforeAll(async () => { previous = process.env.STORAGE_DIR root = await mkdtemp(join(tmpdir(), 'logbuch-media-')) process.env.STORAGE_DIR = root }) afterAll(async () => { if (previous === undefined) { delete process.env.STORAGE_DIR } else { process.env.STORAGE_DIR = previous } await rm(root, { recursive: true, force: true }) }) async function seed() { const [brand] = await db.insert(brands).values({ slug: 'pocket-rocket', name: 'Pocket Rocket' }).returning() const [trakk] = await db.insert(projects).values({ brandId: brand!.id, slug: 'trakk', name: 'Trakk' }).returning() const [orbit] = await db.insert(projects).values({ brandId: brand!.id, slug: 'orbit', name: 'Orbit' }).returning() await db.insert(apiClients).values([ { name: 'Schreibclient', tokenHash: hashToken('lb_write'), mode: 'write', scope: 'internal', projectId: trakk!.id }, { name: 'Leseclient', tokenHash: hashToken('lb_read'), mode: 'read', scope: 'internal', projectId: trakk!.id }, { name: 'Freier Schreibclient', tokenHash: hashToken('lb_write_all'), mode: 'write', scope: 'internal' }, ]) return { trakk: trakk!, orbit: orbit! } } async function png(width: number, height: number): Promise { return sharp({ create: { width, height, channels: 3, background: '#2e7d5b' } }).png().toBuffer() } function upload(body: Buffer, options: { name?: string, token?: string, project?: string, alt?: string, type?: string } = {}) { const form = new FormData() const file = new File([new Uint8Array(body)], options.name ?? 'Aufmacher Bild.png', { type: options.type ?? 'image/png', }) form.set('file', file) if (options.project) { form.set('project', options.project) } if (options.alt) { form.set('alt', options.alt) } const token = options.token ?? 'lb_write' return new Request('http://localhost/api/v1/media', { method: 'POST', headers: { authorization: `Bearer ${token}` }, body: form, }) } function fileRequest(path: string[]) { return getFile(new Request(`http://localhost/api/v1/media/file/${path.join('/')}`), { params: Promise.resolve({ path }), }) } describe('POST /api/v1/media', () => { it('legt einen Datensatz mit Fassungen in den erwarteten Breiten an', async () => { await seed() const response = await postMedia(upload(await png(2000, 1125), { alt: 'Ein ruhiges Schaubild' })) const body = await response.json() expect(response.status).toBe(201) expect(body.width).toBe(2000) expect(body.height).toBe(1125) expect(body.mime).toBe('image/png') expect(body.alt).toBe('Ein ruhiges Schaubild') expect(body.variants.map((variant: { width: number }) => variant.width)).toEqual([480, 960, 1600]) expect(body.variants.every((variant: { format: string }) => variant.format === 'webp')).toBe(true) expect(body.url).toMatch(/^\/api\/v1\/media\/file\//u) const rows = await db.select().from(media) expect(rows).toHaveLength(1) expect(rows[0]!.variants).toHaveLength(3) expect(rows[0]!.variantError).toBeNull() }) it('bereinigt den Dateinamen', async () => { await seed() const response = await postMedia(upload(await png(600, 400), { name: '../Über uns/Größe & Maß.png' })) const body = await response.json() expect(body.originalFilename).toBe('Groesse-Mass.png') }) it('liefert die abgelegten Fassungen über die Auslieferung aus', async () => { await seed() const created = await (await postMedia(upload(await png(2000, 1125)))).json() const variant = created.variants.at(-1) const path = String(variant.url).replace('/api/v1/media/file/', '').split('/') const response = await fileRequest(path) expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('image/webp') expect(response.headers.get('cache-control')).toContain('immutable') expect(Number(response.headers.get('content-length'))).toBeGreaterThan(0) }) it('weist eine zu grosse Datei ab', async () => { await seed() const oversized = Buffer.alloc(15 * 1024 * 1024 + 1, 7) const response = await postMedia(upload(oversized)) expect(response.status).toBe(413) expect(await db.select().from(media)).toHaveLength(0) }) it('weist eine Datei ab, die kein Bild ist', async () => { await seed() const response = await postMedia(upload(Buffer.from('nur text, als bild deklariert'))) expect(response.status).toBe(422) expect(await db.select().from(media)).toHaveLength(0) }) it('weist ein Bildformat ab, das nicht angenommen wird', async () => { await seed() const svg = Buffer.from('') const response = await postMedia(upload(svg, { name: 'zeichnung.svg', type: 'image/svg+xml' })) expect(response.status).toBe(415) expect(await db.select().from(media)).toHaveLength(0) }) it('antwortet ohne Token mit 401', async () => { await seed() const request = new Request('http://localhost/api/v1/media', { method: 'POST', body: new FormData() }) expect((await postMedia(request)).status).toBe(401) }) it('lässt ein Lesetoken nicht hochladen', async () => { await seed() const response = await postMedia(upload(await png(600, 400), { token: 'lb_read' })) expect(response.status).toBe(403) }) it('lässt ein projektgebundenes Token nicht in ein fremdes Projekt schreiben', async () => { await seed() const response = await postMedia(upload(await png(600, 400), { project: 'orbit' })) expect(response.status).toBe(403) }) it('verlangt von einem freien Token die Angabe des Projekts', async () => { await seed() const response = await postMedia(upload(await png(600, 400), { token: 'lb_write_all' })) expect(response.status).toBe(400) }) it('nimmt von einem freien Token ein benanntes Projekt an', async () => { const { orbit } = await seed() const response = await postMedia(upload(await png(600, 400), { token: 'lb_write_all', project: 'orbit' })) const body = await response.json() expect(response.status).toBe(201) expect(body.projectId).toBe(orbit.id) }) }) describe('GET /api/v1/media/file', () => { it('weist jeden Pfad-Ausbruch mit 400 ab', async () => { const escapes = [ ['..', '..', 'etc', 'passwd'], ['..', '.env'], ['projekt', '..', '..', 'secret.txt'], ['', 'etc', 'passwd'], ['.', 'a.webp'], ] for (const path of escapes) { const response = await fileRequest(path) expect(response.status, path.join('/')).toBe(400) } }) it('antwortet für ein unbekanntes Objekt mit 404', async () => { const response = await fileRequest(['projekt', 'bild', 'w960.webp']) expect(response.status).toBe(404) }) }) describe('sanitiseFilename', () => { it('nimmt Pfadanteile heraus und ersetzt Umlaute', () => { expect(sanitiseFilename('/etc/passwd')).toBe('passwd') expect(sanitiseFilename('..\\..\\Größe.png')).toBe('Groesse.png') expect(sanitiseFilename('mein bild (1).PNG')).toBe('mein-bild-1.PNG') }) it('fällt auf einen Ersatznamen zurück, wenn nichts übrig bleibt', () => { expect(sanitiseFilename('...')).toBe('bild') expect(sanitiseFilename('')).toBe('bild') }) })