dorfteich/apps/api/src/files/files.e2e.db.test.ts
Claude Opus 4.8 30891f99cf
All checks were successful
CD / Build and push images (push) Successful in 4m2s
CI / Lint, typecheck, test (push) Successful in 2m46s
CI / Auth e2e pack (push) Successful in 3m45s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 12s
Add non-image attachments with allowlist, SVG policy, and file managers (#61)
Extend uploads (#27, ADR 0011) beyond images to a configurable general
attachment allowlist, plus the page attachments section and the Pond Admin
file manager.

Backend:
- Two instance settings: `upload.allowedExtensions` (lowercase, dot-stripped,
  images always allowed regardless) and `upload.svgPolicy` (reject | sanitize).
- FilesService.resolveUpload: raster images still decided by magic bytes; SVG
  is sanitized with DOMPurify (scripts, event handlers, foreignObject stripped)
  or rejected per policy; everything else is admitted only if its extension is
  on the allowlist. A sanitized SVG's stored bytes are re-accounted so
  pond_usage matches disk.
- Downloads set `Content-Disposition: attachment` for every non-raster type
  (office files, PDFs, SVG) with `nosniff`, so they can never execute inline;
  raster images stay inline for page embeds.
- New endpoints: `GET /ponds/:id/files` (pond_admin: all files + usage + orphan
  flag), `POST /pages/:id/files` and `GET /pages/:id/files` (page-write/read:
  the attachments section). New error code `upload_type_not_allowed` (de+en).

Frontend:
- Page attachments section (AttachmentsPanel): upload, list with type glyph,
  size, and uploader, insert-as-link into the document (an internal media link
  that downloads, never renders inline), and delete. Toggled in the editor.
- Pond file manager (PondFileManager) in pond settings for Pond Admins: every
  file with its referencing page (or an orphan flag) and storage usage.
- Admin uploads settings form (allowlist + SVG policy). New `files` i18n
  namespace (de+en).

Tests:
- files.e2e.db.test.ts: allowlisted non-image accepted and served as a
  download; disallowed extension rejected; renamed-.html-as-.png still fails;
  SVG sanitized (scripts/handlers stripped) and reject-mode rejects; page
  attachment listing; pond file manager usage/orphan; non-admin denied.
- New e2e pack apps/web/e2e/attachments.spec.ts (+ CI step): upload → list →
  insert link (verified attachment disposition + nosniff), disallowed-type
  error, pond file manager usage/orphan.

Local: typecheck, lint, i18n:check, build all green; api-db 184, shared 121,
web 50; attachments pack 3/3, members 3/3, content 5/5. Adds dompurify + jsdom
to the api for server-side SVG sanitization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 02:52:40 +02:00

385 lines
15 KiB
TypeScript

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<Test['parse']>[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<string> {
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<void> {
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<void> {
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('<html><body>evil</body></html>'), '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(
'<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)">' +
'<script>alert(2)</script><rect width="10" height="10"/></svg>',
);
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('<script');
expect(cleaned).not.toContain('onload');
expect(cleaned).toContain('<rect');
// SVG is always a download, never inline.
expect(served.headers['content-disposition']).toContain('attachment');
});
it('rejects an SVG upload when the policy is reject (#61)', async () => {
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('<svg xmlns="http://www.w3.org/2000/svg"/>'), '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);
});
});