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
127 lines
4.8 KiB
TypeScript
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);
|
|
});
|
|
});
|