dorfteich/apps/api/src/public/public.e2e.db.test.ts
Claude Fable 5 9bd25f6ce3 #146: Transparente Einbettung $[[Seite]] ohne Rahmen und Titel
Neues bare-Attr am transclusion-Node; $-Präfix in Markdown-Regel,
Serializer und Autocomplete; HTML-Placeholder trägt
data-transclusion-bare, Server-Expansion und NodeView lassen bei bare
Rahmen und Titel weg. Gleiche Tiefen-/Zyklen-/Permission-Regeln,
zählt weiter als Link. Unit- und DB-Tests ergänzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
2026-07-20 00:34:15 +02:00

204 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('serves a pages comments read-only to an anonymous visitor (issue #133)', async () => {
const page = await prisma.page.findFirstOrThrow({ where: { pondId, slug: pageSlug } });
const root = await prisma.comment.create({
data: { pageId: page.id, authorId: ownerId, body: 'A public remark' },
});
await prisma.comment.create({
data: { pageId: page.id, parentId: root.id, authorId: ownerId, body: 'A public reply' },
});
const res = await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(200);
const body = res.body as {
threads: { root: { body: string }; replies: { body: string }[] }[];
openCount: number;
};
expect(body.openCount).toBe(1);
expect(body.threads).toHaveLength(1);
const [thread] = body.threads;
expect(thread!.root.body).toBe('A public remark');
expect(thread!.replies.map((r) => r.body)).toEqual(['A public reply']);
// A non-public page never reveals its comments either.
await api().get(`/api/v1/public/${privatePondSlug}/${privatePageSlug}/comments`).expect(404);
});
it('expands a page embed to the targets content, cycle-safe (issue #135)', async () => {
const embeddedSlug = `embedded-${suffix}`;
const hostSlug = `host-${suffix}`;
// The embedded page embeds the host back — the expansion must terminate.
await makePage(
pondId,
embeddedSlug,
'Embedded',
`<p>Body of the embedded page.</p>` +
`<div class="dt-transclusion" data-transclusion="${hostSlug}">Host</div>`,
);
await makePage(
pondId,
hostSlug,
'Host',
`<p>Before.</p>` +
`<div class="dt-transclusion" data-transclusion="${embeddedSlug}">Embedded</div>` +
`<div class="dt-transclusion" data-transclusion="ghost-${suffix}">Missing</div>`,
);
const res = await api().get(`/api/v1/public/${pondSlug}/${hostSlug}/content`).expect(200);
const html = (res.body as { html: string }).html;
// The embedded page's body is spliced in, wrapped as an embed.
expect(html).toContain('Body of the embedded page.');
expect(html).toContain('class="dt-embed"');
// No raw placeholder survives; a missing target degrades to a link.
expect(html).not.toContain('dt-transclusion');
expect(html).toContain(`data-wikilink="ghost-${suffix}"`);
});
it('expands a bare embed without frame or title (issue #146)', async () => {
const targetSlug = `bare-target-${suffix}`;
const hostSlug = `bare-host-${suffix}`;
await makePage(pondId, targetSlug, 'Bare Target', '<p>Bare body text.</p>');
await makePage(
pondId,
hostSlug,
'Bare Host',
`<p>Before.</p>` +
`<div class="dt-transclusion" data-transclusion="${targetSlug}"` +
` data-transclusion-bare="1">Bare Target</div>`,
);
const res = await api().get(`/api/v1/public/${pondSlug}/${hostSlug}/content`).expect(200);
const html = (res.body as { html: string }).html;
// The target's body is spliced in verbatim — no dt-embed frame, no title.
expect(html).toContain('Bare body text.');
expect(html).not.toContain('dt-embed');
expect(html).not.toContain('Bare Target</a>');
expect(html).not.toContain('dt-transclusion');
});
it('404s all public 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);
await api().get(`/api/v1/public/${pondSlug}/${pageSlug}/comments`).expect(404);
});
});