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
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>
255 lines
10 KiB
TypeScript
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');
|
|
});
|
|
});
|