f1ee537ab7
- 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>
308 lines
14 KiB
TypeScript
308 lines
14 KiB
TypeScript
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 附件:默认 inline,content-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();
|
||
});
|