import { INestApplication } from '@nestjs/common'; import { CreateGrantInput } from '@dorfteich/shared'; import { PrismaClient } from '@prisma/client'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { PondsService } from '../ponds/ponds.service'; import { TrashService } from '../trash/trash.service'; import { UsersService } from '../users/users.service'; /** * Classification inheritance in the page tree (issue #205, ADR 0022): * children inherit, moves can only raise (audited), lowering needs the * dedicated capability (pond-wide Pond Admin) and is audited — and no * move-like path (reposition, promote-on-trash, promote-on-purge) ever * lowers a level as a side effect. */ describe.skipIf(!hasTestDb)('classification inheritance (e2e, issue #205)', () => { let app: INestApplication; let prisma: PrismaClient; const suffix = uniqueSuffix(); const password = 'vererbte einstufung 123'; const userIds: Record = {}; const cookies: Record = {}; let pondId: string; const api = () => request(app.getHttpServer()); async function makeUser(handle: string): Promise { const users = app.get(UsersService); const username = `${handle}-inherit-${suffix}`; const user = await users.createUser({ username, email: `${username}@example.org`, displayName: `${handle} ${suffix}`, password, locale: 'en', }); userIds[handle] = user.id; await users.markEmailVerified(user.id); const res = await api() .post('/api/v1/auth/login') .send({ usernameOrEmail: username, password }) .expect(200); cookies[handle] = sessionCookieOf(res); } async function createPage(title: string, parentId?: string): Promise<{ id: string }> { const res = await api() .post(`/api/v1/ponds/${pondId}/pages`) .set('Cookie', cookies.owner!) .send({ title, ...(parentId ? { parentId } : {}) }) .expect(201); return res.body as { id: string }; } async function classificationOf(pageId: string): Promise { const res = await api() .get(`/api/v1/pages/${pageId}`) .set('Cookie', cookies.owner!) .expect(200); return (res.body as { classification: string }).classification; } beforeAll(async () => { prisma = createTestPrisma(); await prisma.rateLimit.deleteMany({}); app = await createTestApp(); for (const handle of ['owner', 'editor']) { await makeUser(handle); } await app .get(PondsService) .ensurePersonalPond(await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } })); await prisma.quotaOverride.create({ data: { subjectType: 'USER', subjectId: userIds.owner!, quotaKey: 'additional_ponds', value: 10, }, }); const pond = await api() .post('/api/v1/ponds') .set('Cookie', cookies.owner!) .send({ name: `Inherit Pond ${suffix}` }) .expect(201); pondId = (pond.body as { id: string }).id; const editorGrant: CreateGrantInput = { subjectType: 'user', subjectId: userIds.editor!, role: 'editor', scopeType: 'pond', scopeId: undefined, effect: 'allow', }; await api() .post(`/api/v1/ponds/${pondId}/grants`) .set('Cookie', cookies.owner!) .send(editorGrant) .expect(201); }); afterAll(async () => { const ids = Object.values(userIds); await prisma.auditEntry.deleteMany({ where: { actorId: { in: ids } } }); await prisma.page.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.roleGrant.deleteMany({ where: { pond: { ownerId: { in: ids } } } }); await prisma.quotaOverride.deleteMany({ where: { subjectId: { in: ids } } }); await prisma.pond.deleteMany({ where: { ownerId: { in: ids } } }); await prisma.user.deleteMany({ where: { id: { in: ids } } }); await prisma.$disconnect(); await app.close(); }); it('any writer may raise; a new page inherits its parent level', async () => { const parent = await createPage(`Classified Parent ${suffix}`); // The EDITOR raises — raising is ordinary editorial work, no admin needed. const raised = await api() .patch(`/api/v1/pages/${parent.id}`) .set('Cookie', cookies.editor!) .send({ classification: 'vs_nfd' }) .expect(200); expect((raised.body as { classification: string }).classification).toBe('vs_nfd'); const raiseAudit = await prisma.auditEntry.findFirst({ where: { action: 'page.classification_raised', targetId: parent.id }, }); expect(raiseAudit?.actorId).toBe(userIds.editor); expect(raiseAudit?.details).toMatchObject({ from: 'unclassified', to: 'vs_nfd' }); // Inherit on create: the child starts at the parent's level, not the // instance default. const child = await createPage(`Inherited Child ${suffix}`, parent.id); expect(await classificationOf(child.id)).toBe('vs_nfd'); }); it('moving under a higher-classified parent raises the whole moved subtree, audited', async () => { const top = await createPage(`Secret Top ${suffix}`); await api() .patch(`/api/v1/pages/${top.id}`) .set('Cookie', cookies.owner!) .send({ classification: 'vs_nfd' }) .expect(200); const movedRoot = await createPage(`Open Subtree ${suffix}`); const movedChild = await createPage(`Open Leaf ${suffix}`, movedRoot.id); await api() .patch(`/api/v1/pages/${movedRoot.id}/position`) .set('Cookie', cookies.editor!) .send({ afterId: null, beforeId: null, parentId: top.id }) .expect(200); expect(await classificationOf(movedRoot.id)).toBe('vs_nfd'); expect(await classificationOf(movedChild.id)).toBe('vs_nfd'); for (const pageId of [movedRoot.id, movedChild.id]) { const audit = await prisma.auditEntry.findFirst({ where: { action: 'page.classification_raised', targetId: pageId }, }); expect(audit?.details).toMatchObject({ from: 'unclassified', to: 'vs_nfd', trigger: 'move' }); expect(audit?.actorId).toBe(userIds.editor); } }); it('lowering is denied without the dedicated capability and audited with it', async () => { const page = await createPage(`To Lower ${suffix}`); await api() .patch(`/api/v1/pages/${page.id}`) .set('Cookie', cookies.owner!) .send({ classification: 'vs_nfd' }) .expect(200); // The editor may write the page but holds no pond-admin role → 403. const denied = await api() .patch(`/api/v1/pages/${page.id}`) .set('Cookie', cookies.editor!) .send({ classification: 'unclassified' }) .expect(403); expect((denied.body as { code: string }).code).toBe('classification_lower_forbidden'); expect(await classificationOf(page.id)).toBe('vs_nfd'); // The owner (Pond Admin) may lower — and the sensitive direction is audited. await api() .patch(`/api/v1/pages/${page.id}`) .set('Cookie', cookies.owner!) .send({ classification: 'unclassified' }) .expect(200); expect(await classificationOf(page.id)).toBe('unclassified'); const audit = await prisma.auditEntry.findFirst({ where: { action: 'page.classification_lowered', targetId: page.id }, }); expect(audit?.actorId).toBe(userIds.owner); expect(audit?.details).toMatchObject({ from: 'vs_nfd', to: 'unclassified', trigger: 'edit' }); }); it('no move-like path lowers as a side effect: reposition, trash-promote, purge-promote', async () => { // Moving a classified page under an unclassified parent keeps its level. const openParent = await createPage(`Open Parent ${suffix}`); const classified = await createPage(`Stays Classified ${suffix}`); await api() .patch(`/api/v1/pages/${classified.id}`) .set('Cookie', cookies.owner!) .send({ classification: 'vs_nfd' }) .expect(200); await api() .patch(`/api/v1/pages/${classified.id}/position`) .set('Cookie', cookies.owner!) .send({ afterId: null, beforeId: null, parentId: openParent.id }) .expect(200); expect(await classificationOf(classified.id)).toBe('vs_nfd'); // Trash-promote: trashing the classified middle page re-attaches its // classified child to the open grandparent — the child keeps its level. const middle = await createPage(`Classified Middle ${suffix}`, openParent.id); await api() .patch(`/api/v1/pages/${middle.id}`) .set('Cookie', cookies.owner!) .send({ classification: 'vs_nfd' }) .expect(200); const grandchild = await createPage(`Classified Leaf ${suffix}`, middle.id); expect(await classificationOf(grandchild.id)).toBe('vs_nfd'); await api().delete(`/api/v1/pages/${middle.id}`).set('Cookie', cookies.owner!).expect(204); const promoted = await prisma.page.findUniqueOrThrow({ where: { id: grandchild.id } }); expect(promoted.parentId).toBe(openParent.id); expect(promoted.classification).toBe('VS_NFD'); // Purge-promote: a subtree-trashed child still points at its trashed // parent; purging the parent promotes it — again without lowering. const purgeParent = await createPage(`Purge Parent ${suffix}`, openParent.id); await api() .patch(`/api/v1/pages/${purgeParent.id}`) .set('Cookie', cookies.owner!) .send({ classification: 'vs_nfd' }) .expect(200); const purgeChild = await createPage(`Purge Leaf ${suffix}`, purgeParent.id); await api() .delete(`/api/v1/pages/${purgeParent.id}?mode=subtree`) .set('Cookie', cookies.owner!) .expect(204); const ownerUser = await prisma.user.findUniqueOrThrow({ where: { id: userIds.owner! } }); await app.get(TrashService).purgeNow(ownerUser, purgeParent.id); const promotedAfterPurge = await prisma.page.findUniqueOrThrow({ where: { id: purgeChild.id }, }); expect(promotedAfterPurge.parentId).toBe(openParent.id); expect(promotedAfterPurge.classification).toBe('VS_NFD'); }); });