208 lines
7.5 KiB
JavaScript
208 lines
7.5 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import {
|
|
buildH5HtmlRepairPrompt,
|
|
collectMissingPublicAssetReferences,
|
|
collectMissingPublicHtmlPaths,
|
|
evaluateH5HtmlFinishGuard,
|
|
isH5HtmlFinishGuardEnabled,
|
|
maybeRepairH5HtmlAfterFinish,
|
|
resetH5HtmlFinishGuardAttempts,
|
|
} from './mindspace-h5-html-finish-guard.mjs';
|
|
|
|
const USER = { id: 'user-1', username: 'john' };
|
|
|
|
test('collectMissingPublicAssetReferences finds missing svg referenced by html', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public', 'assets'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(publishDir, 'public', 'kids-posture-business.html'),
|
|
'<img src="assets/kids-posture-hero.svg" alt="hero">',
|
|
'utf8',
|
|
);
|
|
const missing = collectMissingPublicAssetReferences(publishDir);
|
|
assert.deepEqual(missing, [{
|
|
relativePath: 'public/assets/kids-posture-hero.svg',
|
|
htmlRelativePath: 'public/kids-posture-business.html',
|
|
}]);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('collectMissingPublicHtmlPaths detects assistant link without disk file', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
|
|
try {
|
|
const messages = [{
|
|
role: 'assistant',
|
|
content: [{
|
|
type: 'text',
|
|
text: '[页面](https://m.tkmind.cn/MindSpace/user-1/public/missing-page.html) 已生成',
|
|
}],
|
|
}];
|
|
const missing = collectMissingPublicHtmlPaths(messages, USER, publishDir);
|
|
assert.deepEqual(missing, ['public/missing-page.html']);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('evaluateH5HtmlFinishGuard flags missing assets after html exists', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(publishDir, 'public', 'page.html'),
|
|
'<html><body><img src="assets/icon.svg"></body></html>',
|
|
'utf8',
|
|
);
|
|
const evaluation = evaluateH5HtmlFinishGuard({
|
|
messages: [],
|
|
currentUser: USER,
|
|
publishDir,
|
|
});
|
|
assert.equal(evaluation.needsRepair, true);
|
|
assert.equal(evaluation.missingAssets.length, 1);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('evaluateH5HtmlFinishGuard blocks browser storage in any touched public page', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-storage-ban-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(publishDir, 'public', 'ledger.html'),
|
|
'<html><script>sessionStorage.setItem("ledger", "[]"); indexedDB.open("ledger")</script></html>',
|
|
'utf8',
|
|
);
|
|
const evaluation = evaluateH5HtmlFinishGuard({
|
|
messages: [{ role: 'assistant', content: [{ type: 'toolRequest', toolCall: { value: {
|
|
name: 'write_file',
|
|
arguments: { path: 'public/ledger.html' },
|
|
} } }] }],
|
|
currentUser: USER,
|
|
publishDir,
|
|
});
|
|
assert.equal(evaluation.needsRepair, true);
|
|
assert.deepEqual(evaluation.browserStorageViolations, [{
|
|
relativePath: 'public/ledger.html',
|
|
apis: ['sessionStorage', 'IndexedDB'],
|
|
}]);
|
|
assert.match(buildH5HtmlRepairPrompt(evaluation), /当前用户专属 PostgreSQL schema/);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('evaluateH5HtmlFinishGuard blocks computed localStorage obfuscation', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-storage-obfuscation-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(publishDir, 'public', 'bypass.html'),
|
|
`<script>window['local' + 'Sto' + 'rage'].setItem('data', '[]')</script>`,
|
|
'utf8',
|
|
);
|
|
const evaluation = evaluateH5HtmlFinishGuard({
|
|
messages: [{ role: 'assistant', content: [{ type: 'toolRequest', toolCall: { value: {
|
|
name: 'write_file',
|
|
arguments: { path: 'public/bypass.html' },
|
|
} } }] }],
|
|
currentUser: USER,
|
|
publishDir,
|
|
});
|
|
assert.equal(evaluation.needsRepair, true);
|
|
assert.deepEqual(evaluation.browserStorageViolations, [{
|
|
relativePath: 'public/bypass.html',
|
|
apis: ['localStorage'],
|
|
}]);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('buildH5HtmlRepairPrompt lists html and asset targets', () => {
|
|
const prompt = buildH5HtmlRepairPrompt({
|
|
missingHtml: ['public/page.html'],
|
|
missingAssets: [{ relativePath: 'public/assets/icon.svg', htmlRelativePath: 'public/page.html' }],
|
|
});
|
|
assert.match(prompt, /public\/page\.html/);
|
|
assert.match(prompt, /public\/assets\/icon\.svg/);
|
|
assert.match(prompt, /write_file/);
|
|
});
|
|
|
|
test('maybeRepairH5HtmlAfterFinish triggers proxy reply when enabled', async () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
|
|
const previous = process.env.MEMIND_H5_HTML_FINISH_GUARD;
|
|
process.env.MEMIND_H5_HTML_FINISH_GUARD = '1';
|
|
resetH5HtmlFinishGuardAttempts('session-1');
|
|
const calls = [];
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(publishDir, 'public', 'page.html'),
|
|
'<img src="assets/icon.svg">',
|
|
'utf8',
|
|
);
|
|
const result = await maybeRepairH5HtmlAfterFinish({
|
|
sessionId: 'session-1',
|
|
userId: 'user-1',
|
|
currentUser: USER,
|
|
messages: [],
|
|
publishDir,
|
|
tkmindProxy: {
|
|
async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) {
|
|
calls.push({ userId, sessionId, requestId, userMessage });
|
|
},
|
|
},
|
|
});
|
|
assert.equal(result.repaired, true);
|
|
assert.equal(calls.length, 1);
|
|
assert.match(calls[0].requestId, /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
|
|
assert.match(calls[0].userMessage.content[0].text, /icon\.svg/);
|
|
} finally {
|
|
if (previous == null) delete process.env.MEMIND_H5_HTML_FINISH_GUARD;
|
|
else process.env.MEMIND_H5_HTML_FINISH_GUARD = previous;
|
|
resetH5HtmlFinishGuardAttempts('session-1');
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('maybeRepairH5HtmlAfterFinish stays off when flag disabled', async () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-'));
|
|
const previous = process.env.MEMIND_H5_HTML_FINISH_GUARD;
|
|
delete process.env.MEMIND_H5_HTML_FINISH_GUARD;
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(publishDir, 'public', 'page.html'),
|
|
'<img src="assets/icon.svg">',
|
|
'utf8',
|
|
);
|
|
const result = await maybeRepairH5HtmlAfterFinish({
|
|
sessionId: 'session-2',
|
|
userId: 'user-1',
|
|
currentUser: USER,
|
|
messages: [],
|
|
publishDir,
|
|
tkmindProxy: {
|
|
async submitSessionReplyForUser() {
|
|
throw new Error('should not call proxy when disabled');
|
|
},
|
|
},
|
|
});
|
|
assert.equal(result.skipped, 'disabled');
|
|
assert.equal(isH5HtmlFinishGuardEnabled(), false);
|
|
} finally {
|
|
if (previous == null) delete process.env.MEMIND_H5_HTML_FINISH_GUARD;
|
|
else process.env.MEMIND_H5_HTML_FINISH_GUARD = previous;
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|