dorfteich/apps/api/src/public/public.e2e.db.test.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

127 lines
4.8 KiB
TypeScript

import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { PondPermissionCache } from '../permissions/pond-permission-cache';
import { createTestApp } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
/**
* Public read access end to end (issue #56): an anonymous request reads a page
* a `public` grant opens, via both the JSON and HTML endpoints; removing the
* grant 404s both, and a non-public page never resolves. Requests carry no
* session cookie.
*/
describe.skipIf(!hasTestDb)('public read access (e2e, issue #56)', () => {
let app: INestApplication;
let prisma: PrismaClient;
const suffix = uniqueSuffix();
let ownerId: string;
let pondSlug: string;
let pondId: string;
let pageSlug: string;
let privatePondSlug: string;
let privatePageSlug: string;
let publicGrantId: string;
const api = () => request(app.getHttpServer());
async function makePage(pondIdV: string, slug: string, title: string, html: string) {
const page = await prisma.page.create({
data: {
pondId: pondIdV,
slug,
title,
createdBy: ownerId,
sortKey: 'a0',
ydocState: new Uint8Array(),
contentCache: { create: { plainText: title, markdown: title, html, outline: [] } },
},
});
return page;
}
beforeAll(async () => {
prisma = createTestPrisma();
app = await createTestApp();
const owner = await prisma.user.create({
data: {
username: `pub-owner-${suffix}`,
email: `pub-owner-${suffix}@example.test`,
displayName: 'Pub Owner',
},
});
ownerId = owner.id;
pondSlug = `pub-pond-${suffix}`;
const pond = await prisma.pond.create({
data: { slug: pondSlug, name: 'Public Pond', type: 'SHARED', ownerId },
});
pondId = pond.id;
pageSlug = `welcome-${suffix}`;
await makePage(pondId, pageSlug, 'Welcome', '<p>Hello world from a public page.</p>');
const grant = await prisma.roleGrant.create({
data: {
pondId,
subjectType: 'PUBLIC',
subjectId: null,
role: 'READER',
scopeType: 'POND',
scopeId: null,
effect: 'ALLOW',
createdBy: ownerId,
},
});
publicGrantId = grant.id;
privatePondSlug = `priv-pond-${suffix}`;
const priv = await prisma.pond.create({
data: { slug: privatePondSlug, name: 'Private Pond', type: 'SHARED', ownerId },
});
privatePageSlug = `secret-${suffix}`;
await makePage(priv.id, privatePageSlug, 'Secret', '<p>Nobody public should read this.</p>');
});
afterAll(async () => {
await prisma.roleGrant.deleteMany({ where: { pond: { ownerId } } });
await prisma.pageContentCache.deleteMany({ where: { page: { pond: { ownerId } } } });
await prisma.page.deleteMany({ where: { pond: { ownerId } } });
await prisma.pond.deleteMany({ where: { ownerId } });
await prisma.user.deleteMany({ where: { id: ownerId } });
await prisma.$disconnect();
await app.close();
});
it('serves a public page as HTML and JSON to an anonymous visitor', async () => {
const html = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(200);
expect(html.headers['content-type']).toContain('text/html');
expect(html.text).toContain('<!doctype html>');
expect(html.text).toContain('Welcome');
expect(html.text).toContain('Hello world from a public page.');
expect(html.text).toContain('rel="canonical"');
// No session-dependent content leaked into the crawler document.
expect(html.text).not.toContain('dt_session');
const json = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(200);
expect(json.body).toMatchObject({ title: 'Welcome', pondName: 'Public Pond' });
expect((json.body as { html: string }).html).toContain('Hello world');
});
it('never resolves a non-public page for an anonymous visitor', async () => {
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}`).expect(404);
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/content`).expect(404);
await api().get(`/api/v1/public/${pondSlug}/does-not-exist`).expect(404);
});
it('404s both endpoints once the public grant is removed', async () => {
await prisma.roleGrant.delete({ where: { id: publicGrantId } });
// The API route invalidates on its own mutations; this test deletes
// directly, so drop the cached pond context to mirror that (issue #39).
app.get(PondPermissionCache).invalidate(pondId);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}`).expect(404);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/content`).expect(404);
});
});