t22-envelope-frontend.mjs 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128
  1. /**
  2. * 票 22 离线回归:真实 AjaxResult 信封消费与业务错误映射(不联网)。
  3. *
  4. * 覆盖 2026-10-10 真实审计里三项 `frontend-*` 失败检查对应的**同一批领域出口**
  5. * (`fetchBaseArchives` / `fetchMapSnapshot` / `fetchCurrentEvents`),外加
  6. * mock 分支、裸载荷与业务错误类别白名单,用注入的 `fetchImpl` 复现同一形状。
  7. *
  8. * 与 `verify/t18-real-audit.mjs` 的分工:那份跑真实后端;本份可在无后端、
  9. * 无浏览器时锁定消费语义。真机复验仍以票 22 的待验证项 1/2 为准。
  10. */
  11. import assert from 'node:assert/strict'
  12. import { requestCockpitJson, registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
  13. import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
  14. import { fetchBaseArchives } from '../../../src/modules/cockpit/services/infrastructureApi.js'
  15. import { fetchCurrentEvents } from '../../../src/modules/cockpit/services/eventApi.js'
  16. import { REQUEST_REASON } from '../../../src/modules/cockpit/domain/requestReason.js'
  17. import { SECTION_STATE, sectionStateFromBackend, sectionStateFromFreshness } from '../../../src/modules/cockpit/domain/sectionState.js'
  18. const config = { dataSource: 'real', api: { baseUrl: 'http://acceptance.invalid/api/cockpit/v1' } }
  19. const respond = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body })
  20. const envelope = (data) => ({ code: 200, msg: 'success', data })
  21. let failed = 0
  22. async function check(name, action) {
  23. try {
  24. await action()
  25. console.log(`PASS ${name}`)
  26. } catch (error) {
  27. failed += 1
  28. console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`)
  29. }
  30. }
  31. // 一、信封消费:三条真实失败检查对应的领域出口,载荷必须解包而不是整包透传。
  32. await check('base-archives envelope is unwrapped into a non-empty archive section', async () => {
  33. const section = await fetchBaseArchives({ config, fetchImpl: async () => respond(envelope({
  34. totals: { facilityCount: 1006 }, industryDistribution: [{ code: 'WATER_SUPPLY', count: 10 }],
  35. })) })
  36. assert.equal(section.state, 'success')
  37. assert.equal(section.data.facilityCount, 1006)
  38. })
  39. await check('map-snapshot envelope keeps total and snapshotAt', async () => {
  40. const section = await fetchMapSnapshot({ config, pageSize: 1000, fetchImpl: async () => respond(envelope({
  41. snapshotAt: '2026-10-10T10:25:00+08:00', total: 13, monitoringPoints: [], unassociatedEquipment: [],
  42. })) })
  43. assert.equal(section.state, 'success')
  44. assert.equal(section.data.total, 13)
  45. assert.equal(section.data.snapshotAt, '2026-10-10T10:25:00+08:00')
  46. })
  47. // 二、业务错误不得读成成功(HTTP 200 + code=500)。
  48. await check('HTTP200 business 500 is a failed section, never success', async () => {
  49. const section = await fetchCurrentEvents({ config, mode: 'ALARM', fetchImpl: async () => respond({ code: 500, msg: 'failure', data: null }) })
  50. assert.equal(section.state, 'failed')
  51. assert.equal(section.reason, REQUEST_REASON.UNAVAILABLE)
  52. })
  53. await check('HTTP200 business 401/403 stays a credential-class failure', async () => {
  54. const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => respond({ code: 403, msg: 'denied', data: null }) })
  55. assert.equal(result.ok, false)
  56. assert.equal(result.reason, REQUEST_REASON.UNAUTHORIZED)
  57. })
  58. // 三、业务类别映射:白名单内可恢复;白名单外(含对象继承键)一律回空串。
  59. await check('known business category survives the HTTP200 envelope path', async () => {
  60. const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => respond({ code: 500, msg: 'failure', data: { category: 'SNAPSHOT_EXPIRED' } }) })
  61. assert.equal(result.reason, REQUEST_REASON.SNAPSHOT_EXPIRED)
  62. })
  63. for (const category of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf']) {
  64. await check(`unknown business category "${category}" cannot leak a non-contract reason`, async () => {
  65. const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => respond({ code: 500, msg: 'failure', data: { category } }) })
  66. assert.equal(result.reason, REQUEST_REASON.UNAVAILABLE)
  67. })
  68. }
  69. // 四、信封自身不完整 / 非 JSON 时不得冒充成功。
  70. await check('successful envelope without data is a bad payload', async () => {
  71. const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => respond({ code: 200, msg: 'success' }) })
  72. assert.equal(result.ok, false)
  73. assert.equal(result.reason, REQUEST_REASON.BAD_PAYLOAD)
  74. })
  75. await check('non-JSON body stays a bad payload', async () => {
  76. const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => ({
  77. ok: true, status: 200, json: async () => { throw new Error('Unexpected token <') }, text: async () => '<html>',
  78. }) })
  79. assert.equal(result.ok, false)
  80. assert.equal(result.reason, REQUEST_REASON.BAD_PAYLOAD)
  81. })
  82. // 五、mock 分支与裸载荷路径未被信封分支误伤。
  83. await check('plain compatibility payload is returned unchanged', async () => {
  84. const payload = { value: 0 }
  85. const result = await requestCockpitJson('/foundation', { config, fetchImpl: async () => respond(payload) })
  86. assert.equal(result.data, payload)
  87. })
  88. await check('mock handler result is returned as-is without envelope handling', async () => {
  89. resetMockTransportsForTest()
  90. registerMockTransport('/probe/envelope', () => ({ code: 500, msg: 'mock domain field, not a business failure', data: null }))
  91. const result = await requestCockpitJson('/probe/envelope', { config: { dataSource: 'mock' }, fetchImpl: async () => {
  92. throw new Error('mock mode must not fetch')
  93. } })
  94. assert.equal(result.ok, true)
  95. assert.equal(result.data.code, 500)
  96. resetMockTransportsForTest()
  97. })
  98. // 六、后端分区状态/新鲜度映射同样只认自有键(业务错误映射的另一半面)。
  99. for (const state of ['toString', 'constructor', '__proto__', 'valueOf']) {
  100. await check(`unknown backend section state "${state}" cannot leak a non-contract state`, async () => {
  101. assert.equal(sectionStateFromBackend(state), SECTION_STATE.FAILED)
  102. assert.equal(sectionStateFromFreshness(state), null)
  103. })
  104. }
  105. await check('known backend section states and freshness still map 1:1', async () => {
  106. assert.equal(sectionStateFromBackend('SUCCESS'), SECTION_STATE.SUCCESS)
  107. assert.equal(sectionStateFromBackend('UNAVAILABLE'), SECTION_STATE.UNAVAILABLE)
  108. assert.equal(sectionStateFromBackend('READY'), SECTION_STATE.SUCCESS)
  109. assert.equal(sectionStateFromFreshness('STALE'), SECTION_STATE.STALE)
  110. assert.equal(sectionStateFromFreshness('CURRENT'), SECTION_STATE.SUCCESS)
  111. })
  112. console.log(`T22 envelope consumption: ${failed ? 'FAILED' : 'passed'}`)
  113. process.exitCode = failed ? 1 : 0