nonfunc-security.spec.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. /**
  2. * 非功能 / 安全 接口冒烟(远程后端 cfc.iwintrue.com)
  3. * ================================================================
  4. * 目的:把 tests/test-cases/*.md 中「非功能测试用例(性能/兼容/安全)」段里
  5. * **可自动化** 的用例落地为可执行断言,提升接口层可执行覆盖率。
  6. *
  7. * 覆盖的 TC(与文档一一对应):
  8. * - TC-G-N04 未授权访问受保护接口 → 期望 401/403
  9. * - TC-P-N09 / TC-X-N05 IDOR 横向越权(他人家庭)→ 期望 403
  10. * - TC-A-N05 后台接口非管理员访问 → 期望 401/403
  11. * - TC-C-N07 双角色铁律:角色切换需登录;普通家长不可越权切换为未持有角色
  12. * - TC-P-N10 AI 健康教练 XSS(query 注入)→ 不回显原始脚本、不 500
  13. * - TC-A-N06 存储型 XSS:活动反馈 comment → 列表不回显原始脚本
  14. * - TC-P-N11 / TC-X-N08 报告上传非 PDF/恶意文件 → 被拒(非 200)
  15. *
  16. * 运行:npx playwright test e2e/api/nonfunc-security.spec.js -c playwright.config.api.js --project=desktop
  17. * 说明:全部为纯 API 调用,不依赖前端 SPA,契合 CFC-ENV-001(远程无前端)限制。
  18. *
  19. * ⚠️ 断言语义说明:本文件断言的是「安全契约」(受保护接口未授权应返回 401/403、
  20. * 越权应 403)。若远程实际返回其它码,用例会 FAIL —— 这恰好用于暴露后端当前
  21. * 与契约的偏差(即真实安全缺口),属预期内的审计结果,而非用例本身错误。
  22. *
  23. * 健壮性:direct-register 是测试夹具而非被测系统,远端在 CI 部署窗口会偶发返回非
  24. * JSON 导致注册失败;故注册逻辑内置多次重试,避免夹具抖动连累安全断言。
  25. */
  26. const { test, expect } = require('@playwright/test');
  27. const { registerRole, apiPost } = require('../helpers/api');
  28. const XSS = '<script>alert(1)</script>';
  29. /** 带重试的注册(规避远端部署窗口偶发非 JSON 响应);惰性缓存 parent 上下文避免重复注册 */
  30. let _parent = null;
  31. async function getParent(request) {
  32. if (_parent && _parent.token) return _parent;
  33. let lastErr;
  34. for (let i = 0; i < 4; i++) {
  35. try {
  36. const ctx = await registerRole(request, 'parent', 'QA-SEC');
  37. if (ctx && ctx.token) { _parent = ctx; return ctx; }
  38. } catch (e) {
  39. lastErr = e;
  40. }
  41. await new Promise((r) => setTimeout(r, 2500));
  42. }
  43. throw lastErr || new Error('direct-register 重试后仍失败');
  44. }
  45. test.describe('非功能/安全接口冒烟', () => {
  46. // —— 未授权访问(TC-G-N04):受保护接口未登录应被拒(401/403)——
  47. test('[TC-G-N04] 未授权访问能量概览被拒(401/403)', async ({ request }) => {
  48. const r = await apiPost(request, '/api/energy/overview', {}, null);
  49. expect([401, 403].includes(r.code), `能量概览未授权应 401/403,实际 code=${r.code} msg=${r.message}`).toBeTruthy();
  50. });
  51. test('[TC-G-N04] 未授权访问家庭成员列表被拒(401/403)', async ({ request }) => {
  52. const r = await apiPost(request, '/api/family/member/list', {}, null);
  53. expect([401, 403].includes(r.code), `成员列表未授权应 401/403,实际 code=${r.code} msg=${r.message}`).toBeTruthy();
  54. });
  55. test('[TC-G-N04] 未授权调用 AI 健康教练被拒(401/403)', async ({ request }) => {
  56. const r = await apiPost(request, '/api/ai/health-coach/send', { query: '你好' }, null);
  57. expect([401, 403].includes(r.code), `AI 教练未授权应 401/403,实际 code=${r.code} msg=${r.message}`).toBeTruthy();
  58. });
  59. // —— IDOR 横向越权(TC-P-N09 / TC-X-N05):携带本人 token 越权访问他人 familyId → 403 ——
  60. test('[TC-P-N09] IDOR:越权访问他人家庭成员列表 → 403', async ({ request }) => {
  61. const ctx = await getParent(request);
  62. const r = await apiPost(request, '/api/family/member/list', { familyId: 999999999 }, ctx.token);
  63. expect(r.code, `越权访问他人家庭应 403,实际 code=${r.code} msg=${r.message}`).toBe(403);
  64. expect(String(r.message)).toContain('无权访问');
  65. });
  66. // —— 后台鉴权(TC-A-N05):非管理员访问后台接口被拒(401/403)——
  67. // R170 修正:原探针用 `/api/admin-auth/login`(**登录入口,本就应匿名可访问**),
  68. // 空参返回 code=400「手机号和验证码不能为空」是正确行为 → 拿登录接口当「特权入口」
  69. // 属用例设计错误(恒红假缺陷)。改用真实特权面 `/api/admin/families/overview`
  70. // (S-cfc-143/144 收口后匿名应 401)。
  71. test('[TC-A-N05] 非管理员访问后台接口被拒(401/403)', async ({ request }) => {
  72. const r = await apiPost(request, '/api/admin/families/overview', {}, null);
  73. expect([401, 403].includes(r.code), `后台接口未授权应 401/403,实际 code=${r.code} msg=${r.message}`).toBeTruthy();
  74. });
  75. // —— 双角色铁律 / 权限提升防护(TC-C-N07)——
  76. test('[TC-C-N07] 角色切换接口未授权被拒(401/403)', async ({ request }) => {
  77. const r = await apiPost(request, '/api/auth/switch-role', { role: 'parent' }, null);
  78. expect([401, 403].includes(r.code), `切换角色未授权应 401/403,实际 code=${r.code} msg=${r.message}`).toBeTruthy();
  79. });
  80. test('[TC-C-N07] 权限提升防护:家长不可切换为未持有角色', async ({ request }) => {
  81. // 普通家长仅持有 parent/child,尝试切换为 admin 必须被拒(不得签发 token)
  82. const ctx = await getParent(request);
  83. const r = await apiPost(request, '/api/auth/switch-role', { role: 'admin' }, ctx.token);
  84. expect(r.data && r.data.token, `家长不应越权切到 admin(实际 data=${JSON.stringify(r.data)})`).toBeFalsy();
  85. });
  86. test('[TC-C-N07] 角色切换自身合法角色正常(基线)', async ({ request }) => {
  87. const ctx = await getParent(request);
  88. const r = await apiPost(request, '/api/auth/switch-role', { role: 'parent' }, ctx.token);
  89. expect(r.code, `合法角色切换应 200,实际 code=${r.code} msg=${r.message}`).toBe(200);
  90. expect(r.data && r.data.token, '合法切换应签发 token').toBeTruthy();
  91. });
  92. // —— AI 健康教练 XSS(TC-P-N10):query 注入脚本不回显原始脚本且不错 500 ——
  93. test('[TC-P-N10] AI 健康教练 XSS:query 注入脚本不回显原始脚本且不错 500', async ({ request }) => {
  94. const ctx = await getParent(request);
  95. const r = await apiPost(request, '/api/ai/health-coach/send', { query: XSS, conversationId: '' }, ctx.token);
  96. expect(r.code, `AI 教练不应 500,实际 code=${r.code} msg=${r.message}`).not.toBe(500);
  97. expect(JSON.stringify(r)).not.toContain(XSS);
  98. });
  99. // —— 存储型 XSS(TC-A-N06):核心关注点是不回显原始 <script>,与活动是否存在无关 ——
  100. test('[TC-A-N06] 活动反馈存储型 XSS:提交含脚本 comment,列表不回显原始脚本', async ({ request }) => {
  101. const ctx = await getParent(request);
  102. const submit = await apiPost(request, '/api/activity/feedback/submit',
  103. { ratings: '5', comment: XSS, activityId: 1 }, ctx.token);
  104. // 注:若 activityId 不存在后端可能 500(属健壮性,非 XSS 缺口);XSS 关注点仅为「不回显原始脚本」
  105. expect(JSON.stringify(submit)).not.toContain(XSS);
  106. const list = await apiPost(request, '/api/activity/feedback/list', { activityId: 1 }, ctx.token);
  107. expect(JSON.stringify(list)).not.toContain(XSS);
  108. });
  109. // —— 上传恶意/非 PDF 文件被拒(TC-P-N11 / TC-X-N08)——
  110. // R170 修正两处用例缺陷:
  111. // ① 路径错误:`/api/assessment/...` 不存在(线上恒 404),真实路由由
  112. // `HealthReportController` 的类级 `@RequestMapping("/api/health")` 决定 →
  113. // `/api/health/report/upload`。
  114. // ② 判定口径错误:本项目统一 `Result{code,message,data}` + **HTTP 200 承载业务码**,
  115. // 用「HTTP 非 200」判「被拒」恒假红;正确判据是 **业务 code ≠ 200**(拒绝落库)
  116. // 且 HTTP 不出现 5xx(不发生未捕获异常)。
  117. // 实测(R170):非 multipart → code=400「请求格式错误,请使用文件表单上传」;
  118. // multipart 但内容非 PDF → code=500「PDF解析失败…」→ 两者均为业务拒绝,符合契约。
  119. test('[TC-P-N11] 报告上传非 PDF/恶意内容被拒(非 200)', async ({ request }) => {
  120. const ctx = await getParent(request);
  121. // ① 非 multipart 直传 → 业务拒绝(code≠200),HTTP 不 5xx
  122. const r1 = await request.post('/api/health/report/upload', {
  123. data: 'PK\x03\x04not-a-real-pdf',
  124. headers: { 'Content-Type': 'application/octet-stream', Authorization: 'Bearer ' + ctx.token },
  125. timeout: 60000,
  126. });
  127. const s1 = r1.status();
  128. const b1 = await r1.json().catch(() => ({}));
  129. expect(s1, `上传畸形请求不应触发 HTTP 5xx,实际 ${s1}`).toBeLessThan(500);
  130. expect(b1.code, `非表单上传应被业务拒绝(code≠200),实际 HTTP ${s1} code=${b1.code} msg=${b1.message}`).not.toBe(200);
  131. // ② multipart 但内容非 PDF → 业务拒绝(不落库),HTTP 不 5xx
  132. const r2 = await request.post('/api/health/report/upload', {
  133. multipart: { file: { name: 'evil.pdf', mimeType: 'application/pdf', buffer: Buffer.from('PK\x03\x04not-a-real-pdf') } },
  134. headers: { Authorization: 'Bearer ' + ctx.token },
  135. timeout: 60000,
  136. });
  137. const s2 = r2.status();
  138. const b2 = await r2.json().catch(() => ({}));
  139. expect(s2, `伪 PDF 上传不应触发 HTTP 5xx,实际 ${s2}`).toBeLessThan(500);
  140. expect(b2.code, `伪 PDF 应被业务拒绝(code≠200),实际 HTTP ${s2} code=${b2.code} msg=${b2.message}`).not.toBe(200);
  141. expect(b2.data && b2.data.reportId, '被拒的上传不得产生报告记录').toBeFalsy();
  142. });
  143. });