dorfteich/apps/web/e2e/attachments.spec.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

112 lines
4.7 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Non-image attachments pack (issue #61): a page's attachments section uploads
* an allowlisted file, lists it, and inserts it into the document as a
* download link; a disallowed extension is rejected with the localized error;
* the Pond Admin file manager reports usage and flags an orphan. Selectors are
* language-neutral (the UI follows the user's locale) — CSS classes, not the
* button text.
*/
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const PDF = Buffer.from('%PDF-1.4 e2e attachment body');
async function createPage(
context: Awaited<ReturnType<typeof contextForUser>>,
title: string,
): Promise<{ pondSlug: string; pageSlug: string }> {
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title } });
const page = await created.json();
return { pondSlug: pond.slug, pageSlug: page.slug };
}
async function openAttachments(page: Page): Promise<void> {
await page.getByRole('button', { name: /edit|bearbeiten/i }).click();
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
await page.locator('.editor-shell__attachments-toggle').click();
await expect(page.locator('.attachments-panel')).toBeVisible();
}
test('uploads a page attachment, lists it, and inserts a download link', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Attach ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await openAttachments(page);
await page.locator('.attachments-panel__input').setInputFiles({
name: 'report.pdf',
mimeType: 'application/pdf',
buffer: PDF,
});
const item = page.locator('.attachments-item__name', { hasText: 'report.pdf' });
await expect(item).toBeVisible({ timeout: 10000 });
// Insert into the document as a link, then confirm it landed as an anchor.
await page.locator('.attachments-item__insert').first().click();
const link = page.locator('.ProseMirror a[href^="/api/v1/media/"]');
await expect(link).toBeVisible();
// The linked media downloads (attachment disposition) with its filename and
// is never rendered inline as HTML (ADR 0011, security.md §Uploads).
const href = await link.getAttribute('href');
const served = await context.request.get(href!);
expect(served.status()).toBe(200);
expect(served.headers()['content-disposition']).toContain('attachment');
expect(served.headers()['content-disposition']).toContain('report.pdf');
expect(served.headers()['x-content-type-options']).toBe('nosniff');
await context.close();
});
test('rejects a disallowed extension with the localized error', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const { pondSlug, pageSlug } = await createPage(context, `E2E Reject ${Date.now()}`);
const page = await context.newPage();
await page.goto(`/p/${pondSlug}/${pageSlug}`);
await openAttachments(page);
await page.locator('.attachments-panel__input').setInputFiles({
name: 'malware.exe',
mimeType: 'application/octet-stream',
buffer: Buffer.from('MZ not allowed'),
});
await expect(page.locator('.attachments-panel .form-banner--error')).toBeVisible();
await expect(page.locator('.attachments-item__name')).toHaveCount(0);
await context.close();
});
test('pond file manager shows usage and flags an orphan (Pond Admin)', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const ponds = await context.request.get('/api/v1/ponds');
const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal');
// A pond-level upload with no embedding page is an orphan candidate.
const upload = await context.request.post(`/api/v1/ponds/${pond.id}/files`, {
multipart: { file: { name: 'loose.pdf', mimeType: 'application/pdf', buffer: PDF } },
});
expect(upload.ok()).toBeTruthy();
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/settings`);
const manager = page.locator('.pond-file-manager');
await expect(manager).toBeVisible();
await expect(manager.locator('.pond-file-manager__usage')).toBeVisible();
const row = manager.locator('.attachments-item', { hasText: 'loose.pdf' });
await expect(row).toBeVisible();
await expect(row.locator('.attachments-item__orphan')).toBeVisible();
await context.close();
});