All checks were successful
CD / Build and push images (push) Successful in 3m59s
CD / Deploy to Test (push) Successful in 10s
CI / Lint, typecheck, test (push) Successful in 4m20s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m15s
CD / Promote to Int (push) Successful in 13s
CI / Auth e2e pack (push) Successful in 5m42s
CI / Import/export fidelity gate (push) Successful in 47s
DELETE /pages/:id?mode=promote|subtree — promote (the default) moves the page's live children up to its parent; subtree trashes every live descendant with one timestamp and requires write permission on all of them (no partial deletes; trash access is write capability, ADR 0013). Trashed pages keep their parentId. Restore re-attaches to the nearest live ancestor (else root), which makes restore order-independent: restoring a parent afterwards never re-claims an already-restored child. Purge promotes any remaining children to the purged page's parent; the FK's SetNull stays as backstop only. tree-trash.e2e.db.test.ts covers promote, subtree + one-timestamp, the 403 descendant gate (label-DENY editor), order-independent restore, and child promotion on purge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
228 lines
8.3 KiB
TypeScript
228 lines
8.3 KiB
TypeScript
import { INestApplication } from '@nestjs/common';
|
|
import { PageView } 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, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
import { UsersService } from '../users/users.service';
|
|
|
|
/**
|
|
* Delete modes and subtree trash semantics (issue #107): promote vs subtree,
|
|
* the write-on-every-descendant gate, order-independent restore re-attachment,
|
|
* and child promotion on purge.
|
|
*/
|
|
describe.skipIf(!hasTestDb)('page tree trash semantics (e2e, issue #107)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
const suffix = uniqueSuffix();
|
|
const password = 'branches fall upward 12';
|
|
|
|
let ownerCookie: string;
|
|
let editorCookie: string;
|
|
let pondId: string;
|
|
let secretLabelId: string;
|
|
|
|
const api = () => request(app.getHttpServer());
|
|
|
|
async function login(username: string): Promise<string> {
|
|
const res = await api()
|
|
.post('/api/v1/auth/login')
|
|
.send({ usernameOrEmail: username, password })
|
|
.expect(200);
|
|
return sessionCookieOf(res);
|
|
}
|
|
|
|
async function createPage(title: string, parentId?: string): Promise<PageView> {
|
|
const res = await api()
|
|
.post(`/api/v1/ponds/${pondId}/pages`)
|
|
.set('Cookie', ownerCookie)
|
|
.send(parentId ? { title, parentId } : { title })
|
|
.expect(201);
|
|
return res.body as PageView;
|
|
}
|
|
|
|
async function listPages(): Promise<PageView[]> {
|
|
const res = await api()
|
|
.get(`/api/v1/ponds/${pondId}/pages`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(200);
|
|
return res.body as PageView[];
|
|
}
|
|
|
|
async function trashTitles(): Promise<string[]> {
|
|
const res = await api()
|
|
.get(`/api/v1/ponds/${pondId}/trash`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(200);
|
|
return (res.body as PageView[]).map((p) => p.title);
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
prisma = createTestPrisma();
|
|
await prisma.rateLimit.deleteMany({});
|
|
app = await createTestApp();
|
|
const users = app.get(UsersService);
|
|
|
|
const ownerUser = await users.createUser({
|
|
username: `boris-branch-${suffix}`,
|
|
email: `boris-branch-${suffix}@example.org`,
|
|
displayName: 'Boris Branch',
|
|
password,
|
|
locale: 'en',
|
|
});
|
|
await users.markEmailVerified(ownerUser.id);
|
|
const editorUser = await users.createUser({
|
|
username: `edda-editor-${suffix}`,
|
|
email: `edda-editor-${suffix}@example.org`,
|
|
displayName: 'Edda Editor',
|
|
password,
|
|
locale: 'en',
|
|
});
|
|
await users.markEmailVerified(editorUser.id);
|
|
|
|
// Pond, labels, and ALL grants provisioned before any permission
|
|
// resolution touches the pond — raw rows created later would be invisible
|
|
// to the warmed PondPermissionCache.
|
|
const pond = await prisma.pond.create({
|
|
data: {
|
|
slug: `tree-trash-${suffix}`,
|
|
name: 'Tree Trash',
|
|
type: 'SHARED',
|
|
ownerId: ownerUser.id,
|
|
},
|
|
});
|
|
pondId = pond.id;
|
|
await grantOwnerAdmin(prisma, pondId, ownerUser.id);
|
|
const secret = await prisma.label.create({
|
|
data: { pondId, name: 'secret', color: '#334455' },
|
|
});
|
|
secretLabelId = secret.id;
|
|
await prisma.roleGrant.createMany({
|
|
data: [
|
|
{
|
|
pondId,
|
|
subjectType: 'USER',
|
|
subjectId: editorUser.id,
|
|
role: 'EDITOR',
|
|
scopeType: 'POND',
|
|
effect: 'ALLOW',
|
|
createdBy: ownerUser.id,
|
|
},
|
|
{
|
|
pondId,
|
|
subjectType: 'USER',
|
|
subjectId: editorUser.id,
|
|
role: 'EDITOR',
|
|
scopeType: 'LABEL',
|
|
scopeId: secret.id,
|
|
effect: 'DENY',
|
|
createdBy: ownerUser.id,
|
|
},
|
|
],
|
|
});
|
|
|
|
ownerCookie = await login(`boris-branch-${suffix}`);
|
|
editorCookie = await login(`edda-editor-${suffix}`);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await prisma.roleGrant.deleteMany({ where: { pondId } });
|
|
await prisma.label.deleteMany({ where: { pondId } });
|
|
await prisma.page.deleteMany({ where: { pondId } });
|
|
await prisma.pond.deleteMany({ where: { id: pondId } });
|
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
await prisma.$disconnect();
|
|
await app.close();
|
|
});
|
|
|
|
it('promote (the default) re-attaches live children to the deleted page parent', async () => {
|
|
const a = await createPage('Promote A');
|
|
const b = await createPage('Promote B', a.id);
|
|
const c = await createPage('Promote C', b.id);
|
|
|
|
await api().delete(`/api/v1/pages/${a.id}`).set('Cookie', ownerCookie).expect(204);
|
|
|
|
const pages = await listPages();
|
|
expect(pages.find((p) => p.id === b.id)?.parentId).toBeNull(); // promoted to A's parent
|
|
expect(pages.find((p) => p.id === c.id)?.parentId).toBe(b.id); // untouched
|
|
expect(await trashTitles()).toContain('Promote A');
|
|
|
|
// Restoring A later does not re-claim the promoted children.
|
|
await api().post(`/api/v1/pages/${a.id}/restore`).set('Cookie', ownerCookie).expect(201);
|
|
const after = await listPages();
|
|
expect(after.find((p) => p.id === b.id)?.parentId).toBeNull();
|
|
});
|
|
|
|
it('subtree trashes every live descendant together; restore is order-independent', async () => {
|
|
const x = await createPage('Subtree X');
|
|
const y = await createPage('Subtree Y', x.id);
|
|
const z = await createPage('Subtree Z', y.id);
|
|
|
|
await api().delete(`/api/v1/pages/${x.id}?mode=subtree`).set('Cookie', ownerCookie).expect(204);
|
|
const titles = await trashTitles();
|
|
expect(titles).toEqual(expect.arrayContaining(['Subtree X', 'Subtree Y', 'Subtree Z']));
|
|
|
|
// One shared timestamp marks the subtree operation.
|
|
const rows = await prisma.page.findMany({ where: { id: { in: [x.id, y.id, z.id] } } });
|
|
const stamps = new Set(rows.map((r) => r.deletedAt?.toISOString()));
|
|
expect(stamps.size).toBe(1);
|
|
|
|
// Restore the deepest page first: its ancestors are still trashed, so it
|
|
// re-attaches to the nearest live ancestor — the root.
|
|
const zRestored = await api()
|
|
.post(`/api/v1/pages/${z.id}/restore`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(201);
|
|
expect((zRestored.body as PageView).parentId).toBeNull();
|
|
|
|
// Restore the top, then the middle: Y finds X live again and nests under it.
|
|
await api().post(`/api/v1/pages/${x.id}/restore`).set('Cookie', ownerCookie).expect(201);
|
|
const yRestored = await api()
|
|
.post(`/api/v1/pages/${y.id}/restore`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(201);
|
|
expect((yRestored.body as PageView).parentId).toBe(x.id);
|
|
|
|
// Z stays where its restore put it — X does not re-claim it.
|
|
const pages = await listPages();
|
|
expect(pages.find((p) => p.id === z.id)?.parentId).toBeNull();
|
|
});
|
|
|
|
it('subtree requires write on every live descendant (403, nothing deleted)', async () => {
|
|
const parent = await createPage('Gate parent');
|
|
const child = await createPage('Gate child', parent.id);
|
|
await prisma.pageLabel.create({ data: { pageId: child.id, labelId: secretLabelId } });
|
|
|
|
await api()
|
|
.delete(`/api/v1/pages/${parent.id}?mode=subtree`)
|
|
.set('Cookie', editorCookie)
|
|
.expect(403);
|
|
|
|
// Nothing was trashed by the refused subtree delete.
|
|
const pages = await listPages();
|
|
expect(pages.some((p) => p.id === parent.id)).toBe(true);
|
|
expect(pages.some((p) => p.id === child.id)).toBe(true);
|
|
|
|
// The plain promote delete only needs write on the page itself.
|
|
await api().delete(`/api/v1/pages/${parent.id}`).set('Cookie', editorCookie).expect(204);
|
|
});
|
|
|
|
it('purging a parent promotes its children instead of orphaning them', async () => {
|
|
const u = await createPage('Purge U');
|
|
const v = await createPage('Purge V', u.id);
|
|
|
|
await api().delete(`/api/v1/pages/${u.id}?mode=subtree`).set('Cookie', ownerCookie).expect(204);
|
|
await api().delete(`/api/v1/pages/${u.id}/purge`).set('Cookie', ownerCookie).expect(204);
|
|
|
|
const vRow = await prisma.page.findUniqueOrThrow({ where: { id: v.id } });
|
|
expect(vRow.parentId).toBeNull(); // U's parent was the root
|
|
const restored = await api()
|
|
.post(`/api/v1/pages/${v.id}/restore`)
|
|
.set('Cookie', ownerCookie)
|
|
.expect(201);
|
|
expect((restored.body as PageView).parentId).toBeNull();
|
|
});
|
|
});
|