|
|
@@ -3,9 +3,10 @@ import fs from 'node:fs';
|
|
|
import os from 'node:os';
|
|
|
import path from 'node:path';
|
|
|
import test from 'node:test';
|
|
|
-import { resolveClaudeAttachments } from './attachment-resolver.mjs';
|
|
|
+import { resolveClaudeAttachments, resolveImageInputs } from './attachment-resolver.mjs';
|
|
|
+import { toPublicValue } from './public-view.mjs';
|
|
|
import { createRequestKey } from './request-key.mjs';
|
|
|
-import { isInside, resolveOutputRoot, resolveRunDirectory } from './run-paths.mjs';
|
|
|
+import { createReadableRunId, isInside, resolveExistingRunDirectory, resolveOutputRoot, resolveRunDirectory, sanitizeProductName } from './run-paths.mjs';
|
|
|
import { readState, writeState } from './state-store.mjs';
|
|
|
|
|
|
const projectRoot = path.resolve(import.meta.dirname, '..', '..', '..', '..');
|
|
|
@@ -20,13 +21,89 @@ test('request key is deterministic', () => {
|
|
|
assert.equal(a, b);
|
|
|
});
|
|
|
|
|
|
+test('public projection removes nested cost data and sanitizes user-facing arrays', () => {
|
|
|
+ const projected = toPublicValue({
|
|
|
+ assistantMessage: '按受信美元价格与汇率预计 ¥5.30。',
|
|
|
+ summary: { estimatedCostCny: 5.3, total_cost: 8, nested: { totalCostCny: 6, value: 'keep' } },
|
|
|
+ data: { billing_info: { amount: 4 }, run: { budgetLimitCny: 7, usage: { cost: 1.25, actual_cost_cny: 3 }, items: [{ costUnit: 'CNY', revisionEstimatedCostCny: 1 }] } },
|
|
|
+ nextActions: ['人民币预算上限不足 ¥5.30。', '累计费用 CNY 3.00 / ¥3.00']
|
|
|
+ });
|
|
|
+ assert.deepEqual(projected.summary, { nested: { value: 'keep' } });
|
|
|
+ assert.deepEqual(projected.data, { run: { items: [{}] } });
|
|
|
+ assert.doesNotMatch(JSON.stringify(projected), /estimatedCostCny|total_?cost|actual_?cost|budgetLimitCny|costUnit|revisionEstimatedCostCny|usage|billing|¥|¥|CNY|RMB|费用|5\.30|3\.00/);
|
|
|
+});
|
|
|
+
|
|
|
test('run paths reject project escape and skill source outputs', () => {
|
|
|
const packageRoot = path.join(projectRoot, 'fmode-image-set');
|
|
|
assert.throws(() => resolveOutputRoot({ workspaceRoot: projectRoot, outputRoot: path.resolve(projectRoot, '..', 'escape') }), /固定/);
|
|
|
assert.throws(() => resolveOutputRoot({ workspaceRoot: projectRoot, outputRoot: path.join(projectRoot, 'custom-output') }), /固定/);
|
|
|
assert.throws(() => resolveOutputRoot({ workspaceRoot: packageRoot, packageRoot }), /Skill 源码/);
|
|
|
- const paths = resolveRunDirectory({ workspaceRoot: projectRoot, packageRoot, runId: 'run_abcdef' });
|
|
|
+ const paths = resolveRunDirectory({ workspaceRoot: projectRoot, packageRoot, runId: '咖啡豆-abcdef12' });
|
|
|
assert.equal(isInside(projectRoot, paths.runDirectory), true);
|
|
|
+ assert.equal(paths.runDirectory, path.join(projectRoot, '输出', '商品套图', '咖啡豆-abcdef12'));
|
|
|
+});
|
|
|
+
|
|
|
+test('run paths reject a pre-created run directory symlink or junction', () => {
|
|
|
+ const workspace = tempDirectory('run-link-workspace');
|
|
|
+ const outside = tempDirectory('run-link-outside');
|
|
|
+ const outputRoot = path.join(workspace, '输出', '商品套图');
|
|
|
+ const linkedRun = path.join(outputRoot, '链接商品-abcdef12');
|
|
|
+ fs.mkdirSync(outputRoot, { recursive: true });
|
|
|
+ try {
|
|
|
+ fs.symlinkSync(outside, linkedRun, process.platform === 'win32' ? 'junction' : 'dir');
|
|
|
+ assert.throws(() => resolveRunDirectory({ workspaceRoot: workspace, runId: '链接商品-abcdef12' }), /符号链接|联接点/);
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(linkedRun)) fs.unlinkSync(linkedRun);
|
|
|
+ fs.rmSync(workspace, { recursive: true, force: true });
|
|
|
+ fs.rmSync(outside, { recursive: true, force: true });
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('readable run ids sanitize Windows names and keep a stable request suffix', () => {
|
|
|
+ assert.equal(sanitizeProductName(' MORI 咖啡/经典版:*? '), 'MORI-咖啡-经典版');
|
|
|
+ assert.equal(sanitizeProductName('CON'), 'CON-商品');
|
|
|
+ assert.equal(sanitizeProductName('CON.txt'), 'CON-txt');
|
|
|
+ assert.equal(sanitizeProductName('A&B(经典版)☕'), 'A-B-经典版');
|
|
|
+ assert.equal(createReadableRunId({ productName: 'MORI 咖啡', requestKey: 'ABCDEF1234567890' }), 'MORI-咖啡-abcdef12');
|
|
|
+ assert.throws(() => createReadableRunId({ productName: '咖啡', requestKey: 'bad' }), /稳定/);
|
|
|
+});
|
|
|
+
|
|
|
+test('existing run lookup supports the legacy output directory', () => {
|
|
|
+ const workspace = tempDirectory('legacy-run');
|
|
|
+ const legacy = resolveRunDirectory({ workspaceRoot: workspace, runId: 'run_abcdef', layout: 'legacy' });
|
|
|
+ fs.mkdirSync(legacy.runDirectory, { recursive: true });
|
|
|
+ fs.writeFileSync(path.join(legacy.runDirectory, 'state.json'), '{}');
|
|
|
+ try {
|
|
|
+ const found = resolveExistingRunDirectory({ workspaceRoot: workspace, runId: 'run_abcdef' });
|
|
|
+ assert.equal(found.layout, 'legacy');
|
|
|
+ } finally { fs.rmSync(workspace, { recursive: true, force: true }); }
|
|
|
+});
|
|
|
+
|
|
|
+test('explicit local paths deduplicate the same path but preserve distinct files with identical content', () => {
|
|
|
+ const workspace = tempDirectory('local-dedup');
|
|
|
+ const first = path.join(workspace, 'first.png');
|
|
|
+ const second = path.join(workspace, 'second.png');
|
|
|
+ fs.writeFileSync(first, Buffer.from([137, 80, 78, 71]));
|
|
|
+ fs.copyFileSync(first, second);
|
|
|
+ try {
|
|
|
+ const images = resolveImageInputs({ images: [first, first, second], workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false });
|
|
|
+ assert.equal(images.length, 2);
|
|
|
+ assert.deepEqual(images.map(image => image.label), ['first.png', 'second.png']);
|
|
|
+ } finally { fs.rmSync(workspace, { recursive: true, force: true }); }
|
|
|
+});
|
|
|
+
|
|
|
+test('explicit local images reject an internal directory junction or symlink', () => {
|
|
|
+ const workspace = tempDirectory('image-link-workspace');
|
|
|
+ const rootImage = path.join(workspace, 'root.png');
|
|
|
+ const alias = path.join(workspace, 'alias');
|
|
|
+ fs.writeFileSync(rootImage, Buffer.from([137, 80, 78, 71]));
|
|
|
+ try {
|
|
|
+ fs.symlinkSync(workspace, alias, process.platform === 'win32' ? 'junction' : 'dir');
|
|
|
+ assert.throws(() => resolveImageInputs({ imagePath: path.join(alias, 'root.png'), workspaceRoot: workspace, cwd: workspace, allowClaudeAttachments: false }), /符号链接|联接点/);
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(alias)) fs.unlinkSync(alias);
|
|
|
+ fs.rmSync(workspace, { recursive: true, force: true });
|
|
|
+ }
|
|
|
});
|
|
|
|
|
|
test('Claude attachment filters require exact session, prompt, and cwd fields', () => {
|
|
|
@@ -56,6 +133,50 @@ test('state checksum detects tampering', () => {
|
|
|
} finally { fs.rmSync(run, { recursive: true, force: true }); }
|
|
|
});
|
|
|
|
|
|
+test('state read and write reject symlinked or hard-linked state files and temp files', () => {
|
|
|
+ const run = tempDirectory('state-link-run');
|
|
|
+ const outside = tempDirectory('state-link-outside');
|
|
|
+ const outsideFile = path.join(outside, 'outside.json');
|
|
|
+ fs.writeFileSync(outsideFile, 'outside');
|
|
|
+ const stateFile = path.join(run, 'state.json');
|
|
|
+ const tempFile = path.join(run, 'state.json.tmp');
|
|
|
+ try {
|
|
|
+ if (process.platform === 'win32') fs.linkSync(outsideFile, stateFile);
|
|
|
+ else fs.symlinkSync(outsideFile, stateFile, 'file');
|
|
|
+ assert.throws(() => readState(run), /普通.*文件/);
|
|
|
+ assert.throws(() => writeState(run, { status: 'blocked' }), /普通.*文件/);
|
|
|
+ fs.unlinkSync(stateFile);
|
|
|
+ if (process.platform === 'win32') fs.linkSync(outsideFile, tempFile);
|
|
|
+ else fs.symlinkSync(outsideFile, tempFile, 'file');
|
|
|
+ assert.throws(() => writeState(run, { status: 'blocked' }), /普通.*文件/);
|
|
|
+ assert.equal(fs.readFileSync(outsideFile, 'utf8'), 'outside');
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
|
|
|
+ if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
|
|
+ fs.rmSync(run, { recursive: true, force: true });
|
|
|
+ fs.rmSync(outside, { recursive: true, force: true });
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+test('state read and write reject directory junctions at state paths', () => {
|
|
|
+ const run = tempDirectory('state-junction-run');
|
|
|
+ const outside = tempDirectory('state-junction-outside');
|
|
|
+ const stateFile = path.join(run, 'state.json');
|
|
|
+ const tempFile = path.join(run, 'state.json.tmp');
|
|
|
+ try {
|
|
|
+ fs.symlinkSync(outside, tempFile, process.platform === 'win32' ? 'junction' : 'dir');
|
|
|
+ assert.throws(() => writeState(run, { status: 'blocked' }), /普通.*文件/);
|
|
|
+ fs.unlinkSync(tempFile);
|
|
|
+ fs.symlinkSync(outside, stateFile, process.platform === 'win32' ? 'junction' : 'dir');
|
|
|
+ assert.throws(() => readState(run), /普通.*文件/);
|
|
|
+ } finally {
|
|
|
+ if (fs.existsSync(stateFile)) fs.unlinkSync(stateFile);
|
|
|
+ if (fs.existsSync(tempFile)) fs.unlinkSync(tempFile);
|
|
|
+ fs.rmSync(run, { recursive: true, force: true });
|
|
|
+ fs.rmSync(outside, { recursive: true, force: true });
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
test('latest Claude user request with no image never reuses older attachment', () => {
|
|
|
const config = tempDirectory('claude');
|
|
|
const logs = path.join(config, 'projects', 'fixture');
|