Files
maestro/test/attachments.test.ts
wangjia f1ee537ab7 附件内容去重(hash):上传改为内容寻址 + store 按 hash 去重 [tsk_fTul9G4eutVn]
- types: Attachment 增 hash?/size? 可选字段(向后兼容,老数据零迁移)
- 上传端点 POST .../attachments 改为内容寻址:流式计算 sha256,写临时文件再
  按 <sha256>.<ext> 原子 rename;同 hash 已存在则丢弃临时文件(真去重);
  超限/异常清理临时文件不留垃圾。磁盘名仅由内容 hash 决定,杜绝撞名覆盖丢数据。
- store.addAttachments 去重键改为 hash ?? path(later-wins,老数据回退 path)。
- DELETE 端点补注释:附件按 task 隔离,无需跨任务 refcount。
- 安全保持并固化:GET inline 仅硬白名单图片,其余强制 attachment+octet-stream
  +nosniff+CSP sandbox;路径越界防护;hash 命名不含路径可控字符。
- 测试:新增同内容去重/不同内容同名各留一条/响应含 hash+size 等用例。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:22:15 +08:00

308 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store/index.js';
import { buildServer } from '../src/api/server.js';
import type { Attachment } from '../src/model/types.js';
/** 临时数据目录(充当 MAESTRO_DATA_DIR)。 */
function tmpData(): string {
return mkdtempSync(join(tmpdir(), 'maestro-attach-'));
}
/** 在 store 里建项目 + 任务,返回 {store, taskId}。 */
function freshTask(): { store: Store; taskId: string } {
const store = new Store(':memory:');
const p = store.createProject({ name: 'a', repoPath: '/tmp/a-' + Math.random() });
const t = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
return { store, taskId: t.id };
}
/** 把一份附件文件落盘到 <data>/tasks/<id>/attachments/<name>,并返回元数据。 */
function placeFile(dir: string, taskId: string, name: string, mime: string, body: Buffer | string): Attachment {
const attDir = join(dir, 'tasks', taskId, 'attachments');
mkdirSync(attDir, { recursive: true });
writeFileSync(join(attDir, name), body);
return { name, type: mime, path: `tasks/${taskId}/attachments/${name}` };
}
// ───────────────────────── Store 层 ─────────────────────────
test('addAttachments:按内容 hash 去重(later-wins,同内容不同名只一条)', () => {
const { store, taskId } = freshTask();
const hA = 'a'.repeat(64);
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', hash: hA, size: 3, path: `tasks/${taskId}/attachments/${hA}.png` }]);
// 重传相同内容(hash 相同)但改名 → 元数据只保留一条,且取最新 name/type
const hB = 'b'.repeat(64);
const next = store.addAttachments(taskId, [
{ name: 'a-renamed.png', type: 'image/jpeg', hash: hA, size: 3, path: `tasks/${taskId}/attachments/${hA}.png` },
{ name: 'b.pdf', type: 'application/pdf', hash: hB, size: 7, path: `tasks/${taskId}/attachments/${hB}.pdf` },
]);
assert.equal(next.length, 2);
const a = next.find((x) => x.hash === hA);
assert.equal(a?.name, 'a-renamed.png');
assert.equal(a?.type, 'image/jpeg');
// 插入顺序保持:A 在前、B 在后
assert.deepEqual(next.map((x) => x.hash), [hA, hB]);
store.close();
});
test('addAttachments:不同内容(hash 不同)即便同显示名也各保留一条(不再静默覆盖丢数据)', () => {
const { store, taskId } = freshTask();
const h1 = '1'.repeat(64);
const h2 = '2'.repeat(64);
const next = store.addAttachments(taskId, [
{ name: 'screenshot.png', type: 'image/png', hash: h1, size: 3, path: `tasks/${taskId}/attachments/${h1}.png` },
{ name: 'screenshot.png', type: 'image/png', hash: h2, size: 3, path: `tasks/${taskId}/attachments/${h2}.png` },
]);
assert.equal(next.length, 2, '不同内容同名应各保留一条');
assert.deepEqual(next.map((x) => x.hash), [h1, h2]);
store.close();
});
test('addAttachments:老数据无 hash 时回退按 path 去重', () => {
const { store, taskId } = freshTask();
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', path: `tasks/${taskId}/attachments/a.png` }]);
const next = store.addAttachments(taskId, [
{ name: 'a-renamed.png', type: 'image/jpeg', path: `tasks/${taskId}/attachments/a.png` },
]);
assert.equal(next.length, 1);
assert.equal(next[0].name, 'a-renamed.png');
store.close();
});
test('removeAttachment:按磁盘文件名移除,返回 removed;不存在则 removed=null', () => {
const { store, taskId } = freshTask();
store.addAttachments(taskId, [
{ name: 'keep.png', type: 'image/png', path: `tasks/${taskId}/attachments/keep.png` },
{ name: 'gone.pdf', type: 'application/pdf', path: `tasks/${taskId}/attachments/gone.pdf` },
]);
const r1 = store.removeAttachment(taskId, 'gone.pdf');
assert.equal(r1.removed?.path, `tasks/${taskId}/attachments/gone.pdf`);
assert.deepEqual(r1.attachments.map((x) => x.path.split('/').pop()), ['keep.png']);
// 再删不存在的名字 → removed=null,列表不变
const r2 = store.removeAttachment(taskId, 'nope.png');
assert.equal(r2.removed, null);
assert.equal(r2.attachments.length, 1);
store.close();
});
test('removeAttachment / addAttachments:任务不存在抛错', () => {
const store = new Store(':memory:');
assert.throws(() => store.removeAttachment('tsk_nope', 'x'));
assert.throws(() => store.addAttachments('tsk_nope', []));
store.close();
});
// ───────────────────────── API 层(Fastify inject)─────────────────────────
test('GET 附件:默认 inlinecontent-type/length 正确', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const png = Buffer.from('\x89PNG\r\n\x1a\n-fake-image-bytes', 'binary');
store.addAttachments(taskId, [placeFile(dir, taskId, 'pic.png', 'image/png', png)]);
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/pic.png` });
assert.equal(res.statusCode, 200);
assert.match(res.headers['content-type'] as string, /image\/png/);
assert.equal(res.headers['content-length'], String(png.length));
assert.match(res.headers['content-disposition'] as string, /^inline; filename\*=UTF-8''pic\.png/);
await app.close();
store.close();
});
test('GET 附件【安全】:SVG/HTML 等可执行类型强制下载(attachment + octet-stream + nosniff + CSP sandbox),杜绝同源存储型 XSS', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>');
store.addAttachments(taskId, [placeFile(dir, taskId, 'evil.svg', 'image/svg+xml', svg)]);
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/evil.svg` });
assert.equal(res.statusCode, 200);
// 非白名单图片类型(含 svg/html):绝不 inline、绝不以原 MIME 回 → 强制下载 + octet-stream + 双重头兜底
assert.match(res.headers['content-disposition'] as string, /^attachment;/, 'SVG 必须强制 attachment 下载');
assert.match(res.headers['content-type'] as string, /application\/octet-stream/, 'content-type 必须改为 octet-stream(不能回 image/svg+xml');
assert.equal(res.headers['x-content-type-options'], 'nosniff');
assert.match(res.headers['content-security-policy'] as string, /sandbox/);
await app.close();
store.close();
});
test('GET 附件:?download=1 → attachment + RFC5987 文件名(中文不乱码)', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
// 磁盘安全名为 ascii,但原始 name 含中文
store.addAttachments(taskId, [{
name: '设计稿.png', type: 'image/png', path: `tasks/${taskId}/attachments/paste-1.png`,
}]);
placeFile(dir, taskId, 'paste-1.png', 'image/png', 'data');
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/paste-1.png?download=1` });
assert.equal(res.statusCode, 200);
const cd = res.headers['content-disposition'] as string;
assert.match(cd, /^attachment; filename\*=UTF-8''/);
assert.match(cd, new RegExp(encodeURIComponent('设计稿.png')));
await app.close();
store.close();
});
test('DELETE 附件:剔除元数据 + 删磁盘文件;之后 GET 404', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
store.addAttachments(taskId, [placeFile(dir, taskId, 'doc.pdf', 'application/pdf', 'pdfdata')]);
const absFile = join(dir, 'tasks', taskId, 'attachments', 'doc.pdf');
assert.ok(existsSync(absFile));
const app = buildServer({ store });
const del = await app.inject({ method: 'DELETE', url: `/api/tasks/${taskId}/attachments/doc.pdf` });
assert.equal(del.statusCode, 200);
assert.deepEqual(del.json().attachments, []);
assert.ok(!existsSync(absFile), '磁盘文件应被删除');
const get = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/doc.pdf` });
assert.equal(get.statusCode, 404);
await app.close();
store.close();
});
test('GET/DELETE:任务不存在 → 404', async () => {
const store = new Store(':memory:');
const app = buildServer({ store });
const g = await app.inject({ method: 'GET', url: `/api/tasks/tsk_nope/attachments/x.png` });
assert.equal(g.statusCode, 404);
const d = await app.inject({ method: 'DELETE', url: `/api/tasks/tsk_nope/attachments/x.png` });
assert.equal(d.statusCode, 404);
await app.close();
store.close();
});
test('GET:未登记的附件名 → 404(不返回目录外/任意文件)', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
placeFile(dir, taskId, 'secret.png', 'image/png', 'x'); // 落盘但不登记元数据
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/secret.png` });
assert.equal(res.statusCode, 404);
await app.close();
store.close();
});
test('GET:元数据在但磁盘文件丢失 → 404「附件文件已丢失」', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
// 只写元数据,不落盘
store.addAttachments(taskId, [{ name: 'ghost.png', type: 'image/png', path: `tasks/${taskId}/attachments/ghost.png` }]);
const app = buildServer({ store });
const res = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}/attachments/ghost.png` });
assert.equal(res.statusCode, 404);
assert.match(res.json().error, /丢失/);
await app.close();
store.close();
});
test('DELETE:元数据在但磁盘文件已被外部删除 → 仍成功清元数据', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
store.addAttachments(taskId, [{ name: 'ghost.png', type: 'image/png', path: `tasks/${taskId}/attachments/ghost.png` }]);
const app = buildServer({ store });
const res = await app.inject({ method: 'DELETE', url: `/api/tasks/${taskId}/attachments/ghost.png` });
assert.equal(res.statusCode, 200);
assert.deepEqual(res.json().attachments, []);
await app.close();
store.close();
});
function multipart(filename: string, content: string): { payload: Buffer; headers: Record<string, string> } {
const boundary = '----maestrotest';
const body =
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="files"; filename="${filename}"\r\n` +
`Content-Type: image/png\r\n\r\n` +
content + `\r\n` +
`--${boundary}--\r\n`;
return { payload: Buffer.from(body), headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } };
}
test('上传→内容去重端到端:同内容不同名连传 → 元数据 1 条、磁盘 1 份 <sha256>.png', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const app = buildServer({ store });
// 同内容('aaa'),但文件名不同 → 内容寻址应去重为一条、磁盘一份
const m1 = multipart('shot.png', 'aaa');
const up1 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m1.payload, headers: m1.headers });
assert.equal(up1.statusCode, 200);
assert.equal(up1.json().attachments.length, 1);
const m2 = multipart('shot-renamed.png', 'aaa');
const up2 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m2.payload, headers: m2.headers });
assert.equal(up2.statusCode, 200);
const atts = up2.json().attachments;
assert.equal(atts.length, 1, '同内容重传应去重为一条');
// 磁盘上只有 1 个 <sha256>.png(外加可能的 .tmp-* 已清理)
const attDir = join(dir, 'tasks', taskId, 'attachments');
const files = readdirSync(attDir).filter((f) => !f.startsWith('.tmp-'));
assert.equal(files.length, 1, '磁盘应只有一份内容文件');
assert.match(files[0], /^[0-9a-f]{64}\.png$/, '磁盘名应为 <sha256>.png');
// GET /api/tasks/:id 复核:1 条,且含 hash(64 hex)/size 字段
const t = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}` });
const list = t.json().attachments;
assert.equal(list.length, 1);
assert.match(list[0].hash, /^[0-9a-f]{64}$/);
assert.equal(list[0].size, 3);
await app.close();
store.close();
});
test('上传→内容寻址端到端:不同内容同显示名连传 → 元数据 2 条、磁盘 2 份(旧的覆盖丢数据 bug 已消除)', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const app = buildServer({ store });
const m1 = multipart('screenshot.png', 'aaa');
await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m1.payload, headers: m1.headers });
const m2 = multipart('screenshot.png', 'bbb'); // 同名、不同内容
const up2 = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m2.payload, headers: m2.headers });
assert.equal(up2.statusCode, 200);
assert.equal(up2.json().attachments.length, 2, '不同内容同名应保留两条');
const attDir = join(dir, 'tasks', taskId, 'attachments');
const files = readdirSync(attDir).filter((f) => !f.startsWith('.tmp-'));
assert.equal(files.length, 2, '磁盘应有两份内容文件,不再静默覆盖');
await app.close();
store.close();
});
test('上传:响应附件含 hash(64 hex) 与 size 字段', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const app = buildServer({ store });
const m = multipart('pic.png', 'hello-bytes');
const up = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/attachments`, payload: m.payload, headers: m.headers });
assert.equal(up.statusCode, 200);
const att = up.json().attachments[0];
assert.match(att.hash, /^[0-9a-f]{64}$/);
assert.equal(att.size, 'hello-bytes'.length);
// 磁盘名 = path 末段 = <hash>.png
assert.equal(att.path, `tasks/${taskId}/attachments/${att.hash}.png`);
await app.close();
store.close();
});