/** * T04 配置与凭据注入 纯逻辑检查(票 04)。 * * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。 * 运行:node .scratch/cockpit-shell-panel-layout/verify/t04-config.mjs */ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { CONFIG_STATE, CREDENTIAL, DATA_SOURCE, configStatusView, isCredentialFailure, } from '../../../src/modules/cockpit/domain/configStatus.js' import { CONFIG_FILE_PATH, CONFIG_URL_AUTO, ENV_KEYS, builtinConfig, getCockpitConfig, getCockpitConfigState, loadCockpitConfig, mergeConfig, normalizeConfig, readConfigPayload, readEnvConfig, resetCockpitConfigForTest, resolveConfigUrl, } from '../../../src/modules/cockpit/services/cockpitConfig.js' import { REQUEST_REASON, planCockpitRequest, registerMockTransport, requestCockpitJson, resetMockTransportsForTest, } from '../../../src/modules/cockpit/services/cockpitApi.js' // ---------------------------------------------------------------- 假响应 const okJson = (body) => ({ ok: true, status: 200, headers: { get: () => 'application/json' }, text: async () => JSON.stringify(body), json: async () => body, }) const okHtml = () => ({ ok: true, status: 200, text: async () => 'app' }) const brokenJson = () => ({ ok: true, status: 200, text: async () => '{ "dataSource": ' }) const notFound = () => ({ ok: false, status: 404, text: async () => 'Not Found' }) const serverError = () => ({ ok: false, status: 500, text: async () => 'boom' }) const statusOnly = (status, body = '') => ({ ok: status >= 200 && status < 300, status, text: async () => body, json: async () => JSON.parse(body), }) function loadWith(fetchImpl, { scope = {}, env = {}, explicitUrl = CONFIG_URL_AUTO } = {}) { resetCockpitConfigForTest() return loadCockpitConfig({ scope, env: explicitUrl ? { ...env, [ENV_KEYS.configUrl]: explicitUrl } : env, fetchImpl, base: './', documentBase: 'http://localhost:8000/#/cockpit', now: Date.UTC(2026, 8, 17), }) } // ---------------------------------------------------------------- 1. 内置默认 const defaults = builtinConfig() assert.equal(defaults.dataSource, DATA_SOURCE.MOCK, '默认必须是 mock(真接口要显式开启)') assert.deepEqual(defaults.api, { baseUrl: '', token: '' }, '默认不得内建任何后端地址或令牌') assert.equal(defaults.map.cesium.enabled, true, '默认开启 Cesium 引擎') // ---------------------------------------------------------------- 2. 归一化 / 宽松校验 const empty = normalizeConfig({}) assert.deepEqual(empty.config, defaults, '空配置应等于内置默认') assert.deepEqual(empty.issues, [], '空配置不该报问题') const bad = normalizeConfig({ dataSource: 'real接口', api: { baseUrl: 123 }, map: { cesium: { enabled: 'no' } } }) assert.equal(bad.config.dataSource, DATA_SOURCE.MOCK, '非法 dataSource 应退回 mock') assert.equal(bad.config.api.baseUrl, '', '非字符串地址应退回默认') assert.equal(bad.config.map.cesium.enabled, true, '非布尔开关应退回默认') assert.equal(bad.issues.length, 3, `三个错字段各记一条 issue,实际 ${JSON.stringify(bad.issues)}`) const real = normalizeConfig({ dataSource: 'real', api: { baseUrl: ' /api/cockpit/v1 ', token: ' tok ' }, map: { cesium: { enabled: false } } }) assert.equal(real.config.dataSource, DATA_SOURCE.REAL, 'real 应被接受') assert.equal(real.config.api.baseUrl, '/api/cockpit/v1', '地址应去空白') // ADR 0005 修订:api.token 仍被宽松解析(兼容旧配置文件),但**不再进入任何请求**。 assert.equal(real.config.api.token, 'tok', '令牌字段应去空白(仅兼容旧配置,不发请求)') assert.equal(real.config.map.cesium.enabled, false, '显式关闭应被接受') // ---------------------------------------------------------------- 3. 环境变量层 + 深合并 const envConfig = readEnvConfig({ [ENV_KEYS.dataSource]: 'real', [ENV_KEYS.apiBaseUrl]: 'https://gateway.example.com/api/cockpit/v1', [ENV_KEYS.cesiumBaseUrl]: 'https://cdn.example.com/cesium/', IGNORED_KEY: 'nope', }) assert.equal(envConfig.dataSource, 'real', '环境变量应映射成 dataSource') assert.equal(envConfig.api.baseUrl, 'https://gateway.example.com/api/cockpit/v1', '环境变量应映射成 api.baseUrl') assert.equal(envConfig.map.cesium.baseUrl, 'https://cdn.example.com/cesium/', '环境变量应映射成 map.cesium.baseUrl') assert.equal(envConfig.IGNORED_KEY, undefined, '白名单之外的键不进配置') assert.deepEqual(readEnvConfig({}), {}, '没有相关环境变量时返回空对象') assert.deepEqual(readEnvConfig(undefined), {}, 'env 缺失也不应抛错') const merged = mergeConfig({ api: { baseUrl: 'a', token: 'x' }, ws: { url: 'w' } }, { api: { baseUrl: 'b' } }) assert.deepEqual(merged, { api: { baseUrl: 'b', token: 'x' }, ws: { url: 'w' } }, '深合并应保留未覆盖的兄弟字段') // ---------------------------------------------------------------- 4. 配置文件地址解析 assert.equal( resolveConfigUrl({ base: './', documentBase: 'http://localhost:8000/#/cockpit' }), `http://localhost:8000/${CONFIG_FILE_PATH}`, '默认应相对应用基址取 cockpit/config.json(hash 路由不影响)', ) assert.equal( resolveConfigUrl({ base: './', documentBase: 'http://host/app/index.html' }), `http://host/app/${CONFIG_FILE_PATH}`, '子目录部署应落在文档同级目录', ) assert.equal( resolveConfigUrl({ base: 'https://cos.example.com/qiankun/tvt/', documentBase: 'http://host/' }), `https://cos.example.com/qiankun/tvt/${CONFIG_FILE_PATH}`, 'qiankun 注入基址应被采用', ) assert.equal( resolveConfigUrl({ documentBase: 'http://host/app/', explicitUrl: 'cockpit/aerial.webp' }), 'http://host/app/cockpit/aerial.webp', '显式相对 URL 应相对文档解析', ) assert.equal( resolveConfigUrl({ documentBase: 'http://host/', explicitUrl: 'https://cfg.example.com/cockpit.json' }), 'https://cfg.example.com/cockpit.json', '显式绝对 URL 应原样采用', ) assert.equal( resolveConfigUrl({ base: './', documentBase: 'http://host/app/index.html', explicitUrl: CONFIG_URL_AUTO }), `http://host/app/${CONFIG_FILE_PATH}`, "'auto' 应按应用基址取约定路径(qiankun / 子目录下也对)", ) // ---------------------------------------------------------------- 5. 载荷判定 assert.deepEqual((await readConfigPayload(okJson({ a: 1 }))).raw, { a: 1 }, 'JSON 对象应被解析') assert.equal((await readConfigPayload(okHtml())).reason, 'missing', 'HTML 载荷 = 文件不存在(dev SPA 回退)') assert.equal((await readConfigPayload(notFound())).reason, 'missing', '404 = 文件不存在') assert.equal((await readConfigPayload(serverError())).reason, 'http-500', '5xx = 配置不可用') assert.equal((await readConfigPayload(brokenJson())).reason, 'invalid-json', '坏 JSON = 配置不可用') assert.equal((await readConfigPayload(okJson([1, 2]))).reason, 'not-an-object', '数组不是合法配置') // ---------------------------------------------------------------- 6. 三态:成功 / 缺失 / 失败 const readyState = await loadWith(async () => okJson({ dataSource: 'real', api: { baseUrl: '/api/cockpit/v1', token: 'readonly' } })) assert.equal(readyState.state, CONFIG_STATE.READY, '拿到合法配置应为 ready') assert.equal(readyState.source, 'file', '来源应记成 file') assert.equal(readyState.config.dataSource, DATA_SOURCE.REAL, '文件里的 dataSource 应生效') assert.equal(readyState.credential, CREDENTIAL.NONE, 'REST 免鉴权后 credential 恒为 none(WS token 另走 ws.token)') const missingState = await loadWith(async () => okHtml()) assert.equal(missingState.state, CONFIG_STATE.MISSING, 'dev 下 200 HTML(SPA 回退)应判为缺失') assert.equal(missingState.config.dataSource, DATA_SOURCE.MOCK, '缺失时退回内置 mock') assert.equal(missingState.url, `http://localhost:8000/${CONFIG_FILE_PATH}`, '缺失态也应记录尝试过的地址') const failedState = await loadWith(async () => brokenJson()) assert.equal(failedState.state, CONFIG_STATE.FAILED, '坏 JSON 应为 failed') assert.match(failedState.error, /invalid-json/, '失败原因应可读') assert.equal(failedState.config.dataSource, DATA_SOURCE.MOCK, '失败时仍要给出可跑的默认配置') const throwState = await loadWith(async () => { throw new Error('network down') }) assert.equal(throwState.state, CONFIG_STATE.FAILED, '网络异常应为 failed 而不是抛出') assert.match(throwState.error, /network down/, '应带上原始错误信息') const envOnly = await loadWith(async () => notFound(), { env: { [ENV_KEYS.apiBaseUrl]: '/api/cockpit/v1' } }) assert.equal(envOnly.state, CONFIG_STATE.READY, '只有构建期环境变量时也算有配置(不是缺失)') assert.equal(envOnly.source, 'env', '来源应记成 env') assert.equal(envOnly.config.api.baseUrl, '/api/cockpit/v1', '环境变量地址应生效') // 没有显式配置 URL 时**一个请求都不发**:否则每页都会在控制台刷一个配置文件的 404, // 而走查 A11「无页面级报错」会因此变红(本项就是那条回归的守卫)。 let silentCalls = 0 const silentState = await loadWith(async () => { silentCalls += 1 return notFound() }, { explicitUrl: '' }) assert.equal(silentCalls, 0, '没配置 URL 时不得探测配置文件') assert.equal(silentState.state, CONFIG_STATE.MISSING, '没配置 URL 时应是缺失态') assert.equal(silentState.url, '', '缺失态且没探测时不该有尝试过的地址') // 'auto' = 按约定路径真的去拉(部署最常用的一种) let autoUrl = '' const autoState = await loadWith( async (url) => { autoUrl = url return okJson({ dataSource: 'mock' }) }, { explicitUrl: CONFIG_URL_AUTO }, ) assert.equal(autoUrl, `http://localhost:8000/${CONFIG_FILE_PATH}`, "'auto' 应请求应用基址下的约定路径") assert.equal(autoState.state, CONFIG_STATE.READY, "'auto' 拉到合法配置应为 ready") assert.equal(autoState.source, 'file', '来源应记成 file') // 宿主注入:优先级最高,且给了就不再拉文件 let fetchCalls = 0 const injectedState = await loadWith(async () => { fetchCalls += 1 return okJson({ dataSource: 'mock' }) }, { scope: { __COCKPIT_CONFIG__: { dataSource: 'real', api: { baseUrl: '/from-window' } } } }) assert.equal(injectedState.state, CONFIG_STATE.READY, '宿主注入应为 ready') assert.equal(injectedState.source, 'window', '来源应记成 window') assert.equal(injectedState.config.api.baseUrl, '/from-window', '宿主注入应生效') assert.equal(fetchCalls, 0, '宿主注入了就不该再请求配置文件') // 缓存:同一进程内只解析一次 const cachedAgain = await loadCockpitConfig() assert.equal(cachedAgain, injectedState, '第二次调用应直接命中缓存') assert.equal(getCockpitConfig().api.baseUrl, '/from-window', '同步读应拿到同一份配置') assert.equal(getCockpitConfigState().state, CONFIG_STATE.READY, '同步状态应与载入结果一致') // ---------------------------------------------------------------- 7. 请求计划(REST 不带任何凭据) const mockPlan = planCockpitRequest(builtinConfig(), 'map/summary') assert.equal(mockPlan.mode, DATA_SOURCE.MOCK, '默认走 mock') assert.equal(mockPlan.url, '', 'mock 不产生请求地址') assert.equal(mockPlan.headers.Authorization, undefined, 'mock 分支不得携带凭据') assert.equal(mockPlan.path, '/map/summary', '路径应归一化成以 / 开头') const realPlan = planCockpitRequest({ dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1/', token: 'stale-readonly' } }, '/map/summary') assert.equal(realPlan.url, 'https://gw/api/cockpit/v1/map/summary', '真接口地址应由 baseUrl + 路径拼出') assert.equal(realPlan.headers.Authorization, undefined, 'REST 免鉴权后真接口也不带凭据(配置里的 api.token 被忽略)') const gatewayPlan = planCockpitRequest({ dataSource: 'real', api: { baseUrl: '/api/cockpit/v1' } }, '/map/summary') assert.equal(gatewayPlan.headers.Authorization, undefined, '匿名 GET 不需要任何凭据') assert.equal( planCockpitRequest({ dataSource: 'real', api: { baseUrl: '' } }, '/x').error, REQUEST_REASON.NO_BASE_URL, 'real 模式缺地址应给出明确原因,而不是拿页面地址瞎拼', ) // ---------------------------------------------------------------- 8. 统一出口:mock/real + 失败归类 resetMockTransportsForTest() const noHandler = await requestCockpitJson('/map/summary', { config: builtinConfig() }) assert.equal(noHandler.reason, REQUEST_REASON.MOCK_MISSING, '未注册 mock 处理器应给出明确原因(票 05 来注册)') registerMockTransport('map/summary', async () => ({ points: 3 })) const mockOk = await requestCockpitJson('/map/summary', { config: builtinConfig() }) assert.equal(mockOk.ok, true, 'mock 命中应成功') assert.deepEqual(mockOk.data, { points: 3 }, 'mock 数据应原样返回') resetMockTransportsForTest() const realConfig = { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } } const unauthorized = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => statusOnly(401) }) assert.equal(unauthorized.reason, REQUEST_REASON.UNAUTHORIZED, '401 应归类成凭据类失败') assert.equal(isCredentialFailure(unauthorized.reason), true, '凭据类失败应可被分区识别') const realOk = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => statusOnly(200, '{"ok":1}') }) assert.equal(realOk.ok, true, '真接口 200 JSON 应成功') assert.deepEqual(realOk.data, { ok: 1 }, '真接口数据应解析出来') const badPayload = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => okHtml() }) assert.equal(badPayload.reason, REQUEST_REASON.BAD_PAYLOAD, 'dev 下 200 HTML 应判成坏载荷(接口没实现)') const unreachable = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => { throw new Error('ECONNREFUSED') }, }) assert.equal(unreachable.reason, REQUEST_REASON.UNREACHABLE, '网络异常应归类成不可达') assert.equal(isCredentialFailure(unreachable.reason), false, '不可达不属于凭据类失败') // ---------------------------------------------------------------- 9. 顶栏视图映射 const viewReady = configStatusView(injectedState) assert.equal(viewReady.dataSource, DATA_SOURCE.REAL, '视图应带上数据源') assert.equal(viewReady.tone, 'lv4', '就绪用绿色键') assert.match(viewReady.label, /real/, '标签应能读出数据源') const viewFailed = configStatusView(failedState) assert.equal(viewFailed.tone, 'lv1', '载入失败用红色键') assert.equal(viewFailed.state, CONFIG_STATE.FAILED, '失败态应透出') const viewMissing = configStatusView(missingState) assert.equal(viewMissing.tone, 'neutral', '缺失用中性键(还在用内置默认,不是故障)') assert.match(viewMissing.label, /配置缺失/, '缺失文案应可读') const viewRealViaGateway = configStatusView({ state: CONFIG_STATE.READY, credential: CREDENTIAL.NONE, config: { dataSource: DATA_SOURCE.REAL } }) // REST 免鉴权后 configStatus.js 的 detail 文案改为「接口免鉴权(内网隔离)」; // 该断言只锁「detail 不再宣称凭据注入」,具体新文案由 t05/领域断言覆盖。 assert.equal(viewRealViaGateway.detail.includes('凭据由部署层注入'), false, 'real 且无凭据时不得再宣称凭据由部署层注入') // 顶栏视图不得把令牌带到界面层(哪怕配置里确实有) const viewWithSecret = configStatusView({ ...readyState, config: { ...readyState.config, api: { baseUrl: '/x', token: 'super-secret' } } }) assert.equal(JSON.stringify(viewWithSecret).includes('super-secret'), false, '视图模型不得携带令牌') // ---------------------------------------------------------------- 10. 静态守卫(对抗式) const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url) const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8') // 注意:这里查的是**凭据**令牌,不是票 06 的视觉 token(`视觉 token` / `tokens.css` 是设计令牌,允许出现)。 const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/ const credentialFiles = ['CockpitShell.vue', 'components/MapCanvas.vue', 'components/CockpitPanel.vue', 'components/PanelPlaceholder.vue', 'components/StatusTag.vue', 'components/CockpitIcon.vue', 'stores/uiStore.js', 'stores/filterStore.js'] for (const file of credentialFiles) { assert.equal(CREDENTIAL_PATTERN.test(readModule(file)), false, `${file} 不该出现凭据(页面与 store 不碰凭据)`) } const allModuleFiles = [ 'CockpitShell.vue', 'components/MapCanvas.vue', 'components/CockpitPanel.vue', 'components/PanelPlaceholder.vue', 'components/StatusTag.vue', 'components/CockpitIcon.vue', 'domain/configStatus.js', 'domain/panelCatalog.js', 'domain/filterOptions.js', 'domain/statusTones.js', 'services/cockpitApi.js', 'services/cockpitConfig.js', 'services/mapService.js', 'stores/uiStore.js', 'stores/filterStore.js', ] for (const file of allModuleFiles) { const text = readModule(file) assert.equal( /location\.(assign|replace|href)\s*=|window\.location\s*=|['"`]\/login/.test(text), false, `${file} 不该有任何「送去登录页」的跳转(凭据类失败只标数据不可用)`, ) } const envReaders = allModuleFiles.filter((file) => /VITE_COCKPIT_|__COCKPIT_CONFIG__/.test(readModule(file))) assert.deepEqual(envReaders, ['services/cockpitConfig.js'], `配置来源只该在配置服务里读,实际:${envReaders.join(', ')}`) // 只对**代码**测这条守卫:注释会解释被禁的东西(cockpitApi.js 头部记录着 ADR 0005 的 // 契约路径 `/api/cockpit/v1/**`),对全文正则会把文档当违例(t18 的 `code()` 同一条纪律)。 const codeOnly = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1') const addressFiles = allModuleFiles.filter((file) => /\/api\/cockpit/.test(codeOnly(readModule(file)))) assert.deepEqual(addressFiles, [], `页面与组件不该硬编码后端地址,实际:${addressFiles.join(', ')}`) console.log( `T04 检查通过:三态(ready/missing/failed)+ 四层来源 + 宽松校验 + 载荷判定 + mock/real 同一出口 + 凭据只在适配层(${credentialFiles.length} 个页面/store 文件零 Authorization/token,${allModuleFiles.length} 个模块文件零登录跳转)。`, )