Files
memind/mindspace-service/mindspace-rpc-server.test.mjs

898 lines
24 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
mintMindSpaceMcpScopedToken,
} from '../mindspace-mcp-scoped-token.mjs';
import { createMindSpaceRpcRequestHandler } from './mindspace-rpc-server.mjs';
function createStubAdapter() {
const calls = {
writeUploadContent: [],
registerChatDocxArtifact: [],
workspaceTools: [],
};
return {
calls,
kind: 'local',
implementationStatus: 'ready',
serviceFacade: {
async prepareConversationPackageRead() {
return [];
},
},
chatSaveService: {
async createSharedHtml() {
return {
publicUrl: 'https://example.com/shared.html',
filename: 'shared.html',
};
},
async materializeWorkspaceHtml() {
return { changed: false };
},
async readWorkspaceHtml() {
return {
html: '<html></html>',
relativePath: 'public/page.html',
};
},
async ensurePreviewThumbnail() {
return { ready: true };
},
async renderPreviewThumbnailSvg() {
return { svg: '<svg></svg>', contentType: 'image/svg+xml; charset=utf-8' };
},
},
conversationArtifactService: {
async registerChatDocxArtifact(...args) {
calls.registerChatDocxArtifact.push(args);
return { id: 'docx-1' };
},
async registerPublicHtmlArtifacts() {
return [{ artifactId: 'html-1' }];
},
async registerPublishedLongImageArtifact() {
return { id: 'image-1' };
},
async registerWorkspaceFileArtifact() {
return { id: 'file-1' };
},
},
publicFinishService: {
async materializeSessionEvent() {
return { materialized: [], skipped: [] };
},
async syncAfterFinish() {
return {
materialized: [],
skipped: [],
publicHtmlRelativePaths: [],
};
},
async preparePageDataAfterFinish() {
return {
autoBind: {
bound: [],
skipped: [],
errors: [],
},
evaluation: {
structuralPageData: false,
relevantFiles: [],
},
};
},
async prepareWechatPageDataDelivery() {
return {
outcome: { action: 'skip' },
deliveryArtifacts: [],
};
},
async prepareWechatHtmlDelivery() {
return {
confirmedArtifacts: [],
};
},
async ensureWechatFreshPageThumbnails() {
return { ok: true };
},
},
workspacePublicationDeliveryService: {
async listRecentlyModifiedPublicHtml() {
return { relativePaths: [] };
},
async readOwnerPublicAsset() {
return { action: 'not_found' };
},
async renderLongImage() {
return {
bodyBase64: '',
servedName: 'page.long.png',
};
},
async resolveWorkspaceRequest() {
return { action: 'not_found' };
},
async validateRunDeliverables() {
return { errors: [] };
},
},
workspacePageDeliveryService: {
async syncAndDeliver() {
return { sync: {} };
},
},
workspaceToolService: {
async createDirectory() {
calls.workspaceTools.push([
'createDirectory',
...arguments,
]);
return { relativePath: 'public/assets' };
},
async editFile() {
calls.workspaceTools.push([
'editFile',
...arguments,
]);
return { relativePath: 'public/page.html' };
},
async generateLongImage() {
calls.workspaceTools.push([
'generateLongImage',
...arguments,
]);
return {
relativePath:
'public/page.long.png',
};
},
async listDirectory() {
calls.workspaceTools.push([
'listDirectory',
...arguments,
]);
return { entries: [] };
},
async publishPage() {
calls.workspaceTools.push([
'publishPage',
...arguments,
]);
return {
canonicalUrl:
'https://example.com/MindSpace/user-1/public/page.html',
};
},
async readFile() {
calls.workspaceTools.push([
'readFile',
...arguments,
]);
return { content: 'hello' };
},
async writeFile() {
calls.workspaceTools.push([
'writeFile',
...arguments,
]);
return { relativePath: 'public/page.html' };
},
async writeBinaryFile() {
calls.workspaceTools.push([
'writeBinaryFile',
...arguments,
]);
return {
relativePath:
'public/report.docx',
};
},
},
conversationPackageRegistry: {
async ensurePackage() { return { ok: true }; },
async putObjectForSession() { return { ok: true }; },
async recordArtifact() { return { ok: true }; },
async readArtifactObject() { return { ok: true }; },
async readManifestForSession() { return { ok: true }; },
async writeManifestForSession() { return { ok: true }; },
},
assetService: {
async cancelUpload() { return { ok: true }; },
async claimUploadArtifactsForConversation() { return []; },
async completeUpload() { return { ok: true }; },
async createChatAsset() { return { ok: true }; },
async createUpload() { return { ok: true }; },
async deleteAsset() { return { ok: true }; },
async listAssets() { return []; },
async readAssetContent() {
return { ok: true, bodyBase64: '' };
},
async readPublicAssetContent() {
return { ok: true, bodyBase64: '' };
},
async renderAssetPreview() { return { preview: true }; },
async renderAssetThumbnail() { return { thumbnail: true }; },
async syncWorkspaceAssets() { return { synced: true }; },
async writeUploadContent(...args) {
calls.writeUploadContent.push(args);
return { isBuffer: Buffer.isBuffer(args[2]), size: args[2]?.length ?? 0 };
},
async expireStaleUploads() { return { expired: 0 }; },
},
pageService: {
async createFromChat() { return { ok: true }; },
async createPage() { return { ok: true }; },
async deletePage() { return { ok: true }; },
async findPageByRelativePath() { return null; },
async findPlazaContextByWorkspacePath() { return null; },
async findPageBySourceAsset() { return null; },
async getDeletePreview() { return { ok: true }; },
async getPage() { return { ok: true }; },
async listPages() { return []; },
async listVersions() { return []; },
async localizePrivateResources() { return { ok: true }; },
async redactPage() { return { ok: true }; },
async regenerateThumbnail() { return { ok: true }; },
async renderDraftPreview() { return { html: '' }; },
async renderPreview() { return { html: '' }; },
async renderThumbnail() { return { ok: true }; },
async rewriteHtmlDownloadLinksForPage() { return { ok: true }; },
async updatePage() { return { ok: true }; },
async uploadThumbnail() { return { ok: true }; },
},
pageLiveEditService: {
async applyAgentPatch() { return { ok: true }; },
async bindSession() { return { ok: true }; },
async getRevisionSnapshot() { return { ok: true }; },
},
assetAgentService: {
async applyAgentDelete() { return { ok: true }; },
},
publicationService: {
async check() { return { ok: true }; },
async cleanupExpiredUnconfirmedPublications() { return { cleaned: 0 }; },
async getCurrent() { return null; },
async refreshOnlinePublicationHtml() { return null; },
async getPublicHomepage() { return null; },
async getStats() { return { ok: true }; },
async offline() { return { ok: true }; },
async publish() { return { ok: true }; },
async resolvePrivateLink() { return { ok: true }; },
async resolvePublic() { return { ok: true }; },
async updatePublicationStatus() { return { ok: true }; },
},
cleanupService: {
async listCandidates() { return []; },
async runCleanup() { return { ok: true }; },
},
agentJobService: {
async cancelJob() { return { ok: true }; },
async claimJob() { return { ok: true }; },
async claimNextJob() { return null; },
async completeJob() { return { ok: true }; },
async createJob() { return { ok: true }; },
async getAssetForJob() { return { ok: true }; },
async getJob() { return { ok: true }; },
async heartbeat() { return { ok: true }; },
async listJobs() { return []; },
async reapStaleJobs() { return 0; },
async retryJob() { return { ok: true }; },
},
};
}
async function runRequest(handler, { method = 'GET', path = '/', headers = {}, body = '' } = {}) {
const chunks = [];
const req = {
method,
url: path,
headers,
async *[Symbol.asyncIterator]() {
if (body) yield Buffer.from(body);
},
};
const res = {
statusCode: 0,
headers: {},
writeHead(statusCode, responseHeaders) {
this.statusCode = statusCode;
this.headers = responseHeaders;
},
end(chunk) {
if (chunk) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
},
};
await handler(req, res);
return {
statusCode: res.statusCode,
headers: res.headers,
body: chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : null,
};
}
test('health and contract endpoints are exposed without auth', async () => {
const handler = await createMindSpaceRpcRequestHandler({
adapter: createStubAdapter(),
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
serviceMeta: {
buildId: 'mindspace-test-build',
gitSha: 'abc123',
builtAt: '2026-07-27T00:00:00.000Z',
},
});
const health = await runRequest(handler, { path: '/health' });
const contract = await runRequest(handler, { path: '/mindspace/v1/contract' });
assert.equal(health.statusCode, 200);
assert.equal(health.body.ok, true);
assert.equal(health.body.contractVersion, 2);
assert.equal(health.body.buildId, 'mindspace-test-build');
assert.equal(contract.statusCode, 200);
assert.equal(contract.body.contractVersion, 2);
assert.equal(contract.body.service, 'mindspace-service');
assert.equal(contract.body.buildId, 'mindspace-test-build');
assert.equal(contract.body.gitSha, 'abc123');
assert.ok(
contract.body.requiredCapabilities.includes(
'workspace-publication-delivery-authority',
),
);
assert.ok(
contract.body.requiredBindings.workspaceToolService.includes(
'publishPage',
),
);
assert.ok(contract.body.bindings.assetService.includes('renderAssetPreview'));
assert.ok(
contract.body.bindings.chatSaveService.includes(
'materializeWorkspaceHtml',
),
);
assert.ok(
contract.body.bindings.conversationArtifactService
.includes('registerPublicHtmlArtifacts'),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'syncAfterFinish',
),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'preparePageDataAfterFinish',
),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'prepareWechatPageDataDelivery',
),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'prepareWechatHtmlDelivery',
),
);
assert.ok(
contract.body.bindings.publicFinishService.includes(
'ensureWechatFreshPageThumbnails',
),
);
assert.ok(
contract.body.bindings
.workspacePublicationDeliveryService
.includes('resolveWorkspaceRequest'),
);
assert.ok(
contract.body.bindings
.workspaceToolService
.includes('writeBinaryFile'),
);
assert.ok(
contract.body.bindings
.workspaceToolService
.includes('generateLongImage'),
);
});
test('RPC startup rejects an undersized MCP signing secret', async () => {
await assert.rejects(
createMindSpaceRpcRequestHandler({
adapter: createStubAdapter(),
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
'too-short',
},
}),
/at least 16 characters/,
);
});
test('RPC startup does not require MCP token verifier when scoped MCP is disabled', async (t) => {
const memindRoot = fs.mkdtempSync(
path.join(os.tmpdir(), 'mindspace-rpc-no-mcp-'),
);
t.after(() => {
fs.rmSync(memindRoot, {
recursive: true,
force: true,
});
});
fs.copyFileSync(
path.resolve(
'mindspace-server-adapter-contract.mjs',
),
path.join(
memindRoot,
'mindspace-server-adapter-contract.mjs',
),
);
const handler =
await createMindSpaceRpcRequestHandler({
adapter: createStubAdapter(),
env: {
MINDSPACE_MEMIND_ROOT: memindRoot,
},
});
const health = await runRequest(handler, {
method: 'GET',
path: '/health',
});
assert.equal(health.statusCode, 200);
assert.equal(
health.body.mcpScopedToolsEnabled,
false,
);
});
test('rpc invocation enforces auth token and dispatches args to adapter method', async () => {
const handler = await createMindSpaceRpcRequestHandler({
adapter: createStubAdapter(),
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_REMOTE_AUTH_TOKEN: 'secret-token',
},
});
const unauthorized = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/adapter/assetService/renderAssetPreview',
body: JSON.stringify({ args: [{ assetId: 'a1' }] }),
});
const authorized = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/adapter/assetService/renderAssetPreview',
headers: {
authorization: 'Bearer secret-token',
},
body: JSON.stringify({ args: [{ assetId: 'a1' }] }),
});
assert.equal(unauthorized.statusCode, 401);
assert.equal(authorized.statusCode, 200);
assert.deepEqual(authorized.body, { preview: true });
});
test('rpc invocation revives JSON-serialized buffers before dispatch', async () => {
const adapter = createStubAdapter();
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
});
const payload = Buffer.from('asset parity payload');
const response = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/adapter/assetService/writeUploadContent',
body: JSON.stringify({ args: ['user-1', 'upload-1', payload] }),
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { isBuffer: true, size: payload.length });
assert.equal(Buffer.isBuffer(adapter.calls.writeUploadContent[0][2]), true);
});
test('rpc revives generated artifact bodies before dispatching to MindSpace', async () => {
const adapter = createStubAdapter();
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
});
const payload = {
userId: 'user-1',
sessionId: 'session-1',
messageId: 'message-1',
filename: 'chat.docx',
body: Buffer.from('docx payload'),
};
const response = await runRequest(handler, {
method: 'POST',
path:
'/mindspace/v1/adapter/conversationArtifactService/registerChatDocxArtifact',
body: JSON.stringify({ args: [payload] }),
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { id: 'docx-1' });
assert.equal(
Buffer.isBuffer(
adapter.calls.registerChatDocxArtifact[0][0].body,
),
true,
);
});
test('rpc maps publication_not_found to 404 with error code', async () => {
const adapter = createStubAdapter();
adapter.publicationService.resolvePublic = async () => {
const error = new Error('公开页面不存在');
error.code = 'publication_not_found';
throw error;
};
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
});
const response = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/adapter/publicationService/resolvePublic',
body: JSON.stringify({ args: ['john', 'missing-page', null, null, {}] }),
});
assert.equal(response.statusCode, 404);
assert.equal(response.body.code, 'publication_not_found');
assert.match(response.body.message, /公开页面不存在/);
});
test('rpc maps publication_login_required to 401 with error code', async () => {
const adapter = createStubAdapter();
adapter.publicationService.resolvePublic = async () => {
const error = new Error('登录后才能访问此页面');
error.code = 'publication_login_required';
throw error;
};
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
});
const response = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/adapter/publicationService/resolvePublic',
body: JSON.stringify({ args: ['john', 'login-page', null, null, {}] }),
});
assert.equal(response.statusCode, 401);
assert.equal(response.body.code, 'publication_login_required');
});
test('rpc maps chat save validation errors to 400 with error code', async () => {
const adapter = createStubAdapter();
adapter.chatSaveService.materializeWorkspaceHtml =
async () => {
const error = new Error('无效的页面路径');
error.code = 'invalid_page_path';
throw error;
};
const handler = await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
},
});
const response = await runRequest(handler, {
method: 'POST',
path:
'/mindspace/v1/adapter/chatSaveService/materializeWorkspaceHtml',
body: JSON.stringify({
args: [{
userId: 'user-1',
html: '<html>unsafe</html>',
relativePath: '../unsafe.html',
}],
}),
});
assert.equal(response.statusCode, 400);
assert.equal(response.body.code, 'invalid_page_path');
});
test('scoped MCP RPC injects token claims and dispatches only allowed workspace tools', async () => {
const adapter = createStubAdapter();
const secret =
'mindspace-mcp-test-secret-1234';
const token = mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: ['write_file'],
});
const handler =
await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
secret,
},
});
const response = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/mcp/write_file',
headers: {
authorization: `Bearer ${token}`,
},
body: JSON.stringify({
arguments: {
path: 'public/page.html',
content: '<html></html>',
},
}),
});
assert.equal(response.statusCode, 200);
assert.equal(response.body.ok, true);
assert.deepEqual(
adapter.calls.workspaceTools[0],
[
'writeFile',
{
path: 'public/page.html',
content: '<html></html>',
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
},
],
);
});
test('scoped MCP RPC dispatches binary writes and long-image generation to MindSpace', async () => {
const adapter = createStubAdapter();
const secret =
'mindspace-mcp-test-secret-1234';
const token = mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: [
'generate_long_image',
'write_binary_file',
],
});
const handler =
await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
secret,
},
});
const headers = {
authorization: `Bearer ${token}`,
};
const binary = await runRequest(handler, {
method: 'POST',
path:
'/mindspace/v1/mcp/write_binary_file',
headers,
body: JSON.stringify({
arguments: {
path: 'public/report.docx',
bodyBase64:
Buffer.from('PK').toString(
'base64',
),
},
}),
});
const longImage = await runRequest(
handler,
{
method: 'POST',
path:
'/mindspace/v1/mcp/generate_long_image',
headers,
body: JSON.stringify({
arguments: {
html_path:
'public/page.html',
output_path:
'public/page.long.png',
},
}),
},
);
assert.equal(binary.statusCode, 200);
assert.equal(longImage.statusCode, 200);
assert.deepEqual(
adapter.calls.workspaceTools.map(
([method]) => method,
),
[
'writeBinaryFile',
'generateLongImage',
],
);
assert.equal(
adapter.calls.workspaceTools[0][1]
.packageId,
'cp_session-1',
);
});
test('scoped MCP RPC rejects tools and scope overrides outside the token', async () => {
const adapter = createStubAdapter();
const secret =
'mindspace-mcp-test-secret-1234';
const readToken =
mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: ['read_file'],
});
const writeToken =
mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: ['write_file'],
});
const handler =
await createMindSpaceRpcRequestHandler({
adapter,
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
secret,
},
});
const forbidden = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/mcp/write_file',
headers: {
authorization: `Bearer ${readToken}`,
},
body: JSON.stringify({
arguments: {
path: 'public/page.html',
content: '<html></html>',
},
}),
});
const scopeMismatch = await runRequest(
handler,
{
method: 'POST',
path:
'/mindspace/v1/mcp/write_file',
headers: {
authorization: `Bearer ${writeToken}`,
},
body: JSON.stringify({
arguments: {
workspaceRef:
'mindspace://users/user-2/workspace',
path: 'public/page.html',
content: '<html></html>',
},
}),
},
);
assert.equal(forbidden.statusCode, 403);
assert.equal(
forbidden.body.code,
'mcp_tool_forbidden',
);
assert.equal(scopeMismatch.statusCode, 403);
assert.equal(
scopeMismatch.body.code,
'mcp_scope_mismatch',
);
assert.equal(
adapter.calls.workspaceTools.length,
0,
);
});
test('scoped MCP RPC bounds request bodies and reports invalid JSON as client errors', async () => {
const secret =
'mindspace-mcp-test-secret-1234';
const token = mintMindSpaceMcpScopedToken({
secret,
userId: 'user-1',
sessionId: 'session-1',
packageId: 'cp_session-1',
workspaceRef:
'mindspace://users/user-1/workspace',
tools: ['write_file'],
});
const handler =
await createMindSpaceRpcRequestHandler({
adapter: createStubAdapter(),
env: {
MINDSPACE_MEMIND_ROOT: '..',
MINDSPACE_MCP_TOKEN_SECRET:
secret,
MINDSPACE_MCP_MAX_BODY_BYTES:
'1024',
},
});
const headers = {
authorization: `Bearer ${token}`,
};
const tooLarge = await runRequest(handler, {
method: 'POST',
path: '/mindspace/v1/mcp/write_file',
headers,
body: JSON.stringify({
arguments: {
path: 'oa/large.txt',
content: 'x'.repeat(1200),
},
}),
});
const invalidJson = await runRequest(
handler,
{
method: 'POST',
path:
'/mindspace/v1/mcp/write_file',
headers,
body: '{invalid',
},
);
assert.equal(tooLarge.statusCode, 413);
assert.equal(
tooLarge.body.code,
'request_body_too_large',
);
assert.equal(invalidJson.statusCode, 400);
assert.equal(
invalidJson.body.code,
'invalid_json_body',
);
});