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), '发布页'); const service = createPublicationService( { async query(sql) { if (sql.includes('FROM h5_publish_records pr') && sql.includes('COALESCE(u.slug, u.username)')) { return [[ { id: 'pub-1', user_id: 'user-1', owner_id: 'user-1', page_id: 'page-1', page_version_id: 'version-1', title: '发布页', source_session_id: 'session-1', source_message_id: 'message-1', url_slug: 'published', public_url: 'https://m.tkmind.cn/u/john/pages/published', access_mode: 'public', status: 'online', view_count: 0, published_at: 3000, storage_key: storageKey, }, ]]; } return [[]]; }, }, { storageRoot, idFactory: () => 'view-1', }, ); const result = await service.resolvePublic('john', 'published', null, null, {}); assert.equal(result.ownerId, 'user-1'); assert.deepEqual(result.pageSource, { pageId: 'page-1', title: '发布页', sourceSessionId: 'session-1', sourceMessageId: 'message-1', }); }); test('prepareHtmlPublishContent inlines owned private image assets before scanning', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-publish-')); const storageKey = 'users/user-1/assets/asset-1/current.png'; await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true }); await fs.writeFile(path.join(root, storageKey), Buffer.from('png-bytes')); const pool = { async query(sql, params) { if (sql.includes('original_filename')) { return [[]]; } assert.match(sql, /h5_assets/); assert.equal(params[0], 'user-1'); assert.deepEqual(params[1], ['asset-1']); return [ [ { id: 'asset-1', mime_type: 'image/png', storage_key: storageKey, }, ], ]; }, }; try { const html = ''; const prepared = await publicationInternals.prepareHtmlPublishContent({ pool, userId: 'user-1', html, ownerSlug: 'john', urlSlug: 'korean-daily-look', absoluteStoragePath: (key) => path.join(root, key), }); assert.match(prepared, /src="data:image\/png;base64,/); assert.doesNotMatch(prepared, /\/api\/mindspace\/v1\/assets\//); const scan = publicationInternals.scanContent(prepared, { format: 'html' }); assert.equal( scan.findings.some((finding) => finding.type === 'private_resource_reference'), false, ); } finally { await fs.rm(root, { recursive: true, force: true }); } }); test('prepareHtmlPublishContent signs private image assets when imgproxy signer is enabled', async () => { const storageKey = 'users/user-1/images/2026-07-03/demo-asset-1.jpg'; const pool = { async query(sql, params) { if (sql.includes('original_filename')) { return [[]]; } assert.match(sql, /h5_assets/); assert.equal(params[0], 'user-1'); assert.deepEqual(params[1], ['asset-1']); return [ [ { id: 'asset-1', mime_type: 'image/png', storage_key: storageKey, }, ], ]; }, }; const imgproxySigner = { baseUrl: 'https://img.example.test', buildUrl: (baseUrl, storagePath, preset = 'display') => { assert.equal(baseUrl, 'https://img.example.test'); assert.equal(storagePath, storageKey); assert.equal(preset, 'display'); return `https://img.example.test/sig/plain/local:///${storagePath}`; }, }; const prepared = await publicationInternals.prepareHtmlPublishContent({ pool, userId: 'user-1', html: '', ownerSlug: 'john', urlSlug: 'night-market-essay', absoluteStoragePath: () => { throw new Error('should not read private storage when imgproxy is enabled'); }, imgproxySigner: { baseUrl: imgproxySigner.baseUrl, buildUrl: (storagePath, preset = 'display') => imgproxySigner.buildUrl(imgproxySigner.baseUrl, storagePath, preset), }, }); assert.doesNotMatch(prepared, /\/api\/mindspace\/v1\/assets\//); assert.doesNotMatch(prepared, /thumbnail\.png/); assert.doesNotMatch(prepared, /plain\/local:\/\//); }); test('prepareHtmlPublishContent converts signed private images to public standard urls', async () => { const storageKey = 'users/user-1/images/2026-07-03/60270b88dda7b7ccb8a49d8e2746e80b-asset-1.jpg'; const pool = { async query(sql, params) { if (sql.includes('original_filename')) { return [[]]; } assert.match(sql, /h5_assets/); assert.equal(params[0], 'user-1'); assert.deepEqual(params[1], ['asset-1']); return [ [ { id: 'asset-1', mime_type: 'image/jpeg', storage_key: storageKey, }, ], ]; }, }; const imgproxySigner = { baseUrl: 'https://img.example.test', buildUrl: (storagePath, preset = 'display') => `https://img.example.test/sig/rs:fit:1280:1280:0/q:85/plain/local:///${storagePath}@${preset}`, }; const prepared = await publicationInternals.prepareHtmlPublishContent({ pool, userId: 'user-1', html: '', ownerSlug: 'john', urlSlug: 'night-market-essay', absoluteStoragePath: () => { throw new Error('should not read private storage when imgproxy is enabled'); }, imgproxySigner, }); assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-07-03\//); assert.doesNotMatch(prepared, /\/api\/mindspace\/v1\/assets\//); assert.doesNotMatch(prepared, /thumbnail\.png/); assert.doesNotMatch(prepared, /plain\/local:\/\//); }); test('prepareHtmlPublishContent rewrites imgproxy local image urls to public standard images', async () => { const prepared = await publicationInternals.prepareHtmlPublishContent({ pool: { async query() { return [[]]; }, }, userId: 'user-1', html: '', 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: '发布页', publishDir, now: 3000, }); const artifactKinds = calls .filter(([name]) => name === 'recordArtifact') .map(([, input]) => input.artifactKind) .sort(); assert.deepEqual(artifactKinds, ['docx', 'long_image', 'public_html']); const docx = calls.find(([, input]) => input?.artifactKind === 'docx')?.[1]; assert.equal(docx.displayName, 'report.docx'); assert.match(docx.canonicalUrl, /\/MindSpace\/user-1\/public\/report\.docx$/); }); test('publish registers public html and companion artifacts for chat-sourced pages', async () => { const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-publish-flow-')); const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-root-')); const pageStorageKey = 'users/user-1/pages/page-1/versions/v1.html'; const pageHtml = 'Report
发布内容
'; await fs.mkdir(path.dirname(path.join(storageRoot, pageStorageKey)), { recursive: true }); await fs.writeFile(path.join(storageRoot, pageStorageKey), pageHtml); const publishDir = path.join(h5Root, 'MindSpace', 'user-1'); 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 registryCalls = []; let idCounter = 0; const pageRow = { page_id: 'page-1', title: 'Report', summary: '', page_type: 'html', template_id: 'static-html', current_version_id: 'version-1', source_session_id: 'session-1', source_message_id: 'message-1', user_id: 'user-1', space_id: 'space-1', page_version_id: 'version-1', version_no: 1, bundle_asset_id: null, storage_key: pageStorageKey, source_relative_path: 'public/report.html', }; const pool = { async query(sql, params = []) { if (sql.includes('FROM h5_page_records p') && sql.includes('JOIN h5_page_versions')) { assert.deepEqual(params, ['page-1', 'user-1', 'version-1']); return [[pageRow]]; } if (sql.includes('FROM h5_publish_records pr') && sql.includes('pr.page_id <>')) { return [[]]; } if (sql.includes('FROM h5_users')) { return [[{ public_slug: 'john' }]]; } if (sql.includes('FROM h5_assets') || sql.includes('FROM h5_asset_versions')) { return [[]]; } return [[]]; }, async getConnection() { return { async beginTransaction() {}, async commit() {}, async rollback() {}, release() {}, async query(sql) { if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) { return [[{ quota_bytes: 1_000_000, used_bytes: 0, reserved_bytes: 0 }]]; } if (sql.includes('COUNT(DISTINCT page_id)')) { return [[{ public_page_used: 0, page_already_online: 0 }]]; } if (sql.includes('FROM h5_space_categories')) { return [[{ id: 'cat-public' }]]; } if (sql.includes('SELECT id, page_version_id FROM h5_publish_records')) { return [[]]; } return [[]]; }, }; }, }; const service = createPublicationService(pool, { storageRoot, h5Root, idFactory: () => `id-${++idCounter}`, conversationPackageRegistry: { async ensurePackage(input) { registryCalls.push(['ensurePackage', input]); return { id: 'cp-session-1' }; }, async recordArtifact(input) { registryCalls.push(['recordArtifact', input]); return input; }, async writeManifestForSession(input) { registryCalls.push(['writeManifestForSession', input]); return input; }, }, }); try { const publication = await service.publish('user-1', 'page-1', { pageVersionId: 'version-1', accessMode: 'public', urlSlug: 'report', acknowledgedFindingIds: [], }); assert.equal(publication.id, 'id-4'); const artifactKinds = registryCalls .filter(([name]) => name === 'recordArtifact') .map(([, input]) => input.artifactKind) .sort(); assert.deepEqual(artifactKinds, ['docx', 'long_image', 'public_html']); const companion = registryCalls.find(([, input]) => input?.artifactKind === 'docx')?.[1]; assert.equal(companion.messageId, 'message-1'); assert.equal(companion.publicationId, 'id-4'); assert.equal(registryCalls.at(-1)[0], 'writeManifestForSession'); } finally { await fs.rm(storageRoot, { recursive: true, force: true }); await fs.rm(h5Root, { recursive: true, force: true }); } }); test('registerPublicationArtifactForConversation skips pages without source session', async () => { assert.equal( await publicationInternals.registerPublicationArtifactForConversation({ registry: { ensurePackage: async () => assert.fail('should not be called') }, userId: 'user-1', page: { page_id: 'page-1', title: '发布页' }, publication: { id: 'pub-1' }, }), null, ); }); test('prepareHtmlPublishContent rewrites imgproxy urls in srcset and css url contexts', async () => { const prepared = await publicationInternals.prepareHtmlPublishContent({ pool: { async query() { return [[]]; }, }, userId: 'user-1', html: [ '', "", '
', ].join(''), ownerSlug: 'john', urlSlug: 'story', absoluteStoragePath: () => { throw new Error('should not read private storage'); }, }); assert.match(prepared, /\/MindSpace\/user-1\/public\/images\/2026-06-29\/thumb\.jpg 1x/); assert.match(prepared, /\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg 2x/); assert.match(prepared, /url\(https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/bg\.jpg\)/); assert.doesNotMatch(prepared, /plain\/local:\/\//); }); test('prepareHtmlPublishContent preserves absolute MindSpace public image urls', async () => { const prepared = await publicationInternals.prepareHtmlPublishContent({ pool: { async query() { return [[]]; }, }, userId: 'user-1', html: 'demo', 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 = 'demo'; 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; }, ); });