Merge remote-tracking branch 'gitea/develop' into fix/mcp-security-followups

This commit is contained in:
claude_code
2026-06-21 01:21:57 +03:00
96 changed files with 9998 additions and 600 deletions

View File

@@ -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');
});
});

View File

@@ -384,11 +384,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 +706,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 —

View File

@@ -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);
});
});

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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' });

View 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();
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});
});

View File

@@ -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');
});
});

View File

@@ -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)', () => {

View File

@@ -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 };
@@ -187,8 +188,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 +297,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 +372,150 @@ 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',
['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',
['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);
});
});

View File

@@ -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();
});
});
});

View File

@@ -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');
});
});

View File

@@ -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)

View File

@@ -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();
});
});

View File

@@ -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);
});
});