mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
test: cover cyclic page hierarchy denial of service
This commit is contained in:
@@ -1,13 +1,19 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Logger } from '@nestjs/common';
|
import { ForbiddenException, Logger } from '@nestjs/common';
|
||||||
import { createCache } from 'cache-manager';
|
import { createCache } from 'cache-manager';
|
||||||
|
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 { PageService } from '../src/core/page/services/page.service';
|
||||||
import { TrashCleanupService } from '../src/core/page/services/trash-cleanup.service';
|
import { TrashCleanupService } from '../src/core/page/services/trash-cleanup.service';
|
||||||
import { ShareService } from '../src/core/share/share.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 { PageHierarchyCycleError } from '../src/database/helpers/page-hierarchy-cycle';
|
||||||
import { PagePermissionRepo } from '../src/database/repos/page/page-permission.repo';
|
import { PagePermissionRepo } from '../src/database/repos/page/page-permission.repo';
|
||||||
import { PageRepo } from '../src/database/repos/page/page.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 { KyselyDB } from '../src/database/types/kysely.types';
|
||||||
|
import { HealthController } from '../src/integrations/health/health.controller';
|
||||||
import { db, withStatementTimeout } from './support/database';
|
import { db, withStatementTimeout } from './support/database';
|
||||||
import {
|
import {
|
||||||
seedAcyclicPageChain,
|
seedAcyclicPageChain,
|
||||||
@@ -16,6 +22,8 @@ import {
|
|||||||
seedTwoPageCycle,
|
seedTwoPageCycle,
|
||||||
} from './support/page-hierarchy-fixtures';
|
} from './support/page-hierarchy-fixtures';
|
||||||
|
|
||||||
|
const CLIENT_DEADLINE_MS = 1_000;
|
||||||
|
|
||||||
function createPageService(
|
function createPageService(
|
||||||
connection: KyselyDB,
|
connection: KyselyDB,
|
||||||
dependencies: {
|
dependencies: {
|
||||||
@@ -61,6 +69,108 @@ function createPagePermissionRepo(connection: KyselyDB): PagePermissionRepo {
|
|||||||
return new PagePermissionRepo(connection, undefined as never, createCache());
|
return new PagePermissionRepo(connection, undefined as never, createCache());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createBreadcrumbController(connection: KyselyDB): PageController {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new PageController(
|
||||||
|
createPageService(connection),
|
||||||
|
createPageRepo(connection),
|
||||||
|
undefined as never,
|
||||||
|
spaceAbility,
|
||||||
|
pageAccessService,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertControllerUser(pageId: string): Promise<User> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureBeforeClientDeadline<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
): Promise<{
|
||||||
|
error?: unknown;
|
||||||
|
value?: T;
|
||||||
|
}> {
|
||||||
|
let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const deadline = new Promise<never>((_, reject) => {
|
||||||
|
deadlineTimer = setTimeout(
|
||||||
|
() => reject(new Error('Client deadline exceeded')),
|
||||||
|
CLIENT_DEADLINE_MS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = await Promise.race([operation(), deadline]);
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (deadlineTimer) clearTimeout(deadlineTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectLiveHealthCheck(): Promise<void> {
|
||||||
|
const healthController = new HealthController(
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
undefined as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(healthController.checkLive()).resolves.toBe('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
function silenceTrashCleanupDebugLogs(): void {
|
||||||
|
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
|
||||||
|
}
|
||||||
|
|
||||||
async function insertTestUser(pageId: string): Promise<string> {
|
async function insertTestUser(pageId: string): Promise<string> {
|
||||||
const { workspaceId } = await db
|
const { workspaceId } = await db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
@@ -383,6 +493,7 @@ describe('cycle-safe page hierarchy reads', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('trash cleanup logs and skips a corrupt root before continuing with acyclic trash', async () => {
|
it('trash cleanup logs and skips a corrupt root before continuing with acyclic trash', async () => {
|
||||||
|
silenceTrashCleanupDebugLogs();
|
||||||
const { self } = await seedSelfCycle();
|
const { self } = await seedSelfCycle();
|
||||||
const context = await db
|
const context = await db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
@@ -500,6 +611,7 @@ describe('cycle-safe page hierarchy reads', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('trash cleanup aborts before later mutation when hierarchy validation unexpectedly fails', async () => {
|
it('trash cleanup aborts before later mutation when hierarchy validation unexpectedly fails', async () => {
|
||||||
|
silenceTrashCleanupDebugLogs();
|
||||||
const { root } = await seedAcyclicPageChain();
|
const { root } = await seedAcyclicPageChain();
|
||||||
const context = await db
|
const context = await db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
@@ -644,6 +756,91 @@ describe('cycle-safe page hierarchy reads', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])('PageController.getPageBreadcrumbs with $name', (cycle) => {
|
||||||
|
it('fails closed with a cold permission cache before the statement timeout', async () => {
|
||||||
|
const pageId = await cycle.seedColdCycle();
|
||||||
|
const user = await insertControllerUser(pageId);
|
||||||
|
|
||||||
|
await withStatementTimeout(async (connection) => {
|
||||||
|
const controller = createBreadcrumbController(connection);
|
||||||
|
const outcome = await captureBeforeClientDeadline(() =>
|
||||||
|
controller.getPageBreadcrumbs({ pageId }, user),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome.value).toBeUndefined();
|
||||||
|
expect(outcome.error).toBeInstanceOf(ForbiddenException);
|
||||||
|
await expectLiveHealthCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects 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 controller = createBreadcrumbController(connection);
|
||||||
|
const warmBreadcrumbs = await controller.getPageBreadcrumbs(
|
||||||
|
{ pageId },
|
||||||
|
user,
|
||||||
|
);
|
||||||
|
expect(warmBreadcrumbs.map((page) => page.id)).toEqual([pageId]);
|
||||||
|
|
||||||
|
await corrupt(connection);
|
||||||
|
|
||||||
|
const outcome = await captureBeforeClientDeadline(() =>
|
||||||
|
controller.getPageBreadcrumbs({ pageId }, user),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(outcome.value).toBeUndefined();
|
||||||
|
expect(outcome.error).toBeInstanceOf(PageHierarchyCycleError);
|
||||||
|
expect(outcome.error).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'PAGE_HIERARCHY_CYCLE',
|
||||||
|
rootPageId: pageId,
|
||||||
|
}) satisfies Partial<PageHierarchyCycleError>,
|
||||||
|
);
|
||||||
|
await expectLiveHealthCheck();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('ShareService.getShareForPage', () => {
|
describe('ShareService.getShareForPage', () => {
|
||||||
it('returns the nearest valid inherited share without a depth limit', async () => {
|
it('returns the nearest valid inherited share without a depth limit', async () => {
|
||||||
const { root, grandchild } = await seedAcyclicPageChain();
|
const { root, grandchild } = await seedAcyclicPageChain();
|
||||||
|
|||||||
Reference in New Issue
Block a user