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 { createPublicationService, publicationInternals } from './mindspace-publications.mjs'; test('normalizes safe long-page slugs', () => { assert.equal(publicationInternals.normalizeSlug(' product-intro-2026 '), 'product-intro-2026'); assert.throws( () => publicationInternals.normalizeSlug('../private'), (error) => error.code === 'invalid_publish_input', ); assert.throws( () => publicationInternals.normalizeSlug('中文地址'), (error) => error.code === 'invalid_publish_input', ); }); test('accepts all documented access modes', () => { for (const mode of [ 'public', 'password', 'private_link', 'time_limited', 'login_required', 'owner_only', ]) { assert.equal(publicationInternals.normalizeAccessMode(mode), mode); } assert.throws( () => publicationInternals.normalizeAccessMode('team_only'), (error) => error.code === 'invalid_publish_input', ); }); test('hashes access passwords with a random salt', () => { const first = publicationInternals.hashPassword('Publish-Password-2026'); const second = publicationInternals.hashPassword('Publish-Password-2026'); assert.notEqual(first, second); assert.equal(publicationInternals.verifyPassword('Publish-Password-2026', first), true); assert.equal(publicationInternals.verifyPassword('wrong-password', first), false); assert.throws( () => publicationInternals.normalizePassword('short', true), (error) => error.code === 'invalid_publish_input', ); }); test('requires future expiry timestamps for time-limited pages', () => { const future = Date.now() + 60_000; assert.equal(publicationInternals.normalizeExpiresAt(future, true), future); assert.throws( () => publicationInternals.normalizeExpiresAt(Date.now() - 1, true), (error) => error.code === 'invalid_publish_input', ); }); test('classifies devices and strips referrer paths', () => { assert.equal( publicationInternals.deviceType('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Mobile'), 'mobile', ); assert.equal(publicationInternals.deviceType('Googlebot/2.1'), 'bot'); assert.equal(publicationInternals.deviceType('Mozilla/5.0 (Macintosh)'), 'desktop'); assert.equal( publicationInternals.referrerHost('https://example.com/private/path?token=secret'), 'example.com', ); assert.equal(publicationInternals.referrerHost('not a url'), null); }); test('warns for contact details and external links without silently hiding them', () => { const result = publicationInternals.scanContent( '联系 13812345678 或 hello@example.com,详情 https://example.com/path', ); assert.equal(result.status, 'warned'); assert.equal(result.riskLevel, 'medium'); assert.equal(result.allowed, true); assert.deepEqual( result.findings.map((finding) => finding.type), ['phone', 'email', 'external_link'], ); assert.doesNotMatch(result.findings[0].sampleMasked, /13812345678/); }); test('deduplicates the same sensitive value repeated by summary and content', () => { const result = publicationInternals.scanContent( '摘要 qa@example.com\n正文 qa@example.com', ); assert.equal(result.findings[0].occurrenceCount, 1); }); test('blocks identity and bank-card numbers', () => { const result = publicationInternals.scanContent( '身份证 310101199001011234,银行卡 6222021234567890123', ); assert.equal(result.status, 'blocked'); assert.equal(result.riskLevel, 'high'); assert.equal(result.allowed, false); assert.ok(result.findings.every((finding) => finding.blocking)); }); test('publicHomepageResponse summarizes public cards and total views', () => { const result = publicationInternals.publicHomepageResponse( { id: 'user-1', slug: 'john', username: 'john', display_name: 'John', }, [ { id: 'pub-1', page_id: 'page-1', title: '第一篇', summary: '摘要一', template_id: 'report', public_url: '/u/john/pages/one', url_slug: 'one', view_count: 7, published_at: 100, }, { id: 'pub-2', page_id: 'page-2', title: '第二篇', summary: '摘要二', template_id: 'editorial', public_url: '/u/john/pages/two', url_slug: 'two', view_count: 3, published_at: 200, }, ], ); assert.equal(result.owner.displayName, 'John'); assert.equal(result.totalViews, 10); assert.equal(result.pageCount, 2); assert.equal(result.pages[0].publicUrl, '/u/john/pages/one'); }); test('buildPublicationThumbnailFallback points private resources at the public page thumbnail', () => { assert.equal( publicationInternals.buildPublicationThumbnailFallback('john', 'page-037d497f'), '/u/john/pages/page-037d497f.thumbnail.png', ); assert.equal( publicationInternals.buildPublicationThumbnailFallback('木子', 'demo-page'), '/u/%E6%9C%A8%E5%AD%90/pages/demo-page.thumbnail.png', ); }); test('resolvePublic returns page provenance for package artifact registration', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-public-resolve-')); const storageKey = 'users/user-1/publications/pub-1/index.html'; await fs.mkdir(path.dirname(path.join(storageRoot, storageKey)), { recursive: true }); await fs.writeFile(path.join(storageRoot, storageKey), '
',
ownerSlug: 'john',
urlSlug: 'story',
absoluteStoragePath: () => {
throw new Error('should not read private storage');
},
});
assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/);
assert.doesNotMatch(prepared, /plain\/local:\/\//);
});
test('registerPublicationArtifactForConversation records public html artifact', 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 publicationInternals.registerPublicationArtifactForConversation({
registry,
userId: 'user-1',
page: {
page_id: 'page-1',
title: '发布页',
source_session_id: 'session-1',
source_message_id: 'message-1',
},
publication: {
id: 'pub-1',
publicUrl: 'https://m.tkmind.cn/u/john/pages/published',
publishedAt: 3000,
},
bundleAssetId: 'asset-pub',
storageKey: 'users/user-1/publications/pub-1/index.html',
htmlBytes: 256,
now: 3000,
});
assert.equal(result.id, 'ca_pub-1');
assert.deepEqual(calls[0], [
'ensurePackage',
{
userId: 'user-1',
sessionId: 'session-1',
title: '发布页',
now: 3000,
},
]);
assert.equal(calls[1][1].artifactKind, 'public_html');
assert.equal(calls[1][1].publicationId, 'pub-1');
assert.equal(calls[1][1].canonicalUrl, 'https://m.tkmind.cn/u/john/pages/published');
assert.deepEqual(calls[2], [
'writeManifestForSession',
{ userId: 'user-1', sessionId: 'session-1' },
]);
});
test('collectPublicationCompanionArtifacts finds real download and long-image files', async () => {
const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'publication-companions-'));
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
await fs.writeFile(path.join(publishDir, 'public', 'report.docx'), 'docx');
await fs.writeFile(path.join(publishDir, 'public', 'report.long.png'), 'png');
await fs.writeFile(path.join(publishDir, 'public', 'appendix.pdf'), 'pdf');
const artifacts = await publicationInternals.collectPublicationCompanionArtifacts({
publishDir,
htmlRelativePath: 'public/report.html',
html: 'PDF',
});
assert.deepEqual(
artifacts.map((item) => item.workspacePath).sort(),
['public/appendix.pdf', 'public/report.docx', 'public/report.long.png'],
);
});
test('registerPublicationArtifactForConversation records public companions', async () => {
const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'publication-companions-'));
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
await fs.writeFile(path.join(publishDir, 'public', 'report.docx'), 'docx');
await fs.writeFile(path.join(publishDir, 'public', 'report.long.png'), 'png');
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' };
},
};
await publicationInternals.registerPublicationArtifactForConversation({
registry,
userId: 'user-1',
page: {
page_id: 'page-1',
title: '发布页',
source_session_id: 'session-1',
source_message_id: 'message-1',
source_relative_path: 'public/report.html',
},
publication: {
id: 'pub-1',
publicUrl: 'https://m.tkmind.cn/u/john/pages/published',
publishedAt: 3000,
},
bundleAssetId: 'asset-pub',
storageKey: 'users/user-1/publications/pub-1/index.html',
htmlBytes: 256,
html: '
',
ownerSlug: 'john',
urlSlug: 'story',
htmlRelativePath: 'public/index.html',
absoluteStoragePath: () => {
throw new Error('should not read private storage');
},
});
assert.match(
prepared,
/src="http:\/\/127\.0\.0\.1:5173\/MindSpace\/user-1\/public\/images\/2026-07-05\/demo\.jpg"/,
);
assert.doesNotMatch(prepared, /\/MindSpace\/user-1\/images\/2026-07-05\//);
});
test('rewriteBrokenMindSpacePublicImageUrls restores missing public segment', () => {
const html =
'
';
const out = publicationInternals.rewriteBrokenMindSpacePublicImageUrls(html);
assert.match(out, /\/MindSpace\/user-1\/public\/images\/2026-07-05\/demo\.jpg/);
assert.doesNotMatch(out, /\/MindSpace\/user-1\/images\/2026-07-05\//);
});
test('prepareHtmlPublishContent rewrites workspace public asset paths relative to html output', async () => {
const prepared = await publicationInternals.prepareHtmlPublishContent({
pool: {
async query() {
return [[]];
},
},
userId: 'user-1',
html: [
'',
``,
``,
`
`,
].join(''),
ownerSlug: 'john',
urlSlug: 'shared-story',
htmlRelativePath: 'public/shared/shared-story.html',
absoluteStoragePath: () => {
throw new Error('should not read private storage');
},
});
assert.match(prepared, /"cover":"\/u\/john\/public\/images\/2026-06-29\/hero\.jpg"/);
assert.match(prepared, /url\('\/u\/john\/public\/images\/2026-06-29\/bg\.jpg'\)/);
assert.match(prepared, /src="\/u\/john\/public\/\.tmp-images\/card\.png"/);
assert.doesNotMatch(prepared, /src="public\//);
assert.doesNotMatch(prepared, /"cover":"public\//);
});
test('rewritePublicationCanonicalAssetUrls rewrites workspace tmp image paths for /u pages routes', () => {
const html = [
'
',
'
',
``,
].join('');
const out = publicationInternals.rewritePublicationCanonicalAssetUrls(html, 'john');
assert.match(out, /src="\/u\/john\/public\/\.tmp-images\/one\.jpg"/);
assert.match(out, /src="\/u\/john\/public\/\.tmp-images\/two\.jpg"/);
assert.match(out, /url\('\/u\/john\/public\/\.tmp-images\/three\.jpg'\)/);
});
test('publish rejects republish while page is still online', async () => {
const pageRow = {
page_id: 'page-1',
title: 'Report',
summary: '',
page_type: 'article',
template_id: 'editorial',
current_version_id: 'version-1',
source_session_id: null,
source_message_id: null,
user_id: 'user-1',
space_id: 'space-1',
page_version_id: 'version-1',
version_no: 1,
bundle_asset_id: null,
storage_key: 'users/user-1/pages/page-1/versions/v1.md',
content: '# Report',
};
const pool = {
async query(sql, params = []) {
if (sql.includes('FROM h5_page_records p') && sql.includes('JOIN h5_page_versions')) {
return [[pageRow]];
}
if (
sql.includes('FROM h5_publish_records') &&
sql.includes("status = 'online'") &&
sql.includes('page_id = ?')
) {
return [[{ id: 'pub-online', url_slug: 'report', public_url: '/u/john/pages/report' }]];
}
if (sql.includes('FROM h5_publish_records pr') && sql.includes('pr.page_id <>')) {
return [[]];
}
return [[]];
},
async getConnection() {
throw new Error('publish should fail before opening a transaction');
},
};
const service = createPublicationService(pool, {
storageRoot: '/tmp',
h5Root: '/tmp',
idFactory: () => 'id-1',
});
await assert.rejects(
() =>
service.publish('user-1', 'page-1', {
pageVersionId: 'version-1',
accessMode: 'public',
urlSlug: 'report',
acknowledgedFindingIds: [],
}),
(error) => {
assert.equal(error.code, 'page_already_online');
assert.equal(error.details.publicationId, 'pub-online');
return true;
},
);
});