mindspace: close authority boundaries
This commit is contained in:
@@ -0,0 +1,351 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createMindSpaceChatSaveService,
|
||||
mindspaceChatSaveServiceInternals,
|
||||
} from './mindspace-chat-save-service.mjs';
|
||||
|
||||
const USER_ID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
test('chat save authority creates localized shared HTML and its public URL inside MindSpace', async () => {
|
||||
const calls = [];
|
||||
const service = createMindSpaceChatSaveService({
|
||||
pool: { query: async () => [[]] },
|
||||
h5Root: '/srv/h5',
|
||||
storageRoot: '/srv/storage',
|
||||
resolveMindSpaceUserPublishDirFn: () =>
|
||||
'/srv/h5/MindSpace/user-1',
|
||||
async inlinePrivateAssetsInHtmlFn(
|
||||
pool,
|
||||
storageRoot,
|
||||
userId,
|
||||
html,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'inline',
|
||||
pool,
|
||||
storageRoot,
|
||||
userId,
|
||||
html,
|
||||
});
|
||||
return { html: '<html>localized</html>' };
|
||||
},
|
||||
rewriteWorkspacePublicAssetReferencesFn(
|
||||
html,
|
||||
relativePath,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'rewrite',
|
||||
html,
|
||||
relativePath,
|
||||
});
|
||||
return '<html>shared</html>';
|
||||
},
|
||||
async mkdirFn(directory, options) {
|
||||
calls.push({ kind: 'mkdir', directory, options });
|
||||
},
|
||||
async writeFileFn(...args) {
|
||||
calls.push({ kind: 'write', args });
|
||||
},
|
||||
randomUUIDFn: () =>
|
||||
'12345678-1234-4123-8123-123456789abc',
|
||||
buildMindSpacePublicUrlForUserFn(input) {
|
||||
calls.push({ kind: 'url', input });
|
||||
return 'https://example.com/MindSpace/user-1/public/shared/report-12345678.html';
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.createSharedHtml({
|
||||
userId: 'user-1',
|
||||
html: '<html>private</html>',
|
||||
sourceFilename: '../unsafe report.html',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.relativePath,
|
||||
'public/shared/unsafe-report-12345678.html',
|
||||
);
|
||||
assert.equal(
|
||||
result.publicUrl,
|
||||
'https://example.com/MindSpace/user-1/public/shared/report-12345678.html',
|
||||
);
|
||||
assert.equal(calls[0].kind, 'inline');
|
||||
assert.equal(calls[1].kind, 'rewrite');
|
||||
assert.equal(calls[2].kind, 'mkdir');
|
||||
assert.equal(calls[3].kind, 'write');
|
||||
assert.equal(
|
||||
calls[3].args[0],
|
||||
'/srv/h5/MindSpace/user-1/public/shared/unsafe-report-12345678.html',
|
||||
);
|
||||
assert.equal(calls[3].args[1], '<html>shared</html>');
|
||||
assert.equal(calls[4].kind, 'url');
|
||||
});
|
||||
|
||||
test('chat save authority validates and reads workspace HTML inside MindSpace', async () => {
|
||||
const calls = [];
|
||||
const service = createMindSpaceChatSaveService({
|
||||
pool: { query: async () => [[]] },
|
||||
h5Root: '/srv/h5',
|
||||
storageRoot: '/srv/storage',
|
||||
resolvePublishHtmlAbsolutePathFn(
|
||||
h5Root,
|
||||
userId,
|
||||
relativePath,
|
||||
) {
|
||||
calls.push({
|
||||
kind: 'resolve',
|
||||
h5Root,
|
||||
userId,
|
||||
relativePath,
|
||||
});
|
||||
return '/srv/h5/MindSpace/user-1/public/page.html';
|
||||
},
|
||||
async readFileFn(...args) {
|
||||
calls.push({ kind: 'read', args });
|
||||
return '<html><title>Page</title></html>';
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.readWorkspaceHtml({
|
||||
userId: 'user-1',
|
||||
relativePath: 'public/page.html',
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.html,
|
||||
'<html><title>Page</title></html>',
|
||||
);
|
||||
assert.equal(result.relativePath, 'public/page.html');
|
||||
assert.equal(calls[0].kind, 'resolve');
|
||||
assert.equal(calls[1].kind, 'read');
|
||||
});
|
||||
|
||||
test('chat save authority repairs, materializes, and writes workspace HTML inside MindSpace', async () => {
|
||||
const h5Root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'mindspace-chat-save-authority-'),
|
||||
);
|
||||
const storageRoot = path.join(h5Root, 'data', 'mindspace');
|
||||
const storageKey = 'users/u1/assets/asset-good/source.png';
|
||||
const htmlPath = path.join(
|
||||
h5Root,
|
||||
'MindSpace',
|
||||
USER_ID,
|
||||
'public',
|
||||
'report.html',
|
||||
);
|
||||
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
|
||||
await fs.mkdir(
|
||||
path.dirname(path.join(storageRoot, storageKey)),
|
||||
{ recursive: true },
|
||||
);
|
||||
await fs.writeFile(htmlPath, '<html>old</html>', 'utf8');
|
||||
await fs.writeFile(
|
||||
path.join(storageRoot, storageKey),
|
||||
Buffer.from('png payload'),
|
||||
);
|
||||
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('SELECT id FROM h5_assets')) {
|
||||
return [[{ id: 'asset-good' }]];
|
||||
}
|
||||
if (sql.includes('JOIN h5_asset_versions')) {
|
||||
return [[{
|
||||
id: 'asset-good',
|
||||
mime_type: 'image/png',
|
||||
original_filename: 'source.png',
|
||||
storage_key: storageKey,
|
||||
}]];
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
},
|
||||
};
|
||||
let writeCount = 0;
|
||||
const service = createMindSpaceChatSaveService({
|
||||
pool,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
writeFileFn: async (...args) => {
|
||||
writeCount += 1;
|
||||
return fs.writeFile(...args);
|
||||
},
|
||||
});
|
||||
const sourceContent =
|
||||
`/api/mindspace/v1/assets/asset-good/download`;
|
||||
const html =
|
||||
'<html><img src="/api/mindspace/v1/assets/asset-missing/download"></html>';
|
||||
|
||||
const first = await service.materializeWorkspaceHtml({
|
||||
userId: USER_ID,
|
||||
sourceContent,
|
||||
html,
|
||||
relativePath: 'public/report.html',
|
||||
});
|
||||
|
||||
assert.equal(first.changed, true);
|
||||
assert.equal(first.repairedAssetReferences, true);
|
||||
assert.equal(first.materializedAssetCount, 1);
|
||||
assert.equal(first.relativePath, 'public/report.html');
|
||||
assert.match(first.html, /\.tmp-images\/asset-good\.png/);
|
||||
assert.doesNotMatch(first.html, /\/api\/mindspace\/v1\/assets\//);
|
||||
assert.equal(await fs.readFile(htmlPath, 'utf8'), first.html);
|
||||
assert.equal(
|
||||
(
|
||||
await fs.readFile(
|
||||
path.join(
|
||||
h5Root,
|
||||
'MindSpace',
|
||||
USER_ID,
|
||||
'public',
|
||||
'.tmp-images',
|
||||
'asset-good.png',
|
||||
),
|
||||
)
|
||||
).toString(),
|
||||
'png payload',
|
||||
);
|
||||
|
||||
const second = await service.materializeWorkspaceHtml({
|
||||
userId: USER_ID,
|
||||
sourceContent,
|
||||
html: first.html,
|
||||
relativePath: 'public/report.html',
|
||||
});
|
||||
assert.equal(second.changed, false);
|
||||
assert.equal(writeCount, 1);
|
||||
});
|
||||
|
||||
test('chat save authority renders preview thumbnail svg inside MindSpace', async () => {
|
||||
const h5Root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'mindspace-chat-save-thumb-'),
|
||||
);
|
||||
const storageRoot = path.join(h5Root, 'data', 'mindspace');
|
||||
const htmlPath = path.join(
|
||||
h5Root,
|
||||
'MindSpace',
|
||||
USER_ID,
|
||||
'public',
|
||||
'report.html',
|
||||
);
|
||||
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
|
||||
await fs.writeFile(htmlPath, '<html><title>Report</title></html>', 'utf8');
|
||||
|
||||
const pool = { query: async () => [[]] };
|
||||
const service = createMindSpaceChatSaveService({
|
||||
pool,
|
||||
h5Root,
|
||||
storageRoot,
|
||||
});
|
||||
const rendered = await service.renderPreviewThumbnailSvg({
|
||||
userId: USER_ID,
|
||||
relativePath: 'public/report.html',
|
||||
html: '<html><title>Report</title></html>',
|
||||
title: 'Report',
|
||||
subtitle: 'Summary',
|
||||
force: true,
|
||||
});
|
||||
|
||||
assert.match(rendered.svg, /<svg/i);
|
||||
assert.equal(rendered.contentType, 'image/svg+xml; charset=utf-8');
|
||||
assert.equal(rendered.relativePath, 'public/report.html');
|
||||
});
|
||||
|
||||
test('chat save authority ensures preview thumbnail sidecars', async () => {
|
||||
const h5Root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'mindspace-chat-save-ensure-'),
|
||||
);
|
||||
const storageRoot = path.join(h5Root, 'data', 'mindspace');
|
||||
const htmlPath = path.join(
|
||||
h5Root,
|
||||
'MindSpace',
|
||||
USER_ID,
|
||||
'public',
|
||||
'report.html',
|
||||
);
|
||||
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
|
||||
await fs.writeFile(htmlPath, '<html><title>Report</title></html>', 'utf8');
|
||||
|
||||
const service = createMindSpaceChatSaveService({
|
||||
pool: { query: async () => [[]] },
|
||||
h5Root,
|
||||
storageRoot,
|
||||
});
|
||||
const result = await service.ensurePreviewThumbnail({
|
||||
userId: USER_ID,
|
||||
relativePath: 'public/report.html',
|
||||
html: '<html><title>Report</title></html>',
|
||||
title: 'Report',
|
||||
subtitle: 'Summary',
|
||||
force: true,
|
||||
});
|
||||
|
||||
assert.equal(result.ready, true);
|
||||
assert.equal(result.relativePath, 'public/report.html');
|
||||
await fs.access(
|
||||
path.join(
|
||||
h5Root,
|
||||
'MindSpace',
|
||||
USER_ID,
|
||||
'public',
|
||||
'report.thumbnail.svg',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('chat save authority rejects paths outside the public HTML zone', async () => {
|
||||
const service = createMindSpaceChatSaveService({
|
||||
pool: { query: async () => [[]] },
|
||||
h5Root: '/tmp/h5',
|
||||
storageRoot: '/tmp/storage',
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
service.materializeWorkspaceHtml({
|
||||
userId: USER_ID,
|
||||
html: '<html>unsafe</html>',
|
||||
relativePath: '../private/report.html',
|
||||
}),
|
||||
(error) => error?.code === 'invalid_page_path',
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
mindspaceChatSaveServiceInternals
|
||||
.normalizePublicHtmlRelativePath('oa/report.html'),
|
||||
(error) => error?.code === 'invalid_page_path',
|
||||
);
|
||||
});
|
||||
|
||||
test('chat save authority preserves best-effort repair behavior', async () => {
|
||||
let writeCalled = false;
|
||||
const service = createMindSpaceChatSaveService({
|
||||
pool: { query: async () => [[]] },
|
||||
h5Root: '/tmp/h5',
|
||||
storageRoot: '/tmp/storage',
|
||||
repairMissingHtmlAssetReferencesFn: async () => {
|
||||
throw new Error('repair unavailable');
|
||||
},
|
||||
materializePrivateAssetsInWorkspaceHtmlFn: async () => {
|
||||
throw new Error('storage unavailable');
|
||||
},
|
||||
writeFileFn: async () => {
|
||||
writeCalled = true;
|
||||
},
|
||||
});
|
||||
const html = '<html><body>unchanged</body></html>';
|
||||
|
||||
const result = await service.materializeWorkspaceHtml({
|
||||
userId: USER_ID,
|
||||
sourceContent: 'source',
|
||||
html,
|
||||
relativePath: 'report.html',
|
||||
});
|
||||
|
||||
assert.equal(result.html, html);
|
||||
assert.equal(result.relativePath, 'public/report.html');
|
||||
assert.equal(result.changed, false);
|
||||
assert.equal(writeCalled, false);
|
||||
});
|
||||
Reference in New Issue
Block a user