/** * 票 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 () => '', }) }) 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