Some checks failed
CI / Import/export fidelity gate (push) Blocked by required conditions
CD / Build and push images (push) Successful in 3m53s
CD / Deploy to Test (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 4m16s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 14s
CI / Auth e2e pack (push) Has been cancelled
Pages form a tree via a nullable parent_id self-relation (SetNull backstop; the real trash/purge semantics follow with #107). Slugs and URLs stay flat and pond-unique, so moving a page never breaks links. - Shared: generic parent-id tree helpers in tree.ts (labels re-export them; buildLabelTree keeps its name-sorted behavior), MAX_PAGE_DEPTH=6, parentId on PageView, createPageInputSchema.parentId (nullish), repositionPageInputSchema.parentId (optional; absent = keep parent). - API: create validates the parent (same pond, live, depth); PATCH /pages/:id/position reparents atomically with the placement, rejecting cycles (page_cycle) and depth violations (page_depth_exceeded); GET /ponds/:id/pages nulls parentId when the caller may not read the parent, so hidden page ids never leak. - New error codes translated de+en; hierarchy.db.test.ts covers create, 404s, depth, cycle, atomic reparent, and the permission nulling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
234 lines
8.3 KiB
TypeScript
234 lines
8.3 KiB
TypeScript
import { ConflictException, INestApplication, NotFoundException } from '@nestjs/common';
|
|
import { MAX_PAGE_DEPTH } from '@dorfteich/shared';
|
|
import { PrismaClient, User } from '@prisma/client';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { createTestApp } from '../testing/test-app';
|
|
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
import { PagesService } from './pages.service';
|
|
|
|
/**
|
|
* Page hierarchy (issue #106): create-under-parent, reparent via reposition,
|
|
* cycle/depth rejection, and the permission rule that a child of an unreadable
|
|
* parent lists with `parentId: null` (no hidden-page id ever leaks).
|
|
*/
|
|
describe.skipIf(!hasTestDb)('Page hierarchy (db, issue #106)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
let pages: PagesService;
|
|
const suffix = uniqueSuffix();
|
|
let owner: User;
|
|
let reader: User;
|
|
let pondId: string;
|
|
let secretLabelId: string;
|
|
|
|
async function createChain(titles: string[]): Promise<string[]> {
|
|
const ids: string[] = [];
|
|
for (const [index, title] of titles.entries()) {
|
|
const page = await pages.create(owner, pondId, {
|
|
title,
|
|
parentId: index === 0 ? null : ids[index - 1],
|
|
});
|
|
ids.push(page.id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
prisma = createTestPrisma();
|
|
app = await createTestApp();
|
|
pages = app.get(PagesService);
|
|
|
|
owner = await prisma.user.create({
|
|
data: {
|
|
username: `tree-owner-${suffix}`,
|
|
email: `tree-owner-${suffix}@example.test`,
|
|
displayName: 'Tree Owner',
|
|
},
|
|
});
|
|
reader = await prisma.user.create({
|
|
data: {
|
|
username: `tree-reader-${suffix}`,
|
|
email: `tree-reader-${suffix}@example.test`,
|
|
displayName: 'Tree Reader',
|
|
},
|
|
});
|
|
const pond = await prisma.pond.create({
|
|
data: {
|
|
slug: `tree-pond-${suffix}`,
|
|
name: 'Tree Pond',
|
|
type: 'SHARED',
|
|
ownerId: owner.id,
|
|
},
|
|
});
|
|
pondId = pond.id;
|
|
await grantOwnerAdmin(prisma, pondId, owner.id);
|
|
|
|
// Reader grants BEFORE any permission resolution touches the pond — the
|
|
// PondPermissionCache would otherwise serve the pre-grant state.
|
|
const secret = await prisma.label.create({
|
|
data: { pondId, name: 'secret', color: '#334455' },
|
|
});
|
|
secretLabelId = secret.id;
|
|
await prisma.roleGrant.createMany({
|
|
data: [
|
|
{
|
|
pondId,
|
|
subjectType: 'USER',
|
|
subjectId: reader.id,
|
|
role: 'READER',
|
|
scopeType: 'POND',
|
|
effect: 'ALLOW',
|
|
createdBy: owner.id,
|
|
},
|
|
{
|
|
pondId,
|
|
subjectType: 'USER',
|
|
subjectId: reader.id,
|
|
role: 'READER',
|
|
scopeType: 'LABEL',
|
|
scopeId: secret.id,
|
|
effect: 'DENY',
|
|
createdBy: owner.id,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
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: { id: { in: [owner.id, reader.id] } } });
|
|
await prisma.$disconnect();
|
|
await app.close();
|
|
});
|
|
|
|
it('creates a page under a parent and at the root', async () => {
|
|
const root = await pages.create(owner, pondId, { title: 'Root' });
|
|
expect(root.parentId).toBeNull();
|
|
|
|
const child = await pages.create(owner, pondId, { title: 'Child', parentId: root.id });
|
|
expect(child.parentId).toBe(root.id);
|
|
|
|
const list = await pages.list(owner, pondId);
|
|
expect(list.find((p) => p.id === child.id)?.parentId).toBe(root.id);
|
|
expect(list.find((p) => p.id === root.id)?.parentId).toBeNull();
|
|
});
|
|
|
|
it('rejects an unknown, foreign, or trashed parent as 404', async () => {
|
|
await expect(
|
|
pages.create(owner, pondId, { title: 'Orphan', parentId: 'no-such-page' }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
|
|
const foreignPond = await prisma.pond.create({
|
|
data: {
|
|
slug: `tree-foreign-${suffix}`,
|
|
name: 'Foreign',
|
|
type: 'PERSONAL',
|
|
ownerId: owner.id,
|
|
},
|
|
});
|
|
await grantOwnerAdmin(prisma, foreignPond.id, owner.id);
|
|
const foreignPage = await pages.create(owner, foreignPond.id, { title: 'Elsewhere' });
|
|
await expect(
|
|
pages.create(owner, pondId, { title: 'Crossing', parentId: foreignPage.id }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
|
|
const doomed = await pages.create(owner, pondId, { title: 'Doomed' });
|
|
await pages.softDelete(owner, doomed.id);
|
|
await expect(
|
|
pages.create(owner, pondId, { title: 'Under trash', parentId: doomed.id }),
|
|
).rejects.toBeInstanceOf(NotFoundException);
|
|
|
|
await prisma.page.deleteMany({ where: { pondId: foreignPond.id } });
|
|
await prisma.roleGrant.deleteMany({ where: { pondId: foreignPond.id } });
|
|
await prisma.pond.delete({ where: { id: foreignPond.id } });
|
|
});
|
|
|
|
it(`rejects nesting beyond ${MAX_PAGE_DEPTH} levels on create`, async () => {
|
|
const chain = await createChain(
|
|
Array.from({ length: MAX_PAGE_DEPTH }, (_, i) => `Deep ${i + 1}`),
|
|
);
|
|
await expect(
|
|
pages.create(owner, pondId, { title: 'Too deep', parentId: chain[MAX_PAGE_DEPTH - 1] }),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
});
|
|
|
|
it('reparents atomically through the position endpoint', async () => {
|
|
const a = await pages.create(owner, pondId, { title: 'Move A' });
|
|
const b = await pages.create(owner, pondId, { title: 'Move B' });
|
|
const child = await pages.create(owner, pondId, { title: 'Move child', parentId: a.id });
|
|
|
|
// Drag onto B: reparent + append, one call.
|
|
const moved = await pages.reposition(owner, child.id, {
|
|
afterId: b.id,
|
|
beforeId: null,
|
|
parentId: b.id,
|
|
});
|
|
expect(moved.parentId).toBe(b.id);
|
|
|
|
// Back to the root with `parentId: null`.
|
|
const rooted = await pages.reposition(owner, child.id, {
|
|
afterId: null,
|
|
beforeId: a.id,
|
|
parentId: null,
|
|
});
|
|
expect(rooted.parentId).toBeNull();
|
|
|
|
// An absent `parentId` keeps the current parent (plain reorder).
|
|
const reordered = await pages.reposition(owner, child.id, { afterId: b.id, beforeId: null });
|
|
expect(reordered.parentId).toBeNull();
|
|
});
|
|
|
|
it('rejects a move into the page own subtree as page_cycle', async () => {
|
|
const [top, , grandchild] = await createChain(['Cycle 1', 'Cycle 2', 'Cycle 3']);
|
|
let caught: unknown;
|
|
await pages
|
|
.reposition(owner, top!, { afterId: null, beforeId: null, parentId: grandchild! })
|
|
.catch((error: unknown) => {
|
|
caught = error;
|
|
});
|
|
expect(caught).toBeInstanceOf(ConflictException);
|
|
expect((caught as ConflictException).getResponse()).toMatchObject({ code: 'page_cycle' });
|
|
|
|
// Self-parenting is the trivial cycle.
|
|
await expect(
|
|
pages.reposition(owner, top!, { afterId: null, beforeId: null, parentId: top! }),
|
|
).rejects.toBeInstanceOf(ConflictException);
|
|
});
|
|
|
|
it('rejects a move that pushes the subtree past the depth limit', async () => {
|
|
const deep = await createChain(['Limit 1', 'Limit 2', 'Limit 3', 'Limit 4']);
|
|
const [subtreeTop] = await createChain(['Tall 1', 'Tall 2', 'Tall 3']);
|
|
|
|
let caught: unknown;
|
|
await pages
|
|
.reposition(owner, subtreeTop!, { afterId: null, beforeId: null, parentId: deep[3]! })
|
|
.catch((error: unknown) => {
|
|
caught = error;
|
|
});
|
|
expect(caught).toBeInstanceOf(ConflictException);
|
|
expect((caught as ConflictException).getResponse()).toMatchObject({
|
|
code: 'page_depth_exceeded',
|
|
});
|
|
});
|
|
|
|
it('nulls the parentId of a child whose parent the caller may not read', async () => {
|
|
const hidden = await pages.create(owner, pondId, { title: 'Hidden parent' });
|
|
await prisma.pageLabel.create({ data: { pageId: hidden.id, labelId: secretLabelId } });
|
|
const child = await pages.create(owner, pondId, {
|
|
title: 'Visible child',
|
|
parentId: hidden.id,
|
|
});
|
|
|
|
const ownerList = await pages.list(owner, pondId);
|
|
expect(ownerList.find((p) => p.id === child.id)?.parentId).toBe(hidden.id);
|
|
|
|
const readerList = await pages.list(reader, pondId);
|
|
expect(readerList.some((p) => p.id === hidden.id)).toBe(false);
|
|
expect(readerList.find((p) => p.id === child.id)?.parentId).toBeNull();
|
|
});
|
|
});
|