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 = {}; const cookies: Record = {}; let pondId: string; let pageId: string; const api = () => request(app.getHttpServer()); async function makeUser(handle: string): Promise { 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 { 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 { 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 { 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('question'); 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 ' }) .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('