import { randomUUID } from 'node:crypto'; import { ExecutionContext, Logger } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { FastifyAdapter, NestFastifyApplication, } from '@nestjs/platform-fastify'; import { Test } from '@nestjs/testing'; import { createCache } from 'cache-manager'; import * as request from 'supertest'; import { JwtAuthGuard } from '../src/common/guards/jwt-auth.guard'; import { TransformHttpResponseInterceptor } from '../src/common/interceptors/http-response.interceptor'; import { PageController } from '../src/core/page/page.controller'; import { PageAccessService } from '../src/core/page/page-access/page-access.service'; import { PageService } from '../src/core/page/services/page.service'; import { TrashCleanupService } from '../src/core/page/services/trash-cleanup.service'; import { ShareService } from '../src/core/share/share.service'; import SpaceAbilityFactory from '../src/core/casl/abilities/space-ability.factory'; import { PageHierarchyCycleError } from '../src/database/helpers/page-hierarchy-cycle'; import { PagePermissionRepo } from '../src/database/repos/page/page-permission.repo'; import { PageRepo } from '../src/database/repos/page/page.repo'; import { SpaceMemberRepo } from '../src/database/repos/space/space-member.repo'; import { User } from '../src/database/types/entity.types'; import { KyselyDB } from '../src/database/types/kysely.types'; import { HealthController } from '../src/integrations/health/health.controller'; import { assertDisposableTestDatabaseUrl, db, withStatementTimeout, } from './support/database'; import { seedAcyclicPageChain, seedBranchingDescendantTree, seedSelfCycle, seedTwoPageCycle, } from './support/page-hierarchy-fixtures'; const CLIENT_DEADLINE_MS = 1_000; function createPageService( connection: KyselyDB, dependencies: { attachmentQueue?: { add: jest.Mock }; eventEmitter?: { emit: jest.Mock }; } = {}, ): PageService { return new PageService( undefined as never, undefined as never, undefined as never, connection, undefined as never, dependencies.attachmentQueue as never, undefined as never, undefined as never, dependencies.eventEmitter as never, undefined as never, undefined as never, undefined as never, ); } function createPageRepo( connection: KyselyDB, eventEmitter: { emit: jest.Mock } = { emit: jest.fn() }, ): PageRepo { return new PageRepo(connection, undefined as never, eventEmitter as never); } function createShareService(connection: KyselyDB): ShareService { return new ShareService( undefined as never, undefined as never, undefined as never, connection, undefined as never, undefined as never, ); } function createPagePermissionRepo(connection: KyselyDB): PagePermissionRepo { return new PagePermissionRepo(connection, undefined as never, createCache()); } async function createBreadcrumbHttpApp( connection: KyselyDB, user: User, ): Promise<{ app: NestFastifyApplication; authGuard: { canActivate: jest.Mock }; getPageBreadCrumbs: jest.SpiedFunction; }> { const cacheManager = createCache(); const pagePermissionRepo = new PagePermissionRepo( connection, undefined as never, cacheManager, ); const spaceMemberRepo = new SpaceMemberRepo( connection, undefined as never, undefined as never, cacheManager, ); const spaceAbility = new SpaceAbilityFactory(spaceMemberRepo); const pageAccessService = new PageAccessService( pagePermissionRepo, spaceAbility, undefined as never, ); const pageService = createPageService(connection); const getPageBreadCrumbs = jest.spyOn(pageService, 'getPageBreadCrumbs'); const authGuard = { canActivate: jest.fn((context: ExecutionContext) => { const httpRequest = context.switchToHttp().getRequest(); if (httpRequest.headers.authorization !== 'Bearer integration-test') { return false; } httpRequest.user = { user }; return true; }), }; const module = await Test.createTestingModule({ controllers: [HealthController, PageController], providers: [ { provide: PageService, useValue: pageService }, { provide: PageRepo, useValue: createPageRepo(connection) }, { provide: PageAccessService, useValue: pageAccessService }, { provide: SpaceAbilityFactory, useValue: spaceAbility }, ], }) .useMocker(() => ({})) .overrideGuard(JwtAuthGuard) .useValue(authGuard) .compile(); const app = module.createNestApplication( new FastifyAdapter(), { logger: false }, ); app.setGlobalPrefix('api'); app.useGlobalInterceptors( new TransformHttpResponseInterceptor(app.get(Reflector)), ); await app.init(); await app.getHttpAdapter().getInstance().ready(); return { app, authGuard, getPageBreadCrumbs }; } async function insertControllerUser(pageId: string): Promise { const page = await db .selectFrom('pages') .select(['spaceId', 'workspaceId']) .where('id', '=', pageId) .executeTakeFirstOrThrow(); const user = await db .insertInto('users') .values({ email: `breadcrumb-controller-${randomUUID()}@example.com`, name: 'Breadcrumb controller test user', workspaceId: page.workspaceId, }) .returningAll() .executeTakeFirstOrThrow(); await db .insertInto('spaceMembers') .values({ role: 'reader', spaceId: page.spaceId, userId: user.id, }) .execute(); return user; } function silenceTrashCleanupDebugLogs(): void { jest.spyOn(Logger.prototype, 'debug').mockImplementation(); } async function insertTestUser(pageId: string): Promise { const { workspaceId } = await db .selectFrom('pages') .select('workspaceId') .where('id', '=', pageId) .executeTakeFirstOrThrow(); const user = await db .insertInto('users') .values({ email: `cycle-test-${randomUUID()}@example.com`, name: 'Page hierarchy test user', workspaceId, }) .returning('id') .executeTakeFirstOrThrow(); return user.id; } async function insertTwoPageCycleInPageSpace( pageId: string, ): Promise<{ cyclePageId: string; spaceId: string }> { const context = await db .selectFrom('pages') .select(['spaceId', 'workspaceId']) .where('id', '=', pageId) .executeTakeFirstOrThrow(); const a = await db .insertInto('pages') .values({ slugId: randomUUID(), spaceId: context.spaceId, title: 'Same-space cycle A', workspaceId: context.workspaceId, }) .returning('id') .executeTakeFirstOrThrow(); const b = await db .insertInto('pages') .values({ parentPageId: a.id, slugId: randomUUID(), spaceId: context.spaceId, title: 'Same-space cycle B', workspaceId: context.workspaceId, }) .returning('id') .executeTakeFirstOrThrow(); await db .updateTable('pages') .set({ parentPageId: b.id }) .where('id', '=', a.id) .execute(); return { cyclePageId: a.id, spaceId: context.spaceId }; } async function restrictPage( pageId: string, permittedUserId?: string, ): Promise<{ accessLevel: string; pageAccessId: string; pageId: string; }> { const page = await db .selectFrom('pages') .select(['spaceId', 'workspaceId']) .where('id', '=', pageId) .executeTakeFirstOrThrow(); const pageAccess = await db .insertInto('pageAccess') .values({ accessLevel: 'restricted', pageId, spaceId: page.spaceId, workspaceId: page.workspaceId, }) .returning(['accessLevel', 'id', 'pageId']) .executeTakeFirstOrThrow(); if (permittedUserId) { await db .insertInto('pagePermissions') .values({ pageAccessId: pageAccess.id, role: 'writer', userId: permittedUserId, }) .execute(); } return { accessLevel: pageAccess.accessLevel, pageAccessId: pageAccess.id, pageId: pageAccess.pageId, }; } async function insertShare(pageId: string, includeSubPages: boolean) { const page = await db .selectFrom('pages') .select(['spaceId', 'workspaceId']) .where('id', '=', pageId) .executeTakeFirstOrThrow(); return db .insertInto('shares') .values({ includeSubPages, key: `cycle-test-${randomUUID()}`, pageId, searchIndexing: false, spaceId: page.spaceId, workspaceId: page.workspaceId, }) .returning(['id', 'workspaceId']) .executeTakeFirstOrThrow(); } async function expectPageHierarchyCycle( operation: Promise, rootPageId: string, ): Promise { const error = await operation.then( () => undefined, (reason: unknown) => reason, ); expect(error).toBeInstanceOf(PageHierarchyCycleError); expect(error).toEqual( expect.objectContaining({ code: 'PAGE_HIERARCHY_CYCLE', rootPageId, }) satisfies Partial, ); } afterEach(() => { jest.restoreAllMocks(); }); describe('cycle-safe page hierarchy reads', () => { describe('integration database safety', () => { it.each([ 'postgresql://docmost:docmost@127.0.0.1:55432/docmost', 'postgresql://docmost:docmost@database.example/docmost_cycle_test', ])('rejects a non-disposable database URL: %s', (unsafeUrl) => { expect(() => assertDisposableTestDatabaseUrl(unsafeUrl)).toThrow( 'Integration tests require the loopback database docmost_cycle_test', ); }); it('accepts the dedicated loopback integration database URL', () => { expect(() => assertDisposableTestDatabaseUrl( 'postgresql://docmost:docmost@127.0.0.1:55432/docmost_cycle_test', ), ).not.toThrow(); }); }); describe('descendant traversal', () => { it('returns every page in an acyclic branching tree exactly once without internal metadata', async () => { const { root, firstChild, secondChild, grandchild } = await seedBranchingDescendantTree(); const repo = createPageRepo(db); const pages = await repo.getPageAndDescendants(root.id, { includeContent: false, }); expect(pages.map((page) => page.id).sort()).toEqual( [root.id, firstChild.id, secondChild.id, grandchild.id].sort(), ); expect(pages).toHaveLength(4); for (const page of pages) { expect(page).not.toHaveProperty('isCycle'); expect(page).not.toHaveProperty('traversalPath'); } }); describe.each([ ['a self-cycle', async () => (await seedSelfCycle()).self.id], ['a two-page cycle', async () => (await seedTwoPageCycle()).a.id], ])('%s', (_cycleName, seedCycle) => { it('raises PageHierarchyCycleError from the structural descendant read', async () => { const pageId = await seedCycle(); await withStatementTimeout(async (connection) => { const repo = createPageRepo(connection); await expectPageHierarchyCycle( repo.getPageAndDescendants(pageId, { includeContent: false }), pageId, ); }); }); it('raises PageHierarchyCycleError from the restricted descendant read', async () => { const pageId = await seedCycle(); await withStatementTimeout(async (connection) => { const repo = createPageRepo(connection); await expectPageHierarchyCycle( repo.getPageAndDescendantsExcludingRestricted(pageId, { includeContent: false, }), pageId, ); }); }); }); it('preserves restricted-subtree exclusion for an acyclic tree', async () => { const { root, firstChild, secondChild, grandchild } = await seedBranchingDescendantTree(); await restrictPage(firstChild.id); const repo = createPageRepo(db); const pages = await repo.getPageAndDescendantsExcludingRestricted( root.id, { includeContent: false }, ); expect(pages.map((page) => page.id).sort()).toEqual( [root.id, secondChild.id].sort(), ); expect(pages.map((page) => page.id)).not.toContain(firstChild.id); expect(pages.map((page) => page.id)).not.toContain(grandchild.id); for (const page of pages) { expect(page).not.toHaveProperty('isCycle'); expect(page).not.toHaveProperty('isRestricted'); expect(page).not.toHaveProperty('traversalPath'); } }); it('removePage leaves pages and shares unchanged and emits no event on a cycle', async () => { const { a, b } = await seedTwoPageCycle(); const deletedById = await insertTestUser(a.id); const share = await insertShare(b.id, true); const eventEmitter = { emit: jest.fn() }; await withStatementTimeout(async (connection) => { const repo = createPageRepo(connection, eventEmitter); await expectPageHierarchyCycle( repo.removePage(a.id, deletedById, share.workspaceId), a.id, ); }); const storedPages = await db .selectFrom('pages') .select(['id', 'deletedAt', 'deletedById']) .where('id', 'in', [a.id, b.id]) .orderBy('id') .execute(); const storedShare = await db .selectFrom('shares') .select('id') .where('id', '=', share.id) .executeTakeFirst(); expect(storedPages).toEqual( [a.id, b.id] .sort() .map((id) => ({ id, deletedAt: null, deletedById: null })), ); expect(storedShare).toEqual({ id: share.id }); expect(eventEmitter.emit).not.toHaveBeenCalled(); }); it('restorePage leaves deleted pages unchanged and emits no event on a cycle', async () => { const { a, b } = await seedTwoPageCycle(); const deletedAt = new Date('2024-01-02T03:04:05.000Z'); await db .updateTable('pages') .set({ deletedAt }) .where('id', 'in', [a.id, b.id]) .execute(); const eventEmitter = { emit: jest.fn() }; await withStatementTimeout(async (connection) => { const repo = createPageRepo(connection, eventEmitter); await expectPageHierarchyCycle( repo.restorePage(a.id, randomUUID()), a.id, ); }); const storedPages = await db .selectFrom('pages') .select(['id', 'parentPageId', 'deletedAt']) .where('id', 'in', [a.id, b.id]) .orderBy('id') .execute(); expect(storedPages).toEqual( [ { id: a.id, parentPageId: b.id, deletedAt }, { id: b.id, parentPageId: a.id, deletedAt }, ].sort((left, right) => left.id.localeCompare(right.id)), ); expect(eventEmitter.emit).not.toHaveBeenCalled(); }); it('forceDelete leaves pages intact and enqueues and emits nothing on a cycle', async () => { const { a, b } = await seedTwoPageCycle(); const attachmentQueue = { add: jest.fn() }; const eventEmitter = { emit: jest.fn() }; await withStatementTimeout(async (connection) => { const pageService = createPageService(connection, { attachmentQueue, eventEmitter, }); await expectPageHierarchyCycle( pageService.forceDelete(a.id, randomUUID()), a.id, ); }); const storedPageIds = await db .selectFrom('pages') .select('id') .where('id', 'in', [a.id, b.id]) .orderBy('id') .execute(); expect(storedPageIds).toEqual([a.id, b.id].sort().map((id) => ({ id }))); expect(attachmentQueue.add).not.toHaveBeenCalled(); expect(eventEmitter.emit).not.toHaveBeenCalled(); }); it('trash cleanup logs and skips a corrupt root before continuing with acyclic trash', async () => { silenceTrashCleanupDebugLogs(); const { self } = await seedSelfCycle(); const context = await db .selectFrom('pages') .select(['spaceId', 'workspaceId']) .where('id', '=', self.id) .executeTakeFirstOrThrow(); const corruptSideChild = await db .insertInto('pages') .values({ id: 'dddddddd-dddd-4ddd-bddd-dddddddddddd', parentPageId: self.id, slugId: randomUUID(), spaceId: context.spaceId, title: 'Corrupt side child', workspaceId: context.workspaceId, }) .returning(['id', 'parentPageId']) .executeTakeFirstOrThrow(); const healthyRoot = await db .insertInto('pages') .values({ id: 'eeeeeeee-eeee-4eee-beee-eeeeeeeeeeee', slugId: randomUUID(), spaceId: context.spaceId, title: 'Healthy cleanup root', workspaceId: context.workspaceId, }) .returning('id') .executeTakeFirstOrThrow(); const healthyChild = await db .insertInto('pages') .values({ id: 'ffffffff-ffff-4fff-bfff-ffffffffffff', parentPageId: healthyRoot.id, slugId: randomUUID(), spaceId: context.spaceId, title: 'Healthy cleanup child', workspaceId: context.workspaceId, }) .returning('id') .executeTakeFirstOrThrow(); const expiredAt = new Date('2024-01-02T03:04:05.000Z'); await db .updateTable('pages') .set({ deletedAt: expiredAt }) .where('id', 'in', [ self.id, corruptSideChild.id, healthyRoot.id, healthyChild.id, ]) .execute(); const attachmentQueue = { add: jest.fn() }; const loggerError = jest .spyOn(Logger.prototype, 'error') .mockImplementation(); await withStatementTimeout(async (connection) => { const timeoutCleanupService = new TrashCleanupService( connection, attachmentQueue as never, ); await timeoutCleanupService.cleanupOldTrash(); }); const corruptPages = await db .selectFrom('pages') .select(['id', 'parentPageId', 'deletedAt']) .where('id', 'in', [self.id, corruptSideChild.id]) .orderBy('id') .execute(); const healthyPages = await db .selectFrom('pages') .select('id') .where('id', 'in', [healthyRoot.id, healthyChild.id]) .execute(); expect(corruptPages).toEqual( [ { id: self.id, parentPageId: self.id, deletedAt: expiredAt }, { id: corruptSideChild.id, parentPageId: self.id, deletedAt: expiredAt, }, ].sort((left, right) => left.id.localeCompare(right.id)), ); expect(healthyPages).toEqual([]); expect(attachmentQueue.add).toHaveBeenCalledTimes(2); const queuedPageIds = attachmentQueue.add.mock.calls.map( ([, payload]) => payload.pageId, ); expect(queuedPageIds).toEqual( expect.arrayContaining([healthyRoot.id, healthyChild.id]), ); expect(queuedPageIds).not.toEqual( expect.arrayContaining([self.id, corruptSideChild.id]), ); expect(loggerError).toHaveBeenCalledTimes(2); expect(loggerError).toHaveBeenCalledWith( `Failed to cleanup page ${self.id}: Cyclic page hierarchy detected`, expect.stringContaining( 'PageHierarchyCycleError: Cyclic page hierarchy detected', ), ); expect(loggerError).toHaveBeenCalledWith( `Failed to cleanup page ${corruptSideChild.id}: Cyclic page hierarchy detected`, expect.stringContaining( 'PageHierarchyCycleError: Cyclic page hierarchy detected', ), ); expect(Math.max(...loggerError.mock.invocationCallOrder)).toBeLessThan( attachmentQueue.add.mock.invocationCallOrder[0], ); }); it('trash cleanup aborts before later mutation when hierarchy validation unexpectedly fails', async () => { silenceTrashCleanupDebugLogs(); const { root } = await seedAcyclicPageChain(); const context = await db .selectFrom('pages') .select(['spaceId', 'workspaceId']) .where('id', '=', root.id) .executeTakeFirstOrThrow(); const expiredAt = new Date('2024-01-02T03:04:05.000Z'); const failedRootId = '00000000-0000-4000-8000-000000000001'; const firstHealthyRootId = 'eeeeeeee-eeee-4eee-beee-eeeeeeeeeeee'; const secondHealthyRootId = 'ffffffff-ffff-4fff-bfff-ffffffffffff'; await db .insertInto('pages') .values([ { deletedAt: expiredAt, id: failedRootId, slugId: randomUUID(), spaceId: context.spaceId, title: 'Failed validation root', workspaceId: context.workspaceId, }, { deletedAt: expiredAt, id: firstHealthyRootId, slugId: randomUUID(), spaceId: context.spaceId, title: 'First later healthy root', workspaceId: context.workspaceId, }, { deletedAt: expiredAt, id: secondHealthyRootId, slugId: randomUUID(), spaceId: context.spaceId, title: 'Second later healthy root', workspaceId: context.workspaceId, }, ]) .execute(); const attachmentQueue = { add: jest.fn() }; const loggerError = jest .spyOn(Logger.prototype, 'error') .mockImplementation(); const cleanupService = new TrashCleanupService( db, attachmentQueue as never, ); const validationError = new Error('Controlled validation failure'); const validationBoundary = jest.spyOn( cleanupService as unknown as { getPageAncestors: ( pageId: string, ) => Promise>; }, 'getPageAncestors', ); validationBoundary.mockRejectedValueOnce(validationError); await cleanupService.cleanupOldTrash(); const storedExpiredIds = await db .selectFrom('pages') .select('id') .where('id', 'in', [ failedRootId, firstHealthyRootId, secondHealthyRootId, ]) .orderBy('id') .execute(); expect(storedExpiredIds).toEqual([ { id: failedRootId }, { id: firstHealthyRootId }, { id: secondHealthyRootId }, ]); expect(attachmentQueue.add).not.toHaveBeenCalled(); expect(validationBoundary).toHaveBeenCalledTimes(1); expect(validationBoundary).toHaveBeenCalledWith(failedRootId); expect(loggerError).toHaveBeenCalledWith( 'Trash cleanup job failed', validationError.stack, ); }); }); describe('PageService.getPageBreadCrumbs', () => { it('returns every acyclic breadcrumb exactly once in root-to-child order', async () => { const { root, child, grandchild } = await seedAcyclicPageChain(); const pageService = createPageService(db); const breadcrumbs = await pageService.getPageBreadCrumbs(grandchild.id); expect(breadcrumbs.map(({ id, title }) => ({ id, title }))).toEqual([ { id: root.id, title: 'Root' }, { id: child.id, title: 'Child' }, { id: grandchild.id, title: 'Grandchild' }, ]); expect(breadcrumbs).toHaveLength(3); for (const breadcrumb of breadcrumbs) { expect(breadcrumb).not.toHaveProperty('isCycle'); expect(breadcrumb).not.toHaveProperty('traversalPath'); } }); it('raises PageHierarchyCycleError for a self-cycle', async () => { const { self } = await seedSelfCycle(); await withStatementTimeout(async (connection) => { const pageService = createPageService(connection); const breadcrumbs = pageService.getPageBreadCrumbs(self.id); await expect(breadcrumbs).rejects.toBeInstanceOf( PageHierarchyCycleError, ); await expect(breadcrumbs).rejects.toEqual( expect.objectContaining({ code: 'PAGE_HIERARCHY_CYCLE', rootPageId: self.id, }) satisfies Partial, ); }); }); it('raises PageHierarchyCycleError for a two-page cycle', async () => { const { a } = await seedTwoPageCycle(); await withStatementTimeout(async (connection) => { const pageService = createPageService(connection); const breadcrumbs = pageService.getPageBreadCrumbs(a.id); await expect(breadcrumbs).rejects.toBeInstanceOf( PageHierarchyCycleError, ); await expect(breadcrumbs).rejects.toEqual( expect.objectContaining({ code: 'PAGE_HIERARCHY_CYCLE', rootPageId: a.id, }) satisfies Partial, ); }); }); }); describe.each([ { name: 'a self-cycle', seedColdCycle: async () => (await seedSelfCycle()).self.id, seedWarmCycle: async () => { const { root } = await seedAcyclicPageChain(); return { pageId: root.id, corrupt: async (connection: KyselyDB) => { await connection .updateTable('pages') .set({ parentPageId: root.id }) .where('id', '=', root.id) .execute(); }, }; }, }, { name: 'a two-page cycle', seedColdCycle: async () => (await seedTwoPageCycle()).a.id, seedWarmCycle: async () => { const { root, child } = await seedAcyclicPageChain(); return { pageId: root.id, corrupt: async (connection: KyselyDB) => { await connection .updateTable('pages') .set({ parentPageId: child.id }) .where('id', '=', root.id) .execute(); }, }; }, }, ])('POST /api/pages/breadcrumbs with $name', (cycle) => { it('fails closed over HTTP with a cold permission cache and stays live', async () => { const pageId = await cycle.seedColdCycle(); const user = await insertControllerUser(pageId); await withStatementTimeout(async (connection) => { const { app, authGuard, getPageBreadCrumbs } = await createBreadcrumbHttpApp(connection, user); try { const response = await request(app.getHttpServer()) .post('/api/pages/breadcrumbs') .set('Authorization', 'Bearer integration-test') .send({ pageId }) .timeout(CLIENT_DEADLINE_MS); expect(response.status).toBe(403); expect(response.body).not.toHaveProperty('data'); expect(response.text).not.toContain(pageId); expect(authGuard.canActivate).toHaveBeenCalledTimes(1); expect(getPageBreadCrumbs).not.toHaveBeenCalled(); const health = await request(app.getHttpServer()) .get('/api/health/live') .timeout(CLIENT_DEADLINE_MS); expect(health.status).toBe(200); expect(health.body).toEqual( expect.objectContaining({ data: 'ok', success: true }), ); } finally { await app.close(); } }); }); it('rejects over HTTP without a partial payload after warming the permission cache and stays live', async () => { const { pageId, corrupt } = await cycle.seedWarmCycle(); const user = await insertControllerUser(pageId); await withStatementTimeout(async (connection) => { const { app, authGuard, getPageBreadCrumbs } = await createBreadcrumbHttpApp(connection, user); try { const warmResponse = await request(app.getHttpServer()) .post('/api/pages/breadcrumbs') .set('Authorization', 'Bearer integration-test') .send({ pageId }) .timeout(CLIENT_DEADLINE_MS); expect(warmResponse.status).toBe(200); expect( warmResponse.body.data.map((page: { id: string }) => page.id), ).toEqual([pageId]); await corrupt(connection); const cycleResponse = await request(app.getHttpServer()) .post('/api/pages/breadcrumbs') .set('Authorization', 'Bearer integration-test') .send({ pageId }) .timeout(CLIENT_DEADLINE_MS); expect(cycleResponse.status).toBe(500); expect(cycleResponse.body).not.toHaveProperty('data'); expect(cycleResponse.text).not.toContain(pageId); expect(authGuard.canActivate).toHaveBeenCalledTimes(2); expect(getPageBreadCrumbs).toHaveBeenCalledTimes(2); await expect( getPageBreadCrumbs.mock.results[1].value, ).rejects.toEqual( expect.objectContaining({ code: 'PAGE_HIERARCHY_CYCLE', rootPageId: pageId, }) satisfies Partial, ); const health = await request(app.getHttpServer()) .get('/api/health/live') .timeout(CLIENT_DEADLINE_MS); expect(health.status).toBe(200); expect(health.body).toEqual( expect.objectContaining({ data: 'ok', success: true }), ); } finally { await app.close(); } }); }); }); describe('ShareService.getShareForPage', () => { it('returns the nearest valid inherited share without a depth limit', async () => { const { root, grandchild } = await seedAcyclicPageChain(); const context = await db .selectFrom('pages') .select(['spaceId', 'workspaceId']) .where('id', '=', root.id) .executeTakeFirstOrThrow(); let descendantId = grandchild.id; let nearerSharedAncestorId: string; for (let depth = 3; depth <= 52; depth += 1) { const descendant = await db .insertInto('pages') .values({ parentPageId: descendantId, slugId: randomUUID(), spaceId: context.spaceId, title: `Descendant ${depth}`, workspaceId: context.workspaceId, }) .returning('id') .executeTakeFirstOrThrow(); descendantId = descendant.id; if (depth === 26) { nearerSharedAncestorId = descendant.id; } } const fartherShare = await insertShare(root.id, true); const nearerShare = await insertShare(nearerSharedAncestorId, true); const shareService = createShareService(db); const share = await shareService.getShareForPage( descendantId, context.workspaceId, ); expect(share).toEqual( expect.objectContaining({ id: nearerShare.id, pageId: nearerSharedAncestorId, level: 26, }), ); expect(share.id).not.toBe(fartherShare.id); }); it('returns a direct share without traversing corrupt parents', async () => { const { self } = await seedSelfCycle(); const storedShare = await insertShare(self.id, false); await withStatementTimeout(async (connection) => { const shareService = createShareService(connection); await expect( shareService.getShareForPage(self.id, storedShare.workspaceId), ).resolves.toEqual( expect.objectContaining({ id: storedShare.id, pageId: self.id, level: 0, }), ); }); }); it('rejects a share from a different workspace', async () => { const { root } = await seedAcyclicPageChain(); await insertShare(root.id, true); const shareService = createShareService(db); await expect( shareService.getShareForPage(root.id, randomUUID()), ).resolves.toBeUndefined(); }); it('rejects an inherited share that excludes subpages', async () => { const { root, grandchild } = await seedAcyclicPageChain(); const storedShare = await insertShare(root.id, false); const shareService = createShareService(db); await expect( shareService.getShareForPage(grandchild.id, storedShare.workspaceId), ).resolves.toBeUndefined(); }); it('returns undefined for a cyclic chain with no reachable share', async () => { const { a } = await seedTwoPageCycle(); const { workspaceId } = await db .selectFrom('pages') .select('workspaceId') .where('id', '=', a.id) .executeTakeFirstOrThrow(); await withStatementTimeout(async (connection) => { const shareService = createShareService(connection); await expect( shareService.getShareForPage(a.id, workspaceId), ).resolves.toBeUndefined(); }); }); }); describe('single-page permissions', () => { it('preserves unrestricted acyclic access', async () => { const { grandchild } = await seedAcyclicPageChain(); const userId = await insertTestUser(grandchild.id); const repo = createPagePermissionRepo(db); await expect( repo.canUserEditPage(userId, grandchild.id), ).resolves.toEqual({ hasAnyRestriction: false, canAccess: true, canEdit: true, }); await expect( repo.getUserPageAccessLevel(userId, grandchild.id), ).resolves.toEqual({ hasDirectRestriction: false, hasInheritedRestriction: false, hasAnyRestriction: false, canAccess: true, canEdit: true, }); await expect(repo.hasRestrictedAncestor(grandchild.id)).resolves.toBe( false, ); await expect( repo.getUserIdsWithPageAccess(grandchild.id, [userId]), ).resolves.toEqual([userId]); await expect( repo.findRestrictedAncestor(grandchild.id), ).resolves.toBeUndefined(); }); it('preserves permitted acyclic access', async () => { const { root, grandchild } = await seedAcyclicPageChain(); const userId = await insertTestUser(grandchild.id); const restriction = await restrictPage(root.id, userId); const repo = createPagePermissionRepo(db); await expect( repo.canUserEditPage(userId, grandchild.id), ).resolves.toEqual({ hasAnyRestriction: true, canAccess: true, canEdit: true, }); await expect( repo.getUserPageAccessLevel(userId, grandchild.id), ).resolves.toEqual({ hasDirectRestriction: false, hasInheritedRestriction: true, hasAnyRestriction: true, canAccess: true, canEdit: true, }); await expect(repo.hasRestrictedAncestor(grandchild.id)).resolves.toBe( true, ); await expect( repo.getUserIdsWithPageAccess(grandchild.id, [userId]), ).resolves.toEqual([userId]); await expect(repo.findRestrictedAncestor(grandchild.id)).resolves.toEqual( { ...restriction, depth: 2 }, ); }); it('preserves denied acyclic access', async () => { const { root, grandchild } = await seedAcyclicPageChain(); const userId = await insertTestUser(grandchild.id); const restriction = await restrictPage(root.id); const repo = createPagePermissionRepo(db); await expect( repo.canUserEditPage(userId, grandchild.id), ).resolves.toEqual({ hasAnyRestriction: true, canAccess: false, canEdit: false, }); await expect( repo.getUserPageAccessLevel(userId, grandchild.id), ).resolves.toEqual({ hasDirectRestriction: false, hasInheritedRestriction: true, hasAnyRestriction: true, canAccess: false, canEdit: false, }); await expect(repo.hasRestrictedAncestor(grandchild.id)).resolves.toBe( true, ); await expect( repo.getUserIdsWithPageAccess(grandchild.id, [userId]), ).resolves.toEqual([]); await expect(repo.findRestrictedAncestor(grandchild.id)).resolves.toEqual( { ...restriction, depth: 2 }, ); }); describe.each([ ['a self-cycle', async () => (await seedSelfCycle()).self.id], ['a two-page cycle', async () => (await seedTwoPageCycle()).a.id], ])('%s', (_cycleName, seedCycle) => { it('fails canUserEditPage closed', async () => { const pageId = await seedCycle(); const userId = await insertTestUser(pageId); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); await expect(repo.canUserEditPage(userId, pageId)).resolves.toEqual({ hasAnyRestriction: true, canAccess: false, canEdit: false, }); }); }); it('fails getUserPageAccessLevel closed', async () => { const pageId = await seedCycle(); const userId = await insertTestUser(pageId); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); await expect( repo.getUserPageAccessLevel(userId, pageId), ).resolves.toEqual( expect.objectContaining({ hasAnyRestriction: true, canAccess: false, canEdit: false, }), ); }); }); it('treats a cycle as a restricted ancestor', async () => { const pageId = await seedCycle(); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); await expect(repo.hasRestrictedAncestor(pageId)).resolves.toBe(true); }); }); it('filters every candidate user from a cycle', async () => { const pageId = await seedCycle(); const userId = await insertTestUser(pageId); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); await expect( repo.getUserIdsWithPageAccess(pageId, [userId]), ).resolves.toEqual([]); }); }); it('raises PageHierarchyCycleError from findRestrictedAncestor', async () => { const pageId = await seedCycle(); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); const restrictedAncestor = repo.findRestrictedAncestor(pageId); await expect(restrictedAncestor).rejects.toBeInstanceOf( PageHierarchyCycleError, ); await expect(restrictedAncestor).rejects.toEqual( expect.objectContaining({ code: 'PAGE_HIERARCHY_CYCLE', rootPageId: pageId, }) satisfies Partial, ); }); }); }); }); describe('bulk permissions', () => { it('excludes a cyclic page from the unrestricted-space fast path', async () => { const { grandchild } = await seedAcyclicPageChain(); const { cyclePageId, spaceId } = await insertTwoPageCycleInPageSpace( grandchild.id, ); const userId = await insertTestUser(grandchild.id); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); await expect(repo.hasRestrictedPagesInSpace(spaceId)).resolves.toBe( false, ); await expect( repo.filterAccessiblePageIds({ pageIds: [grandchild.id, cyclePageId], userId, spaceId, }), ).resolves.toEqual([grandchild.id]); }); }); it('keeps an accessible acyclic page while excluding a cyclic page', async () => { const { grandchild } = await seedAcyclicPageChain(); const { a } = await seedTwoPageCycle(); const userId = await insertTestUser(grandchild.id); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); await expect( repo.filterAccessiblePageIds({ pageIds: [grandchild.id, a.id], userId, }), ).resolves.toEqual([grandchild.id]); }); }); it('keeps acyclic permission details while excluding a cyclic page', async () => { const { grandchild } = await seedAcyclicPageChain(); const { a } = await seedTwoPageCycle(); const userId = await insertTestUser(grandchild.id); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); await expect( repo.filterAccessiblePageIdsWithPermissions( [grandchild.id, a.id], userId, ), ).resolves.toEqual([{ id: grandchild.id, canEdit: true }]); }); }); it('keeps a parent with an accessible child while excluding a cyclic child', async () => { const { root, child } = await seedAcyclicPageChain(); const { a } = await seedTwoPageCycle(); const userId = await insertTestUser(child.id); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); await expect( repo.getParentIdsWithAccessibleChildren([root.id, a.id], userId), ).resolves.toEqual([root.id]); }); }); it('keeps independent acyclic seeds that share ancestors accessible', async () => { const { firstChild, secondChild, grandchild } = await seedBranchingDescendantTree(); const userId = await insertTestUser(grandchild.id); const pageIds = [firstChild.id, secondChild.id, grandchild.id]; const expectedPageIds = [...pageIds].sort(); await withStatementTimeout(async (connection) => { const repo = createPagePermissionRepo(connection); const accessiblePageIds = await repo.filterAccessiblePageIds({ pageIds, userId, }); const permissionDetails = await repo.filterAccessiblePageIdsWithPermissions(pageIds, userId); expect(accessiblePageIds.sort()).toEqual(expectedPageIds); expect( permissionDetails .map(({ id, canEdit }) => ({ id, canEdit })) .sort((left, right) => left.id.localeCompare(right.id)), ).toEqual( expectedPageIds.map((id) => ({ id, canEdit: true, })), ); }); }); }); });