mindspace: close authority boundaries
This commit is contained in:
@@ -0,0 +1,477 @@
|
||||
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',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user