478 lines
10 KiB
JavaScript
478 lines
10 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 {
|
|
createMindSpaceWorkspaceToolService,
|
|
normalizeMindSpaceWorkspacePath,
|
|
parseMindSpaceWorkspaceRef,
|
|
} from './mindspace-workspace-tool-service.mjs';
|
|
|
|
function createSetup() {
|
|
const calls = [];
|
|
const workspaceRoot = fs.mkdtempSync(
|
|
path.join(
|
|
os.tmpdir(),
|
|
'mindspace-workspace-tools-',
|
|
),
|
|
);
|
|
fs.mkdirSync(
|
|
path.join(workspaceRoot, 'public'),
|
|
{ recursive: true },
|
|
);
|
|
const service =
|
|
createMindSpaceWorkspaceToolService({
|
|
h5Root: '/srv/h5',
|
|
resolveMindSpaceUserPublishDirFn() {
|
|
return workspaceRoot;
|
|
},
|
|
async syncWorkspaceAssets(
|
|
userId,
|
|
options,
|
|
) {
|
|
calls.push({
|
|
kind: 'asset-sync',
|
|
userId,
|
|
options,
|
|
});
|
|
return {
|
|
imported: 1,
|
|
updated: 0,
|
|
skipped: 0,
|
|
};
|
|
},
|
|
conversationArtifactService: {
|
|
async registerPublicHtmlArtifacts(
|
|
input,
|
|
) {
|
|
calls.push({
|
|
kind: 'public-html',
|
|
input,
|
|
});
|
|
return [
|
|
{ artifactId: 'artifact-1' },
|
|
];
|
|
},
|
|
async registerWorkspaceFileArtifact(
|
|
input,
|
|
) {
|
|
calls.push({
|
|
kind: 'workspace-artifact',
|
|
input,
|
|
});
|
|
return {
|
|
id: 'artifact-workspace-1',
|
|
};
|
|
},
|
|
},
|
|
workspacePageDeliveryService: {
|
|
async syncAndDeliver(
|
|
userId,
|
|
options,
|
|
) {
|
|
calls.push({
|
|
kind: 'deliver',
|
|
userId,
|
|
options,
|
|
});
|
|
return {
|
|
publish: {
|
|
published: 1,
|
|
},
|
|
};
|
|
},
|
|
},
|
|
buildMindSpacePublicUrlForUserFn({
|
|
user,
|
|
relativePath,
|
|
}) {
|
|
return `https://example.com/MindSpace/${user.id}/${relativePath}`;
|
|
},
|
|
async renderLongImageFn({
|
|
url,
|
|
outputPath,
|
|
}) {
|
|
calls.push({
|
|
kind: 'render-long-image',
|
|
url,
|
|
outputPath,
|
|
});
|
|
fs.writeFileSync(
|
|
outputPath,
|
|
Buffer.from([
|
|
0x89, 0x50, 0x4e, 0x47,
|
|
]),
|
|
);
|
|
return { bytes: 4 };
|
|
},
|
|
});
|
|
return {
|
|
calls,
|
|
service,
|
|
workspaceRoot,
|
|
scope: {
|
|
workspaceRef:
|
|
'mindspace://users/user-1/workspace',
|
|
sessionId: 'session-1',
|
|
packageId: 'cp_session-1',
|
|
},
|
|
};
|
|
}
|
|
|
|
test('workspace tool service reads, writes, edits, and registers generated artifacts', async (t) => {
|
|
const setup = createSetup();
|
|
t.after(() => {
|
|
fs.rmSync(setup.workspaceRoot, {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
});
|
|
|
|
const written =
|
|
await setup.service.writeFile({
|
|
...setup.scope,
|
|
path: 'public/report.html',
|
|
content:
|
|
'<!doctype html><title>Report</title>',
|
|
messageId: 'message-1',
|
|
});
|
|
const edited =
|
|
await setup.service.editFile({
|
|
...setup.scope,
|
|
path: 'public/report.html',
|
|
old_str: 'Report',
|
|
new_str: 'Updated',
|
|
messageId: 'message-1',
|
|
});
|
|
const read = await setup.service.readFile({
|
|
...setup.scope,
|
|
path: 'public/report.html',
|
|
});
|
|
const listed =
|
|
await setup.service.listDirectory({
|
|
...setup.scope,
|
|
path: 'public',
|
|
});
|
|
|
|
assert.equal(
|
|
written.relativePath,
|
|
'public/report.html',
|
|
);
|
|
assert.equal(
|
|
edited.packageId,
|
|
'cp_session-1',
|
|
);
|
|
assert.match(read.content, /Updated/);
|
|
assert.deepEqual(listed.entries, [
|
|
{ name: 'report.html', type: 'file' },
|
|
]);
|
|
assert.deepEqual(
|
|
setup.calls
|
|
.map((call) => call.kind),
|
|
[
|
|
'public-html',
|
|
'asset-sync',
|
|
'public-html',
|
|
'asset-sync',
|
|
],
|
|
);
|
|
assert.deepEqual(
|
|
setup.calls[1].options.onlyRelativePaths,
|
|
['public/report.html'],
|
|
);
|
|
});
|
|
|
|
test('non-public workspace writes are synchronized and registered even when asset sync may skip unchanged content', async (t) => {
|
|
const setup = createSetup();
|
|
t.after(() => {
|
|
fs.rmSync(setup.workspaceRoot, {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
});
|
|
|
|
const result =
|
|
await setup.service.writeFile({
|
|
...setup.scope,
|
|
path: 'oa/notes.txt',
|
|
content: 'generated notes',
|
|
messageId: 'message-2',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
setup.calls.map((call) => call.kind),
|
|
['workspace-artifact', 'asset-sync'],
|
|
);
|
|
assert.equal(
|
|
setup.calls[0].input.relativePath,
|
|
'oa/notes.txt',
|
|
);
|
|
assert.equal(
|
|
setup.calls[0].input.body,
|
|
'generated notes',
|
|
);
|
|
assert.equal(
|
|
result.registration.workspaceFileArtifact.id,
|
|
'artifact-workspace-1',
|
|
);
|
|
});
|
|
|
|
test('binary workspace writes persist and register package artifacts without exposing physical paths', async (t) => {
|
|
const setup = createSetup();
|
|
t.after(() => {
|
|
fs.rmSync(setup.workspaceRoot, {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
});
|
|
const body = Buffer.from(
|
|
'PK generated docx',
|
|
'utf8',
|
|
);
|
|
|
|
const result =
|
|
await setup.service.writeBinaryFile({
|
|
...setup.scope,
|
|
path: 'public/report.docx',
|
|
bodyBase64: body.toString('base64'),
|
|
messageId: 'message-docx',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
fs.readFileSync(
|
|
path.join(
|
|
setup.workspaceRoot,
|
|
'public',
|
|
'report.docx',
|
|
),
|
|
),
|
|
body,
|
|
);
|
|
assert.deepEqual(
|
|
setup.calls.map((call) => call.kind),
|
|
['workspace-artifact', 'asset-sync'],
|
|
);
|
|
assert.equal(
|
|
Buffer.isBuffer(
|
|
setup.calls[0].input.body,
|
|
),
|
|
true,
|
|
);
|
|
assert.equal(result.sizeBytes, body.length);
|
|
assert.doesNotMatch(
|
|
JSON.stringify(result),
|
|
new RegExp(setup.workspaceRoot),
|
|
);
|
|
});
|
|
|
|
test('long-image generation runs inside MindSpace and registers the generated PNG', async (t) => {
|
|
const setup = createSetup();
|
|
t.after(() => {
|
|
fs.rmSync(setup.workspaceRoot, {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(
|
|
setup.workspaceRoot,
|
|
'public',
|
|
'report.html',
|
|
),
|
|
'<!doctype html><title>Report</title>',
|
|
);
|
|
|
|
const result =
|
|
await setup.service.generateLongImage({
|
|
...setup.scope,
|
|
htmlPath: 'public/report.html',
|
|
outputPath:
|
|
'public/report.long.png',
|
|
messageId: 'message-image',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
setup.calls.map((call) => call.kind),
|
|
[
|
|
'render-long-image',
|
|
'workspace-artifact',
|
|
'asset-sync',
|
|
],
|
|
);
|
|
assert.equal(
|
|
setup.calls[1].input.relativePath,
|
|
'public/report.long.png',
|
|
);
|
|
assert.equal(
|
|
setup.calls[0].url,
|
|
'https://example.com/MindSpace/user-1/public/report.html',
|
|
);
|
|
assert.equal(
|
|
result.canonicalUrl,
|
|
'https://example.com/MindSpace/user-1/public/report.long.png',
|
|
);
|
|
assert.doesNotMatch(
|
|
JSON.stringify(result),
|
|
new RegExp(setup.workspaceRoot),
|
|
);
|
|
});
|
|
|
|
test('workspace tool service refuses to start without direct package artifact authority', () => {
|
|
assert.throws(
|
|
() =>
|
|
createMindSpaceWorkspaceToolService({
|
|
h5Root: '/srv/h5',
|
|
conversationArtifactService: {
|
|
async registerPublicHtmlArtifacts() {
|
|
return [];
|
|
},
|
|
},
|
|
}),
|
|
/registerWorkspaceFileArtifact/,
|
|
);
|
|
});
|
|
|
|
test('publish_page returns a canonical logical URL and delivers only the selected page', async (t) => {
|
|
const setup = createSetup();
|
|
t.after(() => {
|
|
fs.rmSync(setup.workspaceRoot, {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(
|
|
setup.workspaceRoot,
|
|
'public',
|
|
'report.html',
|
|
),
|
|
'<!doctype html><title>Report</title>',
|
|
);
|
|
|
|
const result =
|
|
await setup.service.publishPage({
|
|
...setup.scope,
|
|
path: 'public/report.html',
|
|
});
|
|
|
|
assert.equal(
|
|
result.canonicalUrl,
|
|
'https://example.com/MindSpace/user-1/public/report.html',
|
|
);
|
|
assert.equal(
|
|
setup.calls.at(-1).kind,
|
|
'deliver',
|
|
);
|
|
assert.deepEqual(
|
|
setup.calls.at(-1).options
|
|
.pageDataRelativePaths,
|
|
['public/report.html'],
|
|
);
|
|
});
|
|
|
|
test('workspace tool service rejects path, package, browser-storage, and symlink escapes', async (t) => {
|
|
const setup = createSetup();
|
|
const outsideRoot = fs.mkdtempSync(
|
|
path.join(os.tmpdir(), 'mindspace-outside-'),
|
|
);
|
|
t.after(() => {
|
|
fs.rmSync(setup.workspaceRoot, {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
fs.rmSync(outsideRoot, {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
});
|
|
fs.symlinkSync(
|
|
outsideRoot,
|
|
path.join(setup.workspaceRoot, 'escape'),
|
|
);
|
|
|
|
await assert.rejects(
|
|
setup.service.writeFile({
|
|
...setup.scope,
|
|
path: '../outside.html',
|
|
content: 'bad',
|
|
}),
|
|
(error) =>
|
|
error?.code === 'invalid_workspace_path',
|
|
);
|
|
await assert.rejects(
|
|
setup.service.writeFile({
|
|
...setup.scope,
|
|
packageId: 'cp_other-session',
|
|
path: 'public/report.html',
|
|
content: 'bad',
|
|
}),
|
|
(error) =>
|
|
error?.code ===
|
|
'invalid_workspace_package_scope',
|
|
);
|
|
await assert.rejects(
|
|
setup.service.writeFile({
|
|
...setup.scope,
|
|
path: 'public/report.html',
|
|
content:
|
|
'<script>localStorage.setItem("x", "y")</script>',
|
|
}),
|
|
/localStorage/,
|
|
);
|
|
await assert.rejects(
|
|
setup.service.writeFile({
|
|
...setup.scope,
|
|
path: 'escape/report.html',
|
|
content: 'bad',
|
|
}),
|
|
(error) =>
|
|
error?.code === 'invalid_workspace_path',
|
|
);
|
|
await assert.rejects(
|
|
setup.service.writeBinaryFile({
|
|
...setup.scope,
|
|
path: 'public/report.docx',
|
|
bodyBase64: 'not-base64',
|
|
}),
|
|
(error) =>
|
|
error?.code ===
|
|
'invalid_workspace_binary',
|
|
);
|
|
});
|
|
|
|
test('workspace refs and logical paths normalize without exposing physical roots', () => {
|
|
assert.deepEqual(
|
|
parseMindSpaceWorkspaceRef(
|
|
'mindspace://users/user-1/workspace',
|
|
),
|
|
{
|
|
workspaceRef:
|
|
'mindspace://users/user-1/workspace',
|
|
userId: 'user-1',
|
|
},
|
|
);
|
|
assert.equal(
|
|
normalizeMindSpaceWorkspacePath(
|
|
'public\\report.html',
|
|
),
|
|
'public/report.html',
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
normalizeMindSpaceWorkspacePath(
|
|
'/srv/h5/MindSpace/user-1/public/report.html',
|
|
),
|
|
(error) =>
|
|
error?.code === 'invalid_workspace_path',
|
|
);
|
|
assert.throws(
|
|
() =>
|
|
parseMindSpaceWorkspaceRef(
|
|
'mindspace://users/user%2Fother/workspace',
|
|
),
|
|
(error) =>
|
|
error?.code === 'invalid_workspace_ref',
|
|
);
|
|
});
|