|
|
@@ -0,0 +1,128 @@
|
|
|
+/**
|
|
|
+ * 票 22 离线回归:真实 AjaxResult 信封消费与业务错误映射(不联网)。
|
|
|
+ *
|
|
|
+ * 覆盖 2026-10-10 真实审计里三项 `frontend-*` 失败检查对应的**同一批领域出口**
|
|
|
+ * (`fetchBaseArchives` / `fetchMapSnapshot` / `fetchCurrentEvents`),外加
|
|
|
+ * mock 分支、裸载荷与业务错误类别白名单,用注入的 `fetchImpl` 复现同一形状。
|
|
|
+ *
|
|
|
+ * 与 `verify/t18-real-audit.mjs` 的分工:那份跑真实后端;本份可在无后端、
|
|
|
+ * 无浏览器时锁定消费语义。真机复验仍以票 22 的待验证项 1/2 为准。
|
|
|
+ */
|
|
|
+import assert from 'node:assert/strict'
|
|
|
+import { requestCockpitJson, registerMockTransport, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
|
|
|
+import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
|
|
|
+import { fetchBaseArchives } from '../../../src/modules/cockpit/services/infrastructureApi.js'
|
|
|
+import { fetchCurrentEvents } from '../../../src/modules/cockpit/services/eventApi.js'
|
|
|
+import { REQUEST_REASON } from '../../../src/modules/cockpit/domain/requestReason.js'
|
|
|
+import { SECTION_STATE, sectionStateFromBackend, sectionStateFromFreshness } from '../../../src/modules/cockpit/domain/sectionState.js'
|
|
|
+
|
|
|
+const config = { dataSource: 'real', api: { baseUrl: 'http://acceptance.invalid/api/cockpit/v1' } }
|
|
|
+const respond = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => body })
|
|
|
+const envelope = (data) => ({ code: 200, msg: 'success', data })
|
|
|
+
|
|
|
+let failed = 0
|
|
|
+async function check(name, action) {
|
|
|
+ try {
|
|
|
+ await action()
|
|
|
+ console.log(`PASS ${name}`)
|
|
|
+ } catch (error) {
|
|
|
+ failed += 1
|
|
|
+ console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 一、信封消费:三条真实失败检查对应的领域出口,载荷必须解包而不是整包透传。
|
|
|
+await check('base-archives envelope is unwrapped into a non-empty archive section', async () => {
|
|
|
+ const section = await fetchBaseArchives({ config, fetchImpl: async () => respond(envelope({
|
|
|
+ totals: { facilityCount: 1006 }, industryDistribution: [{ code: 'WATER_SUPPLY', count: 10 }],
|
|
|
+ })) })
|
|
|
+ assert.equal(section.state, 'success')
|
|
|
+ assert.equal(section.data.facilityCount, 1006)
|
|
|
+})
|
|
|
+
|
|
|
+await check('map-snapshot envelope keeps total and snapshotAt', async () => {
|
|
|
+ const section = await fetchMapSnapshot({ config, pageSize: 1000, fetchImpl: async () => respond(envelope({
|
|
|
+ snapshotAt: '2026-10-10T10:25:00+08:00', total: 13, monitoringPoints: [], unassociatedEquipment: [],
|
|
|
+ })) })
|
|
|
+ assert.equal(section.state, 'success')
|
|
|
+ assert.equal(section.data.total, 13)
|
|
|
+ assert.equal(section.data.snapshotAt, '2026-10-10T10:25:00+08:00')
|
|
|
+})
|
|
|
+
|
|
|
+// 二、业务错误不得读成成功(HTTP 200 + code=500)。
|
|
|
+await check('HTTP200 business 500 is a failed section, never success', async () => {
|
|
|
+ const section = await fetchCurrentEvents({ config, mode: 'ALARM', fetchImpl: async () => respond({ code: 500, msg: 'failure', data: null }) })
|
|
|
+ assert.equal(section.state, 'failed')
|
|
|
+ assert.equal(section.reason, REQUEST_REASON.UNAVAILABLE)
|
|
|
+})
|
|
|
+
|
|
|
+await check('HTTP200 business 401/403 stays a credential-class failure', async () => {
|
|
|
+ const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => respond({ code: 403, msg: 'denied', data: null }) })
|
|
|
+ assert.equal(result.ok, false)
|
|
|
+ assert.equal(result.reason, REQUEST_REASON.UNAUTHORIZED)
|
|
|
+})
|
|
|
+
|
|
|
+// 三、业务类别映射:白名单内可恢复;白名单外(含对象继承键)一律回空串。
|
|
|
+await check('known business category survives the HTTP200 envelope path', async () => {
|
|
|
+ const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => respond({ code: 500, msg: 'failure', data: { category: 'SNAPSHOT_EXPIRED' } }) })
|
|
|
+ assert.equal(result.reason, REQUEST_REASON.SNAPSHOT_EXPIRED)
|
|
|
+})
|
|
|
+
|
|
|
+for (const category of ['toString', 'constructor', '__proto__', 'hasOwnProperty', 'valueOf']) {
|
|
|
+ await check(`unknown business category "${category}" cannot leak a non-contract reason`, async () => {
|
|
|
+ const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => respond({ code: 500, msg: 'failure', data: { category } }) })
|
|
|
+ assert.equal(result.reason, REQUEST_REASON.UNAVAILABLE)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// 四、信封自身不完整 / 非 JSON 时不得冒充成功。
|
|
|
+await check('successful envelope without data is a bad payload', async () => {
|
|
|
+ const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => respond({ code: 200, msg: 'success' }) })
|
|
|
+ assert.equal(result.ok, false)
|
|
|
+ assert.equal(result.reason, REQUEST_REASON.BAD_PAYLOAD)
|
|
|
+})
|
|
|
+
|
|
|
+await check('non-JSON body stays a bad payload', async () => {
|
|
|
+ const result = await requestCockpitJson('/map-snapshot', { config, fetchImpl: async () => ({
|
|
|
+ ok: true, status: 200, json: async () => { throw new Error('Unexpected token <') }, text: async () => '<html>',
|
|
|
+ }) })
|
|
|
+ assert.equal(result.ok, false)
|
|
|
+ assert.equal(result.reason, REQUEST_REASON.BAD_PAYLOAD)
|
|
|
+})
|
|
|
+
|
|
|
+// 五、mock 分支与裸载荷路径未被信封分支误伤。
|
|
|
+await check('plain compatibility payload is returned unchanged', async () => {
|
|
|
+ const payload = { value: 0 }
|
|
|
+ const result = await requestCockpitJson('/foundation', { config, fetchImpl: async () => respond(payload) })
|
|
|
+ assert.equal(result.data, payload)
|
|
|
+})
|
|
|
+
|
|
|
+await check('mock handler result is returned as-is without envelope handling', async () => {
|
|
|
+ resetMockTransportsForTest()
|
|
|
+ registerMockTransport('/probe/envelope', () => ({ code: 500, msg: 'mock domain field, not a business failure', data: null }))
|
|
|
+ const result = await requestCockpitJson('/probe/envelope', { config: { dataSource: 'mock' }, fetchImpl: async () => {
|
|
|
+ throw new Error('mock mode must not fetch')
|
|
|
+ } })
|
|
|
+ assert.equal(result.ok, true)
|
|
|
+ assert.equal(result.data.code, 500)
|
|
|
+ resetMockTransportsForTest()
|
|
|
+})
|
|
|
+
|
|
|
+// 六、后端分区状态/新鲜度映射同样只认自有键(业务错误映射的另一半面)。
|
|
|
+for (const state of ['toString', 'constructor', '__proto__', 'valueOf']) {
|
|
|
+ await check(`unknown backend section state "${state}" cannot leak a non-contract state`, async () => {
|
|
|
+ assert.equal(sectionStateFromBackend(state), SECTION_STATE.FAILED)
|
|
|
+ assert.equal(sectionStateFromFreshness(state), null)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+await check('known backend section states and freshness still map 1:1', async () => {
|
|
|
+ assert.equal(sectionStateFromBackend('SUCCESS'), SECTION_STATE.SUCCESS)
|
|
|
+ assert.equal(sectionStateFromBackend('UNAVAILABLE'), SECTION_STATE.UNAVAILABLE)
|
|
|
+ assert.equal(sectionStateFromBackend('READY'), SECTION_STATE.SUCCESS)
|
|
|
+ assert.equal(sectionStateFromFreshness('STALE'), SECTION_STATE.STALE)
|
|
|
+ assert.equal(sectionStateFromFreshness('CURRENT'), SECTION_STATE.SUCCESS)
|
|
|
+})
|
|
|
+
|
|
|
+console.log(`T22 envelope consumption: ${failed ? 'FAILED' : 'passed'}`)
|
|
|
+process.exitCode = failed ? 1 : 0
|