Merge develop into fix/html-embed-hardening (#46)
Some checks failed
Test / test (pull_request) Has been cancelled
Some checks failed
Test / test (pull_request) Has been cancelled
Resolve the html-embed.spec.ts conflict as a union: both #46 and #49 (already in develop) added different test cases to the same file. Keep all of them — stripHtmlEmbedNodes gets #46's root-node case plus develop's deeply-nested, non-object and empty-content cases; #46's collectHtmlEmbedSources and stripDisallowedHtmlEmbedNodes suites and develop's hasHtmlEmbedNode suite all kept; imports unioned. No production code conflicted. Full suite green: server 651, client (16 files), editor-ext 56, mcp 247. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
@@ -162,10 +163,30 @@
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
"ts",
|
||||
"tsx"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"testPathIgnorePatterns": [
|
||||
"/node_modules/",
|
||||
"<rootDir>/core/auth/auth.controller.spec.ts",
|
||||
"<rootDir>/core/auth/services/auth.service.spec.ts",
|
||||
"<rootDir>/core/auth/services/token.service.spec.ts",
|
||||
"<rootDir>/core/comment/comment.service.spec.ts",
|
||||
"<rootDir>/core/group/group.controller.spec.ts",
|
||||
"<rootDir>/core/group/services/group.service.spec.ts",
|
||||
"<rootDir>/core/page/page.controller.spec.ts",
|
||||
"<rootDir>/core/page/services/page.service.spec.ts",
|
||||
"<rootDir>/core/search/search.controller.spec.ts",
|
||||
"<rootDir>/core/search/search.service.spec.ts",
|
||||
"<rootDir>/core/space/services/space.service.spec.ts",
|
||||
"<rootDir>/core/space/space.controller.spec.ts",
|
||||
"<rootDir>/core/user/user.controller.spec.ts",
|
||||
"<rootDir>/core/workspace/services/workspace.service.spec.ts",
|
||||
"<rootDir>/integrations/environment/environment.service.spec.ts",
|
||||
"<rootDir>/integrations/storage/storage.service.spec.ts"
|
||||
],
|
||||
"transform": {
|
||||
"happy-dom.+\\.js$": [
|
||||
"babel-jest",
|
||||
@@ -182,7 +203,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom)(@|/))"
|
||||
|
||||
@@ -45,6 +45,9 @@ import {
|
||||
htmlToMarkdown,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
PageEmbed,
|
||||
} from '@docmost/editor-ext';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
@@ -115,6 +118,9 @@ export const tiptapExtensions = [
|
||||
Status,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
FootnoteReference,
|
||||
FootnotesList,
|
||||
FootnoteDefinition,
|
||||
PageEmbed,
|
||||
] as any;
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { htmlToJson, jsonToHtml } from './collaboration.util';
|
||||
|
||||
const findFirst = (json: any, type: string): any | undefined => {
|
||||
if (!json || typeof json !== 'object') return undefined;
|
||||
if (json.type === type) return json;
|
||||
if (Array.isArray(json.content)) {
|
||||
for (const child of json.content) {
|
||||
const found = findFirst(child, type);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Guards the fragile parse-priority approach that lets a `footnoteReference`
|
||||
* NODE win over the `Superscript` MARK for `<sup>` elements. In the server
|
||||
* `tiptapExtensions` list, Superscript is registered BEFORE the footnote nodes,
|
||||
* so without the priority guard a `<sup data-footnote-ref>` would be parsed as
|
||||
* an (empty) superscript mark and the footnote reference would be lost.
|
||||
*/
|
||||
describe('footnote reference vs superscript mark (server schema round-trip)', () => {
|
||||
const HTML =
|
||||
'<p>Water' +
|
||||
'<sup data-footnote-ref data-id="fn1"></sup>' +
|
||||
' here.</p>' +
|
||||
'<section data-footnotes>' +
|
||||
'<div data-footnote-def data-id="fn1"><p>First note.</p></div>' +
|
||||
'</section>';
|
||||
|
||||
it('parses <sup data-footnote-ref> into a footnoteReference NODE (not a superscript mark)', () => {
|
||||
const json = htmlToJson(HTML);
|
||||
|
||||
const ref = findFirst(json, 'footnoteReference');
|
||||
expect(ref).toBeDefined();
|
||||
expect(ref.attrs.id).toBe('fn1');
|
||||
|
||||
// It must NOT have been swallowed as a superscript mark on text.
|
||||
const superscriptText = JSON.stringify(json).includes('"superscript"');
|
||||
expect(superscriptText).toBe(false);
|
||||
|
||||
// The matching definition survives too.
|
||||
const def = findFirst(json, 'footnoteDefinition');
|
||||
expect(def).toBeDefined();
|
||||
expect(def.attrs.id).toBe('fn1');
|
||||
});
|
||||
|
||||
it('round-trips an empty footnoteReference back to <sup data-footnote-ref>', () => {
|
||||
const json = htmlToJson(HTML);
|
||||
const html = jsonToHtml(json);
|
||||
|
||||
expect(html).toContain('data-footnote-ref');
|
||||
expect(html).toContain('data-id="fn1"');
|
||||
|
||||
// And a second parse still yields the node (stable round-trip).
|
||||
const json2 = htmlToJson(html);
|
||||
const ref2 = findFirst(json2, 'footnoteReference');
|
||||
expect(ref2).toBeDefined();
|
||||
expect(ref2.attrs.id).toBe('fn1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { markdownToHtml, encodeHtmlEmbedSource } from '@docmost/editor-ext';
|
||||
import { htmlToJson } from '../../../collaboration/collaboration.util';
|
||||
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
||||
|
||||
/**
|
||||
* CONTRACT (security): an attacker who controls imported markdown/HTML could try
|
||||
* to smuggle an htmlEmbed in the *serialized* DOM form —
|
||||
* <div data-type="htmlEmbed" data-source="...">
|
||||
* — directly, bypassing the editor's `<!--html-embed:-->` comment marker.
|
||||
*
|
||||
* This exercises the REAL server import conversion path that ImportService uses
|
||||
* (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio
|
||||
* link/iframe normalize pass which does not touch htmlEmbed divs) and asserts
|
||||
* the ACTUAL behaviour so we know whether the strip gate can be bypassed.
|
||||
*
|
||||
* FINDING (documented): the raw embed div DOES round-trip through marked +
|
||||
* htmlToJson into a real `htmlEmbed` node, so `hasHtmlEmbedNode` returns true and
|
||||
* `stripHtmlEmbedNodes` removes it. The serialized-form bypass is therefore
|
||||
* detectable and STRIPPABLE — the write-path gate covers it.
|
||||
*/
|
||||
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
|
||||
it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => {
|
||||
const source = '<script>steal()</script>';
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
const md = [
|
||||
'Hello',
|
||||
'',
|
||||
`<div data-type="htmlEmbed" data-source="${encoded}"></div>`,
|
||||
'',
|
||||
'World',
|
||||
].join('\n');
|
||||
|
||||
const html = await markdownToHtml(md);
|
||||
// marked preserves the raw block-level div verbatim.
|
||||
expect(html).toContain('data-type="htmlEmbed"');
|
||||
|
||||
const json = htmlToJson(html);
|
||||
// The div parses into a real htmlEmbed node carrying the decoded source.
|
||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||
|
||||
// Because it is detected, the write-path gate can strip it for non-admins.
|
||||
const stripped = stripHtmlEmbedNodes(json);
|
||||
expect(hasHtmlEmbedNode(stripped)).toBe(false);
|
||||
// Surrounding non-embed content is retained.
|
||||
expect(JSON.stringify(stripped)).toContain('Hello');
|
||||
expect(JSON.stringify(stripped)).toContain('World');
|
||||
});
|
||||
|
||||
it('round-trips through direct HTML conversion (htmlToJson) and is DETECTED', () => {
|
||||
const source = '<script>steal()</script>';
|
||||
const encoded = encodeHtmlEmbedSource(source);
|
||||
const html = `<p>Hello</p><div data-type="htmlEmbed" data-source="${encoded}"></div><p>World</p>`;
|
||||
|
||||
const json = htmlToJson(html);
|
||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
||||
});
|
||||
|
||||
it('is still DETECTED even when the data-source is NOT valid base64', async () => {
|
||||
// A naive raw inline source (HTML-escaped, not base64) still parses as an
|
||||
// htmlEmbed NODE — the decoder just yields an empty source. Detection (and
|
||||
// therefore stripping) does not depend on the source being well-formed, so
|
||||
// the bypass cannot be hidden by sending a malformed data-source.
|
||||
const md = `<div data-type="htmlEmbed" data-source="<script>x</script>"></div>`;
|
||||
const html = await markdownToHtml(md);
|
||||
const json = htmlToJson(html);
|
||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -105,6 +105,70 @@ describe('stripHtmlEmbedNodes', () => {
|
||||
const result = stripHtmlEmbedNodes(root);
|
||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||
});
|
||||
|
||||
it('strips a deeply nested htmlEmbed (3+ levels: callout > column > paragraph-sibling)', () => {
|
||||
// htmlEmbed sits as a sibling of a paragraph, nested four containers deep.
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'callout',
|
||||
content: [
|
||||
{
|
||||
type: 'columns',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'deep keep' }],
|
||||
},
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>x</script>' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = stripHtmlEmbedNodes(doc);
|
||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||
const col = findFirstChild(result, 'column');
|
||||
// Sibling paragraph survives; only the embed is removed.
|
||||
expect(col.content).toHaveLength(1);
|
||||
expect(col.content[0].type).toBe('paragraph');
|
||||
expect(col.content[0].content[0].text).toBe('deep keep');
|
||||
});
|
||||
|
||||
it('returns non-object / null / array-without-content nodes unchanged', () => {
|
||||
// Non-object inputs are returned as-is (callers persist what they got).
|
||||
expect(stripHtmlEmbedNodes(null as any)).toBeNull();
|
||||
expect(stripHtmlEmbedNodes(undefined as any)).toBeUndefined();
|
||||
expect(stripHtmlEmbedNodes('not-a-node' as any)).toBe('not-a-node');
|
||||
expect(stripHtmlEmbedNodes(42 as any)).toBe(42);
|
||||
|
||||
// An object node with no `content` array is returned shallow-cloned, equal.
|
||||
const leaf = { type: 'paragraph', attrs: { id: 'x' } };
|
||||
const out = stripHtmlEmbedNodes(leaf);
|
||||
expect(out).toEqual(leaf);
|
||||
expect(out).not.toBe(leaf); // new object, input not mutated
|
||||
});
|
||||
|
||||
it('yields empty content (not null/undefined) for a doc whose only child is an htmlEmbed', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [{ type: 'htmlEmbed', attrs: { source: '<b>only</b>' } }],
|
||||
};
|
||||
const result = stripHtmlEmbedNodes(doc) as any;
|
||||
expect(Array.isArray(result.content)).toBe(true);
|
||||
expect(result.content).toHaveLength(0);
|
||||
expect(result.content).not.toBeNull();
|
||||
expect(result.content).not.toBeUndefined();
|
||||
expect(hasHtmlEmbedNode(result)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectHtmlEmbedSources', () => {
|
||||
@@ -270,6 +334,38 @@ describe('stripDisallowedHtmlEmbedNodes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasHtmlEmbedNode (root/odd-shape detection)', () => {
|
||||
it('returns true when the ROOT node itself is an htmlEmbed (not only a child)', () => {
|
||||
const rootEmbed = { type: 'htmlEmbed', attrs: { source: '<script>r</script>' } };
|
||||
expect(hasHtmlEmbedNode(rootEmbed)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a doc with embed-like TEXT but no htmlEmbed node', () => {
|
||||
// The literal string "htmlEmbed" appears only as text content, not as a
|
||||
// node type, so it must NOT be detected.
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'type: htmlEmbed <div data-type="htmlEmbed">' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(hasHtmlEmbedNode(doc)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-object / null / array inputs', () => {
|
||||
expect(hasHtmlEmbedNode(null)).toBe(false);
|
||||
expect(hasHtmlEmbedNode(undefined)).toBe(false);
|
||||
expect(hasHtmlEmbedNode('htmlEmbed')).toBe(false);
|
||||
// A bare array (no `content` wrapper) has no node `type`, so it's false.
|
||||
expect(hasHtmlEmbedNode([{ type: 'htmlEmbed' }] as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canAuthorHtmlEmbed', () => {
|
||||
it('allows owner and admin', () => {
|
||||
expect(canAuthorHtmlEmbed('owner')).toBe(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
serializeSteps,
|
||||
rowToUiMessage,
|
||||
prepareAgentStep,
|
||||
buildErrorAssistantRecord,
|
||||
MAX_AGENT_STEPS,
|
||||
FINAL_STEP_INSTRUCTION,
|
||||
} from './ai-chat.service';
|
||||
@@ -229,3 +230,32 @@ describe('prepareAgentStep', () => {
|
||||
expect(atBoundary?.toolChoice).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit test for buildErrorAssistantRecord: the pure helper that shapes the
|
||||
* assistant-message record persisted on a first-turn (or any) stream failure.
|
||||
* The streamText onError callback builds the formatted error text via
|
||||
* describeProviderError (tested separately) and hands it to this helper; pinning
|
||||
* the record shape here covers the persist-assistant-on-error logic without
|
||||
* having to seam streamText itself.
|
||||
*/
|
||||
describe('buildErrorAssistantRecord', () => {
|
||||
it('records an empty turn with the error text in metadata (finishReason=error)', () => {
|
||||
const rec = buildErrorAssistantRecord('401: Unauthorized');
|
||||
expect(rec).toEqual({
|
||||
text: '',
|
||||
toolCalls: null,
|
||||
metadata: { finishReason: 'error', parts: [], error: '401: Unauthorized' },
|
||||
});
|
||||
});
|
||||
|
||||
it('always produces empty text + empty parts so a failed turn is still recorded', () => {
|
||||
const rec = buildErrorAssistantRecord('boom');
|
||||
// No partial text and no UI parts: the turn exists in history but renders as
|
||||
// an error, with the cause preserved in metadata.error.
|
||||
expect(rec.text).toBe('');
|
||||
expect(rec.metadata.parts).toEqual([]);
|
||||
expect(rec.toolCalls).toBeNull();
|
||||
expect(rec.metadata.error).toBe('boom');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,6 +257,9 @@ export class AiChatService {
|
||||
sessionId,
|
||||
workspace.id,
|
||||
chatId,
|
||||
// Same open-page value used by the system prompt above; exposed to the
|
||||
// model via getCurrentPage so page identity survives prompt mangling.
|
||||
body.openPage,
|
||||
);
|
||||
|
||||
// Merge in admin-configured external MCP tools (web search, etc.; §6.8).
|
||||
@@ -384,11 +387,7 @@ export class AiChatService {
|
||||
this.logger.error(`AI chat stream error: ${errorText}`, e?.stack);
|
||||
// Persist whatever text we have (likely empty) so the turn is recorded,
|
||||
// and record the error text in metadata so it is visible in history.
|
||||
await persistAssistant({
|
||||
text: '',
|
||||
toolCalls: null,
|
||||
metadata: { finishReason: 'error', parts: [], error: errorText },
|
||||
});
|
||||
await persistAssistant(buildErrorAssistantRecord(errorText));
|
||||
await closeExternalClients();
|
||||
},
|
||||
onAbort: async ({ steps }) => {
|
||||
@@ -710,6 +709,26 @@ export function rowToUiMessage(row: AiChatMessage): Omit<UIMessage, 'id'> & {
|
||||
return { id: row.id, role, parts: parts as UIMessage['parts'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the assistant-message record persisted when a turn fails before any text
|
||||
* is produced (the streamText onError path). Pure: it takes the formatted error
|
||||
* text and returns the exact `{ text, toolCalls, metadata }` payload handed to
|
||||
* persistAssistant, so the first-turn-failure recording shape is unit-testable
|
||||
* without seaming streamText. The empty text + empty parts mean the failed turn
|
||||
* is still recorded in history, with the provider cause visible in metadata.
|
||||
*/
|
||||
export function buildErrorAssistantRecord(errorText: string): {
|
||||
text: string;
|
||||
toolCalls: null;
|
||||
metadata: { finishReason: 'error'; parts: []; error: string };
|
||||
} {
|
||||
return {
|
||||
text: '',
|
||||
toolCalls: null,
|
||||
metadata: { finishReason: 'error', parts: [], error: errorText },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce SDK step objects to a compact, JSON-serializable trace for the
|
||||
* `tool_calls` column. Stores only what the UI action-log and history need —
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import {
|
||||
resolveShareAssistantRequest,
|
||||
uiMessageTextLength,
|
||||
type ShareAssistantDeps,
|
||||
} from './public-share-chat.controller';
|
||||
import { AiNotConfiguredException } from '../../integrations/ai/ai-not-configured.exception';
|
||||
import {
|
||||
MAX_SHARE_MESSAGES,
|
||||
MAX_SHARE_MESSAGE_CHARS,
|
||||
} from './public-share-chat.service';
|
||||
import type { UIMessage } from 'ai';
|
||||
|
||||
/**
|
||||
* Unit tests for the extracted pre-hijack funnel (resolveShareAssistantRequest)
|
||||
* and the exported size helper (uiMessageTextLength). The funnel order is
|
||||
* security-relevant: the first failing gate must win, every failure must throw
|
||||
* BEFORE any stream/hijack, and the access-shaped failures must all 404 (no
|
||||
* existence leak). These exercise each branch with hand-rolled mocks — no Nest
|
||||
* module graph, no DB.
|
||||
*/
|
||||
describe('resolveShareAssistantRequest (extracted controller funnel)', () => {
|
||||
/** A fully-passing dep set; individual tests override single collaborators. */
|
||||
function makeDeps(over: {
|
||||
assistantEnabled?: boolean;
|
||||
getShareForPage?: jest.Mock;
|
||||
isSharingAllowed?: jest.Mock;
|
||||
findById?: jest.Mock;
|
||||
hasRestrictedAncestor?: jest.Mock;
|
||||
resolveShareRole?: jest.Mock;
|
||||
getShareChatModel?: jest.Mock;
|
||||
tryConsumeWorkspaceQuota?: jest.Mock;
|
||||
} = {}) {
|
||||
const aiSettings = {
|
||||
isPublicShareAssistantEnabled: jest
|
||||
.fn()
|
||||
.mockResolvedValue(over.assistantEnabled ?? true),
|
||||
};
|
||||
const shareService = {
|
||||
getShareForPage:
|
||||
over.getShareForPage ??
|
||||
jest.fn().mockResolvedValue({
|
||||
id: 'SHARE-A',
|
||||
pageId: 'root-page',
|
||||
spaceId: 'space-1',
|
||||
sharedPage: { id: 'root-page', title: 'Root' },
|
||||
}),
|
||||
isSharingAllowed:
|
||||
over.isSharingAllowed ?? jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const pageRepo = {
|
||||
findById:
|
||||
over.findById ?? jest.fn().mockResolvedValue({ id: 'opened-uuid' }),
|
||||
};
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor:
|
||||
over.hasRestrictedAncestor ?? jest.fn().mockResolvedValue(false),
|
||||
};
|
||||
const publicShareChat = {
|
||||
resolveShareRole:
|
||||
over.resolveShareRole ?? jest.fn().mockResolvedValue(null),
|
||||
getShareChatModel:
|
||||
over.getShareChatModel ?? jest.fn().mockResolvedValue('MODEL'),
|
||||
tryConsumeWorkspaceQuota:
|
||||
over.tryConsumeWorkspaceQuota ?? jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const deps: ShareAssistantDeps = {
|
||||
aiSettings: aiSettings as never,
|
||||
shareService: shareService as never,
|
||||
pageRepo: pageRepo as never,
|
||||
pagePermissionRepo: pagePermissionRepo as never,
|
||||
publicShareChat: publicShareChat as never,
|
||||
};
|
||||
return {
|
||||
deps,
|
||||
aiSettings,
|
||||
shareService,
|
||||
pageRepo,
|
||||
pagePermissionRepo,
|
||||
publicShareChat,
|
||||
};
|
||||
}
|
||||
|
||||
const body = (over: Record<string, unknown> = {}) => ({
|
||||
shareId: 'SHARE-A',
|
||||
pageId: 'opened-page',
|
||||
messages: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
/** Run the funnel and capture the thrown HttpException status (or null). */
|
||||
async function statusOf(
|
||||
deps: ShareAssistantDeps,
|
||||
b: Record<string, unknown>,
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
await resolveShareAssistantRequest(deps, {
|
||||
workspaceId: 'ws-1',
|
||||
body: b as never,
|
||||
});
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) return err.getStatus();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
it('happy path: returns the resolved, non-null request', async () => {
|
||||
const { deps } = makeDeps();
|
||||
const out = await resolveShareAssistantRequest(deps, {
|
||||
workspaceId: 'ws-1',
|
||||
body: body() as never,
|
||||
});
|
||||
expect(out.shareId).toBe('SHARE-A');
|
||||
expect(out.share.id).toBe('SHARE-A');
|
||||
expect(out.model).toBe('MODEL');
|
||||
expect(out.role).toBeNull();
|
||||
expect(out.openedPage).toEqual({ id: 'opened-page', title: 'Root' });
|
||||
});
|
||||
|
||||
it('assistant disabled => 404 and NO share/page/model lookups', async () => {
|
||||
const { deps, shareService, pageRepo, publicShareChat } = makeDeps({
|
||||
assistantEnabled: false,
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(404);
|
||||
expect(shareService.getShareForPage).not.toHaveBeenCalled();
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
expect(publicShareChat.getShareChatModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('share.id !== body.shareId => 404 (cross-share id swap rejected)', async () => {
|
||||
const { deps, publicShareChat } = makeDeps({
|
||||
getShareForPage: jest.fn().mockResolvedValue({
|
||||
id: 'OTHER-SHARE',
|
||||
pageId: 'root',
|
||||
spaceId: 'space-1',
|
||||
sharedPage: null,
|
||||
}),
|
||||
});
|
||||
expect(await statusOf(deps, body({ shareId: 'SHARE-A' }))).toBe(404);
|
||||
// Never reached the model resolution for an unusable share.
|
||||
expect(publicShareChat.getShareChatModel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opened page unresolvable (pageRepo.findById -> null) => fail-closed 404', async () => {
|
||||
const { deps } = makeDeps({
|
||||
findById: jest.fn().mockResolvedValue(null),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(404);
|
||||
});
|
||||
|
||||
it('restricted descendant => 404 (same as out-of-tree, no existence leak)', async () => {
|
||||
const { deps, pagePermissionRepo } = makeDeps({
|
||||
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(404);
|
||||
expect(pagePermissionRepo.hasRestrictedAncestor).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('getShareChatModel throws AiNotConfiguredException => 503', async () => {
|
||||
const { deps } = makeDeps({
|
||||
getShareChatModel: jest
|
||||
.fn()
|
||||
.mockRejectedValue(new AiNotConfiguredException()),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(503);
|
||||
});
|
||||
|
||||
it('getShareChatModel throws a non-AiNotConfigured error => re-thrown (not a 503/404)', async () => {
|
||||
const boom = new Error('boom');
|
||||
const { deps } = makeDeps({
|
||||
getShareChatModel: jest.fn().mockRejectedValue(boom),
|
||||
});
|
||||
await expect(
|
||||
resolveShareAssistantRequest(deps, {
|
||||
workspaceId: 'ws-1',
|
||||
body: body() as never,
|
||||
}),
|
||||
).rejects.toBe(boom);
|
||||
});
|
||||
|
||||
it('tryConsumeWorkspaceQuota false => 429 thrown BEFORE any stream', async () => {
|
||||
const { deps, publicShareChat } = makeDeps({
|
||||
tryConsumeWorkspaceQuota: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
expect(await statusOf(deps, body())).toBe(429);
|
||||
// The quota gate ran AFTER the model resolved (provider configured) but the
|
||||
// function returns/throws before producing a streamable request.
|
||||
expect(publicShareChat.tryConsumeWorkspaceQuota).toHaveBeenCalledWith('ws-1');
|
||||
});
|
||||
|
||||
it('messages over MAX_SHARE_MESSAGES => 413', async () => {
|
||||
const { deps } = makeDeps();
|
||||
const tooMany = Array.from({ length: MAX_SHARE_MESSAGES + 1 }, () => ({
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'hi' }],
|
||||
}));
|
||||
expect(await statusOf(deps, body({ messages: tooMany }))).toBe(413);
|
||||
});
|
||||
|
||||
it('a single message over MAX_SHARE_MESSAGE_CHARS => 413 (uiMessageTextLength)', async () => {
|
||||
const { deps } = makeDeps();
|
||||
const huge = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
|
||||
};
|
||||
expect(await statusOf(deps, body({ messages: [huge] }))).toBe(413);
|
||||
});
|
||||
|
||||
it('the quota gate is checked BEFORE the payload caps (429 wins over 413)', async () => {
|
||||
// Over-cap workspace AND an over-long message: the 429 must surface first, so
|
||||
// an over-cap caller is rejected without even paying the payload-cap scan.
|
||||
const { deps } = makeDeps({
|
||||
tryConsumeWorkspaceQuota: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const huge = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
|
||||
};
|
||||
expect(await statusOf(deps, body({ messages: [huge] }))).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uiMessageTextLength', () => {
|
||||
it('returns 0 for an undefined / parts-less / non-array message', () => {
|
||||
expect(uiMessageTextLength(undefined)).toBe(0);
|
||||
expect(uiMessageTextLength({} as UIMessage)).toBe(0);
|
||||
expect(uiMessageTextLength({ parts: 'nope' } as never)).toBe(0);
|
||||
});
|
||||
|
||||
it('sums the lengths of ONLY the text parts', () => {
|
||||
const msg = {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ type: 'text', text: 'hello' }, // 5
|
||||
{ type: 'tool-call', text: 'IGNORED' }, // non-text: ignored
|
||||
{ type: 'text', text: 'world!' }, // 6
|
||||
{ type: 'text' }, // no text field: ignored
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
expect(uiMessageTextLength(msg)).toBe(11);
|
||||
});
|
||||
|
||||
it('matches the 413 boundary used by the funnel', () => {
|
||||
const atCap = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS) }],
|
||||
} as unknown as UIMessage;
|
||||
const overCap = {
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'x'.repeat(MAX_SHARE_MESSAGE_CHARS + 1) }],
|
||||
} as unknown as UIMessage;
|
||||
expect(uiMessageTextLength(atCap)).toBe(MAX_SHARE_MESSAGE_CHARS);
|
||||
expect(uiMessageTextLength(overCap)).toBeGreaterThan(MAX_SHARE_MESSAGE_CHARS);
|
||||
});
|
||||
});
|
||||
@@ -77,142 +77,25 @@ export class PublicShareChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<void> {
|
||||
const body = (req.body ?? {}) as PublicShareChatStreamBody;
|
||||
const shareId = typeof body.shareId === 'string' ? body.shareId.trim() : '';
|
||||
const pageId = typeof body.pageId === 'string' ? body.pageId.trim() : '';
|
||||
|
||||
// ---- Guardrail funnel (order matters; each failure exits before stream) ----
|
||||
|
||||
// 1. Workspace master toggle. 404 (do not reveal the feature exists).
|
||||
const assistantEnabled = await this.aiSettings.isPublicShareAssistantEnabled(
|
||||
workspace.id,
|
||||
// The whole pre-hijack fact-resolution + cap-ordering block is a pure-ish
|
||||
// helper (collaborators passed in) so every funnel branch — 404 disabled /
|
||||
// share-mismatch / page-unresolvable / restricted, 503 unconfigured, 429
|
||||
// over-cap, 413 too many/too long — is unit-testable against the red-team
|
||||
// boundaries without the full Nest/DB graph. It throws the SAME HttpException
|
||||
// the controller would, and never starts streaming.
|
||||
const resolved = await resolveShareAssistantRequest(
|
||||
{
|
||||
aiSettings: this.aiSettings,
|
||||
shareService: this.shareService,
|
||||
pageRepo: this.pageRepo,
|
||||
pagePermissionRepo: this.pagePermissionRepo,
|
||||
publicShareChat: this.publicShareChat,
|
||||
},
|
||||
{ workspaceId: workspace.id, body },
|
||||
);
|
||||
|
||||
// 2. Share usable? Resolved via the page's share membership, since the page
|
||||
// resolution (getShareForPage) ALSO yields the share + workspace. We
|
||||
// still need basic input to attempt it.
|
||||
// 3. Page in share? The same getShareForPage lookup confirms the opened page
|
||||
// resolves to THIS share tree, PLUS an explicit restricted-ancestor gate
|
||||
// (getShareForPage itself does NOT exclude restricted descendants) so a
|
||||
// restricted page hidden from the public view is graded not-in-share.
|
||||
// (shareUsable + pageInShare are set together below; the funnel grades
|
||||
// them as distinct ordered steps.)
|
||||
let share: Awaited<ReturnType<ShareService['getShareForPage']>> | undefined;
|
||||
let shareUsable = false;
|
||||
let pageInShare = false;
|
||||
if (assistantEnabled && shareId && pageId) {
|
||||
// getShareForPage walks up the tree to the nearest ancestor share,
|
||||
// enforces share.workspaceId === workspaceId and includeSubPages, and
|
||||
// returns undefined when the page is not publicly reachable. NOTE: it
|
||||
// joins only the `shares` table — it does NOT exclude restricted
|
||||
// descendants — so a restricted page inside an includeSubPages share
|
||||
// still resolves here. We add an explicit restricted-ancestor gate below
|
||||
// (same as the public view) so the opened page's title never leaks into
|
||||
// the system prompt for a page the public view 404s.
|
||||
share = await this.shareService.getShareForPage(pageId, workspace.id);
|
||||
if (share && share.id === shareId) {
|
||||
// Confirm sharing is still allowed for the share's space (and not
|
||||
// disabled at workspace/space level) — same gate the public views use.
|
||||
const sharingAllowed = await this.shareService.isSharingAllowed(
|
||||
workspace.id,
|
||||
share.spaceId,
|
||||
);
|
||||
// A restricted descendant is hidden from the public share view; treat
|
||||
// the opened page as not-in-share so the funnel returns the SAME 404 it
|
||||
// returns for an out-of-tree page (uniform, no existence leak).
|
||||
// hasRestrictedAncestor matches on the page UUID only, while the
|
||||
// opened pageId may be a slugId, so resolve to the UUID first (cheap
|
||||
// base-fields lookup, mirroring how getSharedPage resolves the page
|
||||
// before its restricted check).
|
||||
const openedPageRow = await this.pageRepo.findById(pageId);
|
||||
const restricted = openedPageRow
|
||||
? await this.pagePermissionRepo.hasRestrictedAncestor(
|
||||
openedPageRow.id,
|
||||
)
|
||||
: true; // unresolvable opened page => fail closed (treat as not-in-share)
|
||||
// The security-relevant combination (server-resolved share id ===
|
||||
// requested shareId, + sharingAllowed, + the restricted gate) is a pure,
|
||||
// unit-tested helper so the access join point can be exercised against
|
||||
// the red-team boundaries without the full Nest/DB graph.
|
||||
({ shareUsable, pageInShare } = deriveShareAccess({
|
||||
resolvedShareId: share.id,
|
||||
requestedShareId: shareId,
|
||||
sharingAllowed,
|
||||
restricted,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Provider configured? Resolve the model now so an unconfigured provider
|
||||
// yields a clean 503 (AiNotConfiguredException) BEFORE hijack. Only
|
||||
// attempt this once the earlier gates passed, to avoid leaking timing.
|
||||
let model: Awaited<ReturnType<PublicShareChatService['getShareChatModel']>> | undefined;
|
||||
// Admin-selected identity (agent role) for the anonymous assistant, resolved
|
||||
// server-authoritatively. null = built-in locked persona.
|
||||
let role: AiAgentRole | null = null;
|
||||
let providerConfigured = false;
|
||||
if (assistantEnabled && shareUsable && pageInShare) {
|
||||
try {
|
||||
role = await this.publicShareChat.resolveShareRole(workspace.id);
|
||||
model = await this.publicShareChat.getShareChatModel(workspace.id, role);
|
||||
providerConfigured = true;
|
||||
} catch (err) {
|
||||
if (err instanceof AiNotConfiguredException) {
|
||||
providerConfigured = false;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outcome = evaluateShareAssistantFunnel({
|
||||
assistantEnabled,
|
||||
shareUsable,
|
||||
pageInShare,
|
||||
providerConfigured,
|
||||
});
|
||||
if (outcome.ok === false) {
|
||||
// 404 for everything access-shaped (feature/share/page); 503 for config.
|
||||
if (outcome.status === 503) {
|
||||
throw new ServiceUnavailableException('AI is not configured');
|
||||
}
|
||||
throw new NotFoundException('Not found');
|
||||
}
|
||||
|
||||
// 5. Per-WORKSPACE anti-abuse cap (IP-independent; defense in depth). The
|
||||
// per-IP @Throttle above can be evaded by an attacker rotating
|
||||
// `X-Forwarded-For` (the app runs with trustProxy), and each evaded call
|
||||
// spends REAL tokens on the workspace owner's paid AI provider. This cap
|
||||
// is keyed by the server-resolved workspace id (never attacker-
|
||||
// controllable), so it bounds the owner's bill even when the per-IP limit
|
||||
// is fully defeated via XFF spoofing. Checked here, BEFORE res.hijack(),
|
||||
// so an over-cap workspace gets a clean 429 and spends nothing. NOTE:
|
||||
// production should ALSO front this endpoint with a trusted proxy that
|
||||
// REWRITES (not appends) XFF so the per-IP throttle stays meaningful.
|
||||
if (!(await this.publicShareChat.tryConsumeWorkspaceQuota(workspace.id))) {
|
||||
throw new HttpException(
|
||||
'This documentation assistant is temporarily busy. Please try again later.',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Validate / bound the payload (cheap caps; ephemeral, never stored) ----
|
||||
const messages = Array.isArray(body.messages)
|
||||
? (body.messages as UIMessage[])
|
||||
: [];
|
||||
if (messages.length > MAX_SHARE_MESSAGES) {
|
||||
throw new HttpException('Too many messages', 413);
|
||||
}
|
||||
for (const m of messages) {
|
||||
const text = uiMessageTextLength(m);
|
||||
if (text > MAX_SHARE_MESSAGE_CHARS) {
|
||||
throw new HttpException('Message too long', 413);
|
||||
}
|
||||
}
|
||||
|
||||
const openedPage = {
|
||||
id: pageId,
|
||||
title: share?.sharedPage?.title ?? undefined,
|
||||
};
|
||||
const { shareId, share, model, role, messages, openedPage } = resolved;
|
||||
|
||||
// Abort the agent loop when the client disconnects (mirrors ai-chat).
|
||||
const controller = new AbortController();
|
||||
@@ -230,15 +113,15 @@ export class PublicShareChatController {
|
||||
workspaceId: workspace.id,
|
||||
shareId,
|
||||
share: {
|
||||
id: share!.id,
|
||||
pageId: share!.pageId,
|
||||
sharedPage: share!.sharedPage,
|
||||
id: share.id,
|
||||
pageId: share.pageId,
|
||||
sharedPage: share.sharedPage,
|
||||
},
|
||||
openedPage,
|
||||
messages,
|
||||
res,
|
||||
signal: controller.signal,
|
||||
model: model!,
|
||||
model,
|
||||
role,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -255,8 +138,174 @@ export class PublicShareChatController {
|
||||
}
|
||||
}
|
||||
|
||||
/** Sum of the text-part lengths of a UIMessage (cheap, for the size cap). */
|
||||
function uiMessageTextLength(message: UIMessage | undefined): number {
|
||||
/**
|
||||
* The collaborators the pre-hijack funnel needs. Declared as the minimal slice
|
||||
* of each injected service it actually calls, so the resolver can be unit-tested
|
||||
* with hand-rolled mocks (no Nest module graph, no DB).
|
||||
*/
|
||||
export interface ShareAssistantDeps {
|
||||
aiSettings: Pick<AiSettingsService, 'isPublicShareAssistantEnabled'>;
|
||||
shareService: Pick<
|
||||
ShareService,
|
||||
'getShareForPage' | 'isSharingAllowed'
|
||||
>;
|
||||
pageRepo: Pick<PageRepo, 'findById'>;
|
||||
pagePermissionRepo: Pick<PagePermissionRepo, 'hasRestrictedAncestor'>;
|
||||
publicShareChat: Pick<
|
||||
PublicShareChatService,
|
||||
| 'resolveShareRole'
|
||||
| 'getShareChatModel'
|
||||
| 'tryConsumeWorkspaceQuota'
|
||||
>;
|
||||
}
|
||||
|
||||
/** The resolved, validated request ready to stream (everything is non-null). */
|
||||
export interface ResolvedShareAssistantRequest {
|
||||
shareId: string;
|
||||
share: NonNullable<Awaited<ReturnType<ShareService['getShareForPage']>>>;
|
||||
model: Awaited<ReturnType<PublicShareChatService['getShareChatModel']>>;
|
||||
role: AiAgentRole | null;
|
||||
messages: UIMessage[];
|
||||
openedPage: { id: string; title?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-hijack fact-resolution + cap-ordering for the anonymous public-share
|
||||
* assistant, extracted from the controller so every funnel branch is unit-
|
||||
* testable without the Nest/DB graph. Order is security-relevant and each
|
||||
* failure exits BEFORE any stream/hijack:
|
||||
* 1. assistant toggle off => 404 (no share/page/model lookups);
|
||||
* 2. share/page access (deriveShareAccess + evaluateShareAssistantFunnel) =>
|
||||
* 404 (uniform; restricted descendant and out-of-tree look identical);
|
||||
* 3. provider unconfigured => 503 (AiNotConfiguredException), other errors
|
||||
* re-thrown;
|
||||
* 4. per-workspace quota exhausted => 429 (BEFORE any stream/hijack);
|
||||
* 5. payload caps => 413 (too many messages / a single message too long).
|
||||
* Throws the SAME HttpException the controller would; returns the resolved,
|
||||
* non-null request otherwise.
|
||||
*/
|
||||
export async function resolveShareAssistantRequest(
|
||||
deps: ShareAssistantDeps,
|
||||
input: { workspaceId: string; body: PublicShareChatStreamBody },
|
||||
): Promise<ResolvedShareAssistantRequest> {
|
||||
const { workspaceId, body } = input;
|
||||
const shareId = typeof body.shareId === 'string' ? body.shareId.trim() : '';
|
||||
const pageId = typeof body.pageId === 'string' ? body.pageId.trim() : '';
|
||||
|
||||
// 1. Workspace master toggle. 404 (do not reveal the feature exists).
|
||||
const assistantEnabled =
|
||||
await deps.aiSettings.isPublicShareAssistantEnabled(workspaceId);
|
||||
|
||||
// 2/3. Share usable? Page in share? Resolved via the page's share membership,
|
||||
// since getShareForPage ALSO yields the share + workspace. The opened
|
||||
// page is then gated by an explicit restricted-ancestor check (which
|
||||
// getShareForPage does NOT do) so a restricted page hidden from the
|
||||
// public view is graded not-in-share.
|
||||
let share: Awaited<ReturnType<ShareService['getShareForPage']>> | undefined;
|
||||
let shareUsable = false;
|
||||
let pageInShare = false;
|
||||
if (assistantEnabled && shareId && pageId) {
|
||||
share = await deps.shareService.getShareForPage(pageId, workspaceId);
|
||||
if (share && share.id === shareId) {
|
||||
const sharingAllowed = await deps.shareService.isSharingAllowed(
|
||||
workspaceId,
|
||||
share.spaceId,
|
||||
);
|
||||
// hasRestrictedAncestor matches on the page UUID only, while the opened
|
||||
// pageId may be a slugId, so resolve to the UUID first (cheap base-fields
|
||||
// lookup). An unresolvable opened page fails closed (not-in-share).
|
||||
const openedPageRow = await deps.pageRepo.findById(pageId);
|
||||
const restricted = openedPageRow
|
||||
? await deps.pagePermissionRepo.hasRestrictedAncestor(openedPageRow.id)
|
||||
: true;
|
||||
({ shareUsable, pageInShare } = deriveShareAccess({
|
||||
resolvedShareId: share.id,
|
||||
requestedShareId: shareId,
|
||||
sharingAllowed,
|
||||
restricted,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Provider configured? Resolve the model now so an unconfigured provider
|
||||
// yields a clean 503 BEFORE hijack. Only after the access gates pass, to
|
||||
// avoid leaking timing.
|
||||
let model:
|
||||
| Awaited<ReturnType<PublicShareChatService['getShareChatModel']>>
|
||||
| undefined;
|
||||
let role: AiAgentRole | null = null;
|
||||
let providerConfigured = false;
|
||||
if (assistantEnabled && shareUsable && pageInShare) {
|
||||
try {
|
||||
role = await deps.publicShareChat.resolveShareRole(workspaceId);
|
||||
model = await deps.publicShareChat.getShareChatModel(workspaceId, role);
|
||||
providerConfigured = true;
|
||||
} catch (err) {
|
||||
if (err instanceof AiNotConfiguredException) {
|
||||
providerConfigured = false;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const outcome = evaluateShareAssistantFunnel({
|
||||
assistantEnabled,
|
||||
shareUsable,
|
||||
pageInShare,
|
||||
providerConfigured,
|
||||
});
|
||||
if (outcome.ok === false) {
|
||||
// 404 for everything access-shaped (feature/share/page); 503 for config.
|
||||
if (outcome.status === 503) {
|
||||
throw new ServiceUnavailableException('AI is not configured');
|
||||
}
|
||||
throw new NotFoundException('Not found');
|
||||
}
|
||||
|
||||
// 5. Per-WORKSPACE anti-abuse cap (IP-independent; defense in depth). Checked
|
||||
// BEFORE res.hijack(), so an over-cap workspace gets a clean 429 and spends
|
||||
// nothing.
|
||||
if (!(await deps.publicShareChat.tryConsumeWorkspaceQuota(workspaceId))) {
|
||||
throw new HttpException(
|
||||
'This documentation assistant is temporarily busy. Please try again later.',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Validate / bound the payload (cheap caps; ephemeral, never stored) ----
|
||||
const messages = Array.isArray(body.messages)
|
||||
? (body.messages as UIMessage[])
|
||||
: [];
|
||||
if (messages.length > MAX_SHARE_MESSAGES) {
|
||||
throw new HttpException('Too many messages', 413);
|
||||
}
|
||||
for (const m of messages) {
|
||||
if (uiMessageTextLength(m) > MAX_SHARE_MESSAGE_CHARS) {
|
||||
throw new HttpException('Message too long', 413);
|
||||
}
|
||||
}
|
||||
|
||||
const openedPage = {
|
||||
id: pageId,
|
||||
title: share?.sharedPage?.title ?? undefined,
|
||||
};
|
||||
|
||||
// The funnel passed, so share/model are guaranteed present.
|
||||
return {
|
||||
shareId,
|
||||
share: share!,
|
||||
model: model!,
|
||||
role,
|
||||
messages,
|
||||
openedPage,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum of the text-part lengths of a UIMessage (cheap, for the size cap).
|
||||
* Exported so the 413 size-cap logic is unit-testable without the Nest/DB graph.
|
||||
*/
|
||||
export function uiMessageTextLength(message: UIMessage | undefined): number {
|
||||
if (!message?.parts || !Array.isArray(message.parts)) return 0;
|
||||
let total = 0;
|
||||
for (const p of message.parts) {
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
filterShareTranscript,
|
||||
} from './public-share-chat.service';
|
||||
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
|
||||
import { PublicShareWorkspaceLimiter } from './public-share-workspace-limiter';
|
||||
import {
|
||||
PublicShareWorkspaceLimiter,
|
||||
resolveShareAiWorkspaceMax,
|
||||
SHARE_AI_WORKSPACE_MAX_PER_WINDOW,
|
||||
} from './public-share-workspace-limiter';
|
||||
|
||||
/**
|
||||
* Minimal in-memory fake of the slice of ioredis the sliding-window limiter
|
||||
@@ -195,6 +199,54 @@ describe('buildShareSystemPrompt locking', () => {
|
||||
expect(prompt).toContain('read-only assistant');
|
||||
expect(prompt).toContain('anti prompt-injection');
|
||||
});
|
||||
|
||||
it('an opened page with a title injects both the pageId and the title', () => {
|
||||
const prompt = buildShareSystemPrompt({
|
||||
share: null,
|
||||
openedPage: { id: 'page-123', title: 'Getting Started' },
|
||||
});
|
||||
expect(prompt).toContain('(pageId: page-123)');
|
||||
expect(prompt).toContain('"Getting Started"');
|
||||
expect(prompt).toContain('the current page');
|
||||
});
|
||||
|
||||
it('an opened page with a blank/whitespace title falls back to "Untitled"', () => {
|
||||
const prompt = buildShareSystemPrompt({
|
||||
share: null,
|
||||
openedPage: { id: 'page-123', title: ' ' },
|
||||
});
|
||||
expect(prompt).toContain('(pageId: page-123)');
|
||||
expect(prompt).toContain('"Untitled"');
|
||||
});
|
||||
|
||||
it('an empty / blank pageId omits the opened-page context line entirely', () => {
|
||||
const emptyId = buildShareSystemPrompt({
|
||||
share: null,
|
||||
openedPage: { id: '', title: 'Ignored' },
|
||||
});
|
||||
expect(emptyId).not.toContain('pageId:');
|
||||
expect(emptyId).not.toContain('the current page');
|
||||
|
||||
const blankId = buildShareSystemPrompt({
|
||||
share: null,
|
||||
openedPage: { id: ' ', title: 'Ignored' },
|
||||
});
|
||||
expect(blankId).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
it('a present share title is injected; a blank share title is omitted', () => {
|
||||
const withTitle = buildShareSystemPrompt({
|
||||
share: { sharedPageTitle: 'Product Docs' },
|
||||
openedPage: null,
|
||||
});
|
||||
expect(withTitle).toContain('titled "Product Docs"');
|
||||
|
||||
const blankTitle = buildShareSystemPrompt({
|
||||
share: { sharedPageTitle: ' ' },
|
||||
openedPage: null,
|
||||
});
|
||||
expect(blankTitle).not.toContain('This published documentation is titled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicShareChatService model fallback', () => {
|
||||
@@ -306,6 +358,44 @@ describe('PublicShareChatService model fallback', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveShareAiWorkspaceMax (env-overridable per-workspace cap)', () => {
|
||||
const ENV = 'SHARE_AI_WORKSPACE_MAX_PER_HOUR';
|
||||
const original = process.env[ENV];
|
||||
|
||||
afterEach(() => {
|
||||
if (original === undefined) delete process.env[ENV];
|
||||
else process.env[ENV] = original;
|
||||
});
|
||||
|
||||
it('uses a valid positive integer from the env', () => {
|
||||
process.env[ENV] = '42';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(42);
|
||||
});
|
||||
|
||||
it('floors a float value', () => {
|
||||
process.env[ENV] = '99.9';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(99);
|
||||
});
|
||||
|
||||
it('falls back to the default for an unparseable / NaN value', () => {
|
||||
process.env[ENV] = 'not-a-number';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
expect(SHARE_AI_WORKSPACE_MAX_PER_WINDOW).toBe(300);
|
||||
});
|
||||
|
||||
it('falls back to the default when unset', () => {
|
||||
delete process.env[ENV];
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
});
|
||||
|
||||
it('falls back to the default for zero or a negative value (no unlimited / negative cap)', () => {
|
||||
process.env[ENV] = '0';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
process.env[ENV] = '-5';
|
||||
expect(resolveShareAiWorkspaceMax()).toBe(SHARE_AI_WORKSPACE_MAX_PER_WINDOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace cap)', () => {
|
||||
it('allows up to the cap within a window, then 429s (returns false)', async () => {
|
||||
const limiter = makeLimiter(3, 60_000, () => 1_000);
|
||||
@@ -353,6 +443,23 @@ describe('PublicShareWorkspaceLimiter (cluster-wide sliding-window per-workspace
|
||||
expect(await limiter.tryConsume('ws-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('consumes a distinct member slot per call at one FIXED clock value (no same-ms score-collision under-count)', async () => {
|
||||
// All calls happen at the SAME millisecond. The limiter mints a unique member
|
||||
// id per attempt, so distinct calls in the same ms must NOT collide on the
|
||||
// sorted-set score and under-count: exactly `cap` calls are admitted, the
|
||||
// rest rejected — even though every score is identical.
|
||||
const cap = 5;
|
||||
const limiter = makeLimiter(cap, 60_000, () => 7_000); // clock never advances
|
||||
const results: boolean[] = [];
|
||||
for (let i = 0; i < cap + 3; i++) {
|
||||
results.push(await limiter.tryConsume('ws-1'));
|
||||
}
|
||||
// First `cap` admitted, the remaining 3 rejected.
|
||||
expect(results.slice(0, cap)).toEqual(Array(cap).fill(true));
|
||||
expect(results.slice(cap)).toEqual([false, false, false]);
|
||||
expect(results.filter(Boolean)).toHaveLength(cap);
|
||||
});
|
||||
|
||||
it('keeps separate budgets per workspace (one over-cap ws cannot starve another)', async () => {
|
||||
const limiter = makeLimiter(1, 60_000, () => 1_000);
|
||||
expect(await limiter.tryConsume('ws-a')).toBe(true);
|
||||
|
||||
@@ -93,6 +93,56 @@ describe('AiAgentRolesService guards', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('instructions cleared to whitespace => BadRequest, repo.update NOT called', async () => {
|
||||
const { service, repo } = makeService({ existing: makeRow() });
|
||||
await expect(
|
||||
service.update('ws-1', 'r1', {
|
||||
instructions: ' ',
|
||||
} as UpdateAgentRoleDto),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('concurrent soft-delete: row exists on the pre-update lookup but the re-fetch is undefined => BadRequest (not a TypeError)', async () => {
|
||||
// findById returns the live row FIRST (pre-update guard passes), then the
|
||||
// role is soft-deleted concurrently, so the POST-update re-fetch returns
|
||||
// undefined. The service must surface a clean 400, never dereference
|
||||
// undefined (which would throw a TypeError in toView).
|
||||
const { service, repo } = makeService();
|
||||
repo.findById
|
||||
.mockResolvedValueOnce(makeRow())
|
||||
.mockResolvedValueOnce(undefined);
|
||||
await expect(
|
||||
service.update('ws-1', 'r1', { name: 'X' } as UpdateAgentRoleDto),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// The UPDATE ran (the row existed pre-update), but the re-fetch failed.
|
||||
expect(repo.update).toHaveBeenCalled();
|
||||
expect(repo.findById).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('emoji/description tri-state: emoji:"" => null (clear), emoji omitted => undefined (unchanged), description:" " => null', async () => {
|
||||
const { service, repo } = makeService({ existing: makeRow() });
|
||||
|
||||
// emoji explicitly emptied => clear to null; description whitespace => null.
|
||||
await service.update('ws-1', 'r1', {
|
||||
emoji: '',
|
||||
description: ' ',
|
||||
} as UpdateAgentRoleDto);
|
||||
const patch1 = repo.update.mock.calls[0][2];
|
||||
expect(patch1.emoji).toBeNull();
|
||||
expect(patch1.description).toBeNull();
|
||||
|
||||
repo.update.mockClear();
|
||||
|
||||
// emoji omitted => unchanged (undefined passed through to the repo patch).
|
||||
await service.update('ws-1', 'r1', {
|
||||
name: 'Renamed',
|
||||
} as UpdateAgentRoleDto);
|
||||
const patch2 = repo.update.mock.calls[0][2];
|
||||
expect(patch2.emoji).toBeUndefined();
|
||||
expect(patch2.description).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
@@ -136,6 +186,51 @@ describe('AiAgentRolesService guards', () => {
|
||||
expect(repo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('modelConfig:{chatModel} only persists {chatModel} (no driver key)', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
modelConfig: { chatModel: 'gpt-4o' },
|
||||
} as CreateAgentRoleDto);
|
||||
const values = repo.insert.mock.calls[0][0];
|
||||
expect(values.modelConfig).toEqual({ chatModel: 'gpt-4o' });
|
||||
expect('driver' in values.modelConfig).toBe(false);
|
||||
});
|
||||
|
||||
it('modelConfig:{} (empty) normalizes to null', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
modelConfig: {},
|
||||
} as CreateAgentRoleDto);
|
||||
expect(repo.insert.mock.calls[0][0].modelConfig).toBeNull();
|
||||
});
|
||||
|
||||
it('modelConfig:{chatModel:" "} (whitespace-only) normalizes to null', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
modelConfig: { chatModel: ' ' },
|
||||
} as CreateAgentRoleDto);
|
||||
expect(repo.insert.mock.calls[0][0].modelConfig).toBeNull();
|
||||
});
|
||||
|
||||
it('modelConfig:{driver,chatModel} round-trips both fields (trimmed)', async () => {
|
||||
const { service, repo } = makeService();
|
||||
await service.create('ws-1', 'u1', {
|
||||
name: 'R',
|
||||
instructions: 'do',
|
||||
modelConfig: { driver: 'gemini', chatModel: ' gemini-2.0-flash ' },
|
||||
} as CreateAgentRoleDto);
|
||||
expect(repo.insert.mock.calls[0][0].modelConfig).toEqual({
|
||||
driver: 'gemini',
|
||||
chatModel: 'gemini-2.0-flash',
|
||||
});
|
||||
});
|
||||
|
||||
it('duplicate name (Postgres 23505) => ConflictException (409), not 500', async () => {
|
||||
const { service, repo } = makeService();
|
||||
// The partial unique (workspace_id, name) index rejects the insert.
|
||||
@@ -148,6 +243,28 @@ describe('AiAgentRolesService guards', () => {
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('duplicate name 409 message contains the TRIMMED submitted name', async () => {
|
||||
const { service, repo } = makeService();
|
||||
repo.insert.mockRejectedValueOnce({ code: '23505' });
|
||||
await service
|
||||
.create('ws-1', 'u1', {
|
||||
name: ' Researcher ',
|
||||
instructions: 'do',
|
||||
} as CreateAgentRoleDto)
|
||||
.then(
|
||||
() => {
|
||||
throw new Error('expected create to throw');
|
||||
},
|
||||
(err: unknown) => {
|
||||
expect(err).toBeInstanceOf(ConflictException);
|
||||
const message = (err as ConflictException).message;
|
||||
// The trimmed name appears verbatim; the untrimmed padding does not.
|
||||
expect(message).toContain('"Researcher"');
|
||||
expect(message).not.toContain(' Researcher ');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('non-unique-violation error is NOT swallowed (re-thrown as-is)', async () => {
|
||||
const { service, repo } = makeService();
|
||||
const other = Object.assign(new Error('boom'), { code: '23502' });
|
||||
|
||||
30
apps/server/src/core/ai-chat/roles/jsonb-object.spec.ts
Normal file
30
apps/server/src/core/ai-chat/roles/jsonb-object.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { jsonbObject } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
|
||||
|
||||
/**
|
||||
* Unit tests for jsonbObject: the repo helper that encodes a model_config object
|
||||
* as a jsonb bind (or null when there is nothing to persist). It is the last
|
||||
* line of defence before the column write, so the null-vs-bind decision is what
|
||||
* matters here. We assert only null vs non-null because the non-null value is a
|
||||
* kysely `sql` template fragment whose internal shape is an implementation
|
||||
* detail of the SQL tag.
|
||||
*/
|
||||
describe('jsonbObject', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(jsonbObject(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for undefined', () => {
|
||||
expect(jsonbObject(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty object (nothing to persist)', () => {
|
||||
expect(jsonbObject({})).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a (non-null) jsonb bind for a non-empty object', () => {
|
||||
const out = jsonbObject({ driver: 'gemini', chatModel: 'gemini-2.0-flash' });
|
||||
// A real sql fragment is produced, never null/undefined.
|
||||
expect(out).not.toBeNull();
|
||||
expect(out).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { AiService } from '../../../integrations/ai/ai.service';
|
||||
import { AiNotConfiguredException } from '../../../integrations/ai/ai-not-configured.exception';
|
||||
import { roleModelOverride } from './role-model-config';
|
||||
import type { AiAgentRole } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Contract test for the override SHAPE that travels from a role's persisted
|
||||
* `model_config` (via roleModelOverride) into AiService.getChatModel.
|
||||
*
|
||||
* This is the seam between the two halves of the role-model feature:
|
||||
* - roleModelOverride (pure) turns model_config into a ChatModelOverride;
|
||||
* - getChatModel consumes that override to build the model (or to 503).
|
||||
* Wiring the REAL roleModelOverride output into a unit-constructed AiService
|
||||
* (with stubbed deps, no DB) pins that the two agree on the override contract:
|
||||
* - a cross-driver override whose creds are absent => AiNotConfiguredException
|
||||
* naming the role + driver;
|
||||
* - a chatModel-only override keeps the workspace driver/creds (no creds
|
||||
* lookup, no decrypt);
|
||||
* - an ollama cross-driver override => 503 (no silent baseUrl reuse).
|
||||
*/
|
||||
describe('role override -> AiService.getChatModel contract', () => {
|
||||
function role(modelConfig: unknown, name = 'Researcher'): AiAgentRole {
|
||||
return { id: 'r1', name, modelConfig } as unknown as AiAgentRole;
|
||||
}
|
||||
|
||||
function makeService(opts: {
|
||||
workspaceDriver: string;
|
||||
baseUrl?: string;
|
||||
credsApiKeyEnc?: string;
|
||||
}) {
|
||||
const aiSettings = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
driver: opts.workspaceDriver,
|
||||
chatModel: 'gpt-4o-mini',
|
||||
apiKey: 'workspace-key',
|
||||
baseUrl: opts.baseUrl,
|
||||
}),
|
||||
};
|
||||
const aiProviderCredentialsRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
opts.credsApiKeyEnc ? { apiKeyEnc: opts.credsApiKeyEnc } : undefined,
|
||||
),
|
||||
};
|
||||
const secretBox = { decryptSecret: jest.fn().mockReturnValue('decrypted') };
|
||||
const service = new AiService(
|
||||
aiSettings as never,
|
||||
aiProviderCredentialsRepo as never,
|
||||
secretBox as never,
|
||||
);
|
||||
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
|
||||
}
|
||||
|
||||
it('cross-driver override with NO creds => 503 naming the role and the override driver', async () => {
|
||||
const override = roleModelOverride(
|
||||
role({ driver: 'gemini', chatModel: 'gemini-2.0-flash' }),
|
||||
);
|
||||
expect(override).toEqual({
|
||||
driver: 'gemini',
|
||||
chatModel: 'gemini-2.0-flash',
|
||||
roleName: 'Researcher',
|
||||
});
|
||||
|
||||
// Workspace is openai; the gemini override has no configured creds.
|
||||
const { service, aiProviderCredentialsRepo } = makeService({
|
||||
workspaceDriver: 'openai',
|
||||
});
|
||||
|
||||
await service.getChatModel('ws-1', override).then(
|
||||
() => {
|
||||
throw new Error('expected getChatModel to throw');
|
||||
},
|
||||
(err: unknown) => {
|
||||
expect(err).toBeInstanceOf(AiNotConfiguredException);
|
||||
const message = (err as AiNotConfiguredException).message;
|
||||
expect(message).toContain('gemini');
|
||||
expect(message).toContain('Researcher');
|
||||
},
|
||||
);
|
||||
expect(aiProviderCredentialsRepo.find).toHaveBeenCalledWith('ws-1', 'gemini');
|
||||
});
|
||||
|
||||
it('chatModel-only override keeps the workspace driver/creds (no creds lookup, no decrypt)', async () => {
|
||||
const override = roleModelOverride(role({ chatModel: 'gpt-4o' }));
|
||||
// No driver in the override => the workspace driver/creds are reused.
|
||||
expect(override).toEqual({
|
||||
driver: undefined,
|
||||
chatModel: 'gpt-4o',
|
||||
roleName: 'Researcher',
|
||||
});
|
||||
|
||||
const { service, aiProviderCredentialsRepo, secretBox } = makeService({
|
||||
workspaceDriver: 'openai',
|
||||
});
|
||||
|
||||
const model = await service.getChatModel('ws-1', override);
|
||||
expect(model).toBeDefined();
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ollama cross-driver override (workspace driver != ollama) => 503, no baseUrl reuse', async () => {
|
||||
const override = roleModelOverride(
|
||||
role({ driver: 'ollama', chatModel: 'llama3' }, 'Local'),
|
||||
);
|
||||
expect(override).toEqual({
|
||||
driver: 'ollama',
|
||||
chatModel: 'llama3',
|
||||
roleName: 'Local',
|
||||
});
|
||||
|
||||
const { service, aiProviderCredentialsRepo } = makeService({
|
||||
workspaceDriver: 'openai',
|
||||
baseUrl: 'https://openrouter.example/v1',
|
||||
});
|
||||
|
||||
await service.getChatModel('ws-1', override).then(
|
||||
() => {
|
||||
throw new Error('expected getChatModel to throw');
|
||||
},
|
||||
(err: unknown) => {
|
||||
expect(err).toBeInstanceOf(AiNotConfiguredException);
|
||||
const message = (err as AiNotConfiguredException).message;
|
||||
expect(message).toContain('ollama');
|
||||
expect(message).toContain('openai');
|
||||
expect(message).toContain('Local');
|
||||
// The workspace gateway baseUrl must never be reused for ollama.
|
||||
expect(message).not.toContain('openrouter.example');
|
||||
},
|
||||
);
|
||||
// No creds lookup for ollama: we fail before reaching the creds branch.
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,11 @@ export class AiChatToolsService {
|
||||
// agent write (REST + collab) records { actor:'agent', aiChatId } off a
|
||||
// SIGNED claim — non-spoofable, never a client body field (§6.5/§6.6).
|
||||
aiChatId: string,
|
||||
// The page the user currently has open (from the request context), exposed
|
||||
// to the model via getCurrentPage. Optional and last so existing callers
|
||||
// keep compiling. Kept proxy-robust: the model can CALL for the current
|
||||
// page instead of relying on it surviving in the system prompt text.
|
||||
openedPage?: { id?: string; title?: string } | null,
|
||||
): Promise<Record<string, Tool>> {
|
||||
const apiUrl =
|
||||
process.env.MCP_DOCMOST_API_URL ||
|
||||
@@ -210,6 +215,23 @@ export class AiChatToolsService {
|
||||
},
|
||||
}),
|
||||
|
||||
getCurrentPage: tool({
|
||||
description:
|
||||
'Return the page the user is currently viewing — i.e. what "this page", ' +
|
||||
'"the current page", or "here" refers to. Returns the page id and title, ' +
|
||||
'or null if the user is not currently on a page. Call this first whenever ' +
|
||||
'the user refers to the current page without giving an explicit id.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
if (!openedPage?.id) {
|
||||
return { page: null };
|
||||
}
|
||||
return {
|
||||
page: { id: openedPage.id, title: openedPage.title ?? '' },
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
getPage: tool({
|
||||
description:
|
||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { PublicShareChatToolsService } from './public-share-chat-tools.service';
|
||||
|
||||
/**
|
||||
* Mock-based integration tests for the anonymous public-share toolset built by
|
||||
* forShare(). Constructed directly with hand-rolled collaborators (no Nest/DB):
|
||||
* - listSharePages tree assembly (dedupe, single-page root fallback, fail-soft);
|
||||
* - the blank-input guards on search / read.
|
||||
*/
|
||||
describe('PublicShareChatToolsService.forShare', () => {
|
||||
type ToolExec = { execute: (args: unknown) => Promise<unknown> };
|
||||
|
||||
function makeService(over: {
|
||||
getShareTree?: jest.Mock;
|
||||
findById?: jest.Mock;
|
||||
searchPage?: jest.Mock;
|
||||
getShareForPage?: jest.Mock;
|
||||
} = {}) {
|
||||
const shareService = {
|
||||
getShareTree: over.getShareTree ?? jest.fn(),
|
||||
getShareForPage: over.getShareForPage ?? jest.fn(),
|
||||
updatePublicAttachments: jest.fn(),
|
||||
};
|
||||
const searchService = { searchPage: over.searchPage ?? jest.fn() };
|
||||
const pageRepo = { findById: over.findById ?? jest.fn() };
|
||||
const pagePermissionRepo = { hasRestrictedAncestor: jest.fn() };
|
||||
const svc = new PublicShareChatToolsService(
|
||||
shareService as never,
|
||||
searchService as never,
|
||||
pageRepo as never,
|
||||
pagePermissionRepo as never,
|
||||
);
|
||||
return { svc, shareService, searchService, pageRepo, pagePermissionRepo };
|
||||
}
|
||||
|
||||
describe('listSharePages', () => {
|
||||
it('includeSubPages tree: returns deduped, titled pages (root already in tree)', async () => {
|
||||
// getShareTree returns the share root + descendants; the root IS in the
|
||||
// tree, so no extra title lookup is needed and the tree is listed as-is.
|
||||
const { svc, pageRepo } = makeService({
|
||||
getShareTree: jest.fn().mockResolvedValue({
|
||||
share: { pageId: 'root' },
|
||||
pageTree: [
|
||||
{ id: 'root', title: 'Home' },
|
||||
{ id: 'child-1', title: 'Child One' },
|
||||
{ id: 'child-2', title: 'Child Two' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
const out = (await (tools.listSharePages as unknown as ToolExec).execute(
|
||||
{},
|
||||
)) as Array<{ id: string; title: string }>;
|
||||
expect(out).toEqual([
|
||||
{ id: 'root', title: 'Home' },
|
||||
{ id: 'child-1', title: 'Child One' },
|
||||
{ id: 'child-2', title: 'Child Two' },
|
||||
]);
|
||||
// The root was already in the tree => no fallback title lookup.
|
||||
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('single-page share (empty tree): falls back to the root title and PREPENDS it', async () => {
|
||||
const { svc, pageRepo } = makeService({
|
||||
getShareTree: jest.fn().mockResolvedValue({
|
||||
share: { pageId: 'root' },
|
||||
pageTree: [], // includeSubPages=false => empty tree
|
||||
}),
|
||||
findById: jest.fn().mockResolvedValue({ id: 'root', title: 'Solo Page' }),
|
||||
});
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
const out = (await (tools.listSharePages as unknown as ToolExec).execute(
|
||||
{},
|
||||
)) as Array<{ id: string; title: string }>;
|
||||
expect(out).toEqual([{ id: 'root', title: 'Solo Page' }]);
|
||||
expect(pageRepo.findById).toHaveBeenCalledWith('root');
|
||||
});
|
||||
|
||||
it('de-duplicates pages by id, keeping the first (titled) occurrence', async () => {
|
||||
const { svc } = makeService({
|
||||
getShareTree: jest.fn().mockResolvedValue({
|
||||
share: { pageId: 'root' },
|
||||
pageTree: [
|
||||
{ id: 'root', title: 'Home' },
|
||||
{ id: 'dup', title: 'First' },
|
||||
{ id: 'dup', title: 'Second (dropped)' },
|
||||
{ id: 'root', title: 'Home again (dropped)' },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
const out = (await (tools.listSharePages as unknown as ToolExec).execute(
|
||||
{},
|
||||
)) as Array<{ id: string; title: string }>;
|
||||
expect(out).toEqual([
|
||||
{ id: 'root', title: 'Home' },
|
||||
{ id: 'dup', title: 'First' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('getShareTree throws => returns [] (fail-soft, never throws to the model)', async () => {
|
||||
const { svc } = makeService({
|
||||
getShareTree: jest.fn().mockRejectedValue(new Error('db down')),
|
||||
});
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
await expect(
|
||||
(tools.listSharePages as unknown as ToolExec).execute({}),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchSharePages blank guard', () => {
|
||||
it('blank query => [] WITHOUT calling searchService', async () => {
|
||||
const { svc, searchService } = makeService({ searchPage: jest.fn() });
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
await expect(
|
||||
(tools.searchSharePages as unknown as ToolExec).execute({ query: ' ' }),
|
||||
).resolves.toEqual([]);
|
||||
expect(searchService.searchPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharePage blank guard', () => {
|
||||
it('blank pageId => throws "A pageId is required." WITHOUT calling getShareForPage', async () => {
|
||||
const { svc, shareService } = makeService({ getShareForPage: jest.fn() });
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
await expect(
|
||||
(tools.getSharePage as unknown as ToolExec).execute({ pageId: ' ' }),
|
||||
).rejects.toThrow('A pageId is required.');
|
||||
expect(shareService.getShareForPage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,20 @@ import {
|
||||
} from '../../../integrations/audit/audit.service';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
|
||||
// A valid bcrypt hash (cost 10, of an arbitrary throwaway string) used ONLY to
|
||||
// equalize timing in verifyUserCredentials: when the email does not exist or
|
||||
// the user is disabled, we still run ONE bcrypt comparison against this hash
|
||||
// before throwing, so the missing/disabled path takes about the same time as
|
||||
// the real-user wrong-password path. Without it, the "no bcrypt at all" branch
|
||||
// returns measurably faster, leaking whether an email is registered (a user-
|
||||
// enumeration timing oracle, now reachable via /mcp where throttling is only a
|
||||
// spoofable in-memory limiter). This is never used as a real credential.
|
||||
// The cost factor MUST match the production saltRounds (12 — see
|
||||
// common/helpers/utils.ts hashPassword), otherwise the dummy compare runs
|
||||
// faster than a real wrong-password compare and the timing oracle survives.
|
||||
const DUMMY_PASSWORD_HASH =
|
||||
'$2b$12$q/l637TULK3vU3Cmji0y8utpJS/UiftMi3Jdm4Tsi5EIv/0FE7WV.';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@@ -82,6 +96,12 @@ export class AuthService {
|
||||
// recognises this exact message via isCredentialsFailure.
|
||||
const errorMessage = CREDENTIALS_MISMATCH_MESSAGE;
|
||||
if (!user || isUserDisabled(user)) {
|
||||
// Constant-time intent: run ONE bcrypt comparison (against a dummy hash)
|
||||
// even when the user is missing/disabled, so this path takes about the
|
||||
// same time as the real-user wrong-password path below. This closes the
|
||||
// user-enumeration timing oracle (registered vs. not). The result is
|
||||
// intentionally discarded — we always throw the same credentials error.
|
||||
await comparePasswordHash(loginDto.password, DUMMY_PASSWORD_HASH);
|
||||
throw new UnauthorizedException(errorMessage);
|
||||
}
|
||||
|
||||
|
||||
@@ -100,4 +100,40 @@ describe('AuthService no-side-effect contract (item 4)', () => {
|
||||
expect(verifyBody.includes(effect)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Item 4: user-enumeration timing-oracle fix. When the email is missing or the
|
||||
// user is disabled, verifyUserCredentials must still run ONE bcrypt comparison
|
||||
// (against a dummy hash) BEFORE throwing, so the missing/disabled path takes
|
||||
// about the same time as the real-user wrong-password path. Asserted at the
|
||||
// source level for the same reason as the rest of this file: AuthService cannot
|
||||
// be imported under this jest config to spy on comparePasswordHash live.
|
||||
describe('constant-time missing/disabled branch (item 4)', () => {
|
||||
// Isolate the body of the `if (!user || isUserDisabled(user)) { ... }` guard.
|
||||
const guardMatch = verifyBody.match(
|
||||
/if \(!user \|\| isUserDisabled\(user\)\) \{([\s\S]*?)\n {4}\}/,
|
||||
);
|
||||
|
||||
it('the missing/disabled guard runs a bcrypt compare before throwing', () => {
|
||||
expect(guardMatch).not.toBeNull();
|
||||
const guardBody = guardMatch![1];
|
||||
// It performs the dummy bcrypt comparison...
|
||||
expect(guardBody).toContain('comparePasswordHash');
|
||||
// ...and only AFTER that throws the credentials error (compare precedes
|
||||
// the throw STATEMENT — match `throw new`, not the word "throw" in a comment).
|
||||
const compareIdx = guardBody.indexOf('comparePasswordHash');
|
||||
const throwIdx = guardBody.indexOf('throw new');
|
||||
expect(compareIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(throwIdx).toBeGreaterThan(compareIdx);
|
||||
});
|
||||
|
||||
it('uses a module-level dummy hash constant (never a real credential)', () => {
|
||||
// The dummy hash is a module-level constant referenced in the guard, not an
|
||||
// inline literal recomputed per call.
|
||||
expect(verifyBody).toContain('DUMMY_PASSWORD_HASH');
|
||||
// Cost factor MUST be 12 to match production saltRounds, otherwise the
|
||||
// dummy compare is faster than a real wrong-password compare and the
|
||||
// timing oracle survives.
|
||||
expect(source).toMatch(/const DUMMY_PASSWORD_HASH =\s*'\$2b\$12\$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { CREDENTIALS_MISMATCH_MESSAGE } from '../auth.constants';
|
||||
import { hashPassword } from '../../../common/helpers';
|
||||
|
||||
/**
|
||||
* LIVE security contract for AuthService.verifyUserCredentials / login (M4
|
||||
* item 5).
|
||||
*
|
||||
* The (now-fixed) jest config CAN import AuthService at the module level (the
|
||||
* `^src/(.*)$` moduleNameMapper resolves the transitive `src/...` imports and the
|
||||
* ts-jest transform loads the graph). AuthService cannot be `.compile()`-d via
|
||||
* the Nest TestingModule (its full provider graph is not wired here), but it can
|
||||
* be constructed directly with mocked collaborators — which is exactly what we
|
||||
* need to exercise the credential-check decision live.
|
||||
*
|
||||
* The load-bearing property: verifyUserCredentials (and login(), which reuses it)
|
||||
* throws EXACTLY the shared CREDENTIALS_MISMATCH_MESSAGE for all three
|
||||
* credentials-failure cases — unknown email, disabled user, wrong password. The
|
||||
* /mcp Basic brute-force limiter only counts a failure when it recognises THIS
|
||||
* exact message (isCredentialsFailure in mcp-auth.helpers matches the same shared
|
||||
* constant); a reword that diverged here would silently turn /mcp Basic into an
|
||||
* unthrottled password-guessing oracle.
|
||||
*/
|
||||
|
||||
const WORKSPACE_ID = 'ws-1';
|
||||
|
||||
// Build an AuthService with the dependencies verifyUserCredentials/login touch
|
||||
// stubbed, and a userRepo whose findByEmail is overridable per test. Only the
|
||||
// collaborators actually reached on these paths need real behaviour; the rest
|
||||
// are inert mocks (constructor wiring only).
|
||||
function makeAuthService(over: {
|
||||
findByEmail?: jest.Mock;
|
||||
} = {}): {
|
||||
service: AuthService;
|
||||
userRepo: { findByEmail: jest.Mock; updateLastLogin: jest.Mock };
|
||||
sessionService: { createSessionAndToken: jest.Mock };
|
||||
auditService: { log: jest.Mock };
|
||||
} {
|
||||
const userRepo = {
|
||||
findByEmail: over.findByEmail ?? jest.fn(),
|
||||
updateLastLogin: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const sessionService = {
|
||||
createSessionAndToken: jest.fn().mockResolvedValue('issued-token'),
|
||||
};
|
||||
const auditService = { log: jest.fn() };
|
||||
// environmentService: isCloud() false (so throwIfEmailNotVerified does not
|
||||
// require verification) + a stable app secret.
|
||||
const environmentService = {
|
||||
isCloud: jest.fn().mockReturnValue(false),
|
||||
getAppSecret: jest.fn().mockReturnValue('test-secret'),
|
||||
};
|
||||
|
||||
// Constructor signature (auth.service.ts): signupService, tokenService,
|
||||
// sessionService, userSessionRepo, userRepo, userTokenRepo, mailService,
|
||||
// domainService, environmentService, db, auditService.
|
||||
const service = new (AuthService as unknown as new (...args: unknown[]) => AuthService)(
|
||||
{}, // signupService
|
||||
{}, // tokenService
|
||||
sessionService, // sessionService
|
||||
{}, // userSessionRepo
|
||||
userRepo, // userRepo
|
||||
{}, // userTokenRepo
|
||||
{}, // mailService
|
||||
{}, // domainService
|
||||
environmentService, // environmentService
|
||||
{}, // db
|
||||
auditService, // auditService
|
||||
);
|
||||
|
||||
return { service, userRepo, sessionService, auditService };
|
||||
}
|
||||
|
||||
describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)', () => {
|
||||
it('UNKNOWN email -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||
const { service } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.verifyUserCredentials(
|
||||
{ email: 'nobody@example.com', password: 'whatever' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
await expect(
|
||||
service.verifyUserCredentials(
|
||||
{ email: 'nobody@example.com', password: 'whatever' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('DISABLED user -> throws exactly CREDENTIALS_MISMATCH_MESSAGE (no password oracle)', async () => {
|
||||
// A deactivated user must be indistinguishable from a wrong password: same
|
||||
// message, before any password comparison.
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const disabledUser = {
|
||||
id: 'u-1',
|
||||
email: 'disabled@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: new Date(),
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(disabledUser),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.verifyUserCredentials(
|
||||
{ email: 'disabled@example.com', password: 'correct-horse' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
});
|
||||
|
||||
it('WRONG password -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const user = {
|
||||
id: 'u-1',
|
||||
email: 'user@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(user),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.verifyUserCredentials(
|
||||
{ email: 'user@example.com', password: 'wrong-password' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
});
|
||||
|
||||
it('CORRECT credentials -> resolves the matched user (no side effects here)', async () => {
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const user = {
|
||||
id: 'u-1',
|
||||
email: 'user@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service, sessionService, auditService, userRepo } =
|
||||
makeAuthService({ findByEmail: jest.fn().mockResolvedValue(user) });
|
||||
|
||||
const result = await service.verifyUserCredentials(
|
||||
{ email: 'user@example.com', password: 'correct-horse' },
|
||||
WORKSPACE_ID,
|
||||
);
|
||||
expect(result).toBe(user);
|
||||
// verifyUserCredentials is non-side-effecting: no session/audit/lastLogin.
|
||||
expect(sessionService.createSessionAndToken).not.toHaveBeenCalled();
|
||||
expect(auditService.log).not.toHaveBeenCalled();
|
||||
expect(userRepo.updateLastLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthService.login (live credentials-mismatch contract via verifyUserCredentials)', () => {
|
||||
it('UNKNOWN email -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE, mints NO session', async () => {
|
||||
const { service, sessionService } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.login(
|
||||
{ email: 'nobody@example.com', password: 'whatever' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
expect(sessionService.createSessionAndToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WRONG password -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const user = {
|
||||
id: 'u-1',
|
||||
email: 'user@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service } = makeAuthService({
|
||||
findByEmail: jest.fn().mockResolvedValue(user),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.login(
|
||||
{ email: 'user@example.com', password: 'wrong-password' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).rejects.toMatchObject({ message: CREDENTIALS_MISMATCH_MESSAGE });
|
||||
});
|
||||
|
||||
it('CORRECT credentials -> login mints the session (the side-effecting path)', async () => {
|
||||
const passwordHash = await hashPassword('correct-horse');
|
||||
const user = {
|
||||
id: 'u-1',
|
||||
email: 'user@example.com',
|
||||
password: passwordHash,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
emailVerifiedAt: new Date(),
|
||||
};
|
||||
const { service, sessionService, auditService, userRepo } =
|
||||
makeAuthService({ findByEmail: jest.fn().mockResolvedValue(user) });
|
||||
|
||||
await expect(
|
||||
service.login(
|
||||
{ email: 'user@example.com', password: 'correct-horse' },
|
||||
WORKSPACE_ID,
|
||||
),
|
||||
).resolves.toBe('issued-token');
|
||||
// login() reuses verifyUserCredentials but DOES run the three side effects.
|
||||
expect(userRepo.updateLastLogin).toHaveBeenCalledWith('u-1', WORKSPACE_ID);
|
||||
expect(auditService.log).toHaveBeenCalled();
|
||||
expect(sessionService.createSessionAndToken).toHaveBeenCalledWith(user);
|
||||
});
|
||||
|
||||
it('the message login throws is the SAME shared constant the /mcp limiter matches', () => {
|
||||
// Cross-file coupling lock: the constant is the single source of truth shared
|
||||
// by AuthService and mcp-auth.helpers.isCredentialsFailure.
|
||||
expect(CREDENTIALS_MISMATCH_MESSAGE).toBe('Email or password does not match');
|
||||
});
|
||||
});
|
||||
@@ -62,6 +62,7 @@ import { markdownToHtml } from '@docmost/editor-ext';
|
||||
import { WatcherService } from '../../watcher/watcher.service';
|
||||
import { sql } from 'kysely';
|
||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||
import { remapPageEmbedSourceId } from '../transclusion/utils/transclusion-prosemirror.util';
|
||||
import { AuthProvenanceData } from '../../../common/decorators/auth-provenance.decorator';
|
||||
|
||||
@Injectable()
|
||||
@@ -713,12 +714,11 @@ export class PageService {
|
||||
// source page is also part of the copied set, point at its new copy;
|
||||
// otherwise leave it pointing at the original (live embed of original).
|
||||
if (node.type.name === 'pageEmbed') {
|
||||
const sourcePageId = node.attrs.sourcePageId;
|
||||
if (sourcePageId && pageMap.has(sourcePageId)) {
|
||||
const mappedPage = pageMap.get(sourcePageId);
|
||||
//@ts-ignore
|
||||
node.attrs.sourcePageId = mappedPage.newPageId;
|
||||
}
|
||||
// @ts-expect-error ProseMirror Attrs is read-only typed; intentional remap to the duplicated copy
|
||||
node.attrs.sourcePageId = remapPageEmbedSourceId(
|
||||
node.attrs.sourcePageId,
|
||||
(id) => pageMap.get(id)?.newPageId,
|
||||
);
|
||||
}
|
||||
|
||||
// Update internal page links in link marks
|
||||
|
||||
@@ -67,6 +67,12 @@ export class PageTemplateController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
if (page.workspaceId !== user.workspaceId) {
|
||||
// Defense-in-depth: never act on a page outside the caller's workspace.
|
||||
// Use NotFound (not Forbidden) to avoid leaking cross-workspace existence.
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
|
||||
const isTemplate =
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
remapPageEmbedSourceId,
|
||||
remapPageEmbedSourceIds,
|
||||
} from '../utils/transclusion-prosemirror.util';
|
||||
|
||||
/**
|
||||
* Unit tests for the `pageEmbed` remap used by `PageService.duplicatePage`:
|
||||
*
|
||||
* - source page within the copied set -> rewrite to the COPY's new id
|
||||
* - source page NOT in the copied set -> keep the ORIGINAL id (live embed)
|
||||
*
|
||||
* `remapPageEmbedSourceId` is the per-node decision the production
|
||||
* `duplicatePage` callback now calls directly, so these tests guard the real
|
||||
* path rather than a parallel copy. `remapPageEmbedSourceIds` is the JSON
|
||||
* walker that delegates to the same helper; its tests exercise the shared
|
||||
* decision transitively across nested ProseMirror containers.
|
||||
*/
|
||||
describe('remapPageEmbedSourceId (shared per-node decision used by duplicatePage)', () => {
|
||||
it('returns the new copy id when the source IS in the copied set', () => {
|
||||
const idMap = new Map([['old-src', 'new-copy']]);
|
||||
|
||||
const out = remapPageEmbedSourceId('old-src', (id) => idMap.get(id));
|
||||
|
||||
expect(out).toBe('new-copy');
|
||||
});
|
||||
|
||||
it('returns the original id when the source is NOT in the copied set', () => {
|
||||
const idMap = new Map([['old-src', 'new-copy']]);
|
||||
|
||||
const out = remapPageEmbedSourceId('external', (id) => idMap.get(id));
|
||||
|
||||
expect(out).toBe('external');
|
||||
});
|
||||
|
||||
it('returns the original id when resolveNewId yields undefined', () => {
|
||||
const out = remapPageEmbedSourceId('some-id', () => undefined);
|
||||
|
||||
expect(out).toBe('some-id');
|
||||
});
|
||||
|
||||
it('leaves a null source unchanged without consulting the resolver', () => {
|
||||
const resolve = jest.fn(() => 'should-not-be-used');
|
||||
|
||||
const out = remapPageEmbedSourceId(null, resolve);
|
||||
|
||||
expect(out).toBeNull();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves an undefined source unchanged without consulting the resolver', () => {
|
||||
const resolve = jest.fn(() => 'should-not-be-used');
|
||||
|
||||
const out = remapPageEmbedSourceId(undefined, resolve);
|
||||
|
||||
expect(out).toBeUndefined();
|
||||
expect(resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('remapPageEmbedSourceIds (duplicatePage pageEmbed remap)', () => {
|
||||
const docWithEmbeds = (ids: string[]) => ({
|
||||
type: 'doc',
|
||||
content: ids.map((id) => ({
|
||||
type: 'pageEmbed',
|
||||
attrs: { sourcePageId: id },
|
||||
})),
|
||||
});
|
||||
|
||||
it('remaps a source that IS within the copied set to its new copy id', () => {
|
||||
const doc = docWithEmbeds(['old-src']);
|
||||
const idMap = new Map([['old-src', 'new-copy']]);
|
||||
|
||||
const out = remapPageEmbedSourceIds(doc, idMap);
|
||||
|
||||
expect(out.content[0].attrs.sourcePageId).toBe('new-copy');
|
||||
});
|
||||
|
||||
it('keeps the original id for a source NOT in the copied set', () => {
|
||||
const doc = docWithEmbeds(['external']);
|
||||
const idMap = new Map([['old-src', 'new-copy']]); // does not contain "external"
|
||||
|
||||
const out = remapPageEmbedSourceIds(doc, idMap);
|
||||
|
||||
expect(out.content[0].attrs.sourcePageId).toBe('external');
|
||||
});
|
||||
|
||||
it('handles a mixed doc: in-set remapped, out-of-set preserved', () => {
|
||||
const doc = docWithEmbeds(['in-set', 'external']);
|
||||
const idMap = new Map([['in-set', 'in-set-copy']]);
|
||||
|
||||
const out = remapPageEmbedSourceIds(doc, idMap);
|
||||
|
||||
expect(out.content.map((n: any) => n.attrs.sourcePageId)).toEqual([
|
||||
'in-set-copy',
|
||||
'external',
|
||||
]);
|
||||
});
|
||||
|
||||
it('remaps pageEmbeds nested inside columns', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'columnList',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'nested-in' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'nested-out' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const idMap = new Map([['nested-in', 'nested-in-copy']]);
|
||||
|
||||
const out = remapPageEmbedSourceIds(doc, idMap) as any;
|
||||
|
||||
const col0 = out.content[0].content[0].content[0];
|
||||
const col1 = out.content[0].content[1].content[0];
|
||||
expect(col0.attrs.sourcePageId).toBe('nested-in-copy');
|
||||
expect(col1.attrs.sourcePageId).toBe('nested-out');
|
||||
});
|
||||
|
||||
it('remaps pageEmbeds nested inside a callout', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'callout',
|
||||
content: [
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'in-callout' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const idMap = new Map([['in-callout', 'in-callout-copy']]);
|
||||
|
||||
const out = remapPageEmbedSourceIds(doc, idMap) as any;
|
||||
|
||||
expect(out.content[0].content[0].attrs.sourcePageId).toBe(
|
||||
'in-callout-copy',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not descend into a transclusionSource (schema-isolated)', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'transclusionSource',
|
||||
attrs: { id: 'src' },
|
||||
content: [
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'hidden' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const idMap = new Map([['hidden', 'should-not-apply']]);
|
||||
|
||||
const out = remapPageEmbedSourceIds(doc, idMap) as any;
|
||||
|
||||
// The embed inside a source must be left untouched.
|
||||
expect(out.content[0].content[0].attrs.sourcePageId).toBe('hidden');
|
||||
});
|
||||
|
||||
it('leaves embeds missing a sourcePageId untouched', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'pageEmbed', attrs: {} },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: '' } },
|
||||
],
|
||||
};
|
||||
const idMap = new Map([['', 'x']]);
|
||||
|
||||
const out = remapPageEmbedSourceIds(doc, idMap) as any;
|
||||
|
||||
expect(out.content[0].attrs.sourcePageId).toBeUndefined();
|
||||
expect(out.content[1].attrs.sourcePageId).toBe('');
|
||||
});
|
||||
|
||||
it('returns the doc unchanged when idMap is empty', () => {
|
||||
const doc = docWithEmbeds(['a', 'b']);
|
||||
|
||||
const out = remapPageEmbedSourceIds(doc, new Map());
|
||||
|
||||
expect(out.content.map((n: any) => n.attrs.sourcePageId)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,67 @@ describe('collectPageEmbedsFromPmJson', () => {
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([]);
|
||||
});
|
||||
|
||||
it('ignores a pageEmbed whose sourcePageId is not a string', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 123 as any } },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: null as any } },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: { nested: true } as any } },
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: ['arr'] as any } },
|
||||
// a valid one mixed in proves only the bad ones are dropped
|
||||
{ type: 'pageEmbed', attrs: { sourcePageId: 'good' } },
|
||||
],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([
|
||||
{ sourcePageId: 'good' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('collects a pageEmbed nested under multiple block containers', () => {
|
||||
const doc = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'callout',
|
||||
content: [
|
||||
{
|
||||
type: 'columns',
|
||||
content: [
|
||||
{
|
||||
type: 'column',
|
||||
content: [
|
||||
{
|
||||
type: 'details',
|
||||
content: [
|
||||
{
|
||||
type: 'pageEmbed',
|
||||
attrs: { sourcePageId: 'deep' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(collectPageEmbedsFromPmJson(doc)).toEqual([{ sourcePageId: 'deep' }]);
|
||||
});
|
||||
|
||||
it('terminates (does not silently hang) on a self-referencing/cyclic object', () => {
|
||||
// FINDING: there is NO explicit cycle guard. A hand-built cyclic JS object
|
||||
// (which cannot arise from JSON parsing — the real input path) makes the
|
||||
// recursive walk overflow the stack and throw a RangeError. It TERMINATES
|
||||
// with a controlled error rather than recursing unboundedly forever, and a
|
||||
// non-cyclic (JSON-shaped) document is never affected.
|
||||
const node: any = { type: 'doc', content: [] };
|
||||
node.content.push(node); // content array references its own parent node
|
||||
expect(() => collectPageEmbedsFromPmJson(node)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pageEmbed HTML <-> JSON round-trip (server schema)', () => {
|
||||
|
||||
@@ -68,6 +68,7 @@ describe('TransclusionService — template access core (real filter)', () => {
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
return { service, db, pageRepo, spaceMemberRepo, pagePermissionRepo };
|
||||
@@ -172,8 +173,13 @@ describe('TransclusionService — template access core (real filter)', () => {
|
||||
expect((items[2] as any).status).toBe('no_access'); // not space-visible
|
||||
});
|
||||
|
||||
it('honours the DTO-level ≤50 cap by deduping ids passed to the filter', async () => {
|
||||
// The DTO enforces ArrayMaxSize(50); the service dedupes before filtering.
|
||||
it('dedupes source ids before passing them to the access filter', async () => {
|
||||
// NOTE: this test only covers DEDUP, not the ≤50 cap. The ArrayMaxSize(50)
|
||||
// limit is enforced by the DTO (validation layer), so it is never engaged in
|
||||
// the service under unit test — the service receives an already-validated
|
||||
// array and merely dedupes it. Renamed from the old "honours ≤50 cap" title,
|
||||
// which misleadingly implied the cap was exercised here. A real cap test would
|
||||
// belong in a controller/DTO-validation spec, not in this service unit test.
|
||||
const ids = ['a', 'a', 'b'];
|
||||
const { service, db } = makeService({
|
||||
spaceVisibleRows: [],
|
||||
@@ -187,8 +193,103 @@ describe('TransclusionService — template access core (real filter)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.filterViewerAccessiblePageIds — AND ordering (content-leak control)', () => {
|
||||
function makeDb(executeRows: Array<{ id: string }>) {
|
||||
const builder: any = {};
|
||||
builder.selectFrom = jest.fn(() => builder);
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn(() => builder);
|
||||
builder.execute = jest.fn(async () => executeRows);
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeService(opts: {
|
||||
spaceVisibleRows: Array<{ id: string }>;
|
||||
permissionAccessibleIds: string[];
|
||||
}) {
|
||||
const db = makeDb(opts.spaceVisibleRows);
|
||||
const spaceMemberRepo = {
|
||||
getUserSpaceIdsQuery: jest.fn(() => ({ __subquery: true })),
|
||||
};
|
||||
const filterAccessiblePageIds = jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.permissionAccessibleIds);
|
||||
const pagePermissionRepo = { filterAccessiblePageIds };
|
||||
|
||||
const service = new TransclusionService(
|
||||
db as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
{} as any, // pageRepo
|
||||
pagePermissionRepo as any,
|
||||
spaceMemberRepo as any,
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
return { service, filterAccessiblePageIds };
|
||||
}
|
||||
|
||||
it('space-visible AND permission-accessible → returned', async () => {
|
||||
const { service } = makeService({
|
||||
spaceVisibleRows: [{ id: 'p1' }],
|
||||
permissionAccessibleIds: ['p1'],
|
||||
});
|
||||
const out = await service.filterViewerAccessiblePageIds(
|
||||
['p1'],
|
||||
'u1',
|
||||
'w1',
|
||||
);
|
||||
expect(out).toEqual(['p1']);
|
||||
});
|
||||
|
||||
it('space-visible but permission-rejected → dropped', async () => {
|
||||
const { service, filterAccessiblePageIds } = makeService({
|
||||
spaceVisibleRows: [{ id: 'p1' }],
|
||||
permissionAccessibleIds: [],
|
||||
});
|
||||
const out = await service.filterViewerAccessiblePageIds(
|
||||
['p1'],
|
||||
'u1',
|
||||
'w1',
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
// The permission filter only ever sees the space-visible candidate.
|
||||
expect(filterAccessiblePageIds).toHaveBeenCalledWith({
|
||||
pageIds: ['p1'],
|
||||
userId: 'u1',
|
||||
});
|
||||
});
|
||||
|
||||
it('NOT space-visible but permission-accessible → STILL dropped (AND-ordering enforced)', async () => {
|
||||
// The page would pass page-level permission filtering, but it is not visible
|
||||
// at the space level (e.g. a private space the viewer is not a member of).
|
||||
// The space-visibility gate runs FIRST and short-circuits, so the page-level
|
||||
// permission filter is never even consulted — preventing a private-space
|
||||
// content leak via an unrestricted source page.
|
||||
const { service, filterAccessiblePageIds } = makeService({
|
||||
spaceVisibleRows: [],
|
||||
permissionAccessibleIds: ['private-but-permitted'],
|
||||
});
|
||||
const out = await service.filterViewerAccessiblePageIds(
|
||||
['private-but-permitted'],
|
||||
'u1',
|
||||
'w1',
|
||||
);
|
||||
expect(out).toEqual([]);
|
||||
expect(filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.syncPageTemplateReferences — workspace scoping', () => {
|
||||
function makeService(opts: { inWorkspaceIds: string[] }) {
|
||||
function makeService(opts: {
|
||||
inWorkspaceIds: string[];
|
||||
/** existing rows already persisted for the reference page */
|
||||
existingSourceIds?: string[];
|
||||
}) {
|
||||
// db stub: the in-workspace existence query returns only allowed ids.
|
||||
const builder: any = {};
|
||||
builder.selectFrom = jest.fn(() => builder);
|
||||
@@ -201,25 +302,37 @@ describe('TransclusionService.syncPageTemplateReferences — workspace scoping',
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = {
|
||||
findByReferencePageId: jest.fn().mockResolvedValue([]),
|
||||
findByReferencePageId: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
(opts.existingSourceIds ?? []).map((sourcePageId) => ({
|
||||
sourcePageId,
|
||||
})),
|
||||
),
|
||||
insertMany,
|
||||
deleteByReferenceAndSources,
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
builder as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
builder as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
pageTemplateReferencesRepo as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
return { service, insertMany, pageTemplateReferencesRepo };
|
||||
return {
|
||||
service,
|
||||
insertMany,
|
||||
deleteByReferenceAndSources,
|
||||
pageTemplateReferencesRepo,
|
||||
};
|
||||
}
|
||||
|
||||
function docWithEmbeds(sourceIds: string[]) {
|
||||
@@ -264,4 +377,152 @@ describe('TransclusionService.syncPageTemplateReferences — workspace scoping',
|
||||
expect(result.inserted).toBe(0);
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('DELETE branch: an existing in-workspace ref removed from the doc is deleted', async () => {
|
||||
// 'gone' was referenced before but is no longer in the doc; 'stay' remains.
|
||||
const { service, insertMany, deleteByReferenceAndSources } = makeService({
|
||||
inWorkspaceIds: ['stay'],
|
||||
existingSourceIds: ['stay', 'gone'],
|
||||
});
|
||||
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
docWithEmbeds(['stay']),
|
||||
);
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
expect(result.inserted).toBe(0); // 'stay' already existed
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
expect(deleteByReferenceAndSources).toHaveBeenCalledTimes(1);
|
||||
expect(deleteByReferenceAndSources).toHaveBeenCalledWith(
|
||||
'host',
|
||||
'w1', // workspace-scoped delete (#36 defense-in-depth)
|
||||
['gone'],
|
||||
undefined, // no trx supplied
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT delete a stale ref whose source is now cross-workspace if it is also still embedded', async () => {
|
||||
// Edge: 'x' is still embedded in the doc but no longer in-workspace. It is
|
||||
// not in desiredIds (filtered out) AND it exists → it should be deleted, not
|
||||
// kept, because the reference graph must drop the cross-workspace edge.
|
||||
const { service, deleteByReferenceAndSources } = makeService({
|
||||
inWorkspaceIds: [], // 'x' no longer in-workspace
|
||||
existingSourceIds: ['x'],
|
||||
});
|
||||
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
docWithEmbeds(['x']),
|
||||
);
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
expect(deleteByReferenceAndSources).toHaveBeenCalledWith(
|
||||
'host',
|
||||
'w1', // workspace-scoped delete (#36 defense-in-depth)
|
||||
['x'],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.insertTemplateReferencesForPages — per-workspace existence validation', () => {
|
||||
/**
|
||||
* Smart db stub: each existence query is `.where('id','in', ids)` +
|
||||
* `.where('workspaceId','=', wsId)`; `.execute()` returns only the ids that
|
||||
* `validByWorkspace[wsId]` declares in-workspace. The builder snapshots the
|
||||
* last `id`-in list and `workspaceId` value per chain (selectFrom resets).
|
||||
*/
|
||||
function makeDb(validByWorkspace: Record<string, string[]>) {
|
||||
const builder: any = {};
|
||||
let curIds: string[] = [];
|
||||
let curWs: string | undefined;
|
||||
builder.selectFrom = jest.fn(() => {
|
||||
curIds = [];
|
||||
curWs = undefined;
|
||||
return builder;
|
||||
});
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn((col: string, op: string, val: any) => {
|
||||
if (col === 'id' && op === 'in') curIds = val;
|
||||
if (col === 'workspaceId' && op === '=') curWs = val;
|
||||
return builder;
|
||||
});
|
||||
builder.execute = jest.fn(async () => {
|
||||
const valid = new Set(validByWorkspace[curWs ?? ''] ?? []);
|
||||
return curIds.filter((id) => valid.has(id)).map((id) => ({ id }));
|
||||
});
|
||||
return builder;
|
||||
}
|
||||
|
||||
function makeService(validByWorkspace: Record<string, string[]>) {
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = { insertMany };
|
||||
const service = new TransclusionService(
|
||||
makeDb(validByWorkspace) as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
pageTemplateReferencesRepo as any,
|
||||
{} as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
return { service, insertMany };
|
||||
}
|
||||
|
||||
const embedDoc = (ids: string[]) => ({
|
||||
type: 'doc',
|
||||
content: ids.map((id) => ({
|
||||
type: 'pageEmbed',
|
||||
attrs: { sourcePageId: id },
|
||||
})),
|
||||
});
|
||||
|
||||
it('validates each workspace separately: a source in-ws for A but cross-ws for B inserts only the valid delta', async () => {
|
||||
// 'shared' is in-workspace for wA but NOT for wB. Page A embeds 'shared'
|
||||
// (valid → inserted). Page B embeds 'shared' (cross-ws for wB → dropped).
|
||||
const { service, insertMany } = makeService({
|
||||
wA: ['shared'],
|
||||
wB: [], // 'shared' is not a page in wB
|
||||
});
|
||||
|
||||
const result = await service.insertTemplateReferencesForPages([
|
||||
{ id: 'pageA', workspaceId: 'wA', content: embedDoc(['shared']) },
|
||||
{ id: 'pageB', workspaceId: 'wB', content: embedDoc(['shared']) },
|
||||
]);
|
||||
|
||||
expect(result.inserted).toBe(1);
|
||||
expect(insertMany).toHaveBeenCalledTimes(1);
|
||||
expect(insertMany.mock.calls[0][0]).toEqual([
|
||||
{ workspaceId: 'wA', referencePageId: 'pageA', sourcePageId: 'shared' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('inserts the in-workspace deltas for both pages when each is valid in its own workspace', async () => {
|
||||
const { service, insertMany } = makeService({
|
||||
wA: ['a-src'],
|
||||
wB: ['b-src'],
|
||||
});
|
||||
|
||||
const result = await service.insertTemplateReferencesForPages([
|
||||
{ id: 'pageA', workspaceId: 'wA', content: embedDoc(['a-src']) },
|
||||
{ id: 'pageB', workspaceId: 'wB', content: embedDoc(['b-src']) },
|
||||
]);
|
||||
|
||||
expect(result.inserted).toBe(2);
|
||||
const rows = insertMany.mock.calls[0][0];
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ workspaceId: 'wA', referencePageId: 'pageA', sourcePageId: 'a-src' },
|
||||
{ workspaceId: 'wB', referencePageId: 'pageB', sourcePageId: 'b-src' },
|
||||
]),
|
||||
);
|
||||
expect(rows).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Edge-case + anti-leak coverage for `lookupTemplate` that the existing
|
||||
* `page-template-lookup.spec.ts` (stubbed filter) and `page-template-access.spec.ts`
|
||||
* (real filter, happy paths) do not exercise:
|
||||
*
|
||||
* 1. SECURITY anti-leak: when comment-mark stripping THROWS, the item must come
|
||||
* back as `not_found` and NEVER carry raw content (the source's comment marks
|
||||
* could otherwise leak to a viewer). See the `catch` branch in `lookupTemplate`.
|
||||
* 2. A soft-deleted source page resolved through the REAL
|
||||
* `filterViewerAccessiblePageIds` (space-visibility query filters `deletedAt`),
|
||||
* asserting it maps to `not_found`/`no_access` rather than content.
|
||||
*/
|
||||
describe('TransclusionService.lookupTemplate — anti-leak catch branch', () => {
|
||||
const now = new Date('2026-06-20T00:00:00.000Z');
|
||||
|
||||
function makeService(opts: {
|
||||
accessibleIds: string[];
|
||||
pages: Array<{
|
||||
id: string;
|
||||
slugId?: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
content: unknown;
|
||||
updatedAt: Date;
|
||||
}>;
|
||||
}) {
|
||||
const pageRepo = {
|
||||
findManyByIds: jest.fn().mockResolvedValue(opts.pages),
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
{} as any, // db
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
{} as any, // pageTemplateReferencesRepo
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
// Stub the access decision; we are testing the content-prep stage, not access.
|
||||
jest
|
||||
.spyOn(service, 'filterViewerAccessiblePageIds')
|
||||
.mockResolvedValue(opts.accessibleIds);
|
||||
|
||||
return { service, pageRepo };
|
||||
}
|
||||
|
||||
it('returns not_found (NOT raw content) when comment-mark stripping throws', async () => {
|
||||
// An accessible, present page whose stored content is structurally invalid PM:
|
||||
// a `text` node without a `text` field. `jsonToNode` (called inside the try
|
||||
// block) throws "Invalid text node in JSON" on this, which exercises the
|
||||
// service's catch -> not_found anti-leak guard. This uses a REAL malformed
|
||||
// input (no module mocking) so the test stays faithful to production behaviour.
|
||||
const malformedContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
// Missing `text` — Node.fromJSON rejects this and jsonToNode rethrows.
|
||||
type: 'text',
|
||||
marks: [{ type: 'comment', attrs: { commentId: 'leak-me' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { service } = makeService({
|
||||
accessibleIds: ['p1'],
|
||||
pages: [
|
||||
{
|
||||
id: 'p1',
|
||||
slugId: 's1',
|
||||
title: 'Secret',
|
||||
icon: '📄',
|
||||
content: malformedContent,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Silence the expected error log so the suite output stays clean.
|
||||
jest.spyOn((service as any).logger, 'error').mockImplementation(() => {});
|
||||
|
||||
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
const item = items[0] as any;
|
||||
|
||||
// Must degrade to not_found...
|
||||
expect(item.status).toBe('not_found');
|
||||
expect(item.sourcePageId).toBe('p1');
|
||||
|
||||
// ...and must NOT leak ANY content/metadata of the source page.
|
||||
expect(item).not.toHaveProperty('content');
|
||||
expect(item).not.toHaveProperty('title');
|
||||
expect(item).not.toHaveProperty('icon');
|
||||
expect(item).not.toHaveProperty('slugId');
|
||||
expect(item).not.toHaveProperty('sourceUpdatedAt');
|
||||
|
||||
// Hard guarantee: the would-be-leaked comment mark appears nowhere in output.
|
||||
expect(JSON.stringify(item)).not.toContain('leak-me');
|
||||
expect(JSON.stringify(item)).not.toContain('comment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.lookupTemplate — soft-deleted source via real filter', () => {
|
||||
const now = new Date('2026-06-20T00:00:00.000Z');
|
||||
|
||||
/**
|
||||
* Chainable kysely `db` stub mirroring `page-template-access.spec.ts`. The
|
||||
* space-visibility query in `filterViewerAccessiblePageIds` filters
|
||||
* `where('deletedAt','is',null)`; a soft-deleted page is therefore absent from
|
||||
* the rows we resolve here, so the REAL filter is what drops it.
|
||||
*/
|
||||
function makeDb(executeRows: Array<{ id: string }>) {
|
||||
const builder: any = {};
|
||||
builder.selectFrom = jest.fn(() => builder);
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn(() => builder);
|
||||
builder.execute = jest.fn(async () => executeRows);
|
||||
return builder;
|
||||
}
|
||||
|
||||
it('resolves a soft-deleted source to not_found/no_access through the REAL filter', async () => {
|
||||
// The page IS soft-deleted, so the space-visibility query returns no rows for
|
||||
// it (deletedAt filter). We let the real filter run end-to-end.
|
||||
const db = makeDb([]); // soft-deleted -> excluded by the deletedAt='is null' clause
|
||||
|
||||
const spaceMemberRepo = {
|
||||
getUserSpaceIdsQuery: jest.fn(() => ({ __subquery: true })),
|
||||
};
|
||||
const pagePermissionRepo = {
|
||||
filterAccessiblePageIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const pageRepo = {
|
||||
// Even if it were queried, the page is gone; assert via the filter instead.
|
||||
findManyByIds: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const service = new TransclusionService(
|
||||
db as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
pageRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
spaceMemberRepo as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
{} as any,
|
||||
);
|
||||
|
||||
const { items } = await service.lookupTemplate(['deleted-src'], 'u1', 'w1');
|
||||
|
||||
// Soft-deleted source must never resolve to content.
|
||||
expect(items).toEqual([
|
||||
{ sourcePageId: 'deleted-src', status: 'no_access' },
|
||||
]);
|
||||
const item = items[0] as any;
|
||||
expect(item).not.toHaveProperty('content');
|
||||
|
||||
// The real filter short-circuited before page-permission filtering because
|
||||
// the deletedAt-filtered space-visibility query returned nothing.
|
||||
expect(pagePermissionRepo.filterAccessiblePageIds).not.toHaveBeenCalled();
|
||||
// And the verb on the db builder included a deletedAt 'is null' guard, proving
|
||||
// the real path (not a stub) excluded the soft-deleted page.
|
||||
const deletedAtCall = db.where.mock.calls.find(
|
||||
(c: any[]) => c[0] === 'deletedAt',
|
||||
);
|
||||
expect(deletedAtCall).toEqual(['deletedAt', 'is', null]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import * as collabUtil from '../../../../collaboration/collaboration.util';
|
||||
|
||||
/**
|
||||
* Exercises the pure access/mapping logic of `lookupTemplate`:
|
||||
@@ -34,6 +35,7 @@ describe('TransclusionService.lookupTemplate (access mapping)', () => {
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
|
||||
jest
|
||||
@@ -110,4 +112,61 @@ describe('TransclusionService.lookupTemplate (access mapping)', () => {
|
||||
expect((items[1] as any).status).toBeUndefined();
|
||||
expect((items[2] as any).status).toBe('no_access');
|
||||
});
|
||||
|
||||
// Content-prep failure path: if jsonToNode throws for an accessible page, the
|
||||
// item must degrade to not_found and NEVER return content (which would
|
||||
// otherwise carry the source's un-stripped comment marks).
|
||||
describe('content-prep failure → not_found', () => {
|
||||
let jsonToNodeSpy: jest.SpyInstance;
|
||||
|
||||
afterEach(() => {
|
||||
jsonToNodeSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it('maps to not_found and returns no content when jsonToNode throws', async () => {
|
||||
// The page is accessible and present, but content preparation blows up.
|
||||
jsonToNodeSpy = jest
|
||||
.spyOn(collabUtil, 'jsonToNode')
|
||||
.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
|
||||
const contentWithComment = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'secret',
|
||||
marks: [{ type: 'comment', attrs: { commentId: 'leak' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { service } = makeService({
|
||||
accessibleIds: ['p1'],
|
||||
pages: [
|
||||
{
|
||||
id: 'p1',
|
||||
title: 'T',
|
||||
icon: null,
|
||||
content: contentWithComment,
|
||||
updatedAt: now,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Silence the service's error logger for the expected throw.
|
||||
jest.spyOn((service as any).logger, 'error').mockImplementation(() => {});
|
||||
|
||||
const { items } = await service.lookupTemplate(['p1'], 'u1', 'w1');
|
||||
expect(items).toEqual([{ sourcePageId: 'p1', status: 'not_found' }]);
|
||||
// Crucially: no content field, so no comment mark can leak.
|
||||
expect((items[0] as any).content).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
|
||||
/**
|
||||
* Covers two untested, high-risk write paths around `page_template_references`:
|
||||
*
|
||||
* 1. `syncPageTemplateReferences` — the `toDelete` branch: stale references are
|
||||
* removed when the host page no longer embeds a source, while genuinely new
|
||||
* embeds are inserted. We assert `deleteByReferenceAndSources` / `insertMany`
|
||||
* receive the correct rows and the returned `{ inserted, deleted }` counts.
|
||||
*
|
||||
* 2. `insertTemplateReferencesForPages` — the multi-workspace grouping/filtering
|
||||
* branch: candidate source ids are grouped per workspace, each workspace is
|
||||
* validated independently, and cross-workspace sources are dropped.
|
||||
*
|
||||
* Setup/mocking mirrors the existing transclusion specs (page-template-access /
|
||||
* page-template-lookup): `new TransclusionService(...)` is built with the same
|
||||
* 11 positional mock args; only the deps each test touches are real stubs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Chainable kysely `db` stub used by `filterInWorkspaceSourceIds`. Every
|
||||
* `selectFrom(...).select(...).where(...)` returns the same builder; `.execute()`
|
||||
* resolves whatever rows the per-call resolver returns. The resolver receives
|
||||
* the captured `where('id','in', <ids>)` and `where('workspaceId','=', ws)`
|
||||
* arguments so a test can decide, per workspace, which ids "exist".
|
||||
*/
|
||||
function makeWorkspaceScopedDb(
|
||||
resolve: (ids: string[], workspaceId: string) => string[],
|
||||
) {
|
||||
const state = { ids: [] as string[], workspaceId: '' };
|
||||
const builder: any = {};
|
||||
builder.selectFrom = jest.fn(() => builder);
|
||||
builder.select = jest.fn(() => builder);
|
||||
builder.where = jest.fn((col: string, _op: string, val: any) => {
|
||||
if (col === 'id') state.ids = val as string[];
|
||||
if (col === 'workspaceId') state.workspaceId = val as string;
|
||||
return builder;
|
||||
});
|
||||
builder.execute = jest.fn(async () =>
|
||||
resolve(state.ids, state.workspaceId).map((id) => ({ id })),
|
||||
);
|
||||
return builder;
|
||||
}
|
||||
|
||||
function buildService(opts: {
|
||||
db: any;
|
||||
pageTemplateReferencesRepo: any;
|
||||
}) {
|
||||
return new TransclusionService(
|
||||
opts.db,
|
||||
{} as any, // pageTransclusionsRepo
|
||||
{} as any, // pageTransclusionReferencesRepo
|
||||
opts.pageTemplateReferencesRepo,
|
||||
{} as any, // pageRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // attachmentRepo
|
||||
{} as any, // storageService
|
||||
{} as any, // pageAccessService
|
||||
{} as any, // workspaceRepo
|
||||
);
|
||||
}
|
||||
|
||||
const pageEmbedDoc = (sourceIds: string[]) => ({
|
||||
type: 'doc',
|
||||
content: sourceIds.map((id) => ({
|
||||
type: 'pageEmbed',
|
||||
attrs: { sourcePageId: id },
|
||||
})),
|
||||
});
|
||||
|
||||
describe('TransclusionService.syncPageTemplateReferences — toDelete branch', () => {
|
||||
it('deletes stale references and inserts new ones with correct args/counts', async () => {
|
||||
// Every candidate id is treated as in-workspace by the existence query.
|
||||
const db = makeWorkspaceScopedDb((ids) => ids);
|
||||
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = {
|
||||
// existing refs: "keep" stays embedded, "stale-a"/"stale-b" no longer are
|
||||
findByReferencePageId: jest.fn().mockResolvedValue([
|
||||
{ sourcePageId: 'keep' },
|
||||
{ sourcePageId: 'stale-a' },
|
||||
{ sourcePageId: 'stale-b' },
|
||||
]),
|
||||
insertMany,
|
||||
deleteByReferenceAndSources,
|
||||
};
|
||||
|
||||
const service = buildService({ db, pageTemplateReferencesRepo });
|
||||
|
||||
// host now embeds: keep (unchanged) + fresh (new). stale-a/stale-b gone.
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
pageEmbedDoc(['keep', 'fresh']),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 1, deleted: 2 });
|
||||
|
||||
// only the genuinely new embed is inserted (keep already existed)
|
||||
expect(insertMany).toHaveBeenCalledTimes(1);
|
||||
expect(insertMany.mock.calls[0][0]).toEqual([
|
||||
{ workspaceId: 'w1', referencePageId: 'host', sourcePageId: 'fresh' },
|
||||
]);
|
||||
|
||||
// stale references removed, scoped to host + workspace
|
||||
expect(deleteByReferenceAndSources).toHaveBeenCalledTimes(1);
|
||||
const [refPageId, workspaceId, staleSources] =
|
||||
deleteByReferenceAndSources.mock.calls[0];
|
||||
expect(refPageId).toBe('host');
|
||||
expect(workspaceId).toBe('w1');
|
||||
expect([...staleSources].sort()).toEqual(['stale-a', 'stale-b']);
|
||||
});
|
||||
|
||||
it('deletes ALL existing references when the host embeds nothing anymore', async () => {
|
||||
const db = makeWorkspaceScopedDb((ids) => ids);
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = {
|
||||
findByReferencePageId: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ sourcePageId: 'a' }, { sourcePageId: 'b' }]),
|
||||
insertMany,
|
||||
deleteByReferenceAndSources,
|
||||
};
|
||||
|
||||
const service = buildService({ db, pageTemplateReferencesRepo });
|
||||
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
pageEmbedDoc([]), // no embeds left
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 2 });
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
const [, , staleSources] = deleteByReferenceAndSources.mock.calls[0];
|
||||
expect([...staleSources].sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('treats a cross-workspace embed as stale: it never survives to be kept', async () => {
|
||||
// existence query drops "cross-ws"; so an existing ref to it must be deleted
|
||||
const db = makeWorkspaceScopedDb((ids) => ids.filter((id) => id !== 'cross-ws'));
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = {
|
||||
findByReferencePageId: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ sourcePageId: 'cross-ws' }]),
|
||||
insertMany,
|
||||
deleteByReferenceAndSources,
|
||||
};
|
||||
|
||||
const service = buildService({ db, pageTemplateReferencesRepo });
|
||||
|
||||
// host still "embeds" cross-ws in its doc, but it is not in-workspace
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
pageEmbedDoc(['cross-ws']),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 1 });
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
const [, , staleSources] = deleteByReferenceAndSources.mock.calls[0];
|
||||
expect([...staleSources]).toEqual(['cross-ws']);
|
||||
});
|
||||
|
||||
it('no-ops both repos when desired and existing already match', async () => {
|
||||
const db = makeWorkspaceScopedDb((ids) => ids);
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const deleteByReferenceAndSources = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = {
|
||||
findByReferencePageId: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ sourcePageId: 'same' }]),
|
||||
insertMany,
|
||||
deleteByReferenceAndSources,
|
||||
};
|
||||
|
||||
const service = buildService({ db, pageTemplateReferencesRepo });
|
||||
|
||||
const result = await service.syncPageTemplateReferences(
|
||||
'host',
|
||||
'w1',
|
||||
pageEmbedDoc(['same']),
|
||||
);
|
||||
|
||||
expect(result).toEqual({ inserted: 0, deleted: 0 });
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
expect(deleteByReferenceAndSources).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransclusionService.insertTemplateReferencesForPages — multi-workspace grouping', () => {
|
||||
it('groups candidates per workspace and validates each workspace independently', async () => {
|
||||
// Each workspace "owns" only its own source ids. The existence query is
|
||||
// workspace-scoped, so an id from another workspace is dropped.
|
||||
const owned: Record<string, string[]> = {
|
||||
w1: ['s1'],
|
||||
w2: ['s2'],
|
||||
};
|
||||
const executeArgs: Array<{ ids: string[]; workspaceId: string }> = [];
|
||||
const db = makeWorkspaceScopedDb((ids, workspaceId) => {
|
||||
executeArgs.push({ ids: [...ids], workspaceId });
|
||||
const ownedSet = new Set(owned[workspaceId] ?? []);
|
||||
return ids.filter((id) => ownedSet.has(id));
|
||||
});
|
||||
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const pageTemplateReferencesRepo = { insertMany };
|
||||
|
||||
const service = buildService({ db, pageTemplateReferencesRepo });
|
||||
|
||||
// page-a in w1 embeds s1 (valid) + s2 (belongs to w2 -> dropped)
|
||||
// page-b in w2 embeds s2 (valid)
|
||||
const result = await service.insertTemplateReferencesForPages([
|
||||
{ id: 'page-a', workspaceId: 'w1', content: pageEmbedDoc(['s1', 's2']) },
|
||||
{ id: 'page-b', workspaceId: 'w2', content: pageEmbedDoc(['s2']) },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ inserted: 2 });
|
||||
|
||||
expect(insertMany).toHaveBeenCalledTimes(1);
|
||||
const rows = insertMany.mock.calls[0][0];
|
||||
expect(rows).toEqual([
|
||||
{ workspaceId: 'w1', referencePageId: 'page-a', sourcePageId: 's1' },
|
||||
{ workspaceId: 'w2', referencePageId: 'page-b', sourcePageId: 's2' },
|
||||
]);
|
||||
|
||||
// one existence query per workspace, each scoped to that workspace's candidates
|
||||
expect(executeArgs).toHaveLength(2);
|
||||
const w1Call = executeArgs.find((c) => c.workspaceId === 'w1');
|
||||
const w2Call = executeArgs.find((c) => c.workspaceId === 'w2');
|
||||
expect(w1Call?.ids.sort()).toEqual(['s1', 's2']);
|
||||
expect(w2Call?.ids).toEqual(['s2']);
|
||||
});
|
||||
|
||||
it('drops every cross-workspace source and inserts nothing when none are in-workspace', async () => {
|
||||
// No id is owned by its page's workspace -> all filtered out.
|
||||
const db = makeWorkspaceScopedDb(() => []);
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const service = buildService({
|
||||
db,
|
||||
pageTemplateReferencesRepo: { insertMany },
|
||||
});
|
||||
|
||||
const result = await service.insertTemplateReferencesForPages([
|
||||
{ id: 'page-a', workspaceId: 'w1', content: pageEmbedDoc(['x']) },
|
||||
{ id: 'page-b', workspaceId: 'w2', content: pageEmbedDoc(['y']) },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ inserted: 0 });
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dedupes a sourceId shared by two pages in the same workspace into one validation', async () => {
|
||||
const executeArgs: Array<{ ids: string[]; workspaceId: string }> = [];
|
||||
const db = makeWorkspaceScopedDb((ids, workspaceId) => {
|
||||
executeArgs.push({ ids: [...ids], workspaceId });
|
||||
return ids; // all in-workspace
|
||||
});
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const service = buildService({
|
||||
db,
|
||||
pageTemplateReferencesRepo: { insertMany },
|
||||
});
|
||||
|
||||
// both pages embed the same source "shared" in w1
|
||||
const result = await service.insertTemplateReferencesForPages([
|
||||
{ id: 'page-a', workspaceId: 'w1', content: pageEmbedDoc(['shared']) },
|
||||
{ id: 'page-b', workspaceId: 'w1', content: pageEmbedDoc(['shared']) },
|
||||
]);
|
||||
|
||||
// a row per (page, source) pair, but only one existence query for w1
|
||||
expect(result).toEqual({ inserted: 2 });
|
||||
expect(executeArgs).toHaveLength(1);
|
||||
expect(executeArgs[0]).toEqual({ ids: ['shared'], workspaceId: 'w1' });
|
||||
|
||||
const rows = insertMany.mock.calls[0][0];
|
||||
expect(rows).toEqual([
|
||||
{ workspaceId: 'w1', referencePageId: 'page-a', sourcePageId: 'shared' },
|
||||
{ workspaceId: 'w1', referencePageId: 'page-b', sourcePageId: 'shared' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns inserted:0 without querying when no page has embeds', async () => {
|
||||
const execute = jest.fn();
|
||||
const db = makeWorkspaceScopedDb(() => {
|
||||
execute();
|
||||
return [];
|
||||
});
|
||||
const insertMany = jest.fn().mockResolvedValue(undefined);
|
||||
const service = buildService({
|
||||
db,
|
||||
pageTemplateReferencesRepo: { insertMany },
|
||||
});
|
||||
|
||||
const result = await service.insertTemplateReferencesForPages([
|
||||
{ id: 'page-a', workspaceId: 'w1', content: pageEmbedDoc([]) },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ inserted: 0 });
|
||||
expect(insertMany).not.toHaveBeenCalled();
|
||||
// filterInWorkspaceSourceIds short-circuits on empty candidates, so the
|
||||
// existence query never runs.
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { PageTemplateController } from '../page-template.controller';
|
||||
import { TransclusionService } from '../transclusion.service';
|
||||
import { TemplateLookupDto } from '../dto/template-lookup.dto';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageAccessService } from '../../page-access/page-access.service';
|
||||
import { JwtAuthGuard } from '../../../../common/guards/jwt-auth.guard';
|
||||
@@ -90,4 +93,52 @@ describe('PageTemplateController.toggleTemplate', () => {
|
||||
);
|
||||
expect(out).toEqual({ pageId: 'p1', isTemplate: false });
|
||||
});
|
||||
|
||||
it('lookup forwards dto.sourcePageIds + user.id + user.workspaceId to the service', async () => {
|
||||
const expected = { items: [] };
|
||||
(transclusionService.lookupTemplate as jest.Mock).mockResolvedValue(
|
||||
expected,
|
||||
);
|
||||
|
||||
const dto = { sourcePageIds: ['s1', 's2'] } as any;
|
||||
const out = await controller.lookup(dto, user);
|
||||
|
||||
expect(transclusionService.lookupTemplate).toHaveBeenCalledWith(
|
||||
['s1', 's2'],
|
||||
'u1', // user.id
|
||||
'w1', // user.workspaceId
|
||||
);
|
||||
expect(out).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TemplateLookupDto validation (class-validator)', () => {
|
||||
const uuid = (n: number) =>
|
||||
`00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
|
||||
|
||||
it('accepts an array of <=50 valid UUIDs', async () => {
|
||||
const dto = plainToInstance(TemplateLookupDto, {
|
||||
sourcePageIds: [uuid(1), uuid(2)],
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects an over-cap array (ArrayMaxSize 50)', async () => {
|
||||
const dto = plainToInstance(TemplateLookupDto, {
|
||||
sourcePageIds: Array.from({ length: 51 }, (_, i) => uuid(i)),
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].constraints).toHaveProperty('arrayMaxSize');
|
||||
});
|
||||
|
||||
it('rejects a non-UUID member (IsUUID each)', async () => {
|
||||
const dto = plainToInstance(TemplateLookupDto, {
|
||||
sourcePageIds: [uuid(1), 'not-a-uuid'],
|
||||
});
|
||||
const errors = await validate(dto);
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].constraints).toHaveProperty('isUuid');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ function buildService(featureEnabled = true) {
|
||||
{} as any, // db (unused on this path)
|
||||
pageTransclusionsRepo as any,
|
||||
pageTransclusionReferencesRepo as any,
|
||||
{} as any, // pageTemplateReferencesRepo (unused on this path)
|
||||
pageRepo as any,
|
||||
{} as any, // pagePermissionRepo (unused)
|
||||
{} as any, // spaceMemberRepo (unused)
|
||||
|
||||
@@ -317,6 +317,7 @@ export class TransclusionService {
|
||||
if (toDelete.length > 0) {
|
||||
await this.pageTemplateReferencesRepo.deleteByReferenceAndSources(
|
||||
referencePageId,
|
||||
workspaceId,
|
||||
toDelete,
|
||||
trx,
|
||||
);
|
||||
|
||||
@@ -99,6 +99,64 @@ export function collectReferencesFromPmJson(
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the sourcePageId a duplicated pageEmbed should point to: the copy's new
|
||||
* id when the embedded source is part of the copied set, otherwise the original
|
||||
* (a live embed of the original page). Pure — shared by PageService.duplicatePage
|
||||
* (the real path) and the JSON walker below, so both stay in lockstep.
|
||||
*/
|
||||
export function remapPageEmbedSourceId(
|
||||
sourcePageId: string | null | undefined,
|
||||
resolveNewId: (id: string) => string | undefined,
|
||||
): string | null | undefined {
|
||||
if (sourcePageId) {
|
||||
const mapped = resolveNewId(sourcePageId);
|
||||
if (mapped) return mapped;
|
||||
}
|
||||
return sourcePageId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remap the `sourcePageId` of every `pageEmbed` node in a ProseMirror JSON doc
|
||||
* according to `idMap` (old page id -> new page id). Delegates the per-node
|
||||
* decision to the shared `remapPageEmbedSourceId` helper that
|
||||
* `PageService.duplicatePage` also uses, so the production path and this walker
|
||||
* stay in lockstep: when the embedded source page is part of the copied set
|
||||
* (present in `idMap`) the embed is pointed at its new copy; otherwise the
|
||||
* original `sourcePageId` is preserved so it stays a live embed of the original
|
||||
* page. Mutates `doc` in place (and returns it) to match the service's in-place
|
||||
* ProseMirror mutation. Recurses through arbitrary block containers (columns,
|
||||
* callouts, etc.) the same way the collectors do, but does NOT descend into a
|
||||
* `transclusionSource` (schema-isolated).
|
||||
*/
|
||||
export function remapPageEmbedSourceIds<T>(
|
||||
doc: T,
|
||||
idMap: Map<string, string>,
|
||||
): T {
|
||||
const visit = (node: any): void => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (node.type === PAGE_EMBED_TYPE) {
|
||||
if (node.attrs) {
|
||||
node.attrs.sourcePageId = remapPageEmbedSourceId(
|
||||
node.attrs.sourcePageId,
|
||||
(id) => idMap.get(id),
|
||||
);
|
||||
}
|
||||
return; // atom node - no children
|
||||
}
|
||||
|
||||
if (node.type === TRANSCLUSION_TYPE) return;
|
||||
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) visit(child);
|
||||
}
|
||||
};
|
||||
|
||||
visit(doc);
|
||||
return doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks a ProseMirror JSON document and returns one snapshot per unique
|
||||
* `sourcePageId` found on `pageEmbed` nodes (whole-page live embeds). Order
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SearchController } from './search.controller';
|
||||
|
||||
// Direct instantiation with stub deps. The Test.createTestingModule form failed
|
||||
// to resolve SearchService's @InjectKysely() connection token at compile() (the
|
||||
// same Nest-DI/Kysely-token issue addressed in search.service.spec), and this
|
||||
// unit only needs the controller to construct.
|
||||
describe('SearchController', () => {
|
||||
let controller: SearchController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [SearchController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<SearchController>(SearchController);
|
||||
beforeEach(() => {
|
||||
controller = new SearchController(
|
||||
{} as any, // searchService
|
||||
{} as any, // spaceAbility
|
||||
{} as any, // environmentService
|
||||
{} as any, // moduleRef
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
|
||||
@@ -1,18 +1,101 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
describe('SearchService', () => {
|
||||
let service: SearchService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [SearchService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SearchService>(SearchService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
// Construct directly with stub deps. The previous Test.createTestingModule
|
||||
// form could not resolve the @InjectKysely() connection token and failed at
|
||||
// compile() — manual construction mirrors the rest of these unit specs.
|
||||
const service = new SearchService(
|
||||
{} as any, // db
|
||||
{} as any, // pageRepo
|
||||
{} as any, // shareRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
);
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Focused coverage for the `onlyTemplates` flag in `searchSuggestions`, which
|
||||
* restricts page suggestions to template pages (`is_template = true`). The kysely
|
||||
* query builder and repos are mocked the same way the access specs mock chainable
|
||||
* builders: every builder method returns the same builder, `.execute()` resolves
|
||||
* the supplied rows. We assert whether `.where('isTemplate', '=', true)` is added.
|
||||
*/
|
||||
describe('SearchService.searchSuggestions — onlyTemplates filter', () => {
|
||||
function makeService(pageRows: Array<{ id: string }>) {
|
||||
// Chainable page-search builder. Record every `.where(...)` call so we can
|
||||
// assert on the is_template restriction.
|
||||
const pageBuilder: any = {};
|
||||
pageBuilder.select = jest.fn(() => pageBuilder);
|
||||
pageBuilder.where = jest.fn(() => pageBuilder);
|
||||
pageBuilder.orderBy = jest.fn(() => pageBuilder);
|
||||
pageBuilder.limit = jest.fn(() => pageBuilder);
|
||||
pageBuilder.execute = jest.fn(async () => pageRows);
|
||||
|
||||
const db: any = {
|
||||
// searchSuggestions only touches `pages` here (includePages: true).
|
||||
selectFrom: jest.fn(() => pageBuilder),
|
||||
};
|
||||
|
||||
const pageRepo = {
|
||||
// `.select((eb) => this.pageRepo.withSpace(eb))` — return value is ignored
|
||||
// by our builder stub, so a sentinel is enough.
|
||||
withSpace: jest.fn(() => ({ __withSpace: true })),
|
||||
};
|
||||
const shareRepo = {};
|
||||
const spaceMemberRepo = {
|
||||
getUserSpaceIds: jest.fn().mockResolvedValue(['space-1']),
|
||||
};
|
||||
const pagePermissionRepo = {
|
||||
// Let every found page through page-level permission filtering.
|
||||
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, pageBuilder };
|
||||
}
|
||||
|
||||
const isTemplateWhereCall = (pageBuilder: any) =>
|
||||
pageBuilder.where.mock.calls.find((c: any[]) => c[0] === 'isTemplate');
|
||||
|
||||
it('restricts page suggestions to is_template = true when onlyTemplates is set', async () => {
|
||||
const { service, pageBuilder } = makeService([{ id: 'tmpl-1' }]);
|
||||
|
||||
const result = await service.searchSuggestions(
|
||||
{ query: 'plan', includePages: true, onlyTemplates: true } as any,
|
||||
'user-1',
|
||||
'ws-1',
|
||||
);
|
||||
|
||||
// The is_template restriction must be applied to the page query.
|
||||
const call = isTemplateWhereCall(pageBuilder);
|
||||
expect(call).toEqual(['isTemplate', '=', true]);
|
||||
|
||||
// Sanity: the (template) page made it through.
|
||||
expect(result.pages.map((p: any) => p.id)).toEqual(['tmpl-1']);
|
||||
});
|
||||
|
||||
it('does NOT restrict to templates when onlyTemplates is absent', async () => {
|
||||
const { service, pageBuilder } = makeService([{ id: 'any-1' }]);
|
||||
|
||||
await service.searchSuggestions(
|
||||
{ query: 'plan', includePages: true } as any,
|
||||
'user-1',
|
||||
'ws-1',
|
||||
);
|
||||
|
||||
// No is_template clause should be added for a normal page suggestion search.
|
||||
expect(isTemplateWhereCall(pageBuilder)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,3 +131,131 @@ describe('ShareService htmlEmbed server-authoritative kill-switch (real code)',
|
||||
expect(hasHtmlEmbedNode(out)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// Exercises the REAL ShareService.lookupTransclusionForShare post-processing for
|
||||
// the share-served transclusion path: the same server-authoritative htmlEmbed
|
||||
// kill-switch must apply to each transcluded item's content, and a not_found
|
||||
// item must never be run through prepareContentForShare (so its absent content
|
||||
// can't be serialized/leaked). The access graph (shareRepo / isSharingAllowed /
|
||||
// getShareForPage / restricted-ancestor) is stubbed so the strip/serve mapping
|
||||
// runs deterministically; lookupWithAccessSet is mocked to control the items.
|
||||
describe('ShareService.lookupTransclusionForShare htmlEmbed kill-switch (real code)', () => {
|
||||
const SHARE = 'share-1';
|
||||
const SPACE = 'space-1';
|
||||
const SRC = 'src-page';
|
||||
|
||||
function buildTransclusionService(opts: {
|
||||
htmlEmbed?: boolean | undefined;
|
||||
items: any[];
|
||||
}) {
|
||||
const shareRepo = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: SHARE,
|
||||
workspaceId: WS,
|
||||
spaceId: SPACE,
|
||||
})),
|
||||
};
|
||||
const pageRepo = { findById: jest.fn() };
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor: jest.fn(async () => false),
|
||||
};
|
||||
const tokenService = {
|
||||
generateAttachmentToken: jest.fn(async () => 'tok'),
|
||||
};
|
||||
const lookupWithAccessSet = jest.fn(async () => ({ items: opts.items }));
|
||||
const transclusionService = { lookupWithAccessSet };
|
||||
const workspaceRepo = {
|
||||
findById: jest.fn(async () => ({
|
||||
id: WS,
|
||||
settings: { htmlEmbed: opts.htmlEmbed },
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new ShareService(
|
||||
shareRepo as any,
|
||||
pageRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
{} as any, // db (unused — isSharingAllowed stubbed below)
|
||||
tokenService as any,
|
||||
transclusionService as any,
|
||||
workspaceRepo as any,
|
||||
);
|
||||
|
||||
// isSharingAllowed and getShareForPage hit the raw db; stub them so the
|
||||
// access chain resolves SRC as reachable and prepareContentForShare runs.
|
||||
jest.spyOn(service, 'isSharingAllowed').mockResolvedValue(true);
|
||||
jest
|
||||
.spyOn(service, 'getShareForPage')
|
||||
.mockResolvedValue({ pageId: SRC, spaceId: SPACE, id: 's2' } as any);
|
||||
|
||||
return { service, transclusionService, lookupWithAccessSet };
|
||||
}
|
||||
|
||||
const transcludedItemWithEmbed = () => ({
|
||||
sourcePageId: SRC,
|
||||
transclusionId: 't1',
|
||||
content: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'block body' }] },
|
||||
{ type: 'htmlEmbed', attrs: { source: '<script>t()</script>' } },
|
||||
],
|
||||
},
|
||||
sourceUpdatedAt: new Date('2026-06-20T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const refs = [{ sourcePageId: SRC, transclusionId: 't1' }];
|
||||
|
||||
it('toggle OFF: strips htmlEmbed from each transcluded item content', async () => {
|
||||
const { service } = buildTransclusionService({
|
||||
htmlEmbed: false,
|
||||
items: [transcludedItemWithEmbed()],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupTransclusionForShare(SHARE, refs, WS);
|
||||
expect(items).toHaveLength(1);
|
||||
const item = items[0] as any;
|
||||
expect(item.status).toBeUndefined();
|
||||
expect(hasHtmlEmbedNode(item.content)).toBe(false);
|
||||
// Non-embed body of the transcluded block is preserved.
|
||||
expect(JSON.stringify(item.content)).toContain('block body');
|
||||
});
|
||||
|
||||
it('toggle ON: serves htmlEmbed in the transcluded item content', async () => {
|
||||
const { service } = buildTransclusionService({
|
||||
htmlEmbed: true,
|
||||
items: [transcludedItemWithEmbed()],
|
||||
});
|
||||
|
||||
const { items } = await service.lookupTransclusionForShare(SHARE, refs, WS);
|
||||
const item = items[0] as any;
|
||||
expect(item.status).toBeUndefined();
|
||||
expect(hasHtmlEmbedNode(item.content)).toBe(true);
|
||||
expect(JSON.stringify(item.content)).toContain('block body');
|
||||
});
|
||||
|
||||
it('a not_found item is NOT run through prepareContentForShare (no token minting)', async () => {
|
||||
const notFoundItem = {
|
||||
sourcePageId: SRC,
|
||||
transclusionId: 't1',
|
||||
status: 'not_found' as const,
|
||||
};
|
||||
const { service } = buildTransclusionService({
|
||||
htmlEmbed: true,
|
||||
items: [notFoundItem],
|
||||
});
|
||||
// tokenService is reachable via the service; spy on it to assert it is never
|
||||
// touched for a status item (prepareContentForShare mints tokens).
|
||||
const tokenSpy = jest.spyOn(
|
||||
(service as any).tokenService,
|
||||
'generateAttachmentToken',
|
||||
);
|
||||
|
||||
const { items } = await service.lookupTransclusionForShare(SHARE, refs, WS);
|
||||
// not_found is collapsed to no_access for share viewers and carries NO content.
|
||||
const item = items[0] as any;
|
||||
expect(item.status).toBe('no_access');
|
||||
expect(item.content).toBeUndefined();
|
||||
expect(tokenSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { WorkspaceService } from './workspace.service';
|
||||
|
||||
/**
|
||||
* Exercises the REAL WorkspaceService.update htmlEmbed-toggle persistence at the
|
||||
* service seam: an update carrying `htmlEmbed` must call
|
||||
* `workspaceRepo.updateSetting(workspaceId, 'htmlEmbed', value, trx)`, and an
|
||||
* update WITHOUT it must not touch that setting. The repo, db transaction, and
|
||||
* audit service are mocked; `executeTx` runs the callback against a fake trx.
|
||||
*
|
||||
* DEFERRED (DB-only): the "does not clobber sibling settings" guarantee is a
|
||||
* jsonb merge property of `updateSetting`'s SQL and needs a real Postgres to
|
||||
* assert. This spec only asserts the service-level CALL SHAPE.
|
||||
*/
|
||||
describe('WorkspaceService.update — htmlEmbed toggle persistence (real code)', () => {
|
||||
function buildService(opts: { settingsBefore?: Record<string, any> }) {
|
||||
const updateSetting = jest.fn().mockResolvedValue(undefined);
|
||||
const updateWorkspace = jest.fn().mockResolvedValue(undefined);
|
||||
const workspaceRepo = {
|
||||
// First call: read settingsBefore. Second call: return the updated
|
||||
// workspace (must include a licenseKey because update() destructures it).
|
||||
findById: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'w1', settings: opts.settingsBefore ?? {} })
|
||||
.mockResolvedValueOnce({ id: 'w1', name: 'WS', licenseKey: null }),
|
||||
updateSetting,
|
||||
updateWorkspace,
|
||||
};
|
||||
|
||||
// Fake kysely db: only .transaction().execute(cb) is used on this path.
|
||||
const db = {
|
||||
transaction: jest.fn(() => ({
|
||||
execute: jest.fn(async (cb: any) => cb({ __trx: true })),
|
||||
})),
|
||||
};
|
||||
|
||||
const auditService = { log: jest.fn() };
|
||||
|
||||
const service = new WorkspaceService(
|
||||
workspaceRepo as any, // workspaceRepo
|
||||
{} as any, // spaceService
|
||||
{} as any, // spaceMemberService
|
||||
{} as any, // groupRepo
|
||||
{} as any, // groupUserRepo
|
||||
{} as any, // userRepo
|
||||
{} as any, // environmentService
|
||||
{} as any, // domainService
|
||||
{} as any, // licenseCheckService
|
||||
{} as any, // shareRepo
|
||||
{} as any, // watcherRepo
|
||||
{} as any, // favoriteRepo
|
||||
db as any, // db (InjectKysely)
|
||||
{} as any, // attachmentQueue
|
||||
{} as any, // billingQueue
|
||||
{} as any, // aiQueue
|
||||
auditService as any, // auditService
|
||||
{} as any, // userSessionRepo
|
||||
);
|
||||
|
||||
return { service, workspaceRepo, updateSetting, auditService };
|
||||
}
|
||||
|
||||
it('persists htmlEmbed:true via updateSetting with the htmlEmbed key', async () => {
|
||||
const { service, updateSetting } = buildService({});
|
||||
|
||||
await service.update('w1', { htmlEmbed: true } as any);
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledTimes(1);
|
||||
expect(updateSetting).toHaveBeenCalledWith(
|
||||
'w1',
|
||||
'htmlEmbed',
|
||||
true,
|
||||
expect.anything(), // the transaction handle
|
||||
);
|
||||
});
|
||||
|
||||
it('persists htmlEmbed:false (explicit disable is not dropped)', async () => {
|
||||
const { service, updateSetting } = buildService({
|
||||
settingsBefore: { htmlEmbed: true },
|
||||
});
|
||||
|
||||
await service.update('w1', { htmlEmbed: false } as any);
|
||||
|
||||
expect(updateSetting).toHaveBeenCalledWith(
|
||||
'w1',
|
||||
'htmlEmbed',
|
||||
false,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('does NOT call updateSetting when htmlEmbed is undefined in the dto', async () => {
|
||||
const { service, updateSetting } = buildService({});
|
||||
|
||||
await service.update('w1', { name: 'New name' } as any);
|
||||
|
||||
expect(updateSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('audits the htmlEmbed change (before/after) when the value actually changes', async () => {
|
||||
const { service, auditService } = buildService({
|
||||
settingsBefore: { htmlEmbed: false },
|
||||
});
|
||||
|
||||
await service.update('w1', { htmlEmbed: true } as any);
|
||||
|
||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||
const logged = auditService.log.mock.calls[0][0];
|
||||
expect(logged.changes.before.htmlEmbed).toBe(false);
|
||||
expect(logged.changes.after.htmlEmbed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ export class AiAgentRoleRepo {
|
||||
* generated column type is the broad `JsonValue` union, which a concrete object
|
||||
* type is not structurally assignable to.
|
||||
*/
|
||||
function jsonbObject(value: ModelConfigValue | undefined) {
|
||||
export function jsonbObject(value: ModelConfigValue | undefined) {
|
||||
if (value === null || value === undefined || Object.keys(value).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -22,21 +22,6 @@ export class PageTemplateReferencesRepo {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findReferencePageIdsBySource(
|
||||
sourcePageId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string[]> {
|
||||
const rows = await dbOrTx(this.db, trx)
|
||||
.selectFrom('pageTemplateReferences')
|
||||
.select('referencePageId')
|
||||
.distinct()
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('sourcePageId', '=', sourcePageId)
|
||||
.execute();
|
||||
return rows.map((r) => r.referencePageId);
|
||||
}
|
||||
|
||||
async insertMany(
|
||||
rows: InsertablePageTemplateReference[],
|
||||
trx?: KyselyTransaction,
|
||||
@@ -53,12 +38,15 @@ export class PageTemplateReferencesRepo {
|
||||
|
||||
async deleteByReferenceAndSources(
|
||||
referencePageId: string,
|
||||
workspaceId: string,
|
||||
sourcePageIds: string[],
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
if (sourcePageIds.length === 0) return;
|
||||
await dbOrTx(this.db, trx)
|
||||
.deleteFrom('pageTemplateReferences')
|
||||
// Defense-in-depth: scope deletes to the caller's workspace.
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('referencePageId', '=', referencePageId)
|
||||
.where('sourcePageId', 'in', sourcePageIds)
|
||||
.execute();
|
||||
|
||||
@@ -58,4 +58,26 @@ describe('describeProviderError', () => {
|
||||
// 'e | response body: ' + 300 chars + '…'
|
||||
expect(out.length).toBeLessThan('e | response body: '.length + 305);
|
||||
});
|
||||
|
||||
it('uses the fallback for a numeric or boolean (non-object, non-string) error', () => {
|
||||
// typeof number / boolean is neither 'object' nor a non-empty 'string', so
|
||||
// the early branch returns the fallback verbatim.
|
||||
expect(describeProviderError(500, 'AI stream error')).toBe('AI stream error');
|
||||
expect(describeProviderError(0, 'AI stream error')).toBe('AI stream error');
|
||||
expect(describeProviderError(true)).toBe('Unknown error');
|
||||
expect(describeProviderError(false, 'fb')).toBe('fb');
|
||||
});
|
||||
|
||||
it('statusCode present but message undefined => "<code>:" with no trailing space', () => {
|
||||
// `${code}: ${undefined ?? ''}`.trim() collapses to just "<code>:".
|
||||
expect(describeProviderError({ statusCode: 503 })).toBe('503:');
|
||||
// The trailing space after the colon is trimmed away.
|
||||
expect(describeProviderError({ statusCode: 503 }).endsWith(': ')).toBe(false);
|
||||
});
|
||||
|
||||
it('object with neither message nor statusCode nor body => fallback', () => {
|
||||
expect(describeProviderError({}, 'AI stream error')).toBe('AI stream error');
|
||||
// An object carrying only unrelated keys is still treated as message-less.
|
||||
expect(describeProviderError({ foo: 'bar' } as never)).toBe('Unknown error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -171,4 +171,117 @@ describe('AiService.getChatModel role model override', () => {
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a service whose workspace driver is ollama (no apiKey, with a baseUrl).
|
||||
* Complements makeService (which configures openai) for the same-driver and
|
||||
* not-configured ollama cases.
|
||||
*/
|
||||
function makeOllamaService(over: { baseUrl?: string } = {}) {
|
||||
const aiSettings = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
driver: 'ollama',
|
||||
chatModel: 'llama3',
|
||||
apiKey: undefined,
|
||||
baseUrl: over.baseUrl ?? 'http://localhost:11434/v1',
|
||||
}),
|
||||
};
|
||||
const aiProviderCredentialsRepo = { find: jest.fn() };
|
||||
const secretBox = { decryptSecret: jest.fn() };
|
||||
const service = new AiService(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiSettings as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiProviderCredentialsRepo as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
secretBox as any,
|
||||
);
|
||||
return { service, aiSettings, aiProviderCredentialsRepo, secretBox };
|
||||
}
|
||||
|
||||
it('same-driver ollama override (workspace driver=ollama): reuses the workspace ollama baseUrl, no creds lookup/decrypt', async () => {
|
||||
// Workspace driver IS ollama. A role that overrides to ollama (same driver)
|
||||
// legitimately reuses the workspace's configured ollama endpoint — it must
|
||||
// NOT hit the cross-driver 503 path, NOT query ai_provider_credentials, and
|
||||
// NOT decrypt anything (ollama needs no key).
|
||||
const { service, aiProviderCredentialsRepo, secretBox } = makeOllamaService();
|
||||
|
||||
const model = await service.getChatModel('ws-1', {
|
||||
driver: 'ollama',
|
||||
chatModel: 'llama3.1',
|
||||
roleName: 'Local',
|
||||
});
|
||||
|
||||
expect(model).toBeDefined();
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('chatModel-only override on an ollama workspace: reuses the workspace ollama baseUrl, no creds lookup', async () => {
|
||||
// No override.driver on an ollama workspace => the workspace ollama driver +
|
||||
// baseUrl are reused; no creds lookup, no decrypt (the cheap public-share
|
||||
// model-only override path against an ollama workspace).
|
||||
const { service, aiProviderCredentialsRepo, secretBox } = makeOllamaService();
|
||||
|
||||
const model = await service.getChatModel('ws-1', { chatModel: 'mistral' });
|
||||
|
||||
expect(model).toBeDefined();
|
||||
expect(aiProviderCredentialsRepo.find).not.toHaveBeenCalled();
|
||||
expect(secretBox.decryptSecret).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blank chatModel guard: workspace has a driver but a blank chatModel and no override chatModel => AiNotConfiguredException', async () => {
|
||||
// cfg.driver passes the first guard, but cfg.chatModel is blank and the
|
||||
// override carries no chatModel, so the effective chatModel is empty.
|
||||
const aiSettings = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
driver: 'openai',
|
||||
chatModel: '',
|
||||
apiKey: 'workspace-key',
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
};
|
||||
const aiProviderCredentialsRepo = { find: jest.fn() };
|
||||
const secretBox = { decryptSecret: jest.fn() };
|
||||
const service = new AiService(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiSettings as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiProviderCredentialsRepo as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
secretBox as any,
|
||||
);
|
||||
|
||||
await expect(
|
||||
// Override has only a roleName, no chatModel to fill the blank.
|
||||
service.getChatModel('ws-1', { roleName: 'Writer' }),
|
||||
).rejects.toBeInstanceOf(AiNotConfiguredException);
|
||||
});
|
||||
|
||||
it('non-ollama driver with a missing apiKey => AiNotConfiguredException', async () => {
|
||||
// Workspace is openai (non-ollama) with a model but NO apiKey: the combined
|
||||
// `driver !== ollama && !apiKey` guard must 503.
|
||||
const aiSettings = {
|
||||
resolve: jest.fn().mockResolvedValue({
|
||||
driver: 'openai',
|
||||
chatModel: 'gpt-4o-mini',
|
||||
apiKey: undefined,
|
||||
baseUrl: undefined,
|
||||
}),
|
||||
};
|
||||
const aiProviderCredentialsRepo = { find: jest.fn() };
|
||||
const secretBox = { decryptSecret: jest.fn() };
|
||||
const service = new AiService(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiSettings as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
aiProviderCredentialsRepo as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
secretBox as any,
|
||||
);
|
||||
|
||||
await expect(service.getChatModel('ws-1')).rejects.toBeInstanceOf(
|
||||
AiNotConfiguredException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// the Authorization header.
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import { JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
import { CREDENTIALS_MISMATCH_MESSAGE } from '../../core/auth/auth.constants';
|
||||
|
||||
@@ -291,6 +292,14 @@ export interface BearerVerifyDeps {
|
||||
workspaceId?: string;
|
||||
sessionId?: string;
|
||||
}>;
|
||||
// The workspace id of THIS MCP instance, when the caller can resolve it (the
|
||||
// community build is single-workspace, so McpService passes its default
|
||||
// workspace's id). When provided, the token's `workspaceId` claim MUST equal
|
||||
// it, mirroring JwtStrategy's `req.raw.workspaceId !== payload.workspaceId`
|
||||
// guard so a valid ACCESS token from a DIFFERENT workspace cannot be replayed
|
||||
// against this instance in a multi-workspace deployment. Optional so callers /
|
||||
// tests that genuinely cannot resolve an instance workspace are unchanged.
|
||||
expectedWorkspaceId?: string;
|
||||
// Load the user (or undefined) for the disabled check.
|
||||
findUser: (
|
||||
sub: string,
|
||||
@@ -321,6 +330,19 @@ export async function verifyBearerAccess(
|
||||
throw new UnauthorizedException(generic);
|
||||
}
|
||||
|
||||
// Bind the token to THIS instance's workspace (mirrors JwtStrategy). When the
|
||||
// caller resolved an instance workspace id, a token whose `workspaceId` claim
|
||||
// points at another workspace is rejected, so a valid ACCESS token minted in
|
||||
// workspace B cannot be replayed against an MCP instance serving workspace A.
|
||||
// In the single-workspace community build expectedWorkspaceId equals the only
|
||||
// workspace, so this is a no-op there; it only bites a multi-workspace deploy.
|
||||
if (
|
||||
deps.expectedWorkspaceId &&
|
||||
payload.workspaceId !== deps.expectedWorkspaceId
|
||||
) {
|
||||
throw new UnauthorizedException(generic);
|
||||
}
|
||||
|
||||
const user = await deps.findUser(payload.sub, payload.workspaceId);
|
||||
if (!user || user.deactivatedAt || user.deletedAt) {
|
||||
throw new UnauthorizedException(generic);
|
||||
@@ -342,21 +364,129 @@ export async function verifyBearerAccess(
|
||||
|
||||
/**
|
||||
* Detect a genuine JSON-RPC `initialize` request from an already-parsed body.
|
||||
* Mirrors the @modelcontextprotocol/sdk `isInitializeRequest` signal that
|
||||
* packages/mcp/src/http.ts uses to decide whether to mint a session, but
|
||||
* framework/SDK-free so it is unit-testable and usable from the CommonJS
|
||||
* McpService. An initialize request is a single JSON-RPC object whose `method`
|
||||
* is exactly 'initialize'; a batch (array) body is never an initialize request.
|
||||
* Delegates to the @modelcontextprotocol/sdk `isInitializeRequest` predicate —
|
||||
* the SAME predicate packages/mcp/src/http.ts uses to decide whether to mint a
|
||||
* session — so the session-minting side (this server) and the session-creating
|
||||
* side (http.ts) agree EXACTLY on what counts as an initialize request. The SDK
|
||||
* predicate validates the full InitializeRequest shape (jsonrpc, id, method ===
|
||||
* 'initialize', params incl. protocolVersion); a bare `{ method: 'initialize' }`
|
||||
* with no params, a batch (array) body, etc. are NOT initialize requests.
|
||||
*
|
||||
* This is the second half of the session-INIT decision: `isSessionInit` is
|
||||
* (no `mcp-session-id` header) AND `isInitializeRequestBody(body)`. Using it
|
||||
* ensures the side-effecting login() (user_sessions insert + USER_LOGIN audit +
|
||||
* lastLoginAt) only runs for a real initialize, never for an arbitrary
|
||||
* header-less request that http.ts will subsequently 400.
|
||||
* (no `mcp-session-id` header) AND `isInitializeRequestBody(body)`. Matching the
|
||||
* SDK predicate exactly ensures the side-effecting login() (user_sessions insert
|
||||
* + USER_LOGIN audit + lastLoginAt) only runs for a request http.ts will also
|
||||
* accept as an initialize — never for an arbitrary header-less request that
|
||||
* http.ts would subsequently 400 (which would otherwise spam the audit log /
|
||||
* grow user_sessions without ever creating an MCP session).
|
||||
*/
|
||||
export function isInitializeRequestBody(body: unknown): boolean {
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
|
||||
return (body as { method?: unknown }).method === 'initialize';
|
||||
return isInitializeRequest(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of McpService.handle's pre-hijack gauntlet, as a pure value the
|
||||
* caller acts on. Either send a JSON error with a fixed status (`respond`), or
|
||||
* proceed to hijack the response and delegate to the MCP transport (`hijack`).
|
||||
* Keeping this a pure decision (no FastifyReply, no res.hijack) makes the
|
||||
* status/body mapping unit-testable, and guarantees no error path can leak the
|
||||
* password or Authorization header — the body is only ever a fixed string or the
|
||||
* UnauthorizedException's own message.
|
||||
*/
|
||||
export type McpHandleDecision =
|
||||
| { kind: 'respond'; status: number; body: { error: string } }
|
||||
| { kind: 'hijack' };
|
||||
|
||||
/**
|
||||
* Pure mapping of McpService.handle's auth/enablement gauntlet to a response
|
||||
* decision. Precedence mirrors handle():
|
||||
* 1. shared X-MCP-Token mismatch -> 401 {error:'Unauthorized'} (no hijack).
|
||||
* 2. workspace MCP disabled -> 403 {error:'MCP is disabled ...'}.
|
||||
* 3. resolveSessionConfig threw:
|
||||
* - an UnauthorizedException -> 401 with err.message (a SPECIFIC reason;
|
||||
* never the password/header — the message is the only thing surfaced).
|
||||
* - any other error -> 500 generic 'Internal server error'.
|
||||
* 4. otherwise (auth resolved) -> hijack and delegate to the transport.
|
||||
*/
|
||||
export function mapAuthResultToResponse(input: {
|
||||
sharedTokenOk: boolean;
|
||||
enabled: boolean;
|
||||
error?: unknown;
|
||||
}): McpHandleDecision {
|
||||
if (!input.sharedTokenOk) {
|
||||
return { kind: 'respond', status: 401, body: { error: 'Unauthorized' } };
|
||||
}
|
||||
|
||||
if (!input.enabled) {
|
||||
return {
|
||||
kind: 'respond',
|
||||
status: 403,
|
||||
body: { error: 'MCP is disabled for this workspace' },
|
||||
};
|
||||
}
|
||||
|
||||
if (input.error !== undefined) {
|
||||
if (input.error instanceof UnauthorizedException) {
|
||||
return {
|
||||
kind: 'respond',
|
||||
status: 401,
|
||||
body: { error: input.error.message },
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'respond',
|
||||
status: 500,
|
||||
body: { error: 'Internal server error' },
|
||||
};
|
||||
}
|
||||
|
||||
return { kind: 'hijack' };
|
||||
}
|
||||
|
||||
// Result of the EE MFA module's requirement check for the Basic gate. Both
|
||||
// flags absent/false means MFA does not block the password login.
|
||||
export interface BasicGateMfaResult {
|
||||
userHasMfa?: boolean;
|
||||
requiresMfaSetup?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision logic for the /mcp HTTP-Basic pre-token gate, replicating EXACTLY
|
||||
* what AuthController.login enforces before issuing a token, so the Basic path is
|
||||
* not an SSO/MFA bypass. Framework-free (no ModuleRef, no on-disk EE MFA module)
|
||||
* so the SSO/MFA decision is unit-testable in isolation:
|
||||
*
|
||||
* - `ssoEnforced` true -> throw Unauthorized ("enforced SSO"); a password
|
||||
* login is not allowed on an SSO-enforced workspace.
|
||||
* - otherwise, `mfa` is the EE MFA module's requirement result (or undefined
|
||||
* when no EE MFA module is bundled — a community/fork build). If MFA is
|
||||
* present and the user has MFA enabled OR needs MFA setup, throw Unauthorized
|
||||
* telling the caller to use a Bearer access token (Basic cannot complete MFA).
|
||||
* - no SSO + no MFA gate -> resolve (the Basic login is allowed to proceed).
|
||||
*
|
||||
* McpService.enforceBasicLoginGate wires the concrete `validateSsoEnforcement`
|
||||
* result and the lazily-loaded MFA module result into this, so the gate decision
|
||||
* itself carries no framework dependencies. Throws UnauthorizedException on
|
||||
* rejection (surfaced as a clean 401); never logs the password.
|
||||
*/
|
||||
export function decideBasicGate(input: {
|
||||
ssoEnforced: boolean;
|
||||
mfa?: BasicGateMfaResult;
|
||||
}): void {
|
||||
if (input.ssoEnforced) {
|
||||
throw new UnauthorizedException(
|
||||
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
|
||||
);
|
||||
}
|
||||
|
||||
const mfa = input.mfa;
|
||||
if (mfa && (mfa.userHasMfa || mfa.requiresMfaSetup)) {
|
||||
throw new UnauthorizedException(
|
||||
'This account requires multi-factor authentication. MCP HTTP Basic ' +
|
||||
'cannot complete MFA — log in normally and use a Bearer access token ' +
|
||||
'instead.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract a Bearer token from an Authorization header (case-insensitive). */
|
||||
|
||||
259
apps/server/src/integrations/mcp/mcp-basic-login-gate.spec.ts
Normal file
259
apps/server/src/integrations/mcp/mcp-basic-login-gate.spec.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// These tests exercise the REAL McpService.enforceBasicLoginGate (the pre-token
|
||||
// SSO/MFA gate on the /mcp HTTP-Basic path). Unlike the resolveMcpSessionConfig
|
||||
// tests in mcp.service.spec.ts — which STUB the gate and only assert it runs
|
||||
// before login()/verifyCredentials — here the gate logic is instantiated for
|
||||
// real and only its LEAF dependencies are mocked:
|
||||
// - the workspace object (plain object with/without enforceSso),
|
||||
// - the user credentials (plain object),
|
||||
// - the lazily-required EE MFA module (jest.mock with { virtual: true } so we
|
||||
// can simulate BOTH "bundled" and "not bundled" community-build states),
|
||||
// - the injected MfaService instance (via a stub moduleRef).
|
||||
//
|
||||
// McpService cannot normally be imported under jest because it imports
|
||||
// AuthService, which drags in the React email-template graph
|
||||
// (@docmost/transactional/emails/*) that the jest moduleNameMapper does not
|
||||
// resolve. We therefore mock the heavy collaborator modules (auth.service,
|
||||
// token.service, the @docmost/db repos and mcp-auth.helpers) at the module
|
||||
// level so importing mcp.service.ts succeeds. None of those are touched by the
|
||||
// gate itself, so the gate runs unmodified against the real code path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The EE MFA module specifier the jest.mock below intercepts MUST be
|
||||
// byte-for-byte the specifier that mcp.service.ts lazily require()s
|
||||
// ('./../../ee/mfa/services/mfa.service'). jest.mock is hoisted above all
|
||||
// non-hoisted code, so the path is inlined as a literal in the call below
|
||||
// rather than referenced through a const (which would not yet be initialised).
|
||||
// `{ virtual: true }` is required because the EE module does not exist in this
|
||||
// OSS build (there is no src/ee directory) — without it jest cannot register a
|
||||
// mock for a path it cannot resolve on disk.
|
||||
|
||||
// Mutable handle the virtual mock factory reads, so each test can decide whether
|
||||
// the EE module is "bundled" (factory returns a MfaService class) or "not
|
||||
// bundled" (factory throws, mimicking the require() failing on a community
|
||||
// build). jest.mock is hoisted, so the factory must close over this lazily.
|
||||
let mfaModuleState: { bundled: boolean; checkMfaRequirements?: jest.Mock } = {
|
||||
bundled: false,
|
||||
};
|
||||
|
||||
jest.mock(
|
||||
'./../../ee/mfa/services/mfa.service',
|
||||
() => {
|
||||
if (!mfaModuleState.bundled) {
|
||||
// Simulate a community/fork build with no EE MFA module: the real
|
||||
// require() throws, which the gate catches as the "no MFA gate" path.
|
||||
throw new Error('Cannot find module (EE MFA not bundled)');
|
||||
}
|
||||
// "Bundled" build: expose a MfaService class token. The actual instance the
|
||||
// gate calls is resolved through moduleRef.get(MfaModule.MfaService), which
|
||||
// our stub moduleRef returns regardless of the token identity.
|
||||
class MfaService {}
|
||||
return { MfaService };
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
// --- Mock the heavy collaborator modules so importing mcp.service succeeds. ---
|
||||
// The gate never calls into these; they exist only to satisfy the import graph.
|
||||
jest.mock('../../core/auth/services/auth.service', () => ({
|
||||
AuthService: class AuthService {},
|
||||
}));
|
||||
jest.mock('../../core/auth/services/token.service', () => ({
|
||||
TokenService: class TokenService {},
|
||||
}));
|
||||
jest.mock('@docmost/db/repos/workspace/workspace.repo', () => ({
|
||||
WorkspaceRepo: class WorkspaceRepo {},
|
||||
}));
|
||||
jest.mock('@docmost/db/repos/user/user.repo', () => ({
|
||||
UserRepo: class UserRepo {},
|
||||
}));
|
||||
jest.mock('@docmost/db/repos/session/user-session.repo', () => ({
|
||||
UserSessionRepo: class UserSessionRepo {},
|
||||
}));
|
||||
// mcp-auth.helpers exports runtime values the gate relies on (decideBasicGate,
|
||||
// mapAuthResultToResponse, etc.). Keep the REAL helpers so the gate exercises
|
||||
// real logic; only stub FailedLoginLimiter so its constructor runs without a
|
||||
// real sweep timer. The module is framework-free and loads cleanly under jest
|
||||
// (mcp.service.spec.ts already imports it directly), so requireActual is safe.
|
||||
jest.mock('./mcp-auth.helpers', () => {
|
||||
const actual = jest.requireActual('./mcp-auth.helpers');
|
||||
return {
|
||||
...actual,
|
||||
FailedLoginLimiter: class FailedLoginLimiter {
|
||||
sweep() {}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Import AFTER the mocks are registered.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import { McpService } from './mcp.service';
|
||||
|
||||
type GateCreds = { email: string; password: string };
|
||||
|
||||
// Build an McpService instance with stubbed constructor deps. We never call the
|
||||
// auth/db collaborators from the gate, so undefined stand-ins are fine for all
|
||||
// but moduleRef, which the MFA branch reads.
|
||||
function makeService(opts: {
|
||||
checkMfaRequirements?: jest.Mock;
|
||||
}): { service: McpService; gate: (ws: unknown, creds: GateCreds) => Promise<void> } {
|
||||
// Stub moduleRef.get -> returns an object whose checkMfaRequirements is the
|
||||
// provided mock. The gate calls moduleRef.get(MfaModule.MfaService).
|
||||
const moduleRef = {
|
||||
get: jest.fn().mockReturnValue({
|
||||
checkMfaRequirements:
|
||||
opts.checkMfaRequirements ?? jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
};
|
||||
|
||||
const service = new McpService(
|
||||
undefined as never, // environmentService
|
||||
undefined as never, // workspaceRepo
|
||||
undefined as never, // authService
|
||||
undefined as never, // tokenService
|
||||
undefined as never, // userRepo
|
||||
undefined as never, // userSessionRepo
|
||||
moduleRef as never, // moduleRef (read by the MFA branch)
|
||||
);
|
||||
// Stop the constructor's unref'd sweep timer leaking across tests.
|
||||
service.onModuleDestroy();
|
||||
|
||||
// enforceBasicLoginGate is private; reach it through the instance. Calling the
|
||||
// REAL method (not a stub) is the whole point of this suite.
|
||||
const gate = (
|
||||
service as unknown as {
|
||||
enforceBasicLoginGate: (ws: unknown, creds: GateCreds) => Promise<void>;
|
||||
}
|
||||
).enforceBasicLoginGate.bind(service);
|
||||
|
||||
return { service, gate };
|
||||
}
|
||||
|
||||
const CREDS: GateCreds = { email: 'user@example.com', password: 'pw' };
|
||||
|
||||
describe('McpService.enforceBasicLoginGate (REAL gate, leaf deps mocked)', () => {
|
||||
beforeEach(() => {
|
||||
// Reset to the community-build default (no EE module) before each test.
|
||||
mfaModuleState = { bundled: false };
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('SSO enforcement (validateSsoEnforcement)', () => {
|
||||
it('rejects with Unauthorized when the workspace enforces SSO, before any MFA/login', async () => {
|
||||
const { gate } = makeService({});
|
||||
const workspace = { id: 'ws-1', enforceSso: true };
|
||||
|
||||
await expect(gate(workspace, CREDS)).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
// The /mcp 401 surfaces an SSO-specific message (not a generic MCP error).
|
||||
await expect(gate(workspace, CREDS)).rejects.toThrow(/enforced SSO/i);
|
||||
});
|
||||
|
||||
it('does NOT consult the MFA module when SSO is enforced (gate short-circuits)', async () => {
|
||||
// Even if the EE module WERE bundled, the SSO branch throws first, so the
|
||||
// moduleRef MFA lookup must never run.
|
||||
mfaModuleState = {
|
||||
bundled: true,
|
||||
checkMfaRequirements: jest.fn(),
|
||||
};
|
||||
const { service, gate } = makeService({
|
||||
checkMfaRequirements: mfaModuleState.checkMfaRequirements,
|
||||
});
|
||||
const moduleRefGet = (
|
||||
service as unknown as { moduleRef: { get: jest.Mock } }
|
||||
).moduleRef.get;
|
||||
|
||||
await expect(
|
||||
gate({ id: 'ws-1', enforceSso: true }, CREDS),
|
||||
).rejects.toThrow(/enforced SSO/i);
|
||||
// The SSO branch fired before the MFA require/lookup.
|
||||
expect(moduleRefGet).not.toHaveBeenCalled();
|
||||
expect(mfaModuleState.checkMfaRequirements).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('community build: EE MFA module NOT bundled', () => {
|
||||
it('passes (no throw) when SSO is not enforced and the lazy require fails (no MFA gate)', async () => {
|
||||
// mfaModuleState.bundled === false -> the virtual mock factory throws,
|
||||
// exactly like require() of a missing EE module on a community build.
|
||||
const { service, gate } = makeService({});
|
||||
const moduleRefGet = (
|
||||
service as unknown as { moduleRef: { get: jest.Mock } }
|
||||
).moduleRef.get;
|
||||
|
||||
await expect(
|
||||
gate({ id: 'ws-1', enforceSso: false }, CREDS),
|
||||
).resolves.toBeUndefined();
|
||||
// The require() failed, so the gate returned before touching moduleRef.
|
||||
expect(moduleRefGet).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EE MFA module bundled', () => {
|
||||
it('rejects with a "use a Bearer token" signal when the user has MFA enabled', async () => {
|
||||
const check = jest.fn().mockResolvedValue({
|
||||
userHasMfa: true,
|
||||
requiresMfaSetup: false,
|
||||
});
|
||||
mfaModuleState = { bundled: true, checkMfaRequirements: check };
|
||||
const { gate } = makeService({ checkMfaRequirements: check });
|
||||
|
||||
const promise = gate({ id: 'ws-1', enforceSso: false }, CREDS);
|
||||
await expect(promise).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
await expect(
|
||||
gate({ id: 'ws-1', enforceSso: false }, CREDS),
|
||||
).rejects.toThrow(/Bearer access token/i);
|
||||
// The real requirement check was consulted with the creds + workspace.
|
||||
expect(check).toHaveBeenCalledWith(
|
||||
CREDS,
|
||||
{ id: 'ws-1', enforceSso: false },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects when the workspace enforces MFA (requiresMfaSetup)', async () => {
|
||||
// requiresMfaSetup === true models a workspace that enforces MFA for a
|
||||
// user who has not set it up yet; the Basic path cannot complete it.
|
||||
const check = jest.fn().mockResolvedValue({
|
||||
userHasMfa: false,
|
||||
requiresMfaSetup: true,
|
||||
});
|
||||
mfaModuleState = { bundled: true, checkMfaRequirements: check };
|
||||
const { gate } = makeService({ checkMfaRequirements: check });
|
||||
|
||||
await expect(
|
||||
gate({ id: 'ws-1', enforceSso: false }, CREDS),
|
||||
).rejects.toThrow(/Bearer access token/i);
|
||||
});
|
||||
|
||||
it('passes when the user has no MFA and the workspace does not enforce it', async () => {
|
||||
const check = jest.fn().mockResolvedValue({
|
||||
userHasMfa: false,
|
||||
requiresMfaSetup: false,
|
||||
});
|
||||
mfaModuleState = { bundled: true, checkMfaRequirements: check };
|
||||
const { gate } = makeService({ checkMfaRequirements: check });
|
||||
|
||||
await expect(
|
||||
gate({ id: 'ws-1', enforceSso: false }, CREDS),
|
||||
).resolves.toBeUndefined();
|
||||
// The bundled module's requirement check WAS consulted (proving we took
|
||||
// the bundled branch, not the community no-op branch).
|
||||
expect(check).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('passes when checkMfaRequirements returns a falsy result (no requirement flags)', async () => {
|
||||
// Defensive: a bundled module that returns undefined must not reject.
|
||||
const check = jest.fn().mockResolvedValue(undefined);
|
||||
mfaModuleState = { bundled: true, checkMfaRequirements: check };
|
||||
const { gate } = makeService({ checkMfaRequirements: check });
|
||||
|
||||
await expect(
|
||||
gate({ id: 'ws-1', enforceSso: false }, CREDS),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
sharedTokenMatches,
|
||||
clientIp,
|
||||
bindAccessJwtVerifier,
|
||||
extractBearer,
|
||||
decideBasicGate,
|
||||
mapAuthResultToResponse,
|
||||
McpAuthDeps,
|
||||
} from './mcp-auth.helpers';
|
||||
import { JwtType } from '../../core/auth/dto/jwt-payload';
|
||||
@@ -79,6 +82,26 @@ describe('parseBasicAuth', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBearer', () => {
|
||||
it('extracts the token from a "Bearer <token>" header', () => {
|
||||
expect(extractBearer('Bearer abc.def.ghi')).toBe('abc.def.ghi');
|
||||
});
|
||||
|
||||
it('is case-insensitive on the scheme (lowercase + uppercase)', () => {
|
||||
// The split keeps the token as-is; only the scheme is compared lowercased.
|
||||
expect(extractBearer('bearer abc')).toBe('abc');
|
||||
expect(extractBearer('BEARER abc')).toBe('abc');
|
||||
});
|
||||
|
||||
it('returns undefined for a non-Bearer scheme (e.g. Basic)', () => {
|
||||
expect(extractBearer('Basic abc')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for an undefined header', () => {
|
||||
expect(extractBearer(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isCredentialsFailure', () => {
|
||||
it('is true for the credentials-mismatch UnauthorizedException', () => {
|
||||
expect(
|
||||
@@ -185,6 +208,43 @@ describe('FailedLoginLimiter', () => {
|
||||
expect(lim.isBlocked(k, 0)).toBe(true);
|
||||
expect(lim.isBlocked(k, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
describe('sweep (expired-bucket eviction, injectable clock)', () => {
|
||||
// sweep() drops buckets whose windowStart is older than windowMs so
|
||||
// never-revisited keys cannot accumulate forever. It takes an injectable
|
||||
// `now` so the behaviour is deterministic without faking timers.
|
||||
it('drops a bucket strictly older than windowMs', () => {
|
||||
const lim = new FailedLoginLimiter(5, 1000);
|
||||
// Seed a bucket at t=0 (windowStart=0).
|
||||
lim.recordFailure('stale', 0);
|
||||
// Sweep well past the window: now - windowStart = 5000 >= 1000 -> dropped.
|
||||
lim.sweep(5000);
|
||||
// A dropped bucket means a brand-new bucket is created on next touch, so
|
||||
// the prior failure count is gone (a single fresh failure is far from 5).
|
||||
lim.recordFailure('stale', 5001);
|
||||
expect(lim.isBlocked('stale', 5001)).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a bucket exactly at the windowMs boundary (>= is inclusive)', () => {
|
||||
const lim = new FailedLoginLimiter(1, 1000);
|
||||
lim.recordFailure('boundary', 0); // windowStart=0, blocked at threshold 1
|
||||
expect(lim.isBlocked('boundary', 0)).toBe(true);
|
||||
// now - windowStart = 1000 == windowMs -> the >= check evicts it.
|
||||
lim.sweep(1000);
|
||||
// Re-touch at the same instant: a fresh bucket (count 0) is created, so the
|
||||
// key is no longer blocked, proving the boundary bucket was swept.
|
||||
expect(lim.isBlocked('boundary', 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it('retains a fresh bucket still within the window', () => {
|
||||
const lim = new FailedLoginLimiter(1, 1000);
|
||||
lim.recordFailure('fresh', 0); // windowStart=0
|
||||
// now - windowStart = 999 < 1000 -> the bucket survives the sweep.
|
||||
lim.sweep(999);
|
||||
// Still blocked because the bucket (and its count) was retained.
|
||||
expect(lim.isBlocked('fresh', 999)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyBearerAccess (Bearer revocation/disabled checks)', () => {
|
||||
@@ -264,6 +324,31 @@ describe('verifyBearerAccess (Bearer revocation/disabled checks)', () => {
|
||||
),
|
||||
).rejects.toThrow('jwt expired');
|
||||
});
|
||||
|
||||
// Item 3: bind the Bearer token to THIS instance's workspace (mirrors
|
||||
// JwtStrategy). A token whose workspaceId claim differs from the instance
|
||||
// workspace must be rejected; matching/absent expectedWorkspaceId is allowed.
|
||||
it('rejects a token from a DIFFERENT workspace when expectedWorkspaceId is set', async () => {
|
||||
await expect(
|
||||
verifyBearerAccess('t', {
|
||||
...bearerDeps(),
|
||||
expectedWorkspaceId: 'ws-OTHER',
|
||||
}),
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('accepts a token whose workspace matches expectedWorkspaceId', async () => {
|
||||
const res = await verifyBearerAccess('t', {
|
||||
...bearerDeps(),
|
||||
expectedWorkspaceId: 'ws-1',
|
||||
});
|
||||
expect(res).toEqual({ sub: 'user-1', email: 'u@e.com' });
|
||||
});
|
||||
|
||||
it('does NOT enforce a workspace when expectedWorkspaceId is undefined (single-workspace no-op)', async () => {
|
||||
const res = await verifyBearerAccess('t', bearerDeps());
|
||||
expect(res).toEqual({ sub: 'user-1', email: 'u@e.com' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMcpSessionConfig', () => {
|
||||
@@ -587,23 +672,48 @@ describe('resolveMcpSessionConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isInitializeRequestBody (session-INIT detection)', () => {
|
||||
it('true only for a single JSON-RPC object with method === "initialize"', () => {
|
||||
expect(isInitializeRequestBody({ jsonrpc: '2.0', method: 'initialize' })).toBe(
|
||||
true,
|
||||
);
|
||||
// A full, valid JSON-RPC InitializeRequest as the @modelcontextprotocol/sdk
|
||||
// `isInitializeRequest` predicate (which isInitializeRequestBody now delegates
|
||||
// to) requires: jsonrpc + id + method === 'initialize' + params.protocolVersion.
|
||||
const fullInitializeRequest = {
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2024-11-05',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'test-client', version: '1.0.0' },
|
||||
},
|
||||
};
|
||||
|
||||
describe('isInitializeRequestBody (session-INIT detection, matches SDK predicate)', () => {
|
||||
it('true for a FULL valid InitializeRequest (the SDK predicate signal)', () => {
|
||||
expect(isInitializeRequestBody(fullInitializeRequest)).toBe(true);
|
||||
});
|
||||
|
||||
it('false for a bare { method: "initialize" } with no id/params (item 1)', () => {
|
||||
// Item 1: this previously returned true (method-only check) and let an
|
||||
// authenticated client POST a params-less body with no mcp-session-id, which
|
||||
// ran the side-effecting login() before http.ts 400'd it. The SDK predicate
|
||||
// rejects it (no id, no params.protocolVersion), so it no longer mints a
|
||||
// session / audit row.
|
||||
expect(isInitializeRequestBody({ method: 'initialize' })).toBe(false);
|
||||
expect(
|
||||
isInitializeRequestBody({ jsonrpc: '2.0', method: 'initialize' }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isInitializeRequestBody({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('false for a non-initialize method (e.g. tools/call)', () => {
|
||||
expect(
|
||||
isInitializeRequestBody({ jsonrpc: '2.0', method: 'tools/call' }),
|
||||
isInitializeRequestBody({ ...fullInitializeRequest, method: 'tools/call' }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('false for a batch (array) body, null/undefined, or a non-object', () => {
|
||||
expect(
|
||||
isInitializeRequestBody([{ jsonrpc: '2.0', method: 'initialize' }]),
|
||||
).toBe(false);
|
||||
expect(isInitializeRequestBody([fullInitializeRequest])).toBe(false);
|
||||
expect(isInitializeRequestBody(undefined)).toBe(false);
|
||||
expect(isInitializeRequestBody(null)).toBe(false);
|
||||
expect(isInitializeRequestBody('initialize')).toBe(false);
|
||||
@@ -618,8 +728,14 @@ describe('isSessionInit decision (no mcp-session-id AND initialize body)', () =>
|
||||
const decide = (sessionId: string | undefined, body: unknown): boolean =>
|
||||
!sessionId && isInitializeRequestBody(body);
|
||||
|
||||
it('no header + initialize body -> init', () => {
|
||||
expect(decide(undefined, { method: 'initialize' })).toBe(true);
|
||||
it('no header + full initialize body -> init', () => {
|
||||
expect(decide(undefined, fullInitializeRequest)).toBe(true);
|
||||
});
|
||||
|
||||
it('no header + bare params-less initialize body -> NOT init (item 1)', () => {
|
||||
// A header-less { method: 'initialize' } with no params is no longer treated
|
||||
// as an init by the SDK predicate, so it does not mint a session via login().
|
||||
expect(decide(undefined, { method: 'initialize' })).toBe(false);
|
||||
});
|
||||
|
||||
it('no header + non-initialize body -> NOT init (verifyCredentials path)', () => {
|
||||
@@ -627,7 +743,7 @@ describe('isSessionInit decision (no mcp-session-id AND initialize body)', () =>
|
||||
});
|
||||
|
||||
it('has session-id -> never init regardless of body', () => {
|
||||
expect(decide('sess-1', { method: 'initialize' })).toBe(false);
|
||||
expect(decide('sess-1', fullInitializeRequest)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -769,3 +885,138 @@ describe('bindAccessJwtVerifier enforces JwtType.ACCESS (item 3)', () => {
|
||||
expect(res).toEqual({ sub: 'user-1', email: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideBasicGate (pure SSO/MFA pre-token gate, refactor R1)', () => {
|
||||
// The pure decision extracted out of McpService.enforceBasicLoginGate. It is
|
||||
// tested WITHOUT ModuleRef and WITHOUT an on-disk EE MFA module: the SSO verdict
|
||||
// and the MFA requirement result are passed in as plain values.
|
||||
|
||||
it('SSO enforced -> throws Unauthorized ("enforced SSO")', () => {
|
||||
expect(() => decideBasicGate({ ssoEnforced: true })).toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(() => decideBasicGate({ ssoEnforced: true })).toThrow(/enforced SSO/);
|
||||
// SSO takes precedence even if MFA flags are also set.
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: true, mfa: { userHasMfa: true } }),
|
||||
).toThrow(/enforced SSO/);
|
||||
});
|
||||
|
||||
it('no SSO + no MFA module (mfa undefined) -> resolves (Basic allowed)', () => {
|
||||
// A community/fork build with no EE MFA module passes mfa: undefined and the
|
||||
// gate must allow the password login (same as the controller with no MFA).
|
||||
expect(() => decideBasicGate({ ssoEnforced: false })).not.toThrow();
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: false, mfa: undefined }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('MFA present + userHasMfa -> rejects ("use a Bearer access token")', () => {
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: false, mfa: { userHasMfa: true } }),
|
||||
).toThrow(/use a Bearer access token/);
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: false, mfa: { userHasMfa: true } }),
|
||||
).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('MFA present + requiresMfaSetup -> rejects', () => {
|
||||
expect(() =>
|
||||
decideBasicGate({ ssoEnforced: false, mfa: { requiresMfaSetup: true } }),
|
||||
).toThrow(/use a Bearer access token/);
|
||||
});
|
||||
|
||||
it('MFA present but none required (both flags false) -> resolves', () => {
|
||||
expect(() =>
|
||||
decideBasicGate({
|
||||
ssoEnforced: false,
|
||||
mfa: { userHasMfa: false, requiresMfaSetup: false },
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapAuthResultToResponse (handle status/body mapping, refactor R2)', () => {
|
||||
// The pure response decision extracted out of McpService.handle. It maps the
|
||||
// pre-hijack gauntlet (shared token, enablement, auth error) to either a fixed
|
||||
// JSON error response or the hijack path — never leaking the password/header.
|
||||
|
||||
it('wrong X-MCP-Token -> 401 {error:"Unauthorized"} and NOT the hijack path', () => {
|
||||
const d = mapAuthResultToResponse({ sharedTokenOk: false, enabled: true });
|
||||
expect(d).toEqual({
|
||||
kind: 'respond',
|
||||
status: 401,
|
||||
body: { error: 'Unauthorized' },
|
||||
});
|
||||
});
|
||||
|
||||
it('workspace MCP disabled -> 403', () => {
|
||||
const d = mapAuthResultToResponse({ sharedTokenOk: true, enabled: false });
|
||||
expect(d.kind).toBe('respond');
|
||||
if (d.kind === 'respond') {
|
||||
expect(d.status).toBe(403);
|
||||
expect(d.body).toEqual({ error: 'MCP is disabled for this workspace' });
|
||||
}
|
||||
});
|
||||
|
||||
it('an UnauthorizedException -> 401 with err.message; no password/header leaked', () => {
|
||||
// Construct an UnauthorizedException whose message is the SPECIFIC auth reason.
|
||||
const err = new UnauthorizedException('Email or password does not match');
|
||||
const d = mapAuthResultToResponse({
|
||||
sharedTokenOk: true,
|
||||
enabled: true,
|
||||
error: err,
|
||||
});
|
||||
expect(d).toEqual({
|
||||
kind: 'respond',
|
||||
status: 401,
|
||||
body: { error: 'Email or password does not match' },
|
||||
});
|
||||
// The surfaced body is ONLY the exception message — never the raw secret.
|
||||
if (d.kind === 'respond') {
|
||||
const serialized = JSON.stringify(d.body);
|
||||
expect(serialized).not.toContain('password=');
|
||||
expect(serialized).not.toContain('Authorization');
|
||||
expect(serialized).not.toContain('Basic ');
|
||||
expect(serialized).not.toContain('Bearer ');
|
||||
}
|
||||
});
|
||||
|
||||
it('a non-Unauthorized error -> 500 generic (no error detail surfaced)', () => {
|
||||
const err = new Error('db blew up: connection string secret');
|
||||
const d = mapAuthResultToResponse({
|
||||
sharedTokenOk: true,
|
||||
enabled: true,
|
||||
error: err,
|
||||
});
|
||||
expect(d).toEqual({
|
||||
kind: 'respond',
|
||||
status: 500,
|
||||
body: { error: 'Internal server error' },
|
||||
});
|
||||
// The generic body must NOT echo the underlying error message.
|
||||
if (d.kind === 'respond') {
|
||||
expect(d.body.error).not.toContain('secret');
|
||||
}
|
||||
});
|
||||
|
||||
it('happy path (auth resolved, no error) -> hijack', () => {
|
||||
const d = mapAuthResultToResponse({ sharedTokenOk: true, enabled: true });
|
||||
expect(d).toEqual({ kind: 'hijack' });
|
||||
});
|
||||
|
||||
it('shared-token failure takes precedence over disabled/error', () => {
|
||||
// Even with a disabled workspace and an error, a bad shared token is the
|
||||
// first gate, so the response is the uniform 401 Unauthorized.
|
||||
const d = mapAuthResultToResponse({
|
||||
sharedTokenOk: false,
|
||||
enabled: false,
|
||||
error: new UnauthorizedException('should not surface'),
|
||||
});
|
||||
expect(d).toEqual({
|
||||
kind: 'respond',
|
||||
status: 401,
|
||||
body: { error: 'Unauthorized' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
sharedTokenMatches,
|
||||
clientIp,
|
||||
bindAccessJwtVerifier,
|
||||
decideBasicGate,
|
||||
mapAuthResultToResponse,
|
||||
DocmostMcpConfig,
|
||||
ResolvedMcpAuth,
|
||||
} from './mcp-auth.helpers';
|
||||
@@ -154,6 +156,15 @@ export class McpService implements OnModuleDestroy {
|
||||
private async verifyMcpBearer(
|
||||
token: string,
|
||||
): Promise<{ sub?: string; email?: string }> {
|
||||
// Resolve THIS instance's workspace so verifyBearerAccess can bind the
|
||||
// token's `workspaceId` claim to it (mirrors JwtStrategy). The community
|
||||
// build is single-workspace (findFirst), so this is the default workspace
|
||||
// and the check is a no-op here; it only rejects a foreign-workspace token
|
||||
// in a multi-workspace deployment. Undefined (no workspace configured) means
|
||||
// no check — the credentials path would already have failed with no
|
||||
// workspace, and an undefined here keeps the helper a no-op rather than
|
||||
// rejecting every token.
|
||||
const instanceWorkspace = await this.workspaceRepo.findFirst();
|
||||
// The revocation/disabled decision logic lives in the framework-free
|
||||
// verifyBearerAccess helper (unit-testable without the heavy auth graph);
|
||||
// this method only wires in the concrete TokenService + repos.
|
||||
@@ -163,6 +174,7 @@ export class McpService implements OnModuleDestroy {
|
||||
verifyJwt: bindAccessJwtVerifier(this.tokenService) as (
|
||||
t: string,
|
||||
) => Promise<JwtPayload>,
|
||||
expectedWorkspaceId: instanceWorkspace?.id,
|
||||
findUser: (sub, workspaceId) =>
|
||||
this.userRepo.findById(sub, workspaceId),
|
||||
findActiveSession: (sessionId) =>
|
||||
@@ -231,49 +243,54 @@ export class McpService implements OnModuleDestroy {
|
||||
workspace: Workspace,
|
||||
creds: { email: string; password: string },
|
||||
): Promise<void> {
|
||||
// 1) SSO enforcement. validateSsoEnforcement throws BadRequestException; we
|
||||
// re-surface it as Unauthorized so the /mcp 401 path is consistent and a
|
||||
// token is never issued.
|
||||
// 1) SSO enforcement. validateSsoEnforcement throws when the workspace
|
||||
// enforces SSO; we only need the boolean verdict for the pure decision.
|
||||
let ssoEnforced = false;
|
||||
try {
|
||||
validateSsoEnforcement(workspace);
|
||||
} catch {
|
||||
throw new UnauthorizedException(
|
||||
'This workspace has enforced SSO login. Use SSO; MCP HTTP Basic is not allowed.',
|
||||
);
|
||||
ssoEnforced = true;
|
||||
}
|
||||
|
||||
// 2) MFA gate — lazy-require the EE module exactly like AuthController.login.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let MfaModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
MfaModule = require('./../../ee/mfa/services/mfa.service');
|
||||
} catch {
|
||||
// No EE MFA module bundled in this build: same as the controller -> no
|
||||
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
|
||||
return;
|
||||
// On a fork WITHOUT the EE module bundled, mfaResult stays undefined and the
|
||||
// pure gate behaves exactly like the controller (no MFA module -> no MFA
|
||||
// gate). We only LOAD the module + read the requirement flags here; the
|
||||
// accept/reject decision lives in the framework-free decideBasicGate so the
|
||||
// SSO/MFA logic is unit-testable without ModuleRef or the on-disk EE module.
|
||||
let mfaResult: { userHasMfa?: boolean; requiresMfaSetup?: boolean } | undefined;
|
||||
// Only consult the MFA module when SSO has not already disqualified the
|
||||
// request (SSO short-circuits, and skipping the load avoids a needless
|
||||
// require on the SSO-reject path).
|
||||
if (!ssoEnforced) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let MfaModule: any;
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
MfaModule = require('./../../ee/mfa/services/mfa.service');
|
||||
} catch {
|
||||
// No EE MFA module bundled in this build: same as the controller -> no
|
||||
// MFA gate. (A community/fork build has no MFA, so Basic is allowed.)
|
||||
MfaModule = undefined;
|
||||
}
|
||||
|
||||
if (MfaModule) {
|
||||
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
|
||||
strict: false,
|
||||
});
|
||||
// Same requirement check the controller uses. We pass NO FastifyReply
|
||||
// (the controller passes `res` only to set a cookie on the no-MFA happy
|
||||
// path, which we never take here): we only read the requirement flags.
|
||||
mfaResult = await mfaService.checkMfaRequirements(
|
||||
creds,
|
||||
workspace,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mfaService = this.moduleRef.get(MfaModule.MfaService, {
|
||||
strict: false,
|
||||
});
|
||||
// Use the same requirement check the controller uses. We pass NO FastifyReply
|
||||
// (the controller passes `res` only to set a cookie on the no-MFA happy path,
|
||||
// which we never take here): we only read the requirement flags. Be tolerant
|
||||
// of either a (loginInput, workspace) or (loginInput, workspace, res) shape.
|
||||
const mfaResult = await mfaService.checkMfaRequirements(
|
||||
creds,
|
||||
workspace,
|
||||
undefined,
|
||||
);
|
||||
|
||||
if (mfaResult && (mfaResult.userHasMfa || mfaResult.requiresMfaSetup)) {
|
||||
throw new UnauthorizedException(
|
||||
'This account requires multi-factor authentication. MCP HTTP Basic ' +
|
||||
'cannot complete MFA — log in normally and use a Bearer access token ' +
|
||||
'instead.',
|
||||
);
|
||||
}
|
||||
// Pure accept/reject decision (throws UnauthorizedException on rejection).
|
||||
decideBasicGate({ ssoEnforced, mfa: mfaResult });
|
||||
}
|
||||
|
||||
// Lazily create the HTTP handler exactly once. The import is indirected so
|
||||
@@ -333,52 +350,61 @@ export class McpService implements OnModuleDestroy {
|
||||
// matching `X-MCP-Token` header. It now lives in its OWN header so it never
|
||||
// collides with `Authorization`, which carries the per-user credentials.
|
||||
const sharedToken = process.env.MCP_TOKEN;
|
||||
if (sharedToken) {
|
||||
const provided = req.headers['x-mcp-token'];
|
||||
if (!sharedTokenMatches(sharedToken, provided)) {
|
||||
res.status(401).send({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const sharedTokenOk = sharedToken
|
||||
? sharedTokenMatches(sharedToken, req.headers['x-mcp-token'])
|
||||
: true;
|
||||
|
||||
if (!(await this.isEnabled())) {
|
||||
res.status(403).send({ error: 'MCP is disabled for this workspace' });
|
||||
return;
|
||||
}
|
||||
// Short-circuit checks (shared token, enablement) that do not need the auth
|
||||
// resolution. Compute them up front so the response mapping is a single pure
|
||||
// decision (mapAuthResultToResponse) that cannot leak the password/header.
|
||||
const enabled = sharedTokenOk ? await this.isEnabled() : false;
|
||||
|
||||
// Resolve + validate the per-session identity BEFORE hijacking the response
|
||||
// so bad credentials surface as a clean 401 JSON (never a torn response and
|
||||
// never a generic "MCP error"). The resolved config/identity is stashed on
|
||||
// the raw request for the package's resolver + identify hook to read back.
|
||||
let resolved: ResolvedMcpAuth;
|
||||
try {
|
||||
resolved = await this.resolveSessionConfig(req);
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) {
|
||||
// Warn once if the only thing missing is the service account, to keep
|
||||
// the original operator hint.
|
||||
if (
|
||||
!this.credsConfigured() &&
|
||||
!req.headers['authorization'] &&
|
||||
!this.warnedMissingCreds
|
||||
) {
|
||||
this.warnedMissingCreds = true;
|
||||
this.logger.warn(
|
||||
'MCP is enabled but received a request with no credentials and no ' +
|
||||
'MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account configured.',
|
||||
);
|
||||
let resolved: ResolvedMcpAuth | undefined;
|
||||
let authError: unknown;
|
||||
if (sharedTokenOk && enabled) {
|
||||
try {
|
||||
resolved = await this.resolveSessionConfig(req);
|
||||
} catch (err) {
|
||||
authError = err;
|
||||
if (err instanceof UnauthorizedException) {
|
||||
// Warn once if the only thing missing is the service account, to keep
|
||||
// the original operator hint.
|
||||
if (
|
||||
!this.credsConfigured() &&
|
||||
!req.headers['authorization'] &&
|
||||
!this.warnedMissingCreds
|
||||
) {
|
||||
this.warnedMissingCreds = true;
|
||||
this.logger.warn(
|
||||
'MCP is enabled but received a request with no credentials and no ' +
|
||||
'MCP_DOCMOST_EMAIL/MCP_DOCMOST_PASSWORD service account configured.',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.error('MCP auth resolution failed', err as Error);
|
||||
}
|
||||
res.status(401).send({ error: err.message });
|
||||
return;
|
||||
}
|
||||
this.logger.error('MCP auth resolution failed', err as Error);
|
||||
res.status(500).send({ error: 'Internal server error' });
|
||||
}
|
||||
|
||||
// Pure status/body mapping for the whole pre-hijack gauntlet.
|
||||
const decision = mapAuthResultToResponse({
|
||||
sharedTokenOk,
|
||||
enabled,
|
||||
error: authError,
|
||||
});
|
||||
if (decision.kind === 'respond') {
|
||||
res.status(decision.status).send(decision.body);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stash the resolved auth on the raw request so the package's resolver +
|
||||
// identify hook (wired in getHandler) read it back instead of re-parsing.
|
||||
(req.raw as unknown as Record<symbol, unknown>)[MCP_RESOLVED] = resolved;
|
||||
(req.raw as unknown as Record<symbol, unknown>)[MCP_RESOLVED] =
|
||||
resolved as ResolvedMcpAuth;
|
||||
|
||||
// Hand the raw Node req/res to the MCP transport. hijack() tells Fastify
|
||||
// to stop managing this response so the transport can write to it directly.
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PageWsListener } from './page-ws.listener';
|
||||
import { WsTreeService } from '../ws-tree.service';
|
||||
import {
|
||||
PageEvent,
|
||||
PageMovedEvent,
|
||||
TreeNodeSnapshot,
|
||||
} from '../../database/listeners/page.listener';
|
||||
|
||||
@@ -93,3 +94,139 @@ describe('PageWsListener.onPageCreated', () => {
|
||||
expect(wsTree.broadcastRefetchRoot).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PageWsListener delete/move/restore handlers', () => {
|
||||
let listener: PageWsListener;
|
||||
let wsTree: {
|
||||
broadcastPageCreated: jest.Mock;
|
||||
broadcastPageDeleted: jest.Mock;
|
||||
broadcastPageMoved: jest.Mock;
|
||||
broadcastRefetchRoot: jest.Mock;
|
||||
};
|
||||
let warnSpy: jest.SpyInstance;
|
||||
|
||||
const secondSnapshot: TreeNodeSnapshot = {
|
||||
id: 'page-2',
|
||||
slugId: 'slug-2',
|
||||
title: 'World',
|
||||
icon: '📁',
|
||||
position: 'a2',
|
||||
spaceId: 'space-1',
|
||||
parentPageId: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
wsTree = {
|
||||
broadcastPageCreated: jest.fn().mockResolvedValue(undefined),
|
||||
broadcastPageDeleted: jest.fn().mockResolvedValue(undefined),
|
||||
broadcastPageMoved: jest.fn().mockResolvedValue(undefined),
|
||||
broadcastRefetchRoot: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PageWsListener,
|
||||
{ provide: WsTreeService, useValue: wsTree },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
listener = module.get<PageWsListener>(PageWsListener);
|
||||
// The PAGE_RESTORED-without-spaceId branch logs a warning; silence + assert.
|
||||
warnSpy = jest
|
||||
.spyOn(listener['logger'], 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
// --- onPageDeleted (PAGE_SOFT_DELETED / PAGE_DELETED) ---
|
||||
|
||||
it('onPageDeleted with N `pages`: one broadcastPageDeleted per page', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1', 'page-2'],
|
||||
workspaceId: 'ws-1',
|
||||
pages: [snapshot, secondSnapshot],
|
||||
};
|
||||
|
||||
await listener.onPageDeleted(event);
|
||||
|
||||
expect(wsTree.broadcastPageDeleted).toHaveBeenCalledTimes(2);
|
||||
expect(wsTree.broadcastPageDeleted).toHaveBeenNthCalledWith(1, snapshot);
|
||||
expect(wsTree.broadcastPageDeleted).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
secondSnapshot,
|
||||
);
|
||||
});
|
||||
|
||||
it('onPageDeleted with an EMPTY `pages` array: no broadcast', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
pages: [],
|
||||
};
|
||||
|
||||
await listener.onPageDeleted(event);
|
||||
|
||||
expect(wsTree.broadcastPageDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onPageDeleted with UNDEFINED `pages`: no broadcast (no crash)', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await listener.onPageDeleted(event);
|
||||
|
||||
expect(wsTree.broadcastPageDeleted).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// --- onPageMoved (PAGE_MOVED) ---
|
||||
|
||||
it('onPageMoved: forwards the whole event to a single broadcastPageMoved', async () => {
|
||||
const event: PageMovedEvent = {
|
||||
workspaceId: 'ws-1',
|
||||
oldParentId: 'old-parent',
|
||||
hasChildren: false,
|
||||
node: { ...snapshot, parentPageId: 'new-parent', position: 'a5' },
|
||||
};
|
||||
|
||||
await listener.onPageMoved(event);
|
||||
|
||||
expect(wsTree.broadcastPageMoved).toHaveBeenCalledTimes(1);
|
||||
expect(wsTree.broadcastPageMoved).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
// --- onPageRestored (PAGE_RESTORED) ---
|
||||
|
||||
it('onPageRestored WITHOUT spaceId: warns and does NOT refetch', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
};
|
||||
|
||||
await listener.onPageRestored(event);
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('PAGE_RESTORED'),
|
||||
);
|
||||
expect(wsTree.broadcastRefetchRoot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onPageRestored WITH spaceId: one broadcastRefetchRoot scoped to the space', async () => {
|
||||
const event: PageEvent = {
|
||||
pageIds: ['page-1'],
|
||||
workspaceId: 'ws-1',
|
||||
spaceId: 'space-9',
|
||||
};
|
||||
|
||||
await listener.onPageRestored(event);
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
expect(wsTree.broadcastRefetchRoot).toHaveBeenCalledTimes(1);
|
||||
expect(wsTree.broadcastRefetchRoot).toHaveBeenCalledWith('space-9');
|
||||
});
|
||||
});
|
||||
|
||||
259
apps/server/src/ws/ws-service.spec.ts
Normal file
259
apps/server/src/ws/ws-service.spec.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { WsService } from './ws.service';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import {
|
||||
getSpaceRoomName,
|
||||
WS_SPACE_RESTRICTION_CACHE_PREFIX,
|
||||
WS_CACHE_TTL_MS,
|
||||
} from './ws.utils';
|
||||
|
||||
/**
|
||||
* WsService server-side unit tests (M7 item 2):
|
||||
* - spaceHasRestrictions cache lifecycle (miss -> read+set with TTL; hit ->
|
||||
* no re-read; documents the stale-false window).
|
||||
* - broadcastToAuthorizedUsers fan-out (authorized-only delivery, multi-socket
|
||||
* fan-out per user, sockets with no userId skipped).
|
||||
*
|
||||
* Both private methods are exercised through their public entry points:
|
||||
* spaceHasRestrictions via emitTreeEvent, broadcastToAuthorizedUsers via
|
||||
* emitToAuthorizedUsers. WsService is constructed with mocked cache + repo and a
|
||||
* mocked socket.io server, so no live infra is needed.
|
||||
*/
|
||||
|
||||
describe('WsService.spaceHasRestrictions (cache lifecycle, via emitTreeEvent)', () => {
|
||||
let service: WsService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
|
||||
let roomEmit: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
pagePermissionRepo = {
|
||||
hasRestrictedPagesInSpace: jest.fn(),
|
||||
hasRestrictedAncestor: jest.fn(),
|
||||
getUserIdsWithPageAccess: jest.fn(),
|
||||
};
|
||||
cache = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WsService,
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
{ provide: CACHE_MANAGER, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WsService>(WsService);
|
||||
|
||||
roomEmit = jest.fn();
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: roomEmit }),
|
||||
in: jest.fn().mockReturnValue({ fetchSockets: jest.fn() }),
|
||||
};
|
||||
service.setServer(server as never);
|
||||
});
|
||||
|
||||
const cacheKey = (spaceId: string): string =>
|
||||
`${WS_SPACE_RESTRICTION_CACHE_PREFIX}${spaceId}`;
|
||||
|
||||
it('first call MISSES the cache -> reads the repo and sets it with WS_CACHE_TTL_MS', async () => {
|
||||
cache.get.mockResolvedValue(null); // miss
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
||||
pagePermissionRepo.hasRestrictedAncestor.mockResolvedValue(false);
|
||||
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledTimes(1);
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
);
|
||||
// The freshly-read verdict is cached with the 30s TTL.
|
||||
expect(cache.set).toHaveBeenCalledWith(
|
||||
cacheKey('space-1'),
|
||||
true,
|
||||
WS_CACHE_TTL_MS,
|
||||
);
|
||||
});
|
||||
|
||||
it('second call HITS the cache -> the repo is NOT re-read', async () => {
|
||||
// Cache hit returns false (no restrictions) -> open-space fast path.
|
||||
cache.get.mockResolvedValue(false);
|
||||
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(cache.get).toHaveBeenCalledWith(cacheKey('space-1'));
|
||||
// The whole point of the cache: no repo read on a hit.
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
|
||||
expect(cache.set).not.toHaveBeenCalled();
|
||||
// false verdict -> broadcast to the whole room (open-space fast path).
|
||||
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'x' });
|
||||
});
|
||||
|
||||
it('a cached `false` is returned even when restrictions now exist (the stale window)', async () => {
|
||||
// The cache says "no restrictions" (false) but the repo, if asked, would now
|
||||
// say true. spaceHasRestrictions trusts the cached false and never re-reads —
|
||||
// this documents the up-to-TTL stale window the production comment warns about
|
||||
// (a payload can fan out room-wide until the cache is invalidated/expires).
|
||||
cache.get.mockResolvedValue(false);
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(true);
|
||||
|
||||
await service.emitTreeEvent('space-1', 'page-1', { op: 'stale' });
|
||||
|
||||
expect(pagePermissionRepo.hasRestrictedPagesInSpace).not.toHaveBeenCalled();
|
||||
// Treated as open -> the event is broadcast to the WHOLE room.
|
||||
expect(roomEmit).toHaveBeenCalledWith('message', { op: 'stale' });
|
||||
});
|
||||
|
||||
it('caches a `false` verdict too (so the next emit hits, not re-reads)', async () => {
|
||||
cache.get.mockResolvedValueOnce(null); // first call: miss
|
||||
pagePermissionRepo.hasRestrictedPagesInSpace.mockResolvedValue(false);
|
||||
|
||||
await service.emitTreeEvent('space-2', 'page-9', { op: 'y' });
|
||||
|
||||
expect(cache.set).toHaveBeenCalledWith(
|
||||
cacheKey('space-2'),
|
||||
false,
|
||||
WS_CACHE_TTL_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WsService.broadcastToAuthorizedUsers fan-out (via emitToAuthorizedUsers)', () => {
|
||||
let service: WsService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
let cache: { get: jest.Mock; set: jest.Mock; del: jest.Mock };
|
||||
let fetchSockets: jest.Mock;
|
||||
let serverIn: jest.Mock;
|
||||
|
||||
beforeEach(async () => {
|
||||
pagePermissionRepo = {
|
||||
hasRestrictedPagesInSpace: jest.fn(),
|
||||
hasRestrictedAncestor: jest.fn(),
|
||||
getUserIdsWithPageAccess: jest.fn(),
|
||||
};
|
||||
cache = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WsService,
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
{ provide: CACHE_MANAGER, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WsService>(WsService);
|
||||
|
||||
fetchSockets = jest.fn();
|
||||
serverIn = jest.fn().mockReturnValue({ fetchSockets });
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
|
||||
in: serverIn,
|
||||
};
|
||||
service.setServer(server as never);
|
||||
});
|
||||
|
||||
it('only sockets whose userId is in getUserIdsWithPageAccess receive the event', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const okEmit = jest.fn();
|
||||
const noEmit = jest.fn();
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
||||
{ id: 's2', data: { userId: 'user-no' }, emit: noEmit },
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
|
||||
// The authorized set is resolved from the candidate userIds present on the
|
||||
// sockets (deduped), then only those users' sockets get the event.
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
expect.arrayContaining(['user-ok', 'user-no']),
|
||||
);
|
||||
expect(okEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(noEmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a user with TWO sockets receives the event on BOTH (userSocketMap fan-out)', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const tab1 = jest.fn();
|
||||
const tab2 = jest.fn();
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: tab1 },
|
||||
{ id: 's2', data: { userId: 'user-ok' }, emit: tab2 },
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
|
||||
// Both of the authorized user's sockets (e.g. two browser tabs) receive it.
|
||||
expect(tab1).toHaveBeenCalledWith('message', data);
|
||||
expect(tab2).toHaveBeenCalledWith('message', data);
|
||||
// The candidate set is deduped to a single userId even with two sockets.
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
['user-ok'],
|
||||
);
|
||||
});
|
||||
|
||||
it('a socket with NO userId is skipped (not a candidate, never emitted to)', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue(['user-ok']);
|
||||
|
||||
const okEmit = jest.fn();
|
||||
const anonEmit = jest.fn();
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'user-ok' }, emit: okEmit },
|
||||
// Unauthenticated socket: no userId -> excluded from the candidate map.
|
||||
{ id: 's2', data: {}, emit: anonEmit },
|
||||
]);
|
||||
|
||||
const data = { operation: 'moveTreeNode' };
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', data);
|
||||
|
||||
expect(okEmit).toHaveBeenCalledWith('message', data);
|
||||
expect(anonEmit).not.toHaveBeenCalled();
|
||||
// The no-userId socket is not even offered as a candidate to the repo.
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).toHaveBeenCalledWith(
|
||||
'page-1',
|
||||
['user-ok'],
|
||||
);
|
||||
});
|
||||
|
||||
it('no sockets in the room -> no repo lookup, no emit', async () => {
|
||||
fetchSockets.mockResolvedValue([]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-1', 'page-1', { op: 'x' });
|
||||
|
||||
expect(pagePermissionRepo.getUserIdsWithPageAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes through the space room name', async () => {
|
||||
pagePermissionRepo.getUserIdsWithPageAccess.mockResolvedValue([]);
|
||||
fetchSockets.mockResolvedValue([
|
||||
{ id: 's1', data: { userId: 'u' }, emit: jest.fn() },
|
||||
]);
|
||||
|
||||
await service.emitToAuthorizedUsers('space-7', 'page-1', { op: 'x' });
|
||||
|
||||
expect(serverIn).toHaveBeenCalledWith(getSpaceRoomName('space-7'));
|
||||
});
|
||||
});
|
||||
@@ -329,3 +329,109 @@ describe('WsService.emitTreeEvent', () => {
|
||||
expect(anonEmit).toHaveBeenCalledWith('message', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('move-into-restricted disjointness contract (WsTreeService + real WsService)', () => {
|
||||
// CONTRACT: a move under a restricted ancestor PARTITIONS the room. The
|
||||
// authorized set (gets the moveTreeNode via emitToAuthorizedUsers) and its
|
||||
// complement (gets the deleteTreeNode via emitDeleteToUnauthorized) are
|
||||
// disjoint and together cover every socket — and an anonymous (no-userId)
|
||||
// socket lands in the delete set. We wire a REAL WsService (only its repo,
|
||||
// cache and socket server mocked) so both broadcasts run against the SAME fixed
|
||||
// socket set, the way they do in production.
|
||||
let treeService: WsTreeService;
|
||||
let pagePermissionRepo: {
|
||||
hasRestrictedPagesInSpace: jest.Mock;
|
||||
hasRestrictedAncestor: jest.Mock;
|
||||
getUserIdsWithPageAccess: jest.Mock;
|
||||
};
|
||||
|
||||
// Fixed room: two authorized users (one with two sockets), one unauthorized
|
||||
// user, one anonymous socket.
|
||||
const moveSeen: string[] = [];
|
||||
const deleteSeen: string[] = [];
|
||||
|
||||
const mkSocket = (id: string, userId: string | undefined) => ({
|
||||
id,
|
||||
data: userId ? { userId } : {},
|
||||
emit: jest.fn((_event: string, payload: { operation: string }) => {
|
||||
if (payload.operation === 'moveTreeNode') moveSeen.push(id);
|
||||
if (payload.operation === 'deleteTreeNode') deleteSeen.push(id);
|
||||
}),
|
||||
});
|
||||
|
||||
const sockets = [
|
||||
mkSocket('s-ok-1', 'user-ok'), // authorized, tab 1
|
||||
mkSocket('s-ok-2', 'user-ok'), // authorized, tab 2 (fan-out)
|
||||
mkSocket('s-no', 'user-no'), // unauthorized
|
||||
mkSocket('s-anon', undefined), // anonymous (no userId)
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
moveSeen.length = 0;
|
||||
deleteSeen.length = 0;
|
||||
|
||||
pagePermissionRepo = {
|
||||
hasRestrictedPagesInSpace: jest.fn().mockResolvedValue(true),
|
||||
// The move destination IS under a restricted ancestor.
|
||||
hasRestrictedAncestor: jest.fn().mockResolvedValue(true),
|
||||
// Only user-ok is authorized to see the page.
|
||||
getUserIdsWithPageAccess: jest.fn().mockResolvedValue(['user-ok']),
|
||||
};
|
||||
const cache = {
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
set: jest.fn().mockResolvedValue(undefined),
|
||||
del: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WsTreeService,
|
||||
WsService,
|
||||
{ provide: PagePermissionRepo, useValue: pagePermissionRepo },
|
||||
{ provide: CACHE_MANAGER, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const wsService = module.get<WsService>(WsService);
|
||||
const server = {
|
||||
to: jest.fn().mockReturnValue({ emit: jest.fn() }),
|
||||
in: jest.fn().mockReturnValue({
|
||||
fetchSockets: jest.fn().mockResolvedValue(sockets),
|
||||
}),
|
||||
};
|
||||
wsService.setServer(server as never);
|
||||
|
||||
treeService = module.get<WsTreeService>(WsTreeService);
|
||||
});
|
||||
|
||||
it('authorized set (move) and complement (delete) partition the room; anon is in delete', async () => {
|
||||
const event: PageMovedEvent = {
|
||||
workspaceId: 'ws-1',
|
||||
oldParentId: 'old-parent',
|
||||
hasChildren: false,
|
||||
node: { ...snapshot, parentPageId: 'restricted-parent', position: 'a5' },
|
||||
};
|
||||
|
||||
await treeService.broadcastPageMoved(event);
|
||||
|
||||
const moveSet = new Set(moveSeen);
|
||||
const deleteSet = new Set(deleteSeen);
|
||||
|
||||
// Authorized user's BOTH sockets got the move; nobody else did.
|
||||
expect(moveSet).toEqual(new Set(['s-ok-1', 's-ok-2']));
|
||||
// Everyone else (unauthorized + anonymous) got the delete.
|
||||
expect(deleteSet).toEqual(new Set(['s-no', 's-anon']));
|
||||
|
||||
// DISJOINT: no socket received both a move and a delete.
|
||||
const intersection = [...moveSet].filter((id) => deleteSet.has(id));
|
||||
expect(intersection).toEqual([]);
|
||||
|
||||
// PARTITION: the two sets together cover every socket in the room exactly.
|
||||
const union = new Set([...moveSet, ...deleteSet]);
|
||||
expect(union).toEqual(new Set(sockets.map((s) => s.id)));
|
||||
|
||||
// The anonymous socket specifically lands in the DELETE set, never the move.
|
||||
expect(deleteSet.has('s-anon')).toBe(true);
|
||||
expect(moveSet.has('s-anon')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user