workshop-report-parity.test.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import os from 'node:os';
  5. import path from 'node:path';
  6. import crypto from 'node:crypto';
  7. import { createApplication } from '../server/src/core.mjs';
  8. import { attachWorkshopHandlers } from '../server/src/workshop.mjs';
  9. import { runWorkshop } from '../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs';
  10. import { buildModel } from '../src/core/kfs/build.js';
  11. import { entriesForType, reportCenter, TYPE_FOR_ARTIFACT } from '../src/core/library/reports.js';
  12. import { reportOverview } from '../src/core/report.js';
  13. import { seedMemberGroups } from '../src/core/member-groups.js';
  14. import { workshopReview, workshopOutputRefs } from '../src/core/workshop-outputs.js';
  15. import { createFileAdapter } from '../server/src/files.mjs';
  16. import { CORE_DELIVERABLES, DELIVERY_KINDS, DELIVERY_SCHEMA, reportCategoryOf } from '../skills/ksf-workshop-runtime/scripts/report-contract.mjs';
  17. import { saveAndVerifyReport, prepareHtmlReport } from '../skills/ksf-workshop-runtime/scripts/save-report.mjs';
  18. import { reportAttachments } from '../src/core/report-attachments.js';
  19. // Isolated storage: these tests never connect to the production service.
  20. const data = JSON.parse(fs.readFileSync(new URL('../server/references/runtime-data.json', import.meta.url)));
  21. const course = fs.readFileSync(new URL('../skills/ksf-workshop-runtime/references/workshop-course-flow.md', import.meta.url), 'utf8');
  22. const curriculum = [...course.matchAll(/^\| (D[12]-(?:\d\d|N)) \| \d+ \| [^|]+ \| (S[1-5]) \| ([^\n]+)\|/gm)]
  23. .flatMap(([, module, stage, types]) => [...types.matchAll(/`(\w+)`/g)].map(([, type]) => ({ module, stage, type })));
  24. const actor = { id: 'parity-service', kind: 'service', scopes: ['data:read', 'ingest:write'], projectIds: ['*'] };
  25. const agentProfile = { key: 'parity-agent', name: '测试共用智能体', role: 'workshop', runtimePlatform: 'test', channel: 'test' };
  26. function fixture() {
  27. let rows = {}, seq = 0;
  28. const store = {
  29. async transaction(fn) { const prior = structuredClone(rows); try { return await fn(store); } catch (error) { rows = prior; throw error; } },
  30. async lock() {},
  31. async get(name, id) { return structuredClone(rows[name]?.find(row => row.objectId === id) || null); },
  32. async list(name, { where = {}, afterId, limit = 200 } = {}) {
  33. return structuredClone((rows[name] || []).filter(row => Object.entries(where).every(([key, value]) => JSON.stringify(row[key] ?? null) === JSON.stringify(value)))
  34. .filter(row => !afterId || row.objectId > afterId).sort((a, b) => a.objectId.localeCompare(b.objectId)).slice(0, limit));
  35. },
  36. async create(name, values) {
  37. for (const key of Object.keys(values)) assert.ok(data.tables[name][key], `${name}.${key} must exist in the schema`);
  38. const row = { ...structuredClone(values), objectId: 'parity' + String(++seq).padStart(8, '0'), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
  39. (rows[name] ||= []).push(row); return structuredClone(row);
  40. },
  41. async update(name, id, values) { const row = rows[name]?.find(row => row.objectId === id); assert.ok(row); Object.assign(row, structuredClone(values)); return structuredClone(row); },
  42. };
  43. const settings = { signingSecret: 'parity-test-only', functionsUrl: 'https://kangshifu.fmode.cn/api/functions' };
  44. const files = createFileAdapter({ store, crypto, Parse: {}, data, settings });
  45. const app = createApplication({ store, crypto, Parse: {}, data, settings, files });
  46. attachWorkshopHandlers(app, { store, data, files });
  47. const call = async (name, input, identity = actor) => (await app.dispatch(name, structuredClone(input), identity)).data;
  48. return { store, call, files,
  49. async start(index, workshopKey = 'parity-topic') { return call('ksfStartWorkshopSession', {
  50. requestId: `start-${index}-${workshopKey}`, workshopKey, dataMode: 'sample', groupId: `G${index % 5 + 1}`,
  51. participant: { key: `participant-${index}`, name: index < 2 ? '同名组员' : `测试组员${index}`, identityBasis: 'participant_registration' },
  52. agent: { key: agentProfile.key, name: agentProfile.name, role: 'workshop', identityBasis: 'operator_registration', runtimePlatform: 'test', channel: 'test' },
  53. conversation: { key: `conversation-${index}`, channel: 'test' },
  54. }); },
  55. async save(sessionId, item, index, version = 1, chapterId = 'analysis') {
  56. const body = `隔离测试正文 ${index},版本 ${version}。不是实际调研成果。`;
  57. return runWorkshop('workshop-save', {
  58. sessionId, taskKey: `parity-topic:${item.module}:report-${index}`, version, stageCode: item.stage,
  59. title: `隔离测试报告${index}`, artifactType: item.type, rawText: body, sourceIds: [], pendingQuestions: ['需要实际研究验证'],
  60. report: { executive: [body], chapters: [{ id: chapterId, title: '测试正文', sections: [
  61. { kind: 'para', text: body }, { kind: 'table', data: { cols: ['范围', '结论'], rows: [['隔离测试', body]] } },
  62. ] }] },
  63. }, { requestId: `save-${sessionId}-${index}-v${version}`, agentProfile, call });
  64. },
  65. async publicData() {
  66. const raw = Object.fromEntries(['project', 'artifact', 'insight', 'source', 'session', 'opportunity'].map(name => [name, []]));
  67. let cursor;
  68. do { const page = await call('ksfGetPublicBoard', { limit: 7, ...(cursor ? { cursor } : {}) }, null);
  69. for (const name of Object.keys(raw)) raw[name].push(...page[name]); cursor = page.nextCursor;
  70. } while (cursor);
  71. return raw;
  72. },
  73. };
  74. }
  75. test('Skill课表13环节的每种报告均经保存入口、精确回读、公开分页、正式分类和全文模型保留正文', async () => {
  76. assert.equal(new Set(curriculum.map(item => item.module)).size, 13);
  77. assert.equal(new Set(curriculum.map(item => item.type)).size, 7);
  78. const f = fixture(), { sessionId } = await f.start(0), saved = [];
  79. for (const [index, item] of curriculum.entries()) {
  80. const receipt = await f.save(sessionId, item, index);
  81. const read = await f.call('ksfGetRecord', { className: 'KfArtifact', objectId: receipt.artifactId });
  82. assert.equal(read.record.artifactType, item.type);
  83. assert.equal(read.record.stageCode, item.stage);
  84. assert.match(read.record.rawText, new RegExp(`隔离测试正文 ${index}`));
  85. saved.push({ item, receipt, read });
  86. }
  87. const db = buildModel(await f.publicData()), view = workshopReview(db, seedMemberGroups(db.members), { mode: 'sample' });
  88. assert.equal(db.artifacts.length, curriculum.length);
  89. assert.equal(view.rows.length, curriculum.length);
  90. assert.equal(view.hierarchy.flatMap(group => group.members.flatMap(member => member.reports)).length, curriculum.length);
  91. for (const { item, receipt, read } of saved) {
  92. const record = db.artifactById[receipt.artifactId];
  93. assert.deepEqual(record.report, read.record.report);
  94. assert.equal(record.rawText, read.record.rawText);
  95. assert.ok(entriesForType(db, TYPE_FOR_ARTIFACT[item.type]).some(entry => entry.kind === 'artifact' && entry.id === receipt.artifactId));
  96. assert.equal(reportOverview(db, receipt.artifactId).chapters[0].sections[1].data.rows.length, 1);
  97. }
  98. assert.equal(reportCenter(db).entries.filter(entry => entry.kind === 'artifact').length, curriculum.length);
  99. assert.equal(workshopReview(db, null).rows.length, 0, '样例按默认真实资料筛选不会显示,不能误报漏存');
  100. });
  101. test('35位组员共用同一智能体,5组各人的同系列报告和版本不混合,报告分类与组员归属对应', async () => {
  102. const f = fixture(), expected = new Map();
  103. for (let index = 0; index < 35; index++) {
  104. const { sessionId } = await f.start(index), item = curriculum[index % curriculum.length];
  105. await f.save(sessionId, item, 0, 1);
  106. const latest = await f.save(sessionId, item, 0, 2);
  107. expected.set(sessionId, latest.artifactId);
  108. }
  109. const db = buildModel(await f.publicData()), groups = seedMemberGroups(db.members);
  110. const latest = workshopReview(db, groups, { mode: 'sample' });
  111. assert.equal(db.members.length, 35);
  112. assert.equal(groups.groups.length, 5);
  113. assert.equal(latest.rows.length, 35);
  114. assert.equal(workshopReview(db, groups, { mode: 'sample', history: '1' }).rows.length, 70);
  115. for (const group of latest.hierarchy.filter(group => group.memberCount)) {
  116. assert.equal(group.memberCount, 7);
  117. for (const member of group.members) {
  118. assert.equal(member.reports.length, 1);
  119. const row = member.reports[0];
  120. assert.equal(row.artifact.memberId, member.id);
  121. assert.equal(row.id, expected.get(row.artifact.sessionId));
  122. assert.equal(row.artifact.version, 2);
  123. assert.equal(row.reportType, TYPE_FOR_ARTIFACT[row.artifact.type]);
  124. }
  125. }
  126. });
  127. test('七类核心交付物可承载在正式报告类型中,但章节导航与报告分类是两个维度', async () => {
  128. const f = fixture(), { sessionId } = await f.start(0);
  129. const outputs = [
  130. ['market', 'market-analysis', 'category_report'], ['social', 'social-needs', 'voc_report'],
  131. ['evidence', 'evidence-pool', 'voc_report'], ['opportunities', 'opportunity-cards', 'opportunity_card'],
  132. ['validation', 'opportunity-validation', 'assessment_report'], ['value', 'buying-points', 'concept'],
  133. ['proposal', 'product-proposal', 'concept'],
  134. ];
  135. for (const [index, [, anchor, type]] of outputs.entries()) await f.save(sessionId, { module: 'D2-08', stage: 'S5', type }, index, 1, anchor);
  136. const db = buildModel(await f.publicData());
  137. for (const [key] of outputs) assert.equal(db.artifacts.filter(artifact => workshopOutputRefs(artifact).refs.some(ref => ref.key === key)).length, 1);
  138. assert.equal(new Set(db.artifacts.map(reportCategoryOf)).size, 7, '七类核心交付物现在可分别分类');
  139. });
  140. function deliveryTask(sessionId, key = 'market', overrides = {}) {
  141. const output = CORE_DELIVERABLES.find(o => o.key === key), type = DELIVERY_KINDS[key].types[0];
  142. return { sessionId, taskKey: `parity-topic:D2-08:${key}`, version: 1, stageCode: 'S5',
  143. title: '隔离测试:' + output.name, artifactType: type, sourceIds: [], pendingQuestions: ['测试内容待实际研究验证'],
  144. rawText: '隔离测试的完整正文,不代表实际消费者研究。',
  145. report: { executive: ['隔离测试'], delivery: { schema: DELIVERY_SCHEMA, moduleId: 'D2-08', kind: key, contentStatus: 'ready', outputs: [key] },
  146. chapters: [{ id: output.anchor, title: output.name, sections: [{ kind: 'para', text: '该环节的完整测试正文。' }] }] }, ...overrides };
  147. }
  148. function verifier(f, override = {}) {
  149. return { requestId: 'new-save', invoke: (command, task, requestId) => runWorkshop(command, task, { requestId, agentProfile, call: f.call }),
  150. publicRead: p => f.call('ksfGetPublicBoard', p, null),
  151. readAttachment: async url => (await f.files.readFile({ query: Object.fromEntries(new URL(url).searchParams) }, null)).bytes,
  152. ...override };
  153. }
  154. const inlineFile = (name, contentType, bytes) => ({ name, contentType, sha256: crypto.createHash('sha256').update(bytes).digest('hex'), sizeBytes: bytes.length, dataBase64: bytes.toString('base64') });
  155. test('七类新报告经实际Skill保存、精确回读、公开读取校验,个人七项提交状态与分类一致', async () => {
  156. const f = fixture(), { sessionId } = await f.start(0);
  157. for (const { key } of CORE_DELIVERABLES) {
  158. const result = await saveAndVerifyReport(deliveryTask(sessionId, key), verifier(f, { requestId: 'verified-' + key }));
  159. assert.equal(result.recordVerified, true);assert.equal(result.publicDataVerified, true);assert.equal(result.browserVerified, false);
  160. }
  161. const db = buildModel(await f.publicData()), state = seedMemberGroups(db.members), view = workshopReview(db, state, { mode: 'sample' });
  162. const member = view.hierarchy.flatMap(g => g.members).find(m => m.reports.length);
  163. assert.equal(member.topics.length, 1);assert.deepEqual(member.topics[0].outputs.map(o => o.state), Array(7).fill('saved'));
  164. assert.equal(new Set(member.reports.map(r => r.reportType)).size, 7);
  165. for (const row of member.reports) assert.ok(entriesForType(db, row.reportType).some(e => e.id === row.id && e.href));
  166. });
  167. test('默认HTML生成后走实际保存、编号回读、公开投影和文件字节回读;员工确认保持待处理', async t => {
  168. const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ksf-html-parity-'));
  169. t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
  170. const f = fixture(), { sessionId } = await f.start(0);
  171. for (const { key } of CORE_DELIVERABLES) {
  172. const { task, htmlPath } = prepareHtmlReport(deliveryTask(sessionId, key), { directory });
  173. const receipt = await saveAndVerifyReport(task, verifier(f, { requestId: 'html-' + key }));
  174. assert.equal(receipt.filesVerified, 1);assert.equal(receipt.employeeConfirmation, 'pending');
  175. assert.equal(receipt.htmlFiles.length, 1);assert.equal(receipt.publicDataVerified, true);
  176. const file = receipt.htmlFiles[0];
  177. assert.equal(new URL(file.previewUrl).searchParams.get('artifactId'), receipt.artifactId);
  178. assert.equal(new URL(file.previewUrl).searchParams.get('preview'), '1');
  179. const actual = await f.files.readFile({ query: Object.fromEntries(new URL(file.url).searchParams) }, null);
  180. assert.ok(actual.bytes.equals(fs.readFileSync(htmlPath)));
  181. const row = await f.store.get('KfArtifact', receipt.artifactId);
  182. assert.deepEqual(row.report.chapters, task.report.chapters);
  183. assert.equal(row.meta.workshop.formalConfirmation, false);
  184. }
  185. assert.equal((await f.store.list('KfArtifact')).length, CORE_DELIVERABLES.length);
  186. });
  187. test('HTML与图片随报告原子保存、文件字节回读、公开预览关联;直接匿名取私有文件被拒', async () => {
  188. const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId, 'proposal');
  189. task.report.attachments = [inlineFile('最终提案.html', 'text/html', Buffer.from('<!doctype html><meta charset="utf-8"><h1>隔离测试提案</h1>')),
  190. inlineFile('示意图.png', 'image/png', Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a5YkAAAAASUVORK5CYII=', 'base64'))];
  191. const result = await saveAndVerifyReport(task, verifier(f));assert.equal(result.filesVerified, 2);
  192. const replay = await saveAndVerifyReport(task, verifier(f));assert.equal(replay.artifactId, result.artifactId);
  193. assert.equal((await f.store.list('KsfFileObject')).length, 2, '同请求重放不产生重复文件');
  194. const row = await f.store.get('KfArtifact', result.artifactId);
  195. assert.equal(JSON.stringify(row.report).includes('dataBase64'), false);
  196. const files = row.report.attachments;assert.equal(files.length, 2);
  197. assert.equal(reportAttachments({ id: row.objectId, report: row.report }).length, 2);
  198. await assert.rejects(f.files.readFile({ query: { fileId: files[0].id } }, null), { code: 'AUTH_REQUIRED' });
  199. const publicFile = await f.files.readFile({ query: { artifactId: row.objectId, fileId: files[0].id } }, null);
  200. assert.equal(publicFile.name, '最终提案.html');assert.match(publicFile.bytes.toString(), /隔离测试提案/);
  201. await assert.rejects(f.files.readFile({ query: { artifactId: 'another-report', fileId: files[0].id } }, null), { code: 'REFERENCE_NOT_FOUND' });
  202. await f.store.update('KfArtifact', row.objectId, { recordState: 'archived' });
  203. await assert.rejects(f.files.readFile({ query: { artifactId: row.objectId, fileId: files[0].id } }, null), { code: 'REFERENCE_NOT_FOUND' });
  204. });
  205. test('附件内容损坏、伪造回执、跨身份取文件、空交付物均拒绝,失败不遗留报告或文件', async () => {
  206. const f = fixture(), { sessionId } = await f.start(0);
  207. const bad = deliveryTask(sessionId);bad.report.chapters[0].sections = [{ kind: 'table', data: { cols: ['标题'], rows: [[' ']] } }];
  208. await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'empty-report' }), { code: 'VALIDATION_FAILED' });
  209. const attachment = inlineFile('bad.png', 'image/png', Buffer.from('not an image'));
  210. bad.report = deliveryTask(sessionId).report;bad.report.attachments = [attachment];
  211. await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'bad-image' }), { code: 'VALIDATION_FAILED' });
  212. bad.report.attachments = [inlineFile('ok.txt', 'text/plain', Buffer.from('保存后也须回滚')), { ...inlineFile('bad.txt', 'text/plain', Buffer.from('正文')), sha256: 'a'.repeat(64) }];
  213. await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'bad-hash' }), { code: 'UPLOAD_INCOMPLETE' });
  214. bad.report.attachments = [{ name: 'fake.txt', contentType: 'text/plain', sizeBytes: 1, sha256: 'a'.repeat(64), receipt: { fileId: 'fake' } }];
  215. await assert.rejects(f.call('ksfSaveWorkshopTask', { ...bad, requestId: 'fake-file' }), { code: 'UPLOAD_INCOMPLETE' });
  216. assert.equal((await f.store.list('KfArtifact')).length, 0);assert.equal((await f.store.list('KsfFileObject')).length, 0);
  217. assert.equal((await f.store.list('KfSource')).length, 0);
  218. const good = deliveryTask(sessionId);good.report.attachments = [inlineFile('private.txt', 'text/plain', Buffer.from('私有原件'))];
  219. const saved = await saveAndVerifyReport(good, verifier(f));
  220. const [file] = await f.store.list('KsfFileObject');
  221. await assert.rejects(f.files.readFile({ query: { fileId: file.objectId } }, { id: 'other', kind: 'service', scopes: ['data:read'] }), { code: 'FORBIDDEN' });
  222. await f.store.update('KfArtifact', saved.artifactId, { meta: {}, dashboardState: 'draft' });
  223. await assert.rejects(f.files.readFile({ query: { artifactId: saved.artifactId, fileId: file.objectId } }, null), { code: 'REFERENCE_NOT_FOUND' });
  224. });
  225. test('仅有boardVisible回执不算验证完成;回读不符保留编号且不重试保存,旧服务在写前停止', async () => {
  226. const f = fixture(), { sessionId } = await f.start(0), task = deliveryTask(sessionId);
  227. let saves = 0;
  228. const base = verifier(f);
  229. await assert.rejects(saveAndVerifyReport(task, { ...base, invoke: async (command, input, id) => {
  230. if (command === 'workshop-save') saves++;
  231. return base.invoke(command, input, id);
  232. }, publicRead: async () => ({ artifact: [], nextCursor: null }) }), error => {
  233. assert.equal(error.code, 'REPORT_PUBLIC_READBACK_MISMATCH');assert.ok(error.partial.receipt.artifactId);assert.equal(error.partial.automaticRetry, false);return true;
  234. });
  235. assert.equal(saves, 1);assert.equal((await f.store.list('KfArtifact')).length, 1);
  236. await assert.rejects(saveAndVerifyReport(task, { ...base, invoke: async command => {
  237. assert.equal(command, 'workshop-contract');return { workshop: { version: 'ksf.workshop.v1' } };
  238. } }), { code: 'REPORT_DELIVERY_CAPABILITY_UNAVAILABLE' });
  239. });
  240. test('个人交付核对按课题、资料模式、最新版本隔离;草稿不计已交,缺正文、旧成果待定位均明示', async () => {
  241. const f = fixture(), first = await f.start(0), other = await f.start(0, 'another-topic');
  242. await saveAndVerifyReport(deliveryTask(first.sessionId), verifier(f));
  243. const draft = deliveryTask(first.sessionId);draft.version = 2;draft.report.delivery.contentStatus = 'draft';
  244. await saveAndVerifyReport(draft, verifier(f, { requestId: 'save-draft' }));
  245. await saveAndVerifyReport(deliveryTask(other.sessionId, 'proposal', { taskKey: 'another-topic:D2-08:proposal' }), verifier(f, { requestId: 'other-topic' }));
  246. const raw = await f.publicData();
  247. const db = buildModel(raw), view = workshopReview(db, seedMemberGroups(db.members), { mode: 'all', history: '1', reportType: 'value' });
  248. const topics = view.hierarchy.flatMap(g => g.members.flatMap(m => m.topics));
  249. const a = topics.find(t => t.workshopKey === 'parity-topic'), b = topics.find(t => t.workshopKey === 'another-topic');
  250. assert.equal(a.outputs.find(o => o.key === 'market').state, 'draft');
  251. assert.equal(a.outputs.find(o => o.key === 'proposal').state, 'missing');
  252. assert.equal(b.outputs.find(o => o.key === 'proposal').state, 'saved');
  253. assert.equal(b.outputs.find(o => o.key === 'market').state, 'missing');
  254. const legacy = structuredClone(raw.artifact[0]);legacy.objectId = 'legacy-empty';legacy.reportSeriesKey = 'legacy';legacy.version = 1;
  255. legacy.report = { chapters: [{ id: 'evidence-pool', sections: [] }] };legacy.rawText = '';
  256. raw.artifact.push(legacy);
  257. const db2 = buildModel(raw), v2 = workshopReview(db2, seedMemberGroups(db2.members), { mode: 'sample' });
  258. assert.equal(v2.hierarchy.flatMap(g => g.members.flatMap(m => m.topics)).find(t => t.workshopKey === 'parity-topic').outputs.find(o => o.key === 'evidence').state, 'missing-body');
  259. });
  260. test('报告中心仅计成果报告;旧原文可读、无正文不伪造,独立机会及资料分开统计', () => {
  261. const db = buildModel({ artifact: [{ objectId: 'text', title: '旧报告', artifactType: 'voc_report', rawText: '已保存的真实原文。' },
  262. { objectId: 'empty', title: '缺正文', artifactType: 'concept' }], opportunity: [{ objectId: 'opp', title: '独立机会' }],
  263. source: [{ objectId: 'category-source', sourceType: 'category' }] });
  264. const center = reportCenter(db);assert.equal(center.total, 2);assert.equal(center.separateObjects.opportunities, 1);
  265. assert.ok(center.entries.every(e => e.kind === 'artifact'));
  266. assert.equal(center.entries.find(e => e.id === 'text').href, '/report/text');
  267. const read = reportOverview(db, 'text');assert.equal(read.bodyStatus, 'text');assert.match(read.report.subtitle, /尚非结构化/);
  268. assert.equal(read.chapters[0].sections[0].text, '已保存的真实原文。');assert.equal(db.artifactById.text.report, null);
  269. assert.equal(reportOverview(db, 'empty'), null);
  270. });
  271. test('已有上传回执可关联报告;跨身份回执及超限附件在写入前拒绝', async () => {
  272. const f = fixture(), { sessionId } = await f.start(0), bytes = Buffer.from('已通过文件通道的正文。');
  273. const input = inlineFile('来源文件.txt', 'text/plain', bytes);
  274. const prepared = await f.call('ksfPrepareFileUpload', { requestId: 'prepare-report-file', fileName: input.name,
  275. contentType: input.contentType, sizeBytes: input.sizeBytes, sha256: input.sha256 }, { ...actor, scopes: [...actor.scopes,'source:write'] });
  276. const receipt = await f.files.upload({ query: Object.fromEntries(new URL(prepared.upload.url).searchParams),
  277. headers: { 'x-ksf-upload-token': prepared.upload.headers['X-Ksf-Upload-Token'] }, body: bytes });
  278. const attachment = { ...input, receipt };delete attachment.dataBase64;
  279. await assert.rejects(f.store.transaction(s => f.files.saveReportAttachments(s, [attachment], { ...actor, id: 'other-service' })), { code: 'UPLOAD_INCOMPLETE' });
  280. const task = deliveryTask(sessionId);task.report.attachments = [attachment];
  281. const saved = await saveAndVerifyReport(task, verifier(f));assert.equal(saved.filesVerified, 1);
  282. const large = { ...attachment, sizeBytes: 1024 * 1024 + 1 };
  283. await assert.rejects(f.store.transaction(s => f.files.saveReportAttachments(s, [large], actor)), { code: 'VALIDATION_FAILED' });
  284. });