Files
gitmost/apps/server/src/core/search/search.service.query-mode.spec.ts
claude_code 4df79aafd3 test(server): batch 5 authorization, transclusion, search & comment coverage
Test-only. Fills the authorization / data-integrity gaps from the strategy
report. Full server suite: 100 suites / 1031 passed + 1 todo, green.

Authorization (privilege-escalation catches):
- workspace/space ability factories: exact can/cannot per (action,subject) —
  admin cannot Manage Audit, writer/reader cannot Manage Settings/Member, etc.
- findHighestUserSpaceRole, isAdminActingOnOwner.
- WorkspaceService role guards: last-owner lockout, admin-over-owner, self-target.
- SpaceMemberService.validateLastAdmin: never orphan a space without an admin.
- GroupService: default-group immutability, name uniqueness.

Access / data integrity:
- PageAccessService: restriction-vs-space-ability branches for view/edit/comment.
- TransclusionService.unsyncReference: cross-workspace/NotFound boundary asserts
  NO attachment write or ref-row delete on rejection; lookupWithAccessSet
  positional status mapping; listReferences drops private/cross-ws/deleted refs;
  syncPageTransclusions/References diff (no-op on unchanged content).
- SearchService.searchPage: query-mode scoping; leakage modes return empty
  before executing the query.
- CommentService: reply-to-reply guard, agent provenance, self-mention filter,
  no double-notify.

Pure helpers:
- prosemirror extractors (mention dedup-key id-vs-entityId, attachment UUID
  validation, removeMarkTypeFromDoc), collaboration.util (getPageId,
  isEmptyParagraphDoc, stripUnknownNodes unwrap, prosemirrorNodeToYElement).

Reviewed (APPROVE WITH SUGGESTIONS): mutation-resistant, not vacuous.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:40:07 +03:00

223 lines
7.7 KiB
TypeScript

import { SearchService } from './search.service';
/**
* Coverage for SearchService.searchPage query-mode selection (search.service.ts
* @25). searchPage chooses HOW the result set is scoped — by explicit space, by
* the authenticated user's member spaces, or by a share — and must return an
* empty set (without leaking data) for every disallowed combination.
*
* The kysely query builder is mocked with the same chainable pattern as the
* existing search.service.spec.ts: every builder method returns the same builder
* and `.execute()` resolves the supplied rows. Each `.where(...)` call is
* recorded so we can assert exactly which scope clause was applied — that is the
* mutation-resistant signal that distinguishes one query mode from another.
*
* These specs catch cross-space / cross-workspace search leakage and
* share-scope bypass (data exposure).
*/
describe('SearchService.searchPage — query-mode selection', () => {
// Build a chainable selectFrom('pages') builder that records its calls. The
// builder is returned from `db.selectFrom` and is the single object every
// chained call mutates/returns, mirroring the existing spec's pattern.
function makeBuilder(rows: Array<{ id: string; highlight?: string }>) {
const builder: any = {};
builder.select = jest.fn(() => builder);
builder.where = jest.fn(() => builder);
builder.$if = jest.fn(() => builder);
builder.orderBy = jest.fn(() => builder);
builder.limit = jest.fn(() => builder);
builder.offset = jest.fn(() => builder);
builder.execute = jest.fn(async () => rows);
return builder;
}
function makeService(opts?: {
rows?: Array<{ id: string; highlight?: string }>;
share?: any;
isRestricted?: boolean;
descendants?: Array<{ id: string }>;
}) {
const builder = makeBuilder(opts?.rows ?? []);
const db: any = {
selectFrom: jest.fn(() => builder),
};
// `getUserSpaceIdsQuery` returns a sub-query object that searchPage passes
// straight into `.where('spaceId', 'in', <subquery>)`. A sentinel is enough
// to assert the user-scoped branch was taken.
const userSpaceIdsQuery = { __userSpaceIdsQuery: true };
const pageRepo = {
// `.select((eb) => this.pageRepo.withSpace(eb))` — value ignored by stub.
withSpace: jest.fn(() => ({ __withSpace: true })),
getPageAndDescendantsExcludingRestricted: jest
.fn()
.mockResolvedValue(opts?.descendants ?? []),
};
const shareRepo = {
findById: jest.fn().mockResolvedValue(opts?.share ?? null),
};
const spaceMemberRepo = {
getUserSpaceIdsQuery: jest.fn(() => userSpaceIdsQuery),
};
const pagePermissionRepo = {
hasRestrictedAncestor: jest
.fn()
.mockResolvedValue(opts?.isRestricted ?? false),
// Let everything through page-level permission filtering by default.
filterAccessiblePageIds: jest
.fn()
.mockImplementation(async ({ pageIds }: { pageIds: string[] }) => pageIds),
};
const service = new SearchService(
db as any,
pageRepo as any,
shareRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
);
return {
service,
db,
builder,
pageRepo,
shareRepo,
spaceMemberRepo,
pagePermissionRepo,
userSpaceIdsQuery,
};
}
const whereCallFor = (builder: any, column: any) =>
builder.where.mock.calls.find((c: any[]) => c[0] === column);
it('returns {items:[]} for a blank query WITHOUT touching the DB', async () => {
const { service, db } = makeService();
const result = await service.searchPage(
{ query: '' } as any,
{ userId: 'user-1', workspaceId: 'ws-1' },
);
expect(result).toEqual({ items: [] });
// Blank query is rejected before any query builder is constructed.
expect(db.selectFrom).not.toHaveBeenCalled();
});
it('scopes to the explicit spaceId branch', async () => {
const { service, builder, db, spaceMemberRepo, shareRepo } = makeService({
rows: [{ id: 'p-1' }],
});
const result = await service.searchPage(
{ query: 'plan', spaceId: 'space-42' } as any,
{ userId: 'user-1', workspaceId: 'ws-1' },
);
expect(db.selectFrom).toHaveBeenCalledWith('pages');
// The explicit-space branch adds exactly `.where('spaceId', '=', 'space-42')`.
expect(whereCallFor(builder, 'spaceId')).toEqual([
'spaceId',
'=',
'space-42',
]);
// It must NOT fall through to the user-member-spaces or share branch.
expect(spaceMemberRepo.getUserSpaceIdsQuery).not.toHaveBeenCalled();
expect(shareRepo.findById).not.toHaveBeenCalled();
expect(result.items.map((i: any) => i.id)).toEqual(['p-1']);
});
it('scopes an authenticated user WITHOUT spaceId to their member spaces', async () => {
const { service, builder, spaceMemberRepo, userSpaceIdsQuery, shareRepo } =
makeService({ rows: [{ id: 'p-9' }] });
await service.searchPage(
{ query: 'plan' } as any,
{ userId: 'user-7', workspaceId: 'ws-1' },
);
// The user-scoped branch resolves the member-spaces sub-query for that user
// and restricts both spaceId (to that sub-query) and workspaceId.
expect(spaceMemberRepo.getUserSpaceIdsQuery).toHaveBeenCalledWith('user-7');
expect(whereCallFor(builder, 'spaceId')).toEqual([
'spaceId',
'in',
userSpaceIdsQuery,
]);
expect(whereCallFor(builder, 'workspaceId')).toEqual([
'workspaceId',
'=',
'ws-1',
]);
// Authenticated user path must not consult shares.
expect(shareRepo.findById).not.toHaveBeenCalled();
});
it('returns {items:[]} when the share belongs to a DIFFERENT workspace', async () => {
const { service, builder, shareRepo, pagePermissionRepo } = makeService({
share: {
id: 'share-1',
pageId: 'page-1',
workspaceId: 'OTHER-ws',
includeSubPages: false,
},
});
const result = await service.searchPage(
{ query: 'plan', shareId: 'share-1' } as any,
{ workspaceId: 'ws-1' },
);
expect(shareRepo.findById).toHaveBeenCalledWith('share-1');
expect(result).toEqual({ items: [] });
// Workspace mismatch short-circuits before any restricted-ancestor / id
// scoping or DB execution: no leak across workspaces.
expect(pagePermissionRepo.hasRestrictedAncestor).not.toHaveBeenCalled();
expect(builder.execute).not.toHaveBeenCalled();
});
it('returns {items:[]} when the shared page has a restricted ancestor', async () => {
const { service, builder, pagePermissionRepo, pageRepo } = makeService({
share: {
id: 'share-1',
pageId: 'page-1',
workspaceId: 'ws-1',
includeSubPages: true,
},
isRestricted: true,
});
const result = await service.searchPage(
{ query: 'plan', shareId: 'share-1' } as any,
{ workspaceId: 'ws-1' },
);
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalledWith(
'page-1',
);
expect(result).toEqual({ items: [] });
// Restricted ancestor must block before page enumeration and DB execution.
expect(
pageRepo.getPageAndDescendantsExcludingRestricted,
).not.toHaveBeenCalled();
expect(builder.execute).not.toHaveBeenCalled();
});
it('returns {items:[]} with no userId, no spaceId and no shareId', async () => {
const { service, builder, shareRepo } = makeService();
const result = await service.searchPage(
{ query: 'plan' } as any,
{ workspaceId: 'ws-1' },
);
expect(result).toEqual({ items: [] });
// The catch-all else returns empty without scoping/executing or hitting shares.
expect(shareRepo.findById).not.toHaveBeenCalled();
expect(builder.execute).not.toHaveBeenCalled();
});
});