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
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
112 lines
4.7 KiB
TypeScript
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();
|
|
});
|