dorfteich/apps/api/src/pages/classification-inheritance.e2e.db.test.ts
Claude Fable 5 488d0d06f1
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 5m40s
CI / Build container images (pull_request) Successful in 4m34s
CI / Auth e2e pack (pull_request) Successful in 9m7s
CI / Import/export fidelity gate (pull_request) Successful in 1m0s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled
#205: classification inherits down the tree; lowering is a guarded, audited act
New pages take max(instance default, parent level); moving a subtree
under a higher-classified parent raises every member below that level.
No move-like path (reposition, trash-promote, purge-promote) lowers a
level as a side effect — pinned by test. Raising is ordinary editorial
work; lowering requires the dedicated capability canLowerClassification
(pond-wide Pond Admin) in the central permission model. Both directions
are audited (page.classification_raised/_lowered, catalogue v1.1) with
old value, new value, actor and page.

Co-Authored-By: Claude Fable 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:12:26 +02:00

255 lines
10 KiB
TypeScript

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