Files
maestro/test/attachments.test.ts
T
wangjia 2500e3e779 后端:附件读取/下载端点 + 删除端点 + addAttachments 落库去重 (tsk_tttdU3VLCy50)
- store.addAttachments:按 path 去重(later-wins),消除重传产生的悬空元数据
- store.removeAttachment:按磁盘文件名(basename(path))移除元数据,返回 removed
- GET /api/tasks/:id/attachments/:name:inline 预览,?download=1 触发下载(RFC5987 文件名)
- DELETE /api/tasks/:id/attachments/:name:先删元数据再尽力删磁盘文件
- 只服务元数据登记过的附件 + resolve/startsWith 前缀校验,杜绝目录穿越
- 新增 test/attachments.test.ts(去重/读取/下载/删除/穿越/丢失/端到端上传去重)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 01:29:42 +08:00

217 lines
9.7 KiB
TypeScript
Raw 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 } 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:按 path 去重(later-wins', () => {
const { store, taskId } = freshTask();
store.addAttachments(taskId, [{ name: 'a.png', type: 'image/png', path: `tasks/${taskId}/attachments/a.png` }]);
// 重传同一 path(safeName 相同)→ 元数据只保留一条,且取最新 name/type
const next = store.addAttachments(taskId, [
{ name: 'a-renamed.png', type: 'image/jpeg', path: `tasks/${taskId}/attachments/a.png` },
{ name: 'b.pdf', type: 'application/pdf', path: `tasks/${taskId}/attachments/b.pdf` },
]);
assert.equal(next.length, 2);
const a = next.find((x) => x.path.endsWith('/a.png'));
assert.equal(a?.name, 'a-renamed.png');
assert.equal(a?.type, 'image/jpeg');
// 插入顺序保持:a 在前、b 在后
assert.deepEqual(next.map((x) => x.path.split('/').pop()), ['a.png', 'b.pdf']);
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 附件:?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();
});
test('上传→去重端到端:同名文件连传两次,元数据只一条', async () => {
const dir = tmpData();
process.env.MAESTRO_DATA_DIR = dir;
const { store, taskId } = freshTask();
const app = buildServer({ store });
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}` } };
}
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.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);
// 同名 safeName → path 相同 → 去重为一条
const atts = up2.json().attachments;
assert.equal(atts.length, 1, '同名重传应去重为一条');
// 通过 GET /api/tasks/:id 复核
const t = await app.inject({ method: 'GET', url: `/api/tasks/${taskId}` });
assert.equal(t.json().attachments.length, 1);
await app.close();
store.close();
});