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
204 lines
8.1 KiB
TypeScript
204 lines
8.1 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('serves a page’s 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 target’s 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);
|
||
});
|
||
});
|