import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { INestApplication } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import type { Test } from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { AuthTokensService } from '../auth/auth-tokens.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); const pngBuffer = (payload = 'fake-but-signed png bytes'): Buffer => Buffer.concat([PNG_SIGNATURE, Buffer.from(payload)]); /** superagent has no default parser for image/*; buffer the raw bytes ourselves. */ function binaryParser( res: NodeJS.ReadableStream, callback: (err: Error | null, body: Buffer) => void, ): void { const chunks: Buffer[] = []; res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => callback(null, Buffer.concat(chunks))); } type ParseCallback = Parameters[0]; describe.skipIf(!hasTestDb)('files (e2e, issue #27)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'bilder hochladen ist toll 1'; const owner = { username: `fiona-files-${suffix}`, displayName: `Fiona Files ${suffix}` }; const outsider = { username: `otto-files-${suffix}`, displayName: `Otto Outside ${suffix}` }; let ownerCookie: string; let outsiderCookie: string; let pondId: string; const api = () => request(app.getHttpServer()); async function loginOf(username: string): Promise { const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); return sessionCookieOf(res); } async function setPondOverride(quotaKey: string, value: number): Promise { await prisma.quotaOverride.upsert({ where: { subjectType_subjectId_quotaKey: { subjectType: 'POND', subjectId: pondId, quotaKey }, }, create: { subjectType: 'POND', subjectId: pondId, quotaKey, value }, update: { value }, }); } async function clearPondOverrides(): Promise { await prisma.quotaOverride.deleteMany({ where: { subjectType: 'POND', subjectId: pondId } }); } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); const users = app.get(UsersService); const tokens = app.get(AuthTokensService); const ownerUser = await users.createUser({ username: owner.username, email: `${owner.username}@example.org`, displayName: owner.displayName, password, locale: 'en', }); const verifyToken = await tokens.issue(ownerUser.id, 'EMAIL_VERIFICATION', 600); await api().post('/api/v1/auth/verify-email').send({ token: verifyToken }).expect(204); ownerCookie = await loginOf(owner.username); const outsiderUser = await users.createUser({ username: outsider.username, email: `${outsider.username}@example.org`, displayName: outsider.displayName, password, locale: 'en', }); await users.markEmailVerified(outsiderUser.id); outsiderCookie = await loginOf(outsider.username); const ponds = await api().get('/api/v1/ponds').set('Cookie', ownerCookie).expect(200); pondId = ponds.body.find((p: { type: string }) => p.type === 'personal').id; }); afterAll(async () => { await prisma.attachment.deleteMany({ where: { pondId } }); await prisma.page.deleteMany({ where: { pond: { owner: { username: { contains: suffix } } } }, }); const users = await prisma.user.findMany({ where: { username: { contains: suffix } }, select: { id: true }, }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: [pondId, ...users.map((u) => u.id)] } }, }); await prisma.pond.deleteMany({ where: { owner: { username: { contains: suffix } } } }); await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); await prisma.$disconnect(); await app.close(); }); it('roundtrips an uploaded image: same bytes, sniffed content type', async () => { const bytes = pngBuffer('roundtrip'); const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', bytes, 'photo.png') .expect(201); expect(uploaded.body.mimeType).toBe('image/png'); expect(uploaded.body.sizeBytes).toBe(bytes.length); const served = await api() .get(`/api/v1/media/${uploaded.body.id}`) .set('Cookie', ownerCookie) .buffer(true) .parse(binaryParser as unknown as ParseCallback) .expect(200); expect(served.headers['content-type']).toBe('image/png'); expect(served.headers['x-content-type-options']).toBe('nosniff'); expect(Buffer.compare(served.body, bytes)).toBe(0); }); it('accepts an allowlisted non-image file and serves it as a download (#61)', async () => { const pdf = Buffer.from('%PDF-1.4 minimal but allowlisted'); const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', pdf, 'document.pdf') .expect(201); expect(uploaded.body.mimeType).toBe('application/pdf'); const served = await api() .get(`/api/v1/media/${uploaded.body.id}`) .set('Cookie', ownerCookie) .buffer(true) .parse(binaryParser as unknown as ParseCallback) .expect(200); // Non-images are always downloads, never inline (ADR 0011, security.md). expect(served.headers['content-disposition']).toContain('attachment'); expect(served.headers['content-disposition']).toContain('document.pdf'); expect(served.headers['content-type']).toContain('application/pdf'); }); it('rejects a file whose extension is not on the allowlist (#61)', async () => { const res = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', Buffer.from('binary junk'), 'malware.exe') .expect(400); expect(res.body.code).toBe('upload_type_not_allowed'); expect(res.body.details.allowed).toContain('pdf'); }); it('rejects a renamed .html-as-.png via the magic-byte check (#61)', async () => { const res = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', Buffer.from('evil'), 'sneaky.png') .expect(400); // png is validated by bytes, not extension, so the disguise fails the // allowlist (png is not a configured non-image extension either). expect(res.body.code).toBe('upload_type_not_allowed'); }); it('sanitizes an uploaded SVG, stripping scripts and event handlers (#61)', async () => { const settings = app.get(InstanceSettingsService); await settings.set('upload.svgPolicy', 'sanitize', 'test'); const dirty = Buffer.from( '' + '', ); const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', dirty, 'diagram.svg') .expect(201); expect(uploaded.body.mimeType).toBe('image/svg+xml'); const served = await api() .get(`/api/v1/media/${uploaded.body.id}`) .set('Cookie', ownerCookie) .buffer(true) .parse(binaryParser as unknown as ParseCallback) .expect(200); const cleaned = served.body.toString('utf8').toLowerCase(); expect(cleaned).not.toContain(' { const settings = app.get(InstanceSettingsService); await settings.set('upload.svgPolicy', 'reject', 'test'); try { const res = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', Buffer.from(''), 'x.svg') .expect(400); expect(res.body.code).toBe('upload_type_not_allowed'); } finally { await settings.set('upload.svgPolicy', 'sanitize', 'test'); } }); it('rejects an oversize file with a distinct error from quota_exceeded', async () => { await setPondOverride('max_file_bytes', 10); try { const res = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', pngBuffer('this payload is well beyond ten bytes'), 'big.png') .expect(413); expect(res.body.code).toBe('file_too_large'); expect(res.body.details.limitBytes).toBe(10); } finally { await clearPondOverrides(); } }); it('rejects uploads that exceed the storage quota', async () => { await setPondOverride('max_file_bytes', 10_000); await setPondOverride('storage_bytes', 5); try { const res = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', pngBuffer('needs more than five bytes of budget'), 'photo.png') .expect(403); expect(res.body.code).toBe('quota_exceeded'); expect(res.body.details.quotaKey).toBe('storage_bytes'); } finally { await clearPondOverrides(); } }); it('releases quota and removes the on-disk bytes on delete', async () => { const before = await prisma.pondUsage.findUnique({ where: { pondId } }); const startUsage = Number(before?.storageBytesUsed ?? 0); const bytes = pngBuffer('to be deleted'); const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', bytes, 'delete-me.png') .expect(201); const afterUpload = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } }); expect(Number(afterUpload.storageBytesUsed)).toBe(startUsage + bytes.length); const filePath = join(process.env.UPLOADS_DIR!, pondId, uploaded.body.id); expect(existsSync(filePath)).toBe(true); await api().delete(`/api/v1/files/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(204); expect(existsSync(filePath)).toBe(false); const afterDelete = await prisma.pondUsage.findUniqueOrThrow({ where: { pondId } }); expect(Number(afterDelete.storageBytesUsed)).toBe(startUsage); await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(404); }); it('keeps a file reachable after its page is soft-deleted (no eager purge)', async () => { const page = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `With Attachment ${suffix}` }) .expect(201); const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', pngBuffer('page-attached'), 'inline.png') .expect(201); // #27 does not expose pageId on the upload endpoint yet (it is set once // a future story tracks a page's embedded attachments) — associate it // directly to exercise the "stays until purge" guarantee now. await prisma.attachment.update({ where: { id: uploaded.body.id }, data: { pageId: page.body.id }, }); await api().delete(`/api/v1/pages/${page.body.id}`).set('Cookie', ownerCookie).expect(204); await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', ownerCookie).expect(200); const stillThere = await prisma.attachment.findUnique({ where: { id: uploaded.body.id } }); expect(stillThere).not.toBeNull(); expect(stillThere?.deletedAt).toBeNull(); }); it('lists a page attachment for the page and links it (#61)', async () => { const page = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Page Files ${suffix}` }) .expect(201); const uploaded = await api() .post(`/api/v1/pages/${page.body.id}/files`) .set('Cookie', ownerCookie) .attach('file', Buffer.from('%PDF-1.4 attached to a page'), 'report.pdf') .expect(201); expect(uploaded.body.pageId).toBe(page.body.id); const listed = await api() .get(`/api/v1/pages/${page.body.id}/files`) .set('Cookie', ownerCookie) .expect(200); const item = listed.body.find((f: { id: string }) => f.id === uploaded.body.id); expect(item).toBeDefined(); expect(item.uploaderName).toBe(owner.displayName); expect(item.pageTitle).toBe(`Page Files ${suffix}`); }); it('pond file manager reports usage, orphans, and page links (#61)', async () => { const page = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', ownerCookie) .send({ title: `Manager Page ${suffix}` }) .expect(201); const linked = await api() .post(`/api/v1/pages/${page.body.id}/files`) .set('Cookie', ownerCookie) .attach('file', Buffer.from('%PDF-1.4 linked'), 'linked.pdf') .expect(201); const orphan = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', pngBuffer('orphan image'), 'orphan.png') .expect(201); const manager = await api() .get(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .expect(200); expect(manager.body.storageBytesLimit).toBeGreaterThan(0); const usage = await prisma.pondUsage.findUnique({ where: { pondId } }); expect(manager.body.storageBytesUsed).toBe(Number(usage?.storageBytesUsed ?? 0)); const linkedItem = manager.body.files.find((f: { id: string }) => f.id === linked.body.id); const orphanItem = manager.body.files.find((f: { id: string }) => f.id === orphan.body.id); expect(linkedItem.pageTitle).toBe(`Manager Page ${suffix}`); expect(orphanItem.pageTitle).toBeNull(); }); it('denies the pond file manager to a non-admin (#61)', async () => { await api().get(`/api/v1/ponds/${pondId}/files`).set('Cookie', outsiderCookie).expect(404); }); it('hides foreign-pond files from download and delete (404, not 403)', async () => { const uploaded = await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', ownerCookie) .attach('file', pngBuffer('private'), 'private.png') .expect(201); await api().get(`/api/v1/media/${uploaded.body.id}`).set('Cookie', outsiderCookie).expect(404); await api() .delete(`/api/v1/files/${uploaded.body.id}`) .set('Cookie', outsiderCookie) .expect(404); await api() .post(`/api/v1/ponds/${pondId}/files`) .set('Cookie', outsiderCookie) .attach('file', pngBuffer('sneaky'), 'sneaky.png') .expect(404); }); });