417 lines
14 KiB
JavaScript
417 lines
14 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 { 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('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 = '<!doctype html><img src="/api/mindspace/v1/assets/asset-1/download?inline=1&v=1">';
|
|
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 rewrites imgproxy local image urls to public standard images', async () => {
|
|
const prepared = await publicationInternals.prepareHtmlPublishContent({
|
|
pool: {
|
|
async query() {
|
|
return [[]];
|
|
},
|
|
},
|
|
userId: 'user-1',
|
|
html: '<!doctype html><img src="https://tkmind.ai/sig/rs:fit:1280:1280:0/q:85/plain/local:///users/user-1/images/2026-06-29/hero.jpg@jpg">',
|
|
ownerSlug: 'john',
|
|
urlSlug: 'story',
|
|
absoluteStoragePath: () => {
|
|
throw new Error('should not read private storage');
|
|
},
|
|
});
|
|
|
|
assert.match(prepared, /https:\/\/mm\.tkmind\.cn\/MindSpace\/user-1\/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: '<a href="appendix.pdf" download>PDF</a>',
|
|
});
|
|
|
|
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: '<!doctype html><title>发布页</title>',
|
|
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('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: [
|
|
'<!doctype html>',
|
|
"<img srcset='https://img.tkmind.cn/sig/rs:fit:256:256:0/q:70/plain/local:///users/user-1/images/2026-06-29/thumb.jpg@jpg 1x, https://tkmind.ai/sig/rs:fit:1280:1280:0/q:85/plain/local:///users/user-1/images/2026-06-29/hero.jpg@jpg 2x'>",
|
|
'<section style="background-image:url(http://localhost:20081/unsafe/rs:fit:1280:1280:0/q:85/plain/local:///users/user-1/images/2026-06-29/bg.jpg@jpg)"></section>',
|
|
].join(''),
|
|
ownerSlug: 'john',
|
|
urlSlug: 'story',
|
|
absoluteStoragePath: () => {
|
|
throw new Error('should not read private storage');
|
|
},
|
|
});
|
|
|
|
assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/thumb\.jpg 1x/);
|
|
assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg 2x/);
|
|
assert.match(prepared, /url\(https:\/\/mm\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/bg\.jpg\)/);
|
|
assert.doesNotMatch(prepared, /plain\/local:\/\//);
|
|
});
|
|
|
|
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: [
|
|
'<!doctype html>',
|
|
`<meta name="mindspace-cover" content='{"cover":"public/images/2026-06-29/hero.jpg"}'>`,
|
|
`<section style="background-image:url('public/images/2026-06-29/bg.jpg')"></section>`,
|
|
`<img src="public/.tmp-images/card.png">`,
|
|
].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":"\.\.\/images\/2026-06-29\/hero\.jpg"/);
|
|
assert.match(prepared, /url\('\.\.\/images\/2026-06-29\/bg\.jpg'\)/);
|
|
assert.match(prepared, /src="\.\.\/\.tmp-images\/card\.png"/);
|
|
assert.doesNotMatch(prepared, /public\/images\//);
|
|
assert.doesNotMatch(prepared, /public\/\.tmp-images\//);
|
|
});
|