t04-config.mjs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. /**
  2. * T04 配置与凭据注入 纯逻辑检查(票 04)。
  3. *
  4. * 零依赖 node 断言脚本(仓库没配测试运行器,约定见 t01-panel-catalog.mjs)。
  5. * 运行:node .scratch/cockpit-shell-panel-layout/verify/t04-config.mjs
  6. */
  7. import assert from 'node:assert/strict'
  8. import { readFileSync } from 'node:fs'
  9. import {
  10. CONFIG_STATE,
  11. CREDENTIAL,
  12. DATA_SOURCE,
  13. configStatusView,
  14. isCredentialFailure,
  15. } from '../../../src/modules/cockpit/domain/configStatus.js'
  16. import {
  17. CONFIG_FILE_PATH,
  18. CONFIG_URL_AUTO,
  19. ENV_KEYS,
  20. builtinConfig,
  21. getCockpitConfig,
  22. getCockpitConfigState,
  23. loadCockpitConfig,
  24. mergeConfig,
  25. normalizeConfig,
  26. readConfigPayload,
  27. readEnvConfig,
  28. resetCockpitConfigForTest,
  29. resolveConfigUrl,
  30. } from '../../../src/modules/cockpit/services/cockpitConfig.js'
  31. import {
  32. REQUEST_REASON,
  33. planCockpitRequest,
  34. registerMockTransport,
  35. requestCockpitJson,
  36. resetMockTransportsForTest,
  37. } from '../../../src/modules/cockpit/services/cockpitApi.js'
  38. // ---------------------------------------------------------------- 假响应
  39. const okJson = (body) => ({
  40. ok: true,
  41. status: 200,
  42. headers: { get: () => 'application/json' },
  43. text: async () => JSON.stringify(body),
  44. json: async () => body,
  45. })
  46. const okHtml = () => ({ ok: true, status: 200, text: async () => '<!doctype html><html><body>app</body></html>' })
  47. const brokenJson = () => ({ ok: true, status: 200, text: async () => '{ "dataSource": ' })
  48. const notFound = () => ({ ok: false, status: 404, text: async () => 'Not Found' })
  49. const serverError = () => ({ ok: false, status: 500, text: async () => 'boom' })
  50. const statusOnly = (status, body = '') => ({
  51. ok: status >= 200 && status < 300,
  52. status,
  53. text: async () => body,
  54. json: async () => JSON.parse(body),
  55. })
  56. function loadWith(fetchImpl, { scope = {}, env = {}, explicitUrl = CONFIG_URL_AUTO } = {}) {
  57. resetCockpitConfigForTest()
  58. return loadCockpitConfig({
  59. scope,
  60. env: explicitUrl ? { ...env, [ENV_KEYS.configUrl]: explicitUrl } : env,
  61. fetchImpl,
  62. base: './',
  63. documentBase: 'http://localhost:8000/#/cockpit',
  64. now: Date.UTC(2026, 8, 17),
  65. })
  66. }
  67. // ---------------------------------------------------------------- 1. 内置默认
  68. const defaults = builtinConfig()
  69. assert.equal(defaults.dataSource, DATA_SOURCE.MOCK, '默认必须是 mock(真接口要显式开启)')
  70. assert.deepEqual(defaults.api, { baseUrl: '', token: '' }, '默认不得内建任何后端地址或令牌')
  71. assert.equal(defaults.map.cesium.enabled, true, '默认开启 Cesium 引擎')
  72. // ---------------------------------------------------------------- 2. 归一化 / 宽松校验
  73. const empty = normalizeConfig({})
  74. assert.deepEqual(empty.config, defaults, '空配置应等于内置默认')
  75. assert.deepEqual(empty.issues, [], '空配置不该报问题')
  76. const bad = normalizeConfig({ dataSource: 'real接口', api: { baseUrl: 123 }, map: { cesium: { enabled: 'no' } } })
  77. assert.equal(bad.config.dataSource, DATA_SOURCE.MOCK, '非法 dataSource 应退回 mock')
  78. assert.equal(bad.config.api.baseUrl, '', '非字符串地址应退回默认')
  79. assert.equal(bad.config.map.cesium.enabled, true, '非布尔开关应退回默认')
  80. assert.equal(bad.issues.length, 3, `三个错字段各记一条 issue,实际 ${JSON.stringify(bad.issues)}`)
  81. const real = normalizeConfig({ dataSource: 'real', api: { baseUrl: ' /api/cockpit/v1 ', token: ' tok ' }, map: { cesium: { enabled: false } } })
  82. assert.equal(real.config.dataSource, DATA_SOURCE.REAL, 'real 应被接受')
  83. assert.equal(real.config.api.baseUrl, '/api/cockpit/v1', '地址应去空白')
  84. // ADR 0005 修订:api.token 仍被宽松解析(兼容旧配置文件),但**不再进入任何请求**。
  85. assert.equal(real.config.api.token, 'tok', '令牌字段应去空白(仅兼容旧配置,不发请求)')
  86. assert.equal(real.config.map.cesium.enabled, false, '显式关闭应被接受')
  87. // ---------------------------------------------------------------- 3. 环境变量层 + 深合并
  88. const envConfig = readEnvConfig({
  89. [ENV_KEYS.dataSource]: 'real',
  90. [ENV_KEYS.apiBaseUrl]: 'https://gateway.example.com/api/cockpit/v1',
  91. [ENV_KEYS.cesiumBaseUrl]: 'https://cdn.example.com/cesium/',
  92. IGNORED_KEY: 'nope',
  93. })
  94. assert.equal(envConfig.dataSource, 'real', '环境变量应映射成 dataSource')
  95. assert.equal(envConfig.api.baseUrl, 'https://gateway.example.com/api/cockpit/v1', '环境变量应映射成 api.baseUrl')
  96. assert.equal(envConfig.map.cesium.baseUrl, 'https://cdn.example.com/cesium/', '环境变量应映射成 map.cesium.baseUrl')
  97. assert.equal(envConfig.IGNORED_KEY, undefined, '白名单之外的键不进配置')
  98. assert.deepEqual(readEnvConfig({}), {}, '没有相关环境变量时返回空对象')
  99. assert.deepEqual(readEnvConfig(undefined), {}, 'env 缺失也不应抛错')
  100. const merged = mergeConfig({ api: { baseUrl: 'a', token: 'x' }, ws: { url: 'w' } }, { api: { baseUrl: 'b' } })
  101. assert.deepEqual(merged, { api: { baseUrl: 'b', token: 'x' }, ws: { url: 'w' } }, '深合并应保留未覆盖的兄弟字段')
  102. // ---------------------------------------------------------------- 4. 配置文件地址解析
  103. assert.equal(
  104. resolveConfigUrl({ base: './', documentBase: 'http://localhost:8000/#/cockpit' }),
  105. `http://localhost:8000/${CONFIG_FILE_PATH}`,
  106. '默认应相对应用基址取 cockpit/config.json(hash 路由不影响)',
  107. )
  108. assert.equal(
  109. resolveConfigUrl({ base: './', documentBase: 'http://host/app/index.html' }),
  110. `http://host/app/${CONFIG_FILE_PATH}`,
  111. '子目录部署应落在文档同级目录',
  112. )
  113. assert.equal(
  114. resolveConfigUrl({ base: 'https://cos.example.com/qiankun/tvt/', documentBase: 'http://host/' }),
  115. `https://cos.example.com/qiankun/tvt/${CONFIG_FILE_PATH}`,
  116. 'qiankun 注入基址应被采用',
  117. )
  118. assert.equal(
  119. resolveConfigUrl({ documentBase: 'http://host/app/', explicitUrl: 'cockpit/aerial.webp' }),
  120. 'http://host/app/cockpit/aerial.webp',
  121. '显式相对 URL 应相对文档解析',
  122. )
  123. assert.equal(
  124. resolveConfigUrl({ documentBase: 'http://host/', explicitUrl: 'https://cfg.example.com/cockpit.json' }),
  125. 'https://cfg.example.com/cockpit.json',
  126. '显式绝对 URL 应原样采用',
  127. )
  128. assert.equal(
  129. resolveConfigUrl({ base: './', documentBase: 'http://host/app/index.html', explicitUrl: CONFIG_URL_AUTO }),
  130. `http://host/app/${CONFIG_FILE_PATH}`,
  131. "'auto' 应按应用基址取约定路径(qiankun / 子目录下也对)",
  132. )
  133. // ---------------------------------------------------------------- 5. 载荷判定
  134. assert.deepEqual((await readConfigPayload(okJson({ a: 1 }))).raw, { a: 1 }, 'JSON 对象应被解析')
  135. assert.equal((await readConfigPayload(okHtml())).reason, 'missing', 'HTML 载荷 = 文件不存在(dev SPA 回退)')
  136. assert.equal((await readConfigPayload(notFound())).reason, 'missing', '404 = 文件不存在')
  137. assert.equal((await readConfigPayload(serverError())).reason, 'http-500', '5xx = 配置不可用')
  138. assert.equal((await readConfigPayload(brokenJson())).reason, 'invalid-json', '坏 JSON = 配置不可用')
  139. assert.equal((await readConfigPayload(okJson([1, 2]))).reason, 'not-an-object', '数组不是合法配置')
  140. // ---------------------------------------------------------------- 6. 三态:成功 / 缺失 / 失败
  141. const readyState = await loadWith(async () => okJson({ dataSource: 'real', api: { baseUrl: '/api/cockpit/v1', token: 'readonly' } }))
  142. assert.equal(readyState.state, CONFIG_STATE.READY, '拿到合法配置应为 ready')
  143. assert.equal(readyState.source, 'file', '来源应记成 file')
  144. assert.equal(readyState.config.dataSource, DATA_SOURCE.REAL, '文件里的 dataSource 应生效')
  145. assert.equal(readyState.credential, CREDENTIAL.NONE, 'REST 免鉴权后 credential 恒为 none(WS token 另走 ws.token)')
  146. const missingState = await loadWith(async () => okHtml())
  147. assert.equal(missingState.state, CONFIG_STATE.MISSING, 'dev 下 200 HTML(SPA 回退)应判为缺失')
  148. assert.equal(missingState.config.dataSource, DATA_SOURCE.MOCK, '缺失时退回内置 mock')
  149. assert.equal(missingState.url, `http://localhost:8000/${CONFIG_FILE_PATH}`, '缺失态也应记录尝试过的地址')
  150. const failedState = await loadWith(async () => brokenJson())
  151. assert.equal(failedState.state, CONFIG_STATE.FAILED, '坏 JSON 应为 failed')
  152. assert.match(failedState.error, /invalid-json/, '失败原因应可读')
  153. assert.equal(failedState.config.dataSource, DATA_SOURCE.MOCK, '失败时仍要给出可跑的默认配置')
  154. const throwState = await loadWith(async () => {
  155. throw new Error('network down')
  156. })
  157. assert.equal(throwState.state, CONFIG_STATE.FAILED, '网络异常应为 failed 而不是抛出')
  158. assert.match(throwState.error, /network down/, '应带上原始错误信息')
  159. const envOnly = await loadWith(async () => notFound(), { env: { [ENV_KEYS.apiBaseUrl]: '/api/cockpit/v1' } })
  160. assert.equal(envOnly.state, CONFIG_STATE.READY, '只有构建期环境变量时也算有配置(不是缺失)')
  161. assert.equal(envOnly.source, 'env', '来源应记成 env')
  162. assert.equal(envOnly.config.api.baseUrl, '/api/cockpit/v1', '环境变量地址应生效')
  163. // 没有显式配置 URL 时**一个请求都不发**:否则每页都会在控制台刷一个配置文件的 404,
  164. // 而走查 A11「无页面级报错」会因此变红(本项就是那条回归的守卫)。
  165. let silentCalls = 0
  166. const silentState = await loadWith(async () => {
  167. silentCalls += 1
  168. return notFound()
  169. }, { explicitUrl: '' })
  170. assert.equal(silentCalls, 0, '没配置 URL 时不得探测配置文件')
  171. assert.equal(silentState.state, CONFIG_STATE.MISSING, '没配置 URL 时应是缺失态')
  172. assert.equal(silentState.url, '', '缺失态且没探测时不该有尝试过的地址')
  173. // 'auto' = 按约定路径真的去拉(部署最常用的一种)
  174. let autoUrl = ''
  175. const autoState = await loadWith(
  176. async (url) => {
  177. autoUrl = url
  178. return okJson({ dataSource: 'mock' })
  179. },
  180. { explicitUrl: CONFIG_URL_AUTO },
  181. )
  182. assert.equal(autoUrl, `http://localhost:8000/${CONFIG_FILE_PATH}`, "'auto' 应请求应用基址下的约定路径")
  183. assert.equal(autoState.state, CONFIG_STATE.READY, "'auto' 拉到合法配置应为 ready")
  184. assert.equal(autoState.source, 'file', '来源应记成 file')
  185. // 宿主注入:优先级最高,且给了就不再拉文件
  186. let fetchCalls = 0
  187. const injectedState = await loadWith(async () => {
  188. fetchCalls += 1
  189. return okJson({ dataSource: 'mock' })
  190. }, { scope: { __COCKPIT_CONFIG__: { dataSource: 'real', api: { baseUrl: '/from-window' } } } })
  191. assert.equal(injectedState.state, CONFIG_STATE.READY, '宿主注入应为 ready')
  192. assert.equal(injectedState.source, 'window', '来源应记成 window')
  193. assert.equal(injectedState.config.api.baseUrl, '/from-window', '宿主注入应生效')
  194. assert.equal(fetchCalls, 0, '宿主注入了就不该再请求配置文件')
  195. // 缓存:同一进程内只解析一次
  196. const cachedAgain = await loadCockpitConfig()
  197. assert.equal(cachedAgain, injectedState, '第二次调用应直接命中缓存')
  198. assert.equal(getCockpitConfig().api.baseUrl, '/from-window', '同步读应拿到同一份配置')
  199. assert.equal(getCockpitConfigState().state, CONFIG_STATE.READY, '同步状态应与载入结果一致')
  200. // ---------------------------------------------------------------- 7. 请求计划(REST 不带任何凭据)
  201. const mockPlan = planCockpitRequest(builtinConfig(), 'map/summary')
  202. assert.equal(mockPlan.mode, DATA_SOURCE.MOCK, '默认走 mock')
  203. assert.equal(mockPlan.url, '', 'mock 不产生请求地址')
  204. assert.equal(mockPlan.headers.Authorization, undefined, 'mock 分支不得携带凭据')
  205. assert.equal(mockPlan.path, '/map/summary', '路径应归一化成以 / 开头')
  206. const realPlan = planCockpitRequest({ dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1/', token: 'stale-readonly' } }, '/map/summary')
  207. assert.equal(realPlan.url, 'https://gw/api/cockpit/v1/map/summary', '真接口地址应由 baseUrl + 路径拼出')
  208. assert.equal(realPlan.headers.Authorization, undefined, 'REST 免鉴权后真接口也不带凭据(配置里的 api.token 被忽略)')
  209. const gatewayPlan = planCockpitRequest({ dataSource: 'real', api: { baseUrl: '/api/cockpit/v1' } }, '/map/summary')
  210. assert.equal(gatewayPlan.headers.Authorization, undefined, '匿名 GET 不需要任何凭据')
  211. assert.equal(
  212. planCockpitRequest({ dataSource: 'real', api: { baseUrl: '' } }, '/x').error,
  213. REQUEST_REASON.NO_BASE_URL,
  214. 'real 模式缺地址应给出明确原因,而不是拿页面地址瞎拼',
  215. )
  216. // ---------------------------------------------------------------- 8. 统一出口:mock/real + 失败归类
  217. resetMockTransportsForTest()
  218. const noHandler = await requestCockpitJson('/map/summary', { config: builtinConfig() })
  219. assert.equal(noHandler.reason, REQUEST_REASON.MOCK_MISSING, '未注册 mock 处理器应给出明确原因(票 05 来注册)')
  220. registerMockTransport('map/summary', async () => ({ points: 3 }))
  221. const mockOk = await requestCockpitJson('/map/summary', { config: builtinConfig() })
  222. assert.equal(mockOk.ok, true, 'mock 命中应成功')
  223. assert.deepEqual(mockOk.data, { points: 3 }, 'mock 数据应原样返回')
  224. resetMockTransportsForTest()
  225. const realConfig = { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } }
  226. const unauthorized = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => statusOnly(401) })
  227. assert.equal(unauthorized.reason, REQUEST_REASON.UNAUTHORIZED, '401 应归类成凭据类失败')
  228. assert.equal(isCredentialFailure(unauthorized.reason), true, '凭据类失败应可被分区识别')
  229. const realOk = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => statusOnly(200, '{"ok":1}') })
  230. assert.equal(realOk.ok, true, '真接口 200 JSON 应成功')
  231. assert.deepEqual(realOk.data, { ok: 1 }, '真接口数据应解析出来')
  232. const badPayload = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => okHtml() })
  233. assert.equal(badPayload.reason, REQUEST_REASON.BAD_PAYLOAD, 'dev 下 200 HTML 应判成坏载荷(接口没实现)')
  234. const unreachable = await requestCockpitJson('/map/summary', {
  235. config: realConfig,
  236. fetchImpl: async () => {
  237. throw new Error('ECONNREFUSED')
  238. },
  239. })
  240. assert.equal(unreachable.reason, REQUEST_REASON.UNREACHABLE, '网络异常应归类成不可达')
  241. assert.equal(isCredentialFailure(unreachable.reason), false, '不可达不属于凭据类失败')
  242. // ---------------------------------------------------------------- 9. 顶栏视图映射
  243. const viewReady = configStatusView(injectedState)
  244. assert.equal(viewReady.dataSource, DATA_SOURCE.REAL, '视图应带上数据源')
  245. assert.equal(viewReady.tone, 'lv4', '就绪用绿色键')
  246. assert.match(viewReady.label, /real/, '标签应能读出数据源')
  247. const viewFailed = configStatusView(failedState)
  248. assert.equal(viewFailed.tone, 'lv1', '载入失败用红色键')
  249. assert.equal(viewFailed.state, CONFIG_STATE.FAILED, '失败态应透出')
  250. const viewMissing = configStatusView(missingState)
  251. assert.equal(viewMissing.tone, 'neutral', '缺失用中性键(还在用内置默认,不是故障)')
  252. assert.match(viewMissing.label, /配置缺失/, '缺失文案应可读')
  253. const viewRealViaGateway = configStatusView({ state: CONFIG_STATE.READY, credential: CREDENTIAL.NONE, config: { dataSource: DATA_SOURCE.REAL } })
  254. // REST 免鉴权后 configStatus.js 的 detail 文案改为「接口免鉴权(内网隔离)」;
  255. // 该断言只锁「detail 不再宣称凭据注入」,具体新文案由 t05/领域断言覆盖。
  256. assert.equal(viewRealViaGateway.detail.includes('凭据由部署层注入'), false, 'real 且无凭据时不得再宣称凭据由部署层注入')
  257. // 顶栏视图不得把令牌带到界面层(哪怕配置里确实有)
  258. const viewWithSecret = configStatusView({ ...readyState, config: { ...readyState.config, api: { baseUrl: '/x', token: 'super-secret' } } })
  259. assert.equal(JSON.stringify(viewWithSecret).includes('super-secret'), false, '视图模型不得携带令牌')
  260. // ---------------------------------------------------------------- 10. 静态守卫(对抗式)
  261. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  262. const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  263. // 注意:这里查的是**凭据**令牌,不是票 06 的视觉 token(`视觉 token` / `tokens.css` 是设计令牌,允许出现)。
  264. const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/
  265. 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']
  266. for (const file of credentialFiles) {
  267. assert.equal(CREDENTIAL_PATTERN.test(readModule(file)), false, `${file} 不该出现凭据(页面与 store 不碰凭据)`)
  268. }
  269. const allModuleFiles = [
  270. 'CockpitShell.vue',
  271. 'components/MapCanvas.vue',
  272. 'components/CockpitPanel.vue',
  273. 'components/PanelPlaceholder.vue',
  274. 'components/StatusTag.vue',
  275. 'components/CockpitIcon.vue',
  276. 'domain/configStatus.js',
  277. 'domain/panelCatalog.js',
  278. 'domain/filterOptions.js',
  279. 'domain/statusTones.js',
  280. 'services/cockpitApi.js',
  281. 'services/cockpitConfig.js',
  282. 'services/mapService.js',
  283. 'stores/uiStore.js',
  284. 'stores/filterStore.js',
  285. ]
  286. for (const file of allModuleFiles) {
  287. const text = readModule(file)
  288. assert.equal(
  289. /location\.(assign|replace|href)\s*=|window\.location\s*=|['"`]\/login/.test(text),
  290. false,
  291. `${file} 不该有任何「送去登录页」的跳转(凭据类失败只标数据不可用)`,
  292. )
  293. }
  294. const envReaders = allModuleFiles.filter((file) => /VITE_COCKPIT_|__COCKPIT_CONFIG__/.test(readModule(file)))
  295. assert.deepEqual(envReaders, ['services/cockpitConfig.js'], `配置来源只该在配置服务里读,实际:${envReaders.join(', ')}`)
  296. // 只对**代码**测这条守卫:注释会解释被禁的东西(cockpitApi.js 头部记录着 ADR 0005 的
  297. // 契约路径 `/api/cockpit/v1/**`),对全文正则会把文档当违例(t18 的 `code()` 同一条纪律)。
  298. const codeOnly = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
  299. const addressFiles = allModuleFiles.filter((file) => /\/api\/cockpit/.test(codeOnly(readModule(file))))
  300. assert.deepEqual(addressFiles, [], `页面与组件不该硬编码后端地址,实际:${addressFiles.join(', ')}`)
  301. console.log(
  302. `T04 检查通过:三态(ready/missing/failed)+ 四层来源 + 宽松校验 + 载荷判定 + mock/real 同一出口 + 凭据只在适配层(${credentialFiles.length} 个页面/store 文件零 Authorization/token,${allModuleFiles.length} 个模块文件零登录跳转)。`,
  303. )