ソースを参照

票22:业务错误类别白名单只认自有键,补离线信封消费回归

Kazerin 1 週間 前
親
コミット
86db4d49dc

+ 128 - 0
.scratch/cockpit-real-data-wayfinding/verify/t22-envelope-frontend.mjs

@@ -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

+ 6 - 2
src/modules/cockpit/domain/sectionState.js

@@ -57,9 +57,13 @@ const FRESHNESS_TO_SECTION = {
     EMPTY: SECTION_STATE.EMPTY,
 }
 
+/** 白名单只认**自有键**:普通对象下标会把 `toString`/`__proto__` 这类继承键也命中,
+ * 于是后端一个随便的状态串就能把函数漏成「分区状态」。与 mock 分支同一纪律。 */
+const owns = (table, key) => Object.prototype.hasOwnProperty.call(table, key)
+
 /** 后端状态字符串 → 前端分区状态;认不出来的一律当失败,不静默混进「已就绪」。 */
 export function sectionStateFromBackend(state) {
-    return BACKEND_TO_SECTION[state] || SECTION_STATE.FAILED
+    return owns(BACKEND_TO_SECTION, state) ? BACKEND_TO_SECTION[state] : SECTION_STATE.FAILED
 }
 
 /**
@@ -77,7 +81,7 @@ export function sectionStateFromReason(reason) {
 
 /** 新鲜度字符串 → 分区状态;未给出的返回 null(由其它判据决定)。 */
 export function sectionStateFromFreshness(freshness) {
-    return FRESHNESS_TO_SECTION[freshness] || null
+    return owns(FRESHNESS_TO_SECTION, freshness) ? FRESHNESS_TO_SECTION[freshness] : null
 }
 
 /**

+ 4 - 1
src/modules/cockpit/services/cockpitApi.js

@@ -150,7 +150,10 @@ function readBusinessReason(body) {
     const outer = body || {}
     const data = outer.data
     const category = (data && data.category) || outer.category || ''
-    return BACKEND_ERROR_CATEGORY[category] || ''
+    // 只认白名单的**自有键**:普通对象下标会把 `toString`/`__proto__` 这类继承键也命中,
+    // 于是后端一个随便的 category 就能把非契约值(函数、`[object Object]`)写进失败原因。
+    if (typeof category !== 'string' || !Object.prototype.hasOwnProperty.call(BACKEND_ERROR_CATEGORY, category)) return ''
+    return BACKEND_ERROR_CATEGORY[category]
 }
 
 /**