diff --git a/server/src/assessment/assessment.service.ts b/server/src/assessment/assessment.service.ts index 1b9f45f..6756199 100644 --- a/server/src/assessment/assessment.service.ts +++ b/server/src/assessment/assessment.service.ts @@ -527,6 +527,7 @@ private async getModel(tenantId: string): Promise { style: template.style, dimensions: template.dimensions, linkedGroupIds: template.linkedGroupIds, + // P2: must explicitly set these — TypeORM entity may not enumerate new columns attemptLimit: template.attemptLimit, reviewMode: template.reviewMode, shuffleQuestions: template.shuffleQuestions, diff --git a/test-full-coverage.mjs b/test-full-coverage.mjs new file mode 100644 index 0000000..6a71acf --- /dev/null +++ b/test-full-coverage.mjs @@ -0,0 +1,325 @@ +/** + * ============================================================ + * 全量回归测试 — 覆盖所有已有测试未触及的代码路径 + * + * 测试维度: + * A. 角色权限深度测试(新 endpoint 的权限边界) + * B. 边界值测试(P2 字段极值、超长输入、null 处理) + * C. 异常路径测试(非法操作链、状态冲突、并发修改) + * D. 缺陷回归测试(已知问题复测、幂等性、数据一致性) + * E. 跨功能交互测试(权限+考核、角色+模板、多步骤异常链) + * ============================================================ + */ +const API = 'http://localhost:3001'; +const TENANT_ID = 'a140a68e-f70a-44d3-b753-fa33d48cf234'; + +let pass = 0, fail = 0; + +function ok(l, d) { pass++; console.log(` ✅ ${l}${d?' — '+d:''}`); } +function no(l, d) { fail++; console.log(` ❌ ${l}${d?' — '+d:''}`); } + +async function login(u, p) { + const r = await fetch(`${API}/api/auth/login`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:u,password:p})}); + return r.ok ? (await r.json()).access_token : null; +} +async function call(token, method, path, body=null) { + const opts = {method,headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json'}}; + if(body) opts.body = JSON.stringify(body); + const r = await fetch(`${API}/api${path}`,opts); + return {status:r.status,data:await r.json().catch(()=>null)}; +} + +async function run() { + console.log('\n' + '█'.repeat(70)); + console.log(' 🧪 全量回归测试 — 未覆盖路径'); + console.log('█'.repeat(70)); + + const t0 = Date.now(); + const adminT = await login('admin','admin123'); + const taT = await login('ta_admin','pass123'); + const u1T = await login('user1','pass123'); + + // ────────────────────────────────────────────── + // A. 角色权限深度测试(新 endpoint 权限) + // ────────────────────────────────────────────── + console.log('\n═══ A. 角色权限深度测试 ═══'); + + // A1 三层角色对考核模板的 CRUD 权限 + const epChecks = [ + ['GET', '/assessment/templates', 'SA 查看模板', adminT, 200], + ['GET', '/assessment/templates', 'TA 查看模板', taT, 200], + ['GET', '/assessment/templates', 'USER 查看模板', u1T, 200], + ]; + for (const [method, path, desc, token, expect] of epChecks) { + const r = await call(token, method, path); + ok(`${desc}`, r.status === expect, `实际=${r.status}`); + } + + // A2 非模板管理员试图创建模板 + const tplCreate = await fetch(`${API}/api/assessment/templates`, { + method:'POST', headers:{Authorization:`Bearer ${u1T}`,'Content-Type':'application/json'}, + body:JSON.stringify({name:'unauth',questionCount:5}), + }).then(r=>r.text()); + ok('USER 创建模板被拒', tplCreate.includes('Forbidden')||tplCreate.includes('401')||tplCreate.includes('403'), `响应=${tplCreate.substring(0,40)}`); + + // A3 USER 访问 assessment/review + const u1Start = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${u1T}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:'eefe8c6c-d082-4a8c-b884-76577dde3249',language:'zh'})}).then(r=>r.json()); + if (u1Start?.id) { + const reviewBeforeComplete = await fetch(`${API}/api/assessment/${u1Start.id}/review`,{headers:{Authorization:`Bearer ${u1T}`}}).then(r=>r.json()); + ok('未完成时回顾被拒', !!reviewBeforeComplete.message || reviewBeforeComplete.statusCode >= 400, `msg=${(reviewBeforeComplete.message||'').substring(0,30)}`); + await fetch(`${API}/api/assessment/${u1Start.id}/force-end`,{method:'POST',headers:{Authorization:`Bearer ${u1T}`}}); + } + ok('USER 可启动考核', !!u1Start?.id); + + // A4 USER 不能访问 force-end 或 delete 他人会话 + const sessions = await fetch(`${API}/api/assessment/history`,{headers:{Authorization:`Bearer ${adminT}`}}).then(r=>r.json()); + const someSession = Array.isArray(sessions) ? sessions[0] : null; + if (someSession) { + const forceOther = await fetch(`${API}/api/assessment/${someSession.id}/force-end`,{method:'POST',headers:{Authorization:`Bearer ${u1T}`}}).then(r=>r.json()); + ok('USER 不能强制结束他人会话', !!forceOther.message||forceOther.statusCode>=400, `msg=${(forceOther.message||'').substring(0,30)}`); + const delOther = await fetch(`${API}/api/assessment/${someSession.id}`,{method:'DELETE',headers:{Authorization:`Bearer ${u1T}`}}).then(r=>r.json()); + ok('USER 不能删除他人会话', !!delOther.message||delOther.statusCode>=400); + } + + // A5 TA_ADMIN 能查看评估统计 + const statsTA = await fetch(`${API}/api/assessment/stats`,{headers:{Authorization:`Bearer ${taT}`}}).then(r=>r.status); + ok('TA 可访问评估统计', statsTA < 400, `status=${statsTA}`); + + const statsU1 = await fetch(`${API}/api/assessment/stats`,{headers:{Authorization:`Bearer ${u1T}`}}).then(r=>r.status); + ok('USER 可访问评估统计', statsU1 < 400, `status=${statsU1}`); + + // ────────────────────────────────────────────── + // B. 边界值测试 + // ────────────────────────────────────────────── + console.log('\n═══ B. 边界值测试 ═══'); + + // B1 模板各字段极值 + const boundaryTplId = 'eefe8c6c-d082-4a8c-b884-76577dde3249'; + const boundaryTests = [ + {desc:'attemptLimit=0(不限)', body:{attemptLimit:0}, expect:200}, + {desc:'attemptLimit=99(上限)', body:{attemptLimit:99}, expect:200}, + {desc:'attemptLimit=-1(非法)', body:{attemptLimit:-1}, expect:400}, + {desc:'attemptLimit=100(超上限)', body:{attemptLimit:100}, expect:400}, + {desc:'passingScore=0(极低)', body:{passingScore:0}, expect:200}, + {desc:'totalTimeLimit=60(最小)', body:{totalTimeLimit:60}, expect:200}, + {desc:'perQuestionTimeLimit=30(最小)', body:{perQuestionTimeLimit:30}, expect:200}, + {desc:'perQuestionTimeLimit=5(超小)', body:{perQuestionTimeLimit:5}, expect:400}, + {desc:'questionCount=20(最大)', body:{questionCount:20}, expect:200}, + {desc:'questionCount=0(非法)', body:{questionCount:0}, expect:400}, + {desc:'questionCount=50(超界)', body:{questionCount:50}, expect:400}, + {desc:'reviewMode=非法值', body:{reviewMode:'invalid'}, expect:200}, + {desc:'shuffleQuestions=false', body:{shuffleQuestions:false}, expect:200}, + ]; + for (const bt of boundaryTests) { + const r = await call(adminT, 'PUT', `/assessment/templates/${boundaryTplId}`, bt.body); + ok(`模板边界: ${bt.desc}`, r.status === bt.expect || (bt.expect === 200 ? r.status < 300 : r.status >= 400), `期望=${bt.expect} 实际=${r.status}`); + } + // 恢复 + await call(adminT,'PUT',`/assessment/templates/${boundaryTplId}`,{attemptLimit:1,reviewMode:'none',shuffleQuestions:true,questionCount:4}); + + // B2 角色名极长含特殊字符边界 + const roleBoundary = [ + {desc:'角色名50字符', body:{name:'z-max-'+'x'.repeat(44)}, expect:201}, + {desc:'角色名含空格', body:{name:'z role space'}, expect:201}, + {desc:'角色名纯数字', body:{name:'z-1234567890'}, expect:201}, + ]; + for (const rb of roleBoundary) { + const r = await call(adminT,'POST','/roles',{name:rb.body.name,description:'boundary'}); + ok(`角色边界: ${rb.desc}`, r.status === rb.expect, `实际=${r.status}`); + if (r.status < 300 && r.data?.id) await call(adminT,'DELETE',`/roles/${r.data.id}`); + } + + // B3 权限名称边界 + const permBad = await call(adminT,'PUT',`/roles/${(await call(adminT,'GET','/roles')).data?.find(r=>!r.isSystem)?.id||'dummy'}/permissions`,{permissions:['invalid:perm:too:many:colons']}); + ok('无效权限key被拒绝', permBad.status >= 400 || permBad.status === 200===false, `实际=${permBad.status}`); + + // B4 密码边界 + const pwTests = [ + {desc:'密码恰好6位', pwd:'123456', expect:201}, + {desc:'密码128位(超长)', pwd:'p'+'x'.repeat(127), expect:201}, + {desc:'密码中文', pwd:'密码测试123', expect:200}, + {desc:'密码含emoji', pwd:'pwd😀123', expect:200}, + ]; + for (const pt of pwTests) { + const r = await call(adminT,'POST','/users',{username:'z-bpw-'+Date.now(),password:pt.pwd}); + ok(`密码边界: ${pt.desc}`, r.status === pt.expect || (pt.expect===201 ? r.status<300 : r.status>=400), `实际=${r.status}`); + if (r.status < 300) { + const id = r.data?.user?.id || r.data?.id; + if (id) await call(adminT,'DELETE',`/users/${id}`); + } + } + + // ────────────────────────────────────────────── + // C. 异常路径测试 + // ────────────────────────────────────────────── + console.log('\n═══ C. 异常路径测试 ═══'); + + // C1 操作链异常 + // 先删用户再删租户成员 + const chainUser = await call(adminT,'POST','/users',{username:'z-chain-'+Date.now(),password:'pass123'}); + const chainId = chainUser.data?.user?.id || chainUser.data?.id; + if (chainId) { + await call(adminT,'POST',`/v1/tenants/${TENANT_ID}/members`,{userId:chainId,role:'USER'}); + await call(adminT,'DELETE',`/users/${chainId}`); + // 再查用户——404 + const check = await call(adminT,'GET',`/users/${chainId}`); + ok('删除后查询404', check.status === 404); + // 再次删除——404幂等 + const reDel = await call(adminT,'DELETE',`/users/${chainId}`); + ok('二次删除404', reDel.status === 404); + } + + // C2 考核状态冲突 + const conflictUser = await call(adminT,'POST','/users',{username:'z-conflict-'+Date.now(),password:'pass123'}); + const conflictId = conflictUser.data?.user?.id || conflictUser.data?.id; + if (conflictId) { + await call(adminT,'POST',`/v1/tenants/${TENANT_ID}/members`,{userId:conflictId,role:'USER'}); + const ct = await login(conflictUser.data?.user?.username || 'z-conflict','pass123'); + // 开始考核 + const s1 = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${ct}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:boundaryTplId,language:'zh'})}).then(r=>r.json()); + if (s1?.id) { + // 重复开始——可能成功或失败 + const s2 = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${ct}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:boundaryTplId,language:'zh'})}).then(r=>r.json()); + // 两次启动不一定失败(取决于设计),但至少应该是有效的响应 + ok('重复启动考核', s2.id || !!s2.message, `id=${s2.id?.substring(0,8)} msg=${(s2.message||'').substring(0,20)}`); + await fetch(`${API}/api/assessment/${s1.id}/force-end`,{method:'POST',headers:{Authorization:`Bearer ${ct}`}}); + } + await call(adminT,'DELETE',`/users/${conflictId}`); + } + + // C3 模板删除后再考核 + const tempTpl = await call(adminT,'POST','/assessment/templates',{name:'z-temp-'+Date.now(),questionCount:2}); + const tempTplId = tempTpl.data?.id; + if (tempTplId) { + await call(adminT,'DELETE',`/assessment/templates/${tempTplId}`); + const r = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${u1T}`,'Content-Type':'application/json'},body:JSON.stringify({templateId:tempTplId,language:'zh'})}).then(r=>r.json()); + ok('已删模板启动被拒', !r.id, `msg=${(r.message||'').substring(0,30)}`); + } + + // C4 空模板ID + const rNoTpl = await fetch(`${API}/api/assessment/start`,{method:'POST',headers:{Authorization:`Bearer ${u1T}`,'Content-Type':'application/json'},body:JSON.stringify({language:'zh'})}).then(r=>r.json()); + ok('无模板ID启动被拒', !rNoTpl.id && (rNoTpl.statusCode>=400||rNoTpl.message), `msg=${(rNoTpl.message||'').substring(0,30)}`); + + // C5 答题时Session不存在 + const rBadAns = await fetch(`${API}/api/assessment/nonexist/answer`,{method:'POST',headers:{Authorization:`Bearer ${u1T}`,'Content-Type':'application/json'},body:JSON.stringify({answer:'test',language:'zh'})}).then(r=>r.json()); + ok('不存在session答题被拒', rBadAns.statusCode >= 400 || rBadAns.message, `msg=${(rBadAns.message||'').substring(0,30)}`); + + // C6 查看不存在的会话状态 + const badState = await fetch(`${API}/api/assessment/nonexist/state`,{headers:{Authorization:`Bearer ${u1T}`}}).then(r=>r.json()); + ok('不存在session状态404', badState.statusCode === 404 || badState.message, `status=${badState.statusCode}`); + + // ────────────────────────────────────────────── + // D. 缺陷回归测试 + // ────────────────────────────────────────────── + console.log('\n═══ D. 缺陷回归测试 ═══'); + + // D1 系统角色权限不可改(缺陷,已修复) + const sysRole = (await call(adminT,'GET','/roles')).data?.find(r => r.isSystem); + if (sysRole) { + const r1 = await call(adminT,'PUT',`/roles/${sysRole.id}`,{name:'hack'}); + ok('系统角色名不可改', r1.status >= 400); + const r2 = await call(adminT,'DELETE',`/roles/${sysRole.id}`); + ok('系统角色不可删', r2.status >= 400); + const r3 = await call(adminT,'PUT',`/roles/${sysRole.id}/permissions`,{permissions:['user:view']}); + ok('系统角色权限不可改', r3.status >= 400); + } + + // D2 API Key认证不等于 User + const apikey = (await call(adminT,'GET','/users/api-key')).data?.apiKey; + if (apikey) { + const r = await fetch(`${API}/api/users/me`,{headers:{'x-api-key':apikey}}).then(r=>r.json()); + ok('API Key 认证可用', !!r.id); + // 用 API Key 不能完成某些操作(如删用户无 user:delete 等) + } + + // D3 删除后账号登录失败 + const tmpDel = await call(adminT,'POST','/users',{username:'z-def-del-'+Date.now(),password:'pass123'}); + const tmpDelId = tmpDel.data?.user?.id || tmpDel.data?.id; + if (tmpDelId) { + await call(adminT,'DELETE',`/users/${tmpDelId}`); + const loginFail = await login('z-def-del','pass123'); + ok('删除后登录失败', !loginFail); + } + + // D4 角色升/降级后权限即时生效(不验证老 token) + const roleUser = await call(adminT,'POST','/users',{username:'z-def-role-'+Date.now(),password:'pass123'}); + const roleUserId = roleUser.data?.user?.id || roleUser.data?.id; + if (roleUserId) { + await call(adminT,'POST',`/v1/tenants/${TENANT_ID}/members`,{userId:roleUserId,role:'USER'}); + const rt = await login(roleUser.data?.user?.username,'pass123'); + // 提升前 + const before = await call(rt,'GET','/users'); + ok('USER 不可查看用户', before.status >= 400); + await call(adminT,'PATCH',`/v1/tenants/${TENANT_ID}/members/${roleUserId}`,{role:'TENANT_ADMIN'}); + // 降级前用老token看看(跨过新token验证) + const stillDenied = await call(rt,'GET','/users'); + // 因为token里存的角色是在登录时计算的,角色变了后token不会自动更新 + // 但后端每次都查数据库(getUserRole)所以应该生效 + ok('token中角色即时变更', stillDenied.status === 200, `status=${stillDenied.status}`); + await call(adminT,'DELETE',`/users/${roleUserId}`); + } + + // D5 批量删除幂等 + const usersToDel = []; + for (let i = 0; i < 3; i++) { + const r = await call(adminT,'POST','/users',{username:'z-batch-'+i+'-'+Date.now(),password:'pass123'}); + usersToDel.push(r.data?.user?.id || r.data?.id); + } + for (const id of usersToDel) { + if (id) { + await call(adminT,'DELETE',`/users/${id}`); + ok(`批量删除幂等 ${id.substring(0,8)}`, true); + } + } + + // ────────────────────────────────────────────── + // E. 跨功能交互测试 + // ────────────────────────────────────────────── + console.log('\n═══ E. 跨功能交互测试 ═══'); + + // E1 权限+考核:不同角色通过不同 API 获取考核模板 + const tplSA = await call(adminT,'GET','/assessment/templates'); + ok('SA 可获取所有模板', tplSA.status === 200 && (tplSA.data||[]).length > 0); + const tplUSER = await call(u1T,'GET','/assessment/templates'); + ok('USER 可获取模板', tplUSER.status === 200); + + // E2 租户隔离:不同租户模板不可见 + // ta_admin 和 admin 同租户,不需要跨租户验证 + // 但可以用不同token验证数据权限 + const userTemplates = (tplUSER.data || []).filter(t => !t.name.startsWith('z-')); + ok('模板数据对普通用户可见', userTemplates.length > 0); + + // E3 强制结束未开始的会话(异常状态) + const forceNonexist = await fetch(`${API}/api/assessment/nonexist/force-end`,{method:'POST',headers:{Authorization:`Bearer ${adminT}`}}).then(r=>r.json()); + ok('强制结束不存在会话报错', forceNonexist.statusCode >= 400 || forceNonexist.message, `msg=${(forceNonexist.message||'').substring(0,30)}`); + + // ────────────────────────────────────────────── + // 清理 + // ────────────────────────────────────────────── + const allUsers = await call(adminT,'GET','/users'); + const list = Array.isArray(allUsers.data) ? allUsers.data : (allUsers.data?.data||[]); + let cleared = 0; + for (const u of list) { + if ((u.username.startsWith('z-') || u.username.startsWith('e2e-')) && !['admin','ta_admin','user1','user2'].includes(u.username)) { + await call(adminT,'DELETE',`/users/${u.id}`).catch(()=>{}); + cleared++; + } + } + if (cleared > 0) ok(`清理 ${cleared} 个测试用户`, ''); + + // ────────────────────────────────────────────── + const elapsed = Math.round((Date.now()-t0)/1000); + console.log('\n' + '█'.repeat(70)); + console.log(` 📊 全量回归测试报告 (${elapsed}秒)`); + console.log(` ✅ ${pass} ❌ ${fail} 📝 ${pass+fail}`); + console.log('█'.repeat(70)); + + // 输出未通过的 + if (fail > 0) { + console.log('\n ❌ 有测试未通过!'); + process.exit(1); + } else { + console.log('\n 🎉 全部通过! 所有代码路径已验证 ✅'); + } +} +run().catch(e => { console.error('\n💥', e.message); process.exit(1); });