stale
"; + renderRawHtml(container, ""); + expect(container.innerHTML).toBe(""); + }); + + it("clears prior content first on a re-render with new source", () => { + const win = dom.window as unknown as RecordHello
World
`; + + 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 = ``; + const html = await markdownToHtml(md); + const json = htmlToJson(html); + expect(hasHtmlEmbedNode(json)).toBe(true); + expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false); + }); +}); diff --git a/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts b/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts index 6b07ec0b..28a59ea3 100644 --- a/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts +++ b/apps/server/src/common/helpers/prosemirror/html-embed.spec.ts @@ -92,6 +92,102 @@ describe('stripHtmlEmbedNodes', () => { const result = stripHtmlEmbedNodes(doc); expect(result).toEqual(doc); }); + + 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: '' } }, + ], + }, + ], + }, + ], + }, + ], + }; + + 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: 'only' } }], + }; + 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('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: '' } }; + 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:" with no trailing space', () => {
+ // `${code}: ${undefined ?? ''}`.trim() collapses to just ":".
+ 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');
+ });
});
diff --git a/apps/server/src/integrations/ai/ai.service.spec.ts b/apps/server/src/integrations/ai/ai.service.spec.ts
index 7bedc23a..ef44a59d 100644
--- a/apps/server/src/integrations/ai/ai.service.spec.ts
+++ b/apps/server/src/integrations/ai/ai.service.spec.ts
@@ -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,
+ );
+ });
});
diff --git a/apps/server/src/integrations/mcp/mcp-auth.helpers.ts b/apps/server/src/integrations/mcp/mcp-auth.helpers.ts
index 4a0b5be1..0d1237e7 100644
--- a/apps/server/src/integrations/mcp/mcp-auth.helpers.ts
+++ b/apps/server/src/integrations/mcp/mcp-auth.helpers.ts
@@ -359,6 +359,111 @@ export function isInitializeRequestBody(body: unknown): boolean {
return (body as { method?: unknown }).method === 'initialize';
}
+/**
+ * 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). */
export function extractBearer(
authHeader: string | undefined,
diff --git a/apps/server/src/integrations/mcp/mcp.service.spec.ts b/apps/server/src/integrations/mcp/mcp.service.spec.ts
index bf4c8a24..e8a57748 100644
--- a/apps/server/src/integrations/mcp/mcp.service.spec.ts
+++ b/apps/server/src/integrations/mcp/mcp.service.spec.ts
@@ -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 " 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)', () => {
@@ -769,3 +829,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' },
+ });
+ });
+});
diff --git a/apps/server/src/integrations/mcp/mcp.service.ts b/apps/server/src/integrations/mcp/mcp.service.ts
index 7ac16fb6..0af88c65 100644
--- a/apps/server/src/integrations/mcp/mcp.service.ts
+++ b/apps/server/src/integrations/mcp/mcp.service.ts
@@ -25,6 +25,8 @@ import {
sharedTokenMatches,
clientIp,
bindAccessJwtVerifier,
+ decideBasicGate,
+ mapAuthResultToResponse,
DocmostMcpConfig,
ResolvedMcpAuth,
} from './mcp-auth.helpers';
@@ -231,49 +233,54 @@ export class McpService implements OnModuleDestroy {
workspace: Workspace,
creds: { email: string; password: string },
): Promise {
- // 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 +340,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)[MCP_RESOLVED] = resolved;
+ (req.raw as unknown as Record)[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.
diff --git a/apps/server/src/ws/listeners/page-ws.listener.spec.ts b/apps/server/src/ws/listeners/page-ws.listener.spec.ts
index 734e8228..3282d318 100644
--- a/apps/server/src/ws/listeners/page-ws.listener.spec.ts
+++ b/apps/server/src/ws/listeners/page-ws.listener.spec.ts
@@ -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);
+ // 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');
+ });
+});
diff --git a/apps/server/src/ws/ws-service.spec.ts b/apps/server/src/ws/ws-service.spec.ts
new file mode 100644
index 00000000..c87d1493
--- /dev/null
+++ b/apps/server/src/ws/ws-service.spec.ts
@@ -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);
+
+ 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);
+
+ 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'));
+ });
+});
diff --git a/apps/server/src/ws/ws-tree.service.spec.ts b/apps/server/src/ws/ws-tree.service.spec.ts
index 0c511223..973e6b00 100644
--- a/apps/server/src/ws/ws-tree.service.spec.ts
+++ b/apps/server/src/ws/ws-tree.service.spec.ts
@@ -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);
+ 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);
+ });
+
+ 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);
+ });
+});
diff --git a/docs/backlog/feature-test-coverage-deferred.md b/docs/backlog/feature-test-coverage-deferred.md
new file mode 100644
index 00000000..05483b81
--- /dev/null
+++ b/docs/backlog/feature-test-coverage-deferred.md
@@ -0,0 +1,125 @@
+# Отложенные тесты по фичам с коммита 053a9c0d (хвост от PR #49)
+
+## Контекст
+
+PR #49 («test: cover features since 053a9c0d + repair test tooling») закрыл
+основную массу покрытия новых фич gitmost (+~330 тестов: server/Jest,
+client/Vitest, editor-ext/Vitest, packages/mcp/node:test) и починил
+тест-инструментарий (FIX-0 сломанные спеки transclusion, BUILD-0 сборка
+editor-ext перед серверными тестами, INFRA-0 резолв `.tsx` email-шаблонов).
+
+Часть тестов из принятого тест-плана **намеренно отложена** — им нужен
+тестовый Postgres, реальный Redis или HTTP/e2e-харнес, которых в проекте
+сейчас нет, либо инвазивный рефактор продакшн-кода. Ниже — что осталось и
+почему, чтобы не потерять.
+
+---
+
+## 1. Интеграционные тесты против БД (нужен тестовый Postgres)
+
+Сейчас все repo-зависимые проверки делаются на моках; SQL-уровень не
+исполняется. Чтобы покрыть это честно, нужен поднимаемый в CI Postgres
+(testcontainers или сервис в pipeline) + хелпер миграций.
+
+- **`AiAgentRoleRepo` — изоляция и индексы.**
+ `apps/server/src/database/repos/ai-agent-roles/ai-agent-roles.repo.ts`.
+ Проверить против реальной БД: `findById`/`listByWorkspace` исключают
+ soft-deleted строки; `findById` для roleId из ЧУЖОГО workspace → undefined
+ (tenant-изоляция); дубль имени в одном workspace → 23505; то же имя
+ переиспользуемо после softDelete (partial unique index
+ `WHERE deleted_at IS NULL`, миграция `20260620T120000-ai-agent-roles.ts`);
+ одинаковое имя в разных workspace разрешено. Это «хребет» безопасности —
+ сейчас только предполагается unit-моками.
+
+- **`AiChatRepo.findByCreator` — join role-badge.**
+ `apps/server/src/database/repos/ai-chat/ai-chat.repo.ts` (~:27-70).
+ Чат с enabled-ролью → roleName/roleEmoji заполнены; с soft-deleted ролью →
+ бейдж NULL; с DISABLED ролью → бейдж NULL (должно совпадать с
+ `resolveRoleForRequest`); ORDER BY квалифицирован `aiChats.*` (нет
+ ambiguous column после join). Не проверяемо чистым unit-ом.
+
+- **`WorkspaceService.update` / `WorkspaceRepo.updateSetting` — jsonb-merge.**
+ `apps/server/src/core/workspace/services/workspace.service.ts` (~:514),
+ `apps/server/src/database/repos/workspace/workspace.repo.ts` (~:275).
+ Сейчас покрыта только форма вызова сервиса
+ (`workspace-html-embed.spec.ts`). Не покрыто (нужна БД): `htmlEmbed:true`
+ персистится через jsonb-merge **не затирая** соседние настройки (ai,
+ sharing). Это и есть «kill-switch пишется» — критично, что write-половина
+ тоггла не ломает остальной settings-namespace.
+
+- **FK `page_template_references` onDelete('cascade').**
+ Миграция `20260620T131000-page-template-references.ts`. Проверить, что
+ удаление source/reference-страницы каскадит строки ссылок.
+
+## 2. HTTP / e2e-харнес (его нет в apps/server)
+
+- **Public-share ассистент: обход per-IP throttle ротацией XFF, но
+ per-workspace cap держит.**
+ Контроллер использует стоковый `@UseGuards(ThrottlerGuard)`
+ (`apps/server/src/core/ai-chat/public-share-chat.controller.ts`), IP берётся
+ из Fastify `trustProxy` → `X-Forwarded-For`. Единственный оправданный e2e
+ (named journey «аноним спамит ассистента»): ротация XFF обходит per-IP
+ лимит 5/min, но per-workspace cost-cap всё равно отдаёт 429. Требует
+ поднятого HTTP-слоя Nest + trusted-proxy конфигурации.
+
+- **Достоверность Lua-окна cost-cap против реального Redis.**
+ `apps/server/src/core/ai-chat/public-share-workspace-limiter.ts`
+ (`SLIDING_WINDOW_LUA`). Сейчас cap тестируется против TS-реализации
+ `FakeRedis` в `public-share-chat.spec.ts` — баг в самой Lua-строке
+ (`>=` vs `>`, неверный PEXPIRE) не поймается. Нужен интеграционный тест
+ против реального/testcontainers Redis.
+
+## 3. Полная интеграция `AiChatService.stream` (рефактор R1-stream)
+
+`apps/server/src/core/ai-chat/ai-chat.service.ts`. В PR #49 извлечён и
+покрыт только чистый `buildErrorAssistantRecord`. Полные интеграционные
+сценарии — **запись чата, упавшего на первом ходу** (onError), жизненный
+цикл external-MCP клиентов (закрытие при throw/onFinish), и
+**история восстанавливается из БД, а не из `body.messages`** (анти-tamper) —
+требуют сидирования SDK `streamText` (инъекция/seam колбэков `onError`/
+`onFinish`/`onAbort` + `res.hijack`). Отложено, чтобы не дестабилизировать
+287-строчный `stream()`; делать вместе с выносом testable turn-pipeline.
+
+---
+
+## Сопутствующие НЕ-тестовые находки (отдельные задачи)
+
+Всплыли во время написания тестов; чинить отдельными PR, не в тест-ветке.
+
+- **Нет серверной валидации «допустимого набора моделей» для роли.**
+ `chatModel` — свободная строка `MaxLength(200)`
+ (`apps/server/src/core/ai-chat/roles/dto/agent-role.dto.ts`); невалидная
+ модель принимается и падает только в рантайме как provider-ошибка/503.
+ Плюс клиентский enum драйверов
+ (`ai-agent-role-form.tsx`) захардкожен и может разойтись с серверным
+ `AI_DRIVERS` (`apps/server/src/integrations/ai/ai.types.ts`) — кандидат на
+ shared-константу или contract-тест.
+
+- **`WsService.invalidateSpaceRestrictionCache` не имеет вызывающих.**
+ `apps/server/src/ws/ws.service.ts` (~:44-48). Кэш `spaceHasRestrictions`
+ (TTL 30с) ничем не инвалидируется при изменении ограничений → реальное
+ 30-секундное окно устаревания (риск утечки заголовков/метаданных дерева).
+ Привязать инвалидацию к ручкам restrict/grant/revoke.
+
+- **Серверный guard рекурсии page-embed.**
+ Cap глубины/циклов `PAGE_EMBED_MAX_DEPTH=5` — только клиентский
+ (`page-embed-view.tsx`). Серверный `/pages/template/lookup` ограничен лишь
+ throttle 30/60с + `ArrayMaxSize(50)`. Оценить, нужен ли серверный guard
+ раскрытия.
+
+- **`collectPageEmbedsFromPmJson` без cycle-guard.**
+ `apps/server/src/core/page/transclusion/utils/transclusion-prosemirror.util.ts`
+ (~:108-139). На циклическом объекте — `RangeError` (stack overflow). Через
+ JSON-парсинг недостижимо (реальный вход), поэтому низкий приоритет; тест
+ закрепляет текущее поведение.
+
+- **Предсуществующий долг jest-инфраструктуры (блокирует часть интеграций).**
+ 16 серверных сьютов падают: (а) NestJS DI — стоковые `should be defined`
+ через `Test.createTestingModule(...).compile()` без провайдеров (auth,
+ page, comment, group, space, search, user, workspace, token, storage,
+ environment); (б) lib0 ESM — `Cannot use import statement outside a module`
+ из `lib0/decoding.js` по цепочке `@hocuspocus/server` (comment.service,
+ page.service, page.controller). `lib0` не входит в jest
+ `transformIgnorePatterns`. Пока это так, полноценные интеграционные тесты
+ сервисов/контроллеров через полный DI-граф невозможны (в PR #49 такие
+ тесты сделаны прямым конструированием с моками).
diff --git a/packages/editor-ext/src/lib/html-embed/html-embed-codec.spec.ts b/packages/editor-ext/src/lib/html-embed/html-embed-codec.spec.ts
new file mode 100644
index 00000000..fbee45d2
--- /dev/null
+++ b/packages/editor-ext/src/lib/html-embed/html-embed-codec.spec.ts
@@ -0,0 +1,116 @@
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ encodeHtmlEmbedSource,
+ decodeHtmlEmbedSource,
+} from "./html-embed";
+
+// Unit coverage for the base64 codec used by the htmlEmbed node's
+// data-source attribute (html-embed.ts). The codec has two branches:
+// - the BROWSER branch: btoa(encodeURIComponent(s)) / decodeURIComponent(atob(s));
+// - the NODE fallback: Buffer.from(..).toString("base64") / Buffer.from(s,"base64").
+// Server-side schema parsing (htmlToJson with no global btoa/atob) hits the
+// fallback, so both branches must round-trip identically; otherwise an embed
+// encoded in the browser would decode wrong on the server (or vice versa).
+//
+// We force the fallback by temporarily DELETING globalThis.btoa/atob (jsdom
+// provides them in this env), restoring them after each test so the suite stays
+// hermetic.
+
+const realBtoa = globalThis.btoa;
+const realAtob = globalThis.atob;
+
+function deleteBase64Globals(): void {
+ // @ts-expect-error — intentionally removing the globals to exercise the
+ // `typeof btoa !== "function"` Node fallback branch in the codec.
+ delete globalThis.btoa;
+ // @ts-expect-error — see above.
+ delete globalThis.atob;
+}
+
+afterEach(() => {
+ // Always restore so one test's stubbing never leaks into another.
+ globalThis.btoa = realBtoa;
+ globalThis.atob = realAtob;
+});
+
+describe("html-embed codec — browser btoa/atob branch", () => {
+ it("round-trips ASCII source", () => {
+ const src = "";
+ const enc = encodeHtmlEmbedSource(src);
+ expect(enc).not.toBe("");
+ // base64 of the encodeURIComponent form never contains a raw '<'.
+ expect(enc).not.toContain("<");
+ expect(decodeHtmlEmbedSource(enc)).toBe(src);
+ });
+
+ it("round-trips UTF-8 / non-Latin1 source (the reason for encodeURIComponent)", () => {
+ const src = 'héllo → 世界 𝕏
';
+ const enc = encodeHtmlEmbedSource(src);
+ expect(decodeHtmlEmbedSource(enc)).toBe(src);
+ });
+});
+
+describe("html-embed codec — Node Buffer fallback branch", () => {
+ it("encode uses the Buffer fallback when btoa is unavailable and still round-trips (UTF-8)", () => {
+ const src = 'héllo → 世界 𝕏';
+
+ deleteBase64Globals();
+ // With the globals gone, encode must take the Buffer path...
+ const encFallback = encodeHtmlEmbedSource(src);
+ expect(encFallback).not.toBe("");
+ // ...and decode (also via Buffer) must recover the exact source.
+ expect(decodeHtmlEmbedSource(encFallback)).toBe(src);
+ });
+
+ it("the Buffer fallback produces the SAME bytes the browser branch does (cross-env parity)", () => {
+ const src = 'café — 日本語';
+
+ // Browser branch (globals intact).
+ const encBrowser = encodeHtmlEmbedSource(src);
+
+ // Fallback branch.
+ deleteBase64Globals();
+ const encFallback = encodeHtmlEmbedSource(src);
+
+ // Identical base64 => an embed encoded in either environment decodes
+ // identically in the other (server <-> client losslessness).
+ expect(encFallback).toBe(encBrowser);
+
+ // And the fallback can decode what the browser produced.
+ expect(decodeHtmlEmbedSource(encBrowser)).toBe(src);
+ });
+
+ it("empty string -> '' on both encode and decode in the fallback (early return, branch never reached)", () => {
+ deleteBase64Globals();
+ expect(encodeHtmlEmbedSource("")).toBe("");
+ expect(decodeHtmlEmbedSource("")).toBe("");
+ });
+
+ it("decode of malformed base64 -> '' via the catch branch (fallback)", () => {
+ // In the Buffer fallback, Buffer.from(..,'base64') is lenient and never
+ // throws, so to hit the catch we need a payload whose DECODED bytes are an
+ // invalid percent-escape, which makes decodeURIComponent throw. base64 of a
+ // lone '%' decodes back to '%', and decodeURIComponent('%') is a URIError.
+ const badBase64 = Buffer.from("%", "utf-8").toString("base64"); // "JQ=="
+
+ deleteBase64Globals();
+ // Sanity: the raw decode really does throw, so we're exercising the catch.
+ expect(() =>
+ decodeURIComponent(Buffer.from(badBase64, "base64").toString("utf-8")),
+ ).toThrow();
+ // The codec swallows it and returns "" rather than propagating.
+ expect(decodeHtmlEmbedSource(badBase64)).toBe("");
+ });
+});
+
+describe("html-embed codec — decode of malformed input (browser branch)", () => {
+ it("returns '' for input atob rejects (catch branch)", () => {
+ // atob throws on characters outside the base64 alphabet; the codec catches
+ // it and returns "" instead of throwing.
+ expect(decodeHtmlEmbedSource("@@not-base64@@")).toBe("");
+ });
+
+ it("empty string short-circuits to '' (never calls atob)", () => {
+ expect(decodeHtmlEmbedSource("")).toBe("");
+ });
+});
diff --git a/packages/editor-ext/src/lib/markdown/html-embed-marked.spec.ts b/packages/editor-ext/src/lib/markdown/html-embed-marked.spec.ts
new file mode 100644
index 00000000..7904f063
--- /dev/null
+++ b/packages/editor-ext/src/lib/markdown/html-embed-marked.spec.ts
@@ -0,0 +1,105 @@
+import { describe, expect, it } from "vitest";
+import { htmlEmbedExtension } from "./utils/html-embed.marked";
+import { markdownToHtml } from "./index";
+import { encodeHtmlEmbedSource } from "../html-embed/html-embed";
+
+// CONTRACT tests for the marked block tokenizer that rebuilds an htmlEmbed node
+// from the `` marker (html-embed.marked.ts), plus the
+// observable round-trip through markdownToHtml.
+//
+// These pin the REAL tokenizer behaviour the import path depends on:
+// - the tokenizer rule is anchored (^) and only accepts the base64 alphabet
+// [A-Za-z0-9+/=], so a marker with non-base64 chars is NOT tokenized and
+// survives as a literal HTML comment (not silently turned into something the
+// server's strip no longer recognizes);
+// - start() reports the correct index of the next marker so marked invokes the
+// tokenizer at the right offset when a marker sits mid-document / after text;
+// - a marker with surrounding text on the SAME line is split out into its own
+// embed div while the surrounding text becomes ordinary paragraphs.
+//
+// The contract is asserted against the actual exported extension and pipeline —
+// no behaviour is invented; the expectations were read off the real tokenizer.
+
+const SAMPLE = "x";
+const ENC = encodeHtmlEmbedSource(SAMPLE);
+
+describe("htmlEmbed marked tokenizer — start()", () => {
+ it("returns the index of a marker that sits mid-document", () => {
+ const src = `hello world `;
+ expect(htmlEmbedExtension.start(src)).toBe(src.indexOf("`)).toBe(0);
+ });
+
+ it("returns -1 when there is no marker", () => {
+ expect(htmlEmbedExtension.start("no marker here")).toBe(-1);
+ });
+});
+
+describe("htmlEmbed marked tokenizer — tokenizer()", () => {
+ it("tokenizes a marker at the start of the input, capturing the base64 payload", () => {
+ const token = htmlEmbedExtension.tokenizer(``);
+ expect(token).toBeTruthy();
+ expect(token!.type).toBe("htmlEmbed");
+ expect(token!.raw).toBe(``);
+ expect(token!.encoded).toBe(ENC);
+ });
+
+ it("tokenizes an EMPTY marker (the [A-Za-z0-9+/=]* class allows zero chars)", () => {
+ const token = htmlEmbedExtension.tokenizer("");
+ expect(token).toBeTruthy();
+ expect(token!.encoded).toBe("");
+ expect(token!.raw).toBe("");
+ });
+
+ it("does NOT tokenize when text precedes the marker (rule is anchored ^)", () => {
+ // marked relies on start() to advance to the marker; the tokenizer itself
+ // only matches at offset 0, so a non-anchored call returns undefined.
+ expect(
+ htmlEmbedExtension.tokenizer(`hello `),
+ ).toBeUndefined();
+ });
+
+ it("does NOT tokenize a marker containing a non-base64 char ('$')", () => {
+ expect(
+ htmlEmbedExtension.tokenizer(""),
+ ).toBeUndefined();
+ });
+
+ it("does NOT tokenize a marker containing a space", () => {
+ expect(
+ htmlEmbedExtension.tokenizer(""),
+ ).toBeUndefined();
+ });
+
+ it("renderer emits the embed div the node's parseHTML recognizes", () => {
+ const token = htmlEmbedExtension.tokenizer(``)!;
+ const html = htmlEmbedExtension.renderer(token as any);
+ expect(html).toBe(
+ ``,
+ );
+ });
+});
+
+describe("htmlEmbed marked tokenizer — markdownToHtml round-trip", () => {
+ it("splits a marker out of surrounding same-line text into its own embed div", async () => {
+ const html = await markdownToHtml(`before after`);
+ // The marker became the embed div...
+ expect(html).toContain(
+ ``,
+ );
+ // ...and the surrounding text survived as ordinary paragraph content.
+ expect(html).toContain("before");
+ expect(html).toContain("after");
+ });
+
+ it("leaves a marker with non-base64 chars as a literal comment (NOT an embed div)", async () => {
+ const html = await markdownToHtml("");
+ // It is NOT tokenized into an embed div the server would strip...
+ expect(html).not.toContain('data-type="htmlEmbed"');
+ // ...it passes through unchanged as a literal HTML comment.
+ expect(html).toContain("");
+ });
+});
diff --git a/packages/editor-ext/src/lib/page-embed/page-embed.spec.ts b/packages/editor-ext/src/lib/page-embed/page-embed.spec.ts
new file mode 100644
index 00000000..95638090
--- /dev/null
+++ b/packages/editor-ext/src/lib/page-embed/page-embed.spec.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import { getSchema } from "@tiptap/core";
+import { generateHTML, generateJSON } from "@tiptap/html";
+import { Document } from "@tiptap/extension-document";
+import { Paragraph } from "@tiptap/extension-paragraph";
+import { Text } from "@tiptap/extension-text";
+import { PageEmbed } from "./page-embed";
+
+// CONTRACT tests for the PageEmbed node's parse/render round-trip
+// (page-embed.ts). The whole-page live embed stores ONLY a `sourcePageId`
+// reference; renderHTML must serialize it as `data-source-page-id` and parseHTML
+// must recover it. If this attribute mapping drifts, an embed saved to HTML loses
+// its target page on reload (the node view would have nothing to fetch).
+//
+// We assert at the editor-ext schema level using the same Tiptap utilities the
+// other editor-ext tests use (getSchema + @tiptap/html generateHTML/generateJSON
+// over a jsdom DOM), driving a real HTML -> node JSON -> HTML round-trip through
+// the node's actual addAttributes()/parseHTML()/renderHTML().
+
+// Minimal schema: a doc of blocks, plus the PageEmbed block node under test.
+const extensions = [Document, Paragraph, Text, PageEmbed];
+
+describe("PageEmbed schema", () => {
+ it("registers the pageEmbed node in the schema", () => {
+ const schema = getSchema(extensions);
+ expect(schema.nodes.pageEmbed).toBeTruthy();
+ });
+});
+
+describe("PageEmbed parse/render round-trip", () => {
+ it("recovers sourcePageId from data-source-page-id on parse (HTML -> JSON)", () => {
+ const html = ``;
+ const json = generateJSON(html, extensions);
+
+ const node = json.content?.[0];
+ expect(node?.type).toBe("pageEmbed");
+ expect(node?.attrs?.sourcePageId).toBe("pg-123");
+ });
+
+ it("emits data-source-page-id on render (JSON -> HTML)", () => {
+ const json = {
+ type: "doc",
+ content: [{ type: "pageEmbed", attrs: { sourcePageId: "pg-456" } }],
+ };
+ const html = generateHTML(json, extensions);
+
+ expect(html).toContain('data-type="pageEmbed"');
+ expect(html).toContain('data-source-page-id="pg-456"');
+ });
+
+ it("survives a full HTML -> node -> HTML round-trip (attribute preserved)", () => {
+ const start = ``;
+
+ // HTML -> node JSON -> HTML.
+ const json = generateJSON(start, extensions);
+ const html = generateHTML(json, extensions);
+
+ // The id survived the round-trip in the serialized HTML...
+ expect(html).toContain('data-source-page-id="pg-789"');
+
+ // ...and re-parsing the round-tripped HTML yields the same id (stable across
+ // an extra pass — no loss, no duplication).
+ const json2 = generateJSON(html, extensions);
+ expect(json2.content?.[0]?.attrs?.sourcePageId).toBe("pg-789");
+ });
+
+ it("omits data-source-page-id entirely when sourcePageId is null (renderHTML guard)", () => {
+ // The renderHTML maps a null/empty id to {} (no attribute), so an embed
+ // without a target page does not emit a stray empty attribute.
+ const json = {
+ type: "doc",
+ content: [{ type: "pageEmbed", attrs: { sourcePageId: null } }],
+ };
+ const html = generateHTML(json, extensions);
+
+ expect(html).toContain('data-type="pageEmbed"');
+ expect(html).not.toContain("data-source-page-id");
+ });
+
+ it("parses a div without the attribute to a null sourcePageId (default)", () => {
+ const html = ``;
+ const json = generateJSON(html, extensions);
+
+ expect(json.content?.[0]?.type).toBe("pageEmbed");
+ // getAttribute returns null when absent; parseHTML returns it verbatim.
+ expect(json.content?.[0]?.attrs?.sourcePageId).toBeNull();
+ });
+});
diff --git a/packages/mcp/test/unit/http-idle-eviction.test.mjs b/packages/mcp/test/unit/http-idle-eviction.test.mjs
new file mode 100644
index 00000000..6521f268
--- /dev/null
+++ b/packages/mcp/test/unit/http-idle-eviction.test.mjs
@@ -0,0 +1,273 @@
+// Unit tests for createMcpHttpHandler's idle-session eviction (http.ts).
+//
+// http.ts keeps one transport per MCP session alive between requests, keyed by
+// the mcp-session-id header, and runs a periodic sweep (setInterval, every 5
+// min) that closes any transport idle longer than the idle TTL
+// (MCP_SESSION_IDLE_MS, default 30 min) and drops its lastSeen + sessionIdentity
+// bookkeeping. Routing a request to an existing transport refreshes its
+// lastSeen.
+//
+// We drive this DETERMINISTICALLY rather than waiting wall-clock: the env knob
+// MCP_SESSION_IDLE_MS is read ONCE when the handler is created, so we set it
+// small; and node:test's mock.timers lets us mock both `setInterval` (the sweep)
+// and `Date` (the lastSeen comparison clock) so ticking advances the clock and
+// fires the sweep on demand.
+//
+// IMPORTANT mock.timers semantics: when a tick spans MULTIPLE timer fires (or
+// overshoots a fire), the callbacks all observe Date.now() == the FINAL ticked
+// time, not their individual scheduled times. So to make the sweep's
+// `now - lastSeen` comparison meaningful we tick EXACTLY to a sweep boundary
+// (a multiple of the sweep interval): then Date.now() inside the sweep equals
+// that boundary. The mocked clock starts at 0, so sweeps fire at SWEEP, 2*SWEEP,
+// ... We pin each session's lastSeen by establishing/touching it at a known
+// pre-boundary clock, then tick the remaining delta to land exactly on the
+// boundary.
+//
+// Sessions are established over a real loopback http server (so the SDK's
+// StreamableHTTPServerTransport gets genuine Node req/res and a real
+// mcp-session-id), exactly like http-resolver.test.mjs, and the server is closed
+// in a finally.
+//
+// Eviction is asserted via its OBSERVABLE effect: once a session is evicted its
+// transport is gone from the handler's internal map, so a subsequent non-init
+// request replaying that session id is treated as unknown (400 "no valid
+// session ID") — the same response an id that was never established would get.
+// An active (recently-seen) session is retained and its subsequent request is
+// NOT a 400.
+import { test, mock } from "node:test";
+import assert from "node:assert/strict";
+
+const INIT_BODY = {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: {
+ protocolVersion: "2025-03-26",
+ capabilities: {},
+ clientInfo: { name: "test", version: "0.0.0" },
+ },
+};
+
+const SWEEP_MS = 5 * 60 * 1000; // setInterval cadence in http.ts.
+
+// Spin a loopback http server bridging every request into the MCP handler with
+// its JSON body parsed, mirroring the embedding host. Returns { call, close }.
+async function startLoopback(handler) {
+ const http = await import("node:http");
+ const server = http.createServer((req, res) => {
+ let raw = "";
+ req.on("data", (c) => (raw += c));
+ req.on("end", () => {
+ const body = raw ? JSON.parse(raw) : undefined;
+ handler.handleRequest(req, res, body).catch(() => {
+ if (!res.headersSent) {
+ res.statusCode = 500;
+ res.end();
+ }
+ });
+ });
+ });
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
+ const { port } = server.address();
+
+ const call = (headers, body) =>
+ new Promise((resolve) => {
+ const r = http.request(
+ {
+ host: "127.0.0.1",
+ port,
+ method: "POST",
+ path: "/mcp",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json, text/event-stream",
+ ...headers,
+ },
+ },
+ (resp) => {
+ let data = "";
+ resp.on("data", (c) => (data += c));
+ resp.on("end", () =>
+ resolve({
+ statusCode: resp.statusCode,
+ sessionId: resp.headers["mcp-session-id"],
+ body: data,
+ }),
+ );
+ },
+ );
+ r.end(JSON.stringify(body));
+ });
+
+ return { call, close: () => new Promise((r) => server.close(r)) };
+}
+
+// The sweep closes transports asynchronously (void transport.close()), whose
+// onclose then removes the entry from the internal map. Yield to the event loop
+// so those microtasks settle before we assert the observable effect.
+const settle = () => new Promise((r) => setImmediate(r));
+
+// Set the idle TTL env knob (read once at handler creation) and enable mocked
+// setInterval + Date BEFORE creating the handler, so the sweep interval and
+// every Date.now() (lastSeen at init, lastSeen on routing, and the sweep's
+// comparison) all run on the same mocked clock. Returns restore() to undo it.
+function withMockedTimers(idleMs) {
+ const prevIdle = process.env.MCP_SESSION_IDLE_MS;
+ process.env.MCP_SESSION_IDLE_MS = String(idleMs);
+ mock.timers.enable({ apis: ["setInterval", "Date"] });
+ return () => {
+ mock.timers.reset();
+ if (prevIdle === undefined) delete process.env.MCP_SESSION_IDLE_MS;
+ else process.env.MCP_SESSION_IDLE_MS = prevIdle;
+ };
+}
+
+test("idle session is evicted by the sweep; an active session is retained", async () => {
+ // A small TTL: idle longer than 1s triggers eviction. Both sessions start at
+ // clock 0; we keep one fresh (touch it just before the sweep) and leave the
+ // other idle, then fire ONE sweep exactly on its boundary.
+ const idleMs = 1000;
+ const restore = withMockedTimers(idleMs);
+
+ const { createMcpHttpHandler } = await import("../../build/http.js");
+ const handler = createMcpHttpHandler(() => ({
+ apiUrl: "http://127.0.0.1:3000/api",
+ getToken: async () => "t",
+ }));
+
+ const lb = await startLoopback(handler);
+ try {
+ // T0 (clock 0): establish both sessions; lastSeen(A) = lastSeen(B) = 0.
+ const a = await lb.call({}, INIT_BODY);
+ const b = await lb.call({}, INIT_BODY);
+ assert.ok(a.sessionId, "session A must get an mcp-session-id");
+ assert.ok(b.sessionId, "session B must get an mcp-session-id");
+ assert.notEqual(a.sessionId, b.sessionId, "distinct sessions");
+
+ // Advance to just before the first sweep boundary (SWEEP - 1ms): no sweep
+ // fires yet (boundary not reached). lastSeen(A) stays 0.
+ mock.timers.tick(SWEEP_MS - 1);
+ // Touch ONLY B here, refreshing lastSeen(B) to SWEEP-1 (active); A is left
+ // idle since clock 0.
+ const touchB = await lb.call(
+ { "mcp-session-id": b.sessionId },
+ { jsonrpc: "2.0", method: "ping", id: 5 },
+ );
+ assert.notEqual(touchB.statusCode, 400, "B alive right before the sweep");
+
+ // Land EXACTLY on the sweep boundary (clock = SWEEP). Inside the sweep
+ // Date.now() == SWEEP, so:
+ // idle(A) = SWEEP - 0 = SWEEP > TTL(1s) -> A EVICTED
+ // idle(B) = SWEEP - (SWEEP-1) = 1ms < TTL(1s) -> B RETAINED
+ mock.timers.tick(1);
+ await settle();
+
+ // OBSERVABLE EFFECT 1 — A evicted: replaying its session id on a non-init
+ // request is now treated as unknown (400, no valid session).
+ const aAfter = await lb.call(
+ { "mcp-session-id": a.sessionId },
+ { jsonrpc: "2.0", method: "ping", id: 10 },
+ );
+ assert.equal(aAfter.statusCode, 400, "evicted session id is unknown -> 400");
+ assert.match(aAfter.body, /no valid session ID/);
+
+ // OBSERVABLE EFFECT 2 — B retained: a subsequent request on its session id
+ // is routed to the live transport, NOT rejected as an unknown session.
+ const bAfter = await lb.call(
+ { "mcp-session-id": b.sessionId },
+ { jsonrpc: "2.0", method: "ping", id: 11 },
+ );
+ assert.notEqual(
+ bAfter.statusCode,
+ 400,
+ "active session must survive the sweep (not 400)",
+ );
+ } finally {
+ await lb.close();
+ restore();
+ }
+});
+
+test("a session left idle past the TTL is dropped so its id becomes unknown", async () => {
+ // Simplest single-session eviction: establish a session, let it go idle past
+ // the TTL, fire the sweep on its boundary, and confirm its id is now unknown
+ // (400). Pins the core "lastSeen older than TTL -> closed and dropped" path.
+ const idleMs = 1000;
+ const restore = withMockedTimers(idleMs);
+
+ const { createMcpHttpHandler } = await import("../../build/http.js");
+ const handler = createMcpHttpHandler(() => ({
+ apiUrl: "http://127.0.0.1:3000/api",
+ getToken: async () => "t",
+ }));
+
+ const lb = await startLoopback(handler);
+ try {
+ const s = await lb.call({}, INIT_BODY);
+ assert.ok(s.sessionId, "session must get an mcp-session-id");
+
+ // Fire the first sweep exactly on its boundary: Date.now() == SWEEP, idle =
+ // SWEEP - 0 = SWEEP > TTL, so the untouched session is evicted.
+ mock.timers.tick(SWEEP_MS);
+ await settle();
+
+ const after = await lb.call(
+ { "mcp-session-id": s.sessionId },
+ { jsonrpc: "2.0", method: "ping", id: 30 },
+ );
+ assert.equal(after.statusCode, 400, "idle session id is unknown -> 400");
+ assert.match(after.body, /no valid session ID/);
+ } finally {
+ await lb.close();
+ restore();
+ }
+});
+
+test("activity refreshes lastSeen so a busy session is never evicted", async () => {
+ // A session kept busy (a request just before the sweep) refreshes its
+ // lastSeen, so even though it was created long ago the sweep must not evict
+ // it. Pins the "routing to an existing transport refreshes its idle
+ // timestamp" branch of http.ts.
+ const idleMs = 1000;
+ const restore = withMockedTimers(idleMs);
+
+ const { createMcpHttpHandler } = await import("../../build/http.js");
+ const handler = createMcpHttpHandler(() => ({
+ apiUrl: "http://127.0.0.1:3000/api",
+ getToken: async () => "t",
+ }));
+
+ const lb = await startLoopback(handler);
+ try {
+ const s = await lb.call({}, INIT_BODY);
+ assert.ok(s.sessionId, "session must get an mcp-session-id");
+
+ // Age to just before the sweep boundary, then touch the session so its
+ // lastSeen is refreshed to SWEEP-1 (well within the TTL of the imminent
+ // sweep).
+ mock.timers.tick(SWEEP_MS - 1);
+ const touch = await lb.call(
+ { "mcp-session-id": s.sessionId },
+ { jsonrpc: "2.0", method: "ping", id: 40 },
+ );
+ assert.notEqual(touch.statusCode, 400, "session still alive before sweep");
+
+ // Land exactly on the sweep boundary: idle = SWEEP - (SWEEP-1) = 1ms < TTL,
+ // so the busy session is retained.
+ mock.timers.tick(1);
+ await settle();
+
+ const after = await lb.call(
+ { "mcp-session-id": s.sessionId },
+ { jsonrpc: "2.0", method: "ping", id: 41 },
+ );
+ assert.notEqual(
+ after.statusCode,
+ 400,
+ "a session touched just before the sweep must not be evicted",
+ );
+ } finally {
+ await lb.close();
+ restore();
+ }
+});