lesson-attachments.test.mjs 5.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. import {test} from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import vm from 'node:vm';
  4. import {readFileSync} from 'node:fs';
  5. const read=path=>readFileSync(new URL(path,import.meta.url),'utf8');
  6. const gateway=read('../deploy-admin-functions.mjs').match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
  7. const feedback={contentSummary:'阅读短文',trainingSummary:'写作练习',noHomework:true};
  8. const teacher={id:'teacher',get:key=>({company:{id:'7pIbDBJmKx'},legacyUserId:30,legacyGroupId:3}[key])};
  9. const pdf={fileName:'知识点.pdf',contentType:'application/pdf',base64:Buffer.from('%PDF-1.4\nnotes').toString('base64'),orderId:'901'};
  10. function setup(){
  11. let order={GeneralID:901,pl:30,courseCategoryKey:'primary_writing'},actions=['editFeedback'],uploads=0;
  12. const receipts=new Map(),requests=[];
  13. const c=vm.createContext({atob,Blob,FormData,AbortSignal,fetch:async(url,request)=>{
  14. uploads++;assert.equal(request.body.get('file').type,requests.at(-1)?.contentType||pdf.contentType);
  15. return {ok:true,text:async()=>JSON.stringify({retcode:1,result:'/UploadFiles/notes.pdf'})};
  16. }});
  17. vm.runInContext(gateway+'\n'+read('../cloud/lesson-workflow.js')+'\n'+read('../cloud/lesson-attachments.js'),c);
  18. c.orderDetailRow=async()=>order;c.lessonOrderCapabilities=async()=>({allowedActions:actions});c.appLegacyConfig=async()=>({url:'https://media.example/api',apiId:'test',apiKey:'test'});
  19. c.randomObjectId=()=>String(receipts.size+1).padStart(10,'0');
  20. c.Psql={none:async(sql,args)=>{const[id,company,appointment,coach,name,url,contentType,size]=args;receipts.set(id,{id,company,appointment,coach,name,url,contentType,size});requests.push({contentType});},
  21. query:async(sql,[company,appointment,coach,ids])=>ids.map(id=>receipts.get(id)).filter(row=>row&&row.company===company&&row.appointment===appointment&&row.coach===coach).map(({id,name,url,contentType,size})=>({id,name,url,contentType,size}))};
  22. return {c,receipts,requests,setOrder:value=>order={...order,...value},setActions:value=>actions=value,uploads:()=>uploads};
  23. }
  24. test('本节语法写作老师上传常用文档和照片,服务端保存原始中文名及课次归属',async()=>{
  25. const {c,receipts,setOrder}=setup();
  26. for(const category of ['primary_writing','middle_writing','high_writing']){
  27. setOrder({courseCategoryKey:category});const file=await c.uploadLessonAttachment(pdf,teacher);
  28. assert.equal(file.name,'知识点.pdf');assert.equal(file.contentType,'application/pdf');assert.equal(file.url,'https://media.example/UploadFiles/notes.pdf');assert.equal(receipts.get(file.id).appointment,'901');assert.equal(receipts.get(file.id).coach,30);
  29. }
  30. const png={...pdf,fileName:'笔记.png',contentType:'image/png',base64:Buffer.from([137,80,78,71,13,10,26,10]).toString('base64')};
  31. // Verify transport type independently of the previous document upload.
  32. c.fetch=async(_url,request)=>{assert.equal(request.body.get('file').type,'image/png');return{ok:true,text:async()=>JSON.stringify({retcode:1,result:'https://media.example/note.png'})};};
  33. assert.equal((await c.uploadLessonAttachment(png,teacher)).name,'笔记.png');
  34. });
  35. test('学生、其他老师、外帐套、非写作课和已锁定反馈不能上传,拦截发生在媒体请求之前',async()=>{
  36. const {c,setOrder,setActions,uploads}=setup();
  37. for(const user of [{get:key=>({company:{id:'7pIbDBJmKx'},legacyUserId:20,legacyGroupId:1}[key])},{get:key=>({company:{id:'7pIbDBJmKx'},legacyUserId:31,legacyGroupId:3}[key])},{get:key=>key==='company'?{id:'foreign'}:null}])await assert.rejects(c.uploadLessonAttachment(pdf,user),/仅本节课|无权/);
  38. setOrder({courseCategoryKey:'word'});await assert.rejects(c.uploadLessonAttachment(pdf,teacher),/语法写作课/);
  39. setOrder({courseCategoryKey:'primary_writing'});setActions([]);await assert.rejects(c.uploadLessonAttachment(pdf,teacher),/不可编辑/);assert.equal(uploads(),0);
  40. });
  41. test('拒绝伪造文件类型、超限、无效编码及不支持的文件,原通用上传仍仅允许图片',async()=>{
  42. const {c,uploads}=setup();
  43. for(const patch of [{fileName:'test.exe'},{base64:Buffer.from('not a PDF').toString('base64')},{base64:'!!!'},{base64:Buffer.alloc(5*1024*1024+1).toString('base64')},{fileName:'../note.pdf'}])await assert.rejects(c.uploadLessonAttachment({...pdf,...patch},teacher),/请选择|内容|编码/);
  44. await assert.rejects(c.uploadAppMedia(pdf),/文件类型/);assert.equal(uploads(),0);
  45. });
  46. test('反馈只接受本节课的已上传附件编号,客户端伪造的名称和地址会被覆盖',async()=>{
  47. const {c,setOrder}=setup();const file=await c.uploadLessonAttachment(pdf,teacher);
  48. const parsed=c.lessonFeedbackInput({feedback:JSON.stringify({...feedback,attachments:[{id:file.id,name:'伪造',url:'javascript:alert(1)'}]})});
  49. await c.resolveLessonFeedbackAttachments({orderId:'901'},teacher,parsed);assert.equal(parsed.attachments[0].name,'知识点.pdf');assert.equal(parsed.attachments[0].url,file.url);
  50. setOrder({GeneralID:902});await assert.rejects(c.resolveLessonFeedbackAttachments({orderId:'902'},teacher,{attachments:[{id:file.id}]}),/不属于本节课/);
  51. for(const attachments of [[{id:''}],Array.from({length:11},(_,i)=>({id:String(i)})),[{id:'same'},{id:'same'}],'invalid'])assert.throws(()=>c.lessonFeedbackInput({feedback:JSON.stringify({...feedback,attachments})}),/附件/);
  52. });
  53. test('附件归属检查不会拦截成功提交后学生评价触发的幂等重试,锁定由SQL事务判断',async()=>{
  54. const {c,setActions}=setup(),file=await c.uploadLessonAttachment(pdf,teacher);
  55. setActions([]);
  56. let commands=0;c.Psql.one=async(sql,args)=>{commands++;assert.match(sql,/xs_lesson_feedback_update/);assert.equal(JSON.parse(args[6]).attachments[0].id,file.id);return{result:{saved:true}};};
  57. const result=await c.appLessonFeedbackUpdate({orderId:'901',requestKey:'attachment-retry-001',expectedUpdatedAt:'2026-10-29T00:00:00Z',feedback:JSON.stringify({...feedback,attachments:[file]})},teacher);
  58. assert.equal(result.saved,true);assert.equal(commands,1);
  59. });