352 lines
9.6 KiB
JavaScript
352 lines
9.6 KiB
JavaScript
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);
|
|
});
|