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
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
77 lines
3.4 KiB
TypeScript
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();
|
|
});
|