Files
memind/mindspace-pages.test.mjs
john d3239ff292 fix: MindSpace remote sync, thumbnail fallback, and Plaza URL guards
Ensure page sync runs via pageSyncService in remote mode, fall back to
workspace HTML when storage assets are missing, and prevent production
Portal from linking to loopback Plaza URLs baked in at build time.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 07:57:04 +08:00

298 lines
10 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 { createPageService, pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
import { PUBLISH_ROOT_DIR } from './user-publish.mjs';
test('parseJsonColumn accepts mysql json objects and strings', () => {
const objectValue = { relative_path: 'public/demo.html', auto_synced: true };
assert.deepEqual(pageInternals.parseJsonColumn(objectValue), objectValue);
assert.deepEqual(
pageInternals.parseJsonColumn(JSON.stringify(objectValue)),
objectValue,
);
assert.deepEqual(pageInternals.parseJsonColumn(null, { ok: true }), { ok: true });
});
test('normalizePageInput trims values and constrains templates', () => {
const result = pageInternals.normalizePageInput({
title: ' 页面标题 ',
content: ' 页面正文 ',
templateId: 'unknown-template',
});
assert.equal(result.title, '页面标题');
assert.equal(result.content, '页面正文');
assert.equal(result.templateId, 'editorial');
assert.equal(result.contentBytes, Buffer.byteLength('页面正文'));
});
test('normalizePageInput rejects empty and oversized page content', () => {
assert.throws(
() => pageInternals.normalizePageInput({ title: '', content: '正文' }),
{ code: 'invalid_page_input' },
);
assert.throws(
() =>
pageInternals.normalizePageInput({
title: '页面',
content: '文'.repeat(400_000),
}),
{ code: 'page_content_too_large' },
);
});
test('normalizePageInput maps html content to static-html template', () => {
const result = pageInternals.normalizePageInput({
title: '页面',
content: '<!doctype html><html><body>Hi</body></html>',
contentFormat: 'html',
});
assert.equal(result.contentFormat, 'html');
assert.equal(result.templateId, 'static-html');
assert.equal(result.pageType, 'html');
});
test('renderHtmlPreview injects restrictive CSP', () => {
const html = pageInternals.renderHtmlPreview(
'<!doctype html><html><head></head><body><h1>Hi</h1></body></html>',
);
assert.match(html, /Content-Security-Policy/);
assert.match(html, /script-src 'none'/);
});
test('renderPublicationHtml serves html pages without escaping markup', () => {
const html = pageInternals.renderPublicationHtml({
page_type: 'html',
title: '麻婆豆腐',
summary: '川菜',
template_id: 'static-html',
version_no: 1,
content: '<!doctype html><html><head><title>麻婆豆腐</title></head><body><h1>麻婆豆腐</h1></body></html>',
});
assert.match(html, /<h1>麻婆豆腐<\/h1>/);
assert.doesNotMatch(html, /&lt;h1&gt;/);
assert.doesNotMatch(html, /MINDSPACE/);
});
test('normalizeListPageFilters clamps limit and offset', () => {
assert.deepEqual(pageInternals.normalizeListPageFilters({ limit: 999, offset: -5 }), {
limit: 100,
offset: 0,
});
assert.deepEqual(pageInternals.normalizeListPageFilters({ limit: 15, offset: 30 }), {
limit: 15,
offset: 30,
});
});
test('preview rendering escapes active HTML and emits a restrictive CSP', () => {
const html = pageInternals.renderPreviewHtml({
title: '<script>alert(1)</script>',
summary: '<img src=x onerror=alert(1)>',
content: '# 标题\n\n<script>alert(2)</script>',
templateId: 'report',
versionNo: 2,
});
assert.match(html, /Content-Security-Policy/);
assert.match(html, /default-src 'none'/);
assert.doesNotMatch(html, /<script>alert/);
assert.match(html, /&lt;script&gt;alert/);
assert.match(html, /DRAFT · V2/);
});
test('previewContentSecurityPolicy allows external assets for html pages', () => {
const htmlCsp = pageInternals.previewContentSecurityPolicy('html');
assert.match(htmlCsp, /style-src 'unsafe-inline' https:/);
assert.match(htmlCsp, /img-src data: https:/);
const markdownCsp = pageInternals.previewContentSecurityPolicy('markdown');
assert.match(markdownCsp, /style-src 'unsafe-inline'/);
assert.doesNotMatch(markdownCsp, /https:/);
});
test('buildPageDeleteSummary lists cascade delete impact', () => {
const lines = pageInternals.buildPageDeleteSummary({
page: { title: '麻婆豆腐', versionCount: 2 },
onlinePublication: { publicUrl: '/u/john/pages/mapo' },
offlinePublicationCount: 1,
plazaPosts: [{ id: 'post-1', title: '广场帖', status: 'published', publicationId: 'pub-1' }],
linkedAssets: [{ id: 'a1' }, { id: 'a2' }],
linkedAssetBytes: 2048,
linkedAgentJobCount: 1,
preservesSourceAsset: true,
});
assert.match(lines[0], /麻婆豆腐/);
assert.ok(lines.some((line) => line.includes('在线公开链接')));
assert.ok(lines.some((line) => line.includes('广场')));
assert.ok(lines.some((line) => line.includes('原始上传资料不会被删除')));
});
test('normalizeWorkspaceRelativePath unifies bare html filenames under public/', () => {
assert.equal(normalizeWorkspaceRelativePath('ai-robot-report.html'), 'public/ai-robot-report.html');
assert.equal(normalizeWorkspaceRelativePath('public/ai-robot-report.html'), 'public/ai-robot-report.html');
assert.equal(normalizeWorkspaceRelativePath('/public/demo.html'), 'public/demo.html');
});
test('registerPageArtifactForConversation records page artifact and refreshes manifest', async () => {
const calls = [];
const registry = {
async ensurePackage(input) {
calls.push(['ensurePackage', input]);
return { id: 'cp_session-1' };
},
async recordArtifact(input) {
calls.push(['recordArtifact', input]);
return input;
},
async writeManifestForSession(input) {
calls.push(['writeManifestForSession', input]);
return { storageKey: 'users/user-1/conversations/session-1/manifest.json' };
},
};
const result = await pageInternals.registerPageArtifactForConversation({
registry,
userId: 'user-1',
source: { sessionId: 'session-1', messageId: 'message-1' },
page: {
id: 'page-1',
currentVersionId: 'version-1',
title: '旅行计划',
sourceMessageId: 'message-fallback',
},
contentAssetId: 'asset-1',
contentMimeType: 'text/html',
contentBytes: 128,
storageKey: 'users/user-1/pages/page-1/versions/v1.html',
versionNo: 1,
now: 1000,
});
assert.equal(result.id, 'ca_page-1_version-1');
assert.deepEqual(calls[0], [
'ensurePackage',
{
userId: 'user-1',
sessionId: 'session-1',
title: '旅行计划',
now: 1000,
},
]);
assert.equal(calls[1][1].artifactKind, 'page');
assert.equal(calls[1][1].messageId, 'message-1');
assert.equal(calls[1][1].storageKey, 'users/user-1/pages/page-1/versions/v1.html');
assert.equal(calls[1][1].canonicalUrl, '/api/mindspace/v1/pages/page-1/preview');
assert.deepEqual(calls[2], [
'writeManifestForSession',
{ userId: 'user-1', sessionId: 'session-1' },
]);
});
test('registerPageArtifactForConversation skips missing registry or session safely', async () => {
assert.equal(
await pageInternals.registerPageArtifactForConversation({
registry: null,
userId: 'user-1',
source: { sessionId: 'session-1' },
page: { id: 'page-1' },
}),
null,
);
assert.equal(
await pageInternals.registerPageArtifactForConversation({
registry: { ensurePackage: async () => assert.fail('should not be called') },
userId: 'user-1',
source: {},
page: { id: 'page-1' },
}),
null,
);
});
test('renderThumbnail falls back to workspace html when storage asset is missing', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-thumb-'));
const storageRoot = path.join(tmp, 'storage');
const h5Root = tmp;
const userId = 'user-1';
const pageId = 'page-1';
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId);
const html =
'<!doctype html><html><head><title>Demo</title></head><body><h1>Hi</h1></body></html>';
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
await fs.writeFile(path.join(publishDir, 'public/index.html'), html);
const pool = {
query: async (sql) => {
if (String(sql).includes('FROM h5_page_records')) {
return [[{
title: 'Demo',
summary: '',
page_type: 'html',
version_no: 1,
storage_key: `users/${userId}/pages/missing/versions/v1.md`,
source_snapshot_json: { relative_path: 'public/index.html' },
}]];
}
if (String(sql).includes('FROM h5_users')) {
return [[{ username: 'demo' }]];
}
return [[]];
},
};
const pageService = createPageService(pool, { storageRoot, h5Root });
const svg = await pageService.renderThumbnail(userId, pageId);
assert.match(svg, /<svg/i);
assert.match(svg, /Demo/);
});
test('getPage falls back to workspace html when storage asset is missing', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-get-page-'));
const storageRoot = path.join(tmp, 'storage');
const h5Root = tmp;
const userId = 'user-1';
const pageId = 'page-1';
const publishDir = path.join(h5Root, PUBLISH_ROOT_DIR, userId);
const html =
'<!doctype html><html><head><title>Page Demo</title></head><body><h1>Body</h1></body></html>';
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
await fs.writeFile(path.join(publishDir, 'public/index.html'), html);
const pool = {
query: async (sql) => {
if (String(sql).includes('FROM h5_page_records')) {
return [[{
id: pageId,
user_id: userId,
title: 'Page Demo',
summary: '',
page_type: 'html',
template_id: 'static-html',
current_version_id: 'version-1',
status: 'draft',
visibility: 'private',
category_code: 'draft',
version_no: 1,
storage_key: `users/${userId}/pages/missing/versions/v1.md`,
source_snapshot_json: { relative_path: 'public/index.html' },
created_at: 1,
updated_at: 1,
}]];
}
if (String(sql).includes('FROM h5_users')) {
return [[{ username: 'demo' }]];
}
return [[]];
},
};
const pageService = createPageService(pool, { storageRoot, h5Root });
const page = await pageService.getPage(userId, pageId);
assert.equal(page.title, 'Page Demo');
assert.match(page.content, /<h1>Body<\/h1>/);
});