dorfteich/apps/web/e2e/public.spec.ts
Claude Opus 4.8 fc41c91003
All checks were successful
CD / Build and push images (push) Successful in 3m13s
CI / Lint, typecheck, test (push) Successful in 2m30s
CI / Auth e2e pack (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add public read access and server-rendered page HTML (#56)
Anonymous visitors read what `public` grants allow, via the SPA and a
server-rendered HTML endpoint for crawlers / PDF export (ADR 0005/0009).

- api `public/`: `GET /public/:pondSlug/:pageSlug` returns a self-contained
  HTML document (content cache + minimal chrome + canonical link, no
  session-dependent content), and `…/content` returns JSON for the SPA. Both
  are `@Public()` and resolve the `public` subject through the shared resolver
  (PermissionService) — denied or missing → 404, so non-public pages never
  reveal their existence (security.md). Cached image nodes (`data-file-id`)
  are resolved to `/api/v1/media/:fileId` for the static render.
- media: `GET /media/:fileId` is `@Public()` too, so embedded images on a
  public page stream to anonymous visitors; the attachment guard still gates
  on the `public` grant (non-public → 404).
- web: a lightweight read-only `PublicPageView` at `/public/:pondSlug/:pageSlug`
  (outside the auth guard) renders the server HTML — deliberately without
  importing the collaborative editor, so anonymous readers load no editor
  bundle. New `public` i18n namespace (de+en).
- tests: `public.e2e.db.test.ts` (HTML + JSON served for a public page; a
  non-public page never resolves; removing the grant 404s both) and a browser
  `public` pack (anonymous reads a public page and its image via the SPA;
  a non-public page shows "not found") with its own CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-09 23:46:25 +02:00

77 lines
3.4 KiB
TypeScript

import { expect, test } from '@playwright/test';
import type { BrowserContext } from '@playwright/test';
import { contextForUser } from './helpers';
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
/**
* Public read access (issue #56): an anonymous visitor reads a page a `public`
* grant opens, through the SPA's read-only view (no editor bundle), and its
* embedded image streams too; a non-public page never resolves. Uses the seeded
* `content-fixtures` pond (owned by fixture-user) whose "Fixture Image" page
* carries a real servable image.
*/
async function pondId(owner: BrowserContext, slug: string): Promise<string> {
const res = await owner.request.get(`/api/v1/ponds/${slug}`);
return ((await res.json()) as { id: string }).id;
}
test('an anonymous visitor reads a public page and its image via the SPA', async ({ browser }) => {
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const id = await pondId(owner, 'content-fixtures');
const grant = await owner.request.post(`/api/v1/ponds/${id}/grants`, {
data: { subjectType: 'public', role: 'reader', scopeType: 'pond', effect: 'allow' },
});
const grantId = ((await grant.json()) as { id: string }).id;
try {
const anon = await browser.newContext({ baseURL: BASE_URL }); // no session
const page = await anon.newPage();
await page.goto('/public/content-fixtures/fixture-image');
// The read-only public view renders — with no collaborative editor.
await expect(page.locator('.public-page__badge')).toBeVisible();
await expect(page.locator('.public-page__title')).toContainText('Fixture Image');
await expect(page.locator('.ProseMirror')).toHaveCount(0);
// The embedded image streams to the anonymous visitor (media honors public):
// fetch its resolved /media URL from the same session-less context.
const src = await page.locator('.public-page__body img').first().getAttribute('src');
expect(src).toMatch(/^\/api\/v1\/media\//);
const media = await anon.request.get(src!);
expect(media.status()).toBe(200);
expect(media.headers()['content-type']).toContain('image/');
await anon.close();
} finally {
await owner.request.delete(`/api/v1/ponds/${id}/grants/${grantId}`);
await owner.close();
}
});
test('a non-public page never resolves for an anonymous visitor', async ({ browser }) => {
// A fresh private pond + page (no public grant) — isolated from any shared
// fixture pond so the negative case cannot be polluted by another test.
const owner = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = (await (
await owner.request.post('/api/v1/ponds', { data: { name: `Private ${Date.now()}` } })
).json()) as { id: string; slug: string };
const page = (await (
await owner.request.post(`/api/v1/ponds/${pond.id}/pages`, { data: { title: 'Secret' } })
).json()) as { slug: string };
const anon = await browser.newContext({ baseURL: BASE_URL });
const view = await anon.newPage();
// The SPA shows "not found"…
await view.goto(`/public/${pond.slug}/${page.slug}`);
await expect(view.locator('.public-page__body')).toHaveCount(0);
// …and the content endpoint hides the page's existence.
const res = await anon.request.get(`/api/v1/public/${pond.slug}/${page.slug}/content`);
expect(res.status()).toBe(404);
await anon.close();
await owner.close();
});