Some checks failed
CI / Lint, typecheck, test (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m43s
CD / Deploy to Test (push) Successful in 12s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Failing after 3m1s
CI / Import/export fidelity gate (push) Has been skipped
New comments panel on the page (toggle next to attachments, unread badge counting comments newer than the last localStorage-recorded visit): threaded display with relative times and author names, a Markdown composer with hints, edit/delete for authors, resolve moving threads into a collapsed resolved <details> section with reopen, and a permission-aware composer — hidden with a hint when the pond's policy bars the viewer (readers always see the discussion). The pond settings page gains the "who may comment" select. Fixes PondsService.update silently dropping commentPolicy from the settings merge (found by the new two-user Playwright pack; the DB test now exercises the real pond PATCH). New comments i18n namespace (de+en); the pack runs as its own CI step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
|
|
import { INestApplication } from '@nestjs/common';
|
|
import type { CommentView, PageCommentsView } from '@dorfteich/shared';
|
|
import { PrismaClient, User } from '@prisma/client';
|
|
import request from 'supertest';
|
|
import * as Y from 'yjs';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
import { UsersService } from '../users/users.service';
|
|
|
|
/**
|
|
* Threaded page comments end to end (issue #91): CRUD with the permission
|
|
* matrix and the pond's comment policy, resolve semantics with the
|
|
* default-collapsed list response, the shared escaping pipeline, and the
|
|
* trash/purge behavior.
|
|
*/
|
|
describe.skipIf(!hasTestDb)('comments (e2e, issue #91)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
const suffix = uniqueSuffix();
|
|
const password = 'kommentare sind gespraech 1';
|
|
const users: Record<string, User> = {};
|
|
const cookies: Record<string, string> = {};
|
|
let pondId: string;
|
|
let pageId: string;
|
|
|
|
const api = () => request(app.getHttpServer());
|
|
|
|
async function makeUser(handle: string): Promise<void> {
|
|
const service = app.get(UsersService);
|
|
const username = `cm-${handle}-${suffix}`;
|
|
const user = await service.createUser({
|
|
username,
|
|
email: `${username}@example.org`,
|
|
displayName: `Cm ${handle}`,
|
|
password,
|
|
locale: 'en',
|
|
});
|
|
await service.markEmailVerified(user.id);
|
|
users[handle] = user;
|
|
cookies[handle] = sessionCookieOf(
|
|
await api()
|
|
.post('/api/v1/auth/login')
|
|
.send({ usernameOrEmail: username, password })
|
|
.expect(200),
|
|
);
|
|
}
|
|
|
|
async function makePage(slug: string): Promise<string> {
|
|
const id = randomUUID();
|
|
await prisma.page.create({
|
|
data: {
|
|
id,
|
|
pondId,
|
|
title: slug,
|
|
slug,
|
|
ydocState: new Uint8Array(Y.encodeStateAsUpdate(new Y.Doc())),
|
|
sortKey: `a${slug}`,
|
|
createdBy: users.owner!.id,
|
|
},
|
|
});
|
|
return id;
|
|
}
|
|
|
|
async function grant(handle: string, role: 'EDITOR' | 'READER'): Promise<void> {
|
|
await prisma.roleGrant.create({
|
|
data: {
|
|
pondId,
|
|
subjectType: 'USER',
|
|
subjectId: users[handle]!.id,
|
|
role,
|
|
scopeType: 'POND',
|
|
effect: 'ALLOW',
|
|
createdBy: users.owner!.id,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function setPolicy(policy: 'readers' | 'editors'): Promise<void> {
|
|
const pond = await prisma.pond.findUniqueOrThrow({ where: { id: pondId } });
|
|
await prisma.pond.update({
|
|
where: { id: pondId },
|
|
data: { settings: { ...(pond.settings as object), commentPolicy: policy } },
|
|
});
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
prisma = createTestPrisma();
|
|
await prisma.rateLimit.deleteMany({});
|
|
app = await createTestApp();
|
|
for (const handle of ['owner', 'editor', 'reader', 'outsider']) await makeUser(handle);
|
|
|
|
const pond = await prisma.pond.create({
|
|
data: {
|
|
slug: `cm-pond-${suffix}`,
|
|
name: 'Comment Pond',
|
|
type: 'SHARED',
|
|
ownerId: users.owner!.id,
|
|
},
|
|
});
|
|
pondId = pond.id;
|
|
await prisma.roleGrant.create({
|
|
data: {
|
|
pondId,
|
|
subjectType: 'USER',
|
|
subjectId: users.owner!.id,
|
|
role: 'POND_ADMIN',
|
|
scopeType: 'POND',
|
|
effect: 'ALLOW',
|
|
createdBy: users.owner!.id,
|
|
},
|
|
});
|
|
await grant('editor', 'EDITOR');
|
|
await grant('reader', 'READER');
|
|
pageId = await makePage('discussion');
|
|
});
|
|
|
|
afterAll(async () => {
|
|
const ids = Object.values(users).map((u) => u.id);
|
|
await prisma.comment.deleteMany({ where: { page: { pondId } } });
|
|
await prisma.roleGrant.deleteMany({ where: { pondId } });
|
|
await prisma.page.deleteMany({ where: { pondId } });
|
|
await prisma.pond.deleteMany({ where: { id: pondId } });
|
|
await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } });
|
|
await prisma.session.deleteMany({ where: { userId: { in: ids } } });
|
|
await prisma.userIdentity.deleteMany({ where: { userId: { in: ids } } });
|
|
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
|
await prisma.$disconnect();
|
|
await app.close();
|
|
});
|
|
|
|
it('runs thread CRUD under the permission matrix (readers policy)', async () => {
|
|
// Reader may comment when the policy allows readers (the default).
|
|
const rootRes = await api()
|
|
.post(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.reader!)
|
|
.send({ body: 'A **question** from a reader', anchor: 'para-1' })
|
|
.expect(201);
|
|
const root = rootRes.body as CommentView;
|
|
expect(root.html).toContain('<strong>question</strong>');
|
|
expect(root.anchor).toBe('para-1');
|
|
|
|
// Editor replies; the reply refuses to carry an anchor.
|
|
const replyRes = await api()
|
|
.post(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.editor!)
|
|
.send({ body: 'An answer', parentId: root.id, anchor: 'ignored' })
|
|
.expect(201);
|
|
expect((replyRes.body as CommentView).anchor).toBeNull();
|
|
|
|
// Replies to replies are rejected.
|
|
await api()
|
|
.post(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.reader!)
|
|
.send({ body: 'nested', parentId: (replyRes.body as CommentView).id })
|
|
.expect(400);
|
|
|
|
// The outsider sees nothing at all — 404, not 403 (issue #60).
|
|
await api()
|
|
.get(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.outsider!)
|
|
.expect(404);
|
|
await api()
|
|
.post(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.outsider!)
|
|
.send({ body: 'sneaky' })
|
|
.expect(404);
|
|
|
|
// Edit: only the author.
|
|
await api()
|
|
.patch(`/api/v1/comments/${root.id}`)
|
|
.set('Cookie', cookies.editor!)
|
|
.send({ body: 'hijacked' })
|
|
.expect(403);
|
|
const edited = await api()
|
|
.patch(`/api/v1/comments/${root.id}`)
|
|
.set('Cookie', cookies.reader!)
|
|
.send({ body: 'A *clarified* question' })
|
|
.expect(200);
|
|
expect((edited.body as CommentView).editedAt).not.toBeNull();
|
|
|
|
// Delete: the author cannot take replies down with the root …
|
|
await api().delete(`/api/v1/comments/${root.id}`).set('Cookie', cookies.reader!).expect(409);
|
|
// … but a pond admin can delete any thread (cascade).
|
|
await api().delete(`/api/v1/comments/${root.id}`).set('Cookie', cookies.owner!).expect(204);
|
|
const after = await api()
|
|
.get(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.reader!)
|
|
.expect(200);
|
|
expect((after.body as PageCommentsView).threads).toHaveLength(0);
|
|
});
|
|
|
|
it('enforces the editors-only policy', async () => {
|
|
// Through the real pond PATCH — guards the settings merge in
|
|
// PondsService.update (a silently dropped commentPolicy broke the UI
|
|
// pack during #92).
|
|
await api()
|
|
.patch(`/api/v1/ponds/${pondId}`)
|
|
.set('Cookie', cookies.owner!)
|
|
.send({ commentPolicy: 'editors' })
|
|
.expect(200);
|
|
await api()
|
|
.post(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.reader!)
|
|
.send({ body: 'readers barred' })
|
|
.expect(403);
|
|
const res = await api()
|
|
.post(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.editor!)
|
|
.send({ body: 'editors pass' })
|
|
.expect(201);
|
|
// Reading stays open to readers regardless of the write policy.
|
|
await api().get(`/api/v1/pages/${pageId}/comments`).set('Cookie', cookies.reader!).expect(200);
|
|
// Resolve follows the same policy: readers barred, editors allowed.
|
|
await api()
|
|
.post(`/api/v1/comments/${(res.body as CommentView).id}/resolve`)
|
|
.set('Cookie', cookies.reader!)
|
|
.expect(403);
|
|
await api()
|
|
.post(`/api/v1/comments/${(res.body as CommentView).id}/resolve`)
|
|
.set('Cookie', cookies.editor!)
|
|
.expect(201);
|
|
await api()
|
|
.delete(`/api/v1/comments/${(res.body as CommentView).id}`)
|
|
.set('Cookie', cookies.editor!)
|
|
.expect(204);
|
|
await setPolicy('readers');
|
|
});
|
|
|
|
it('filters resolved threads and collapses them by default', async () => {
|
|
const open = await api()
|
|
.post(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.reader!)
|
|
.send({ body: 'still open' })
|
|
.expect(201);
|
|
const done = await api()
|
|
.post(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.reader!)
|
|
.send({ body: 'done soon' })
|
|
.expect(201);
|
|
await api()
|
|
.post(`/api/v1/comments/${(done.body as CommentView).id}/resolve`)
|
|
.set('Cookie', cookies.reader!)
|
|
.expect(201);
|
|
|
|
const all = (
|
|
await api().get(`/api/v1/pages/${pageId}/comments`).set('Cookie', cookies.reader!).expect(200)
|
|
).body as PageCommentsView;
|
|
expect(all.openCount).toBe(1);
|
|
expect(all.resolvedCount).toBe(1);
|
|
const resolvedThread = all.threads.find((t) => t.resolved)!;
|
|
expect(resolvedThread.collapsed).toBe(true);
|
|
expect(all.threads.find((t) => !t.resolved)!.collapsed).toBe(false);
|
|
|
|
const onlyOpen = (
|
|
await api()
|
|
.get(`/api/v1/pages/${pageId}/comments?filter=open`)
|
|
.set('Cookie', cookies.reader!)
|
|
.expect(200)
|
|
).body as PageCommentsView;
|
|
expect(onlyOpen.threads).toHaveLength(1);
|
|
expect(onlyOpen.threads[0]?.root.body).toBe('still open');
|
|
|
|
// Unresolve re-opens the thread.
|
|
await api()
|
|
.delete(`/api/v1/comments/${(done.body as CommentView).id}/resolve`)
|
|
.set('Cookie', cookies.reader!)
|
|
.expect(200);
|
|
const reopened = (
|
|
await api()
|
|
.get(`/api/v1/pages/${pageId}/comments?filter=resolved`)
|
|
.set('Cookie', cookies.reader!)
|
|
.expect(200)
|
|
).body as PageCommentsView;
|
|
expect(reopened.threads).toHaveLength(0);
|
|
|
|
// Cleanup for the next test.
|
|
await api()
|
|
.delete(`/api/v1/comments/${(open.body as CommentView).id}`)
|
|
.set('Cookie', cookies.owner!);
|
|
await api()
|
|
.delete(`/api/v1/comments/${(done.body as CommentView).id}`)
|
|
.set('Cookie', cookies.owner!);
|
|
});
|
|
|
|
it('renders bodies through the shared escaping pipeline', async () => {
|
|
const res = await api()
|
|
.post(`/api/v1/pages/${pageId}/comments`)
|
|
.set('Cookie', cookies.reader!)
|
|
.send({ body: 'look <script>alert(1)</script> <img src=x onerror=alert(2)>' })
|
|
.expect(201);
|
|
const view = res.body as CommentView;
|
|
// Smuggled markup arrives as escaped, inert text — assert the escaped
|
|
// form is present, never `not.toContain` on words inside it (#82 lesson).
|
|
expect(view.html).toContain('<script>');
|
|
expect(view.html).not.toContain('<script>');
|
|
expect(view.html).not.toContain('<img');
|
|
await api().delete(`/api/v1/comments/${view.id}`).set('Cookie', cookies.owner!).expect(204);
|
|
});
|
|
|
|
it('hides comments with the trashed page and removes them on purge', async () => {
|
|
const page2 = await makePage('doomed');
|
|
const created = await api()
|
|
.post(`/api/v1/pages/${page2}/comments`)
|
|
.set('Cookie', cookies.editor!)
|
|
.send({ body: 'about to vanish' })
|
|
.expect(201);
|
|
|
|
await prisma.page.update({
|
|
where: { id: page2 },
|
|
data: { deletedAt: new Date(), deletedBy: users.owner!.id },
|
|
});
|
|
// Trash hides: the list 404s, and so does every comment mutation.
|
|
await api().get(`/api/v1/pages/${page2}/comments`).set('Cookie', cookies.editor!).expect(404);
|
|
await api()
|
|
.patch(`/api/v1/comments/${(created.body as CommentView).id}`)
|
|
.set('Cookie', cookies.editor!)
|
|
.send({ body: 'necromancy' })
|
|
.expect(404);
|
|
|
|
// Purge removes the rows (FK cascade).
|
|
await prisma.page.delete({ where: { id: page2 } });
|
|
expect(await prisma.comment.count({ where: { pageId: page2 } })).toBe(0);
|
|
});
|
|
});
|