t04-config.mjs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  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. assert.equal(real.config.api.token, 'tok', '令牌应去空白')
  85. assert.equal(real.config.map.cesium.enabled, false, '显式关闭应被接受')
  86. // ---------------------------------------------------------------- 3. 环境变量层 + 深合并
  87. const envConfig = readEnvConfig({
  88. [ENV_KEYS.dataSource]: 'real',
  89. [ENV_KEYS.apiBaseUrl]: 'https://gateway.example.com/api/cockpit/v1',
  90. [ENV_KEYS.cesiumBaseUrl]: 'https://cdn.example.com/cesium/',
  91. IGNORED_KEY: 'nope',
  92. })
  93. assert.equal(envConfig.dataSource, 'real', '环境变量应映射成 dataSource')
  94. assert.equal(envConfig.api.baseUrl, 'https://gateway.example.com/api/cockpit/v1', '环境变量应映射成 api.baseUrl')
  95. assert.equal(envConfig.map.cesium.baseUrl, 'https://cdn.example.com/cesium/', '环境变量应映射成 map.cesium.baseUrl')
  96. assert.equal(envConfig.IGNORED_KEY, undefined, '白名单之外的键不进配置')
  97. assert.deepEqual(readEnvConfig({}), {}, '没有相关环境变量时返回空对象')
  98. assert.deepEqual(readEnvConfig(undefined), {}, 'env 缺失也不应抛错')
  99. const merged = mergeConfig({ api: { baseUrl: 'a', token: 'x' }, ws: { url: 'w' } }, { api: { baseUrl: 'b' } })
  100. assert.deepEqual(merged, { api: { baseUrl: 'b', token: 'x' }, ws: { url: 'w' } }, '深合并应保留未覆盖的兄弟字段')
  101. // ---------------------------------------------------------------- 4. 配置文件地址解析
  102. assert.equal(
  103. resolveConfigUrl({ base: './', documentBase: 'http://localhost:8000/#/cockpit' }),
  104. `http://localhost:8000/${CONFIG_FILE_PATH}`,
  105. '默认应相对应用基址取 cockpit/config.json(hash 路由不影响)',
  106. )
  107. assert.equal(
  108. resolveConfigUrl({ base: './', documentBase: 'http://host/app/index.html' }),
  109. `http://host/app/${CONFIG_FILE_PATH}`,
  110. '子目录部署应落在文档同级目录',
  111. )
  112. assert.equal(
  113. resolveConfigUrl({ base: 'https://cos.example.com/qiankun/tvt/', documentBase: 'http://host/' }),
  114. `https://cos.example.com/qiankun/tvt/${CONFIG_FILE_PATH}`,
  115. 'qiankun 注入基址应被采用',
  116. )
  117. assert.equal(
  118. resolveConfigUrl({ documentBase: 'http://host/app/', explicitUrl: 'cockpit/aerial.webp' }),
  119. 'http://host/app/cockpit/aerial.webp',
  120. '显式相对 URL 应相对文档解析',
  121. )
  122. assert.equal(
  123. resolveConfigUrl({ documentBase: 'http://host/', explicitUrl: 'https://cfg.example.com/cockpit.json' }),
  124. 'https://cfg.example.com/cockpit.json',
  125. '显式绝对 URL 应原样采用',
  126. )
  127. assert.equal(
  128. resolveConfigUrl({ base: './', documentBase: 'http://host/app/index.html', explicitUrl: CONFIG_URL_AUTO }),
  129. `http://host/app/${CONFIG_FILE_PATH}`,
  130. "'auto' 应按应用基址取约定路径(qiankun / 子目录下也对)",
  131. )
  132. // ---------------------------------------------------------------- 5. 载荷判定
  133. assert.deepEqual((await readConfigPayload(okJson({ a: 1 }))).raw, { a: 1 }, 'JSON 对象应被解析')
  134. assert.equal((await readConfigPayload(okHtml())).reason, 'missing', 'HTML 载荷 = 文件不存在(dev SPA 回退)')
  135. assert.equal((await readConfigPayload(notFound())).reason, 'missing', '404 = 文件不存在')
  136. assert.equal((await readConfigPayload(serverError())).reason, 'http-500', '5xx = 配置不可用')
  137. assert.equal((await readConfigPayload(brokenJson())).reason, 'invalid-json', '坏 JSON = 配置不可用')
  138. assert.equal((await readConfigPayload(okJson([1, 2]))).reason, 'not-an-object', '数组不是合法配置')
  139. // ---------------------------------------------------------------- 6. 三态:成功 / 缺失 / 失败
  140. const readyState = await loadWith(async () => okJson({ dataSource: 'real', api: { baseUrl: '/api/cockpit/v1', token: 'readonly' } }))
  141. assert.equal(readyState.state, CONFIG_STATE.READY, '拿到合法配置应为 ready')
  142. assert.equal(readyState.source, 'file', '来源应记成 file')
  143. assert.equal(readyState.config.dataSource, DATA_SOURCE.REAL, '文件里的 dataSource 应生效')
  144. assert.equal(readyState.credential, CREDENTIAL.CONFIG, '配置里带令牌时应标成 config')
  145. const missingState = await loadWith(async () => okHtml())
  146. assert.equal(missingState.state, CONFIG_STATE.MISSING, 'dev 下 200 HTML(SPA 回退)应判为缺失')
  147. assert.equal(missingState.config.dataSource, DATA_SOURCE.MOCK, '缺失时退回内置 mock')
  148. assert.equal(missingState.url, `http://localhost:8000/${CONFIG_FILE_PATH}`, '缺失态也应记录尝试过的地址')
  149. const failedState = await loadWith(async () => brokenJson())
  150. assert.equal(failedState.state, CONFIG_STATE.FAILED, '坏 JSON 应为 failed')
  151. assert.match(failedState.error, /invalid-json/, '失败原因应可读')
  152. assert.equal(failedState.config.dataSource, DATA_SOURCE.MOCK, '失败时仍要给出可跑的默认配置')
  153. const throwState = await loadWith(async () => {
  154. throw new Error('network down')
  155. })
  156. assert.equal(throwState.state, CONFIG_STATE.FAILED, '网络异常应为 failed 而不是抛出')
  157. assert.match(throwState.error, /network down/, '应带上原始错误信息')
  158. const envOnly = await loadWith(async () => notFound(), { env: { [ENV_KEYS.apiBaseUrl]: '/api/cockpit/v1' } })
  159. assert.equal(envOnly.state, CONFIG_STATE.READY, '只有构建期环境变量时也算有配置(不是缺失)')
  160. assert.equal(envOnly.source, 'env', '来源应记成 env')
  161. assert.equal(envOnly.config.api.baseUrl, '/api/cockpit/v1', '环境变量地址应生效')
  162. // 没有显式配置 URL 时**一个请求都不发**:否则每页都会在控制台刷一个配置文件的 404,
  163. // 而走查 A11「无页面级报错」会因此变红(本项就是那条回归的守卫)。
  164. let silentCalls = 0
  165. const silentState = await loadWith(async () => {
  166. silentCalls += 1
  167. return notFound()
  168. }, { explicitUrl: '' })
  169. assert.equal(silentCalls, 0, '没配置 URL 时不得探测配置文件')
  170. assert.equal(silentState.state, CONFIG_STATE.MISSING, '没配置 URL 时应是缺失态')
  171. assert.equal(silentState.url, '', '缺失态且没探测时不该有尝试过的地址')
  172. // 'auto' = 按约定路径真的去拉(部署最常用的一种)
  173. let autoUrl = ''
  174. const autoState = await loadWith(
  175. async (url) => {
  176. autoUrl = url
  177. return okJson({ dataSource: 'mock' })
  178. },
  179. { explicitUrl: CONFIG_URL_AUTO },
  180. )
  181. assert.equal(autoUrl, `http://localhost:8000/${CONFIG_FILE_PATH}`, "'auto' 应请求应用基址下的约定路径")
  182. assert.equal(autoState.state, CONFIG_STATE.READY, "'auto' 拉到合法配置应为 ready")
  183. assert.equal(autoState.source, 'file', '来源应记成 file')
  184. // 宿主注入:优先级最高,且给了就不再拉文件
  185. let fetchCalls = 0
  186. const injectedState = await loadWith(async () => {
  187. fetchCalls += 1
  188. return okJson({ dataSource: 'mock' })
  189. }, { scope: { __COCKPIT_CONFIG__: { dataSource: 'real', api: { baseUrl: '/from-window' } } } })
  190. assert.equal(injectedState.state, CONFIG_STATE.READY, '宿主注入应为 ready')
  191. assert.equal(injectedState.source, 'window', '来源应记成 window')
  192. assert.equal(injectedState.config.api.baseUrl, '/from-window', '宿主注入应生效')
  193. assert.equal(fetchCalls, 0, '宿主注入了就不该再请求配置文件')
  194. // 缓存:同一进程内只解析一次
  195. const cachedAgain = await loadCockpitConfig()
  196. assert.equal(cachedAgain, injectedState, '第二次调用应直接命中缓存')
  197. assert.equal(getCockpitConfig().api.baseUrl, '/from-window', '同步读应拿到同一份配置')
  198. assert.equal(getCockpitConfigState().state, CONFIG_STATE.READY, '同步状态应与载入结果一致')
  199. // ---------------------------------------------------------------- 7. 请求计划(凭据只在这里出现)
  200. const mockPlan = planCockpitRequest(builtinConfig(), 'map/summary')
  201. assert.equal(mockPlan.mode, DATA_SOURCE.MOCK, '默认走 mock')
  202. assert.equal(mockPlan.url, '', 'mock 不产生请求地址')
  203. assert.equal(mockPlan.headers.Authorization, undefined, 'mock 分支不得携带凭据')
  204. assert.equal(mockPlan.path, '/map/summary', '路径应归一化成以 / 开头')
  205. const realPlan = planCockpitRequest({ dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1/', token: 'readonly' } }, '/map/summary')
  206. assert.equal(realPlan.url, 'https://gw/api/cockpit/v1/map/summary', '真接口地址应由 baseUrl + 路径拼出')
  207. assert.equal(realPlan.headers.Authorization, 'Bearer readonly', '真接口应统一附加部署层给的令牌')
  208. assert.equal(realPlan.credential, CREDENTIAL.CONFIG, '凭据来源应可观测')
  209. const gatewayPlan = planCockpitRequest({ dataSource: 'real', api: { baseUrl: '/api/cockpit/v1' } }, '/map/summary')
  210. assert.equal(gatewayPlan.headers.Authorization, undefined, '没有配置令牌时不加头(靠部署层网关补)')
  211. assert.equal(gatewayPlan.credential, CREDENTIAL.NONE, '凭据来源应为 none')
  212. assert.equal(
  213. planCockpitRequest({ dataSource: 'real', api: { baseUrl: '' } }, '/x').error,
  214. REQUEST_REASON.NO_BASE_URL,
  215. 'real 模式缺地址应给出明确原因,而不是拿页面地址瞎拼',
  216. )
  217. // ---------------------------------------------------------------- 8. 统一出口:mock/real + 失败归类
  218. resetMockTransportsForTest()
  219. const noHandler = await requestCockpitJson('/map/summary', { config: builtinConfig() })
  220. assert.equal(noHandler.reason, REQUEST_REASON.MOCK_MISSING, '未注册 mock 处理器应给出明确原因(票 05 来注册)')
  221. registerMockTransport('map/summary', async () => ({ points: 3 }))
  222. const mockOk = await requestCockpitJson('/map/summary', { config: builtinConfig() })
  223. assert.equal(mockOk.ok, true, 'mock 命中应成功')
  224. assert.deepEqual(mockOk.data, { points: 3 }, 'mock 数据应原样返回')
  225. resetMockTransportsForTest()
  226. const realConfig = { dataSource: 'real', api: { baseUrl: 'https://gw/api/cockpit/v1', token: 'readonly' } }
  227. const unauthorized = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => statusOnly(401) })
  228. assert.equal(unauthorized.reason, REQUEST_REASON.UNAUTHORIZED, '401 应归类成凭据类失败')
  229. assert.equal(isCredentialFailure(unauthorized.reason), true, '凭据类失败应可被分区识别')
  230. const realOk = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => statusOnly(200, '{"ok":1}') })
  231. assert.equal(realOk.ok, true, '真接口 200 JSON 应成功')
  232. assert.deepEqual(realOk.data, { ok: 1 }, '真接口数据应解析出来')
  233. const badPayload = await requestCockpitJson('/map/summary', { config: realConfig, fetchImpl: async () => okHtml() })
  234. assert.equal(badPayload.reason, REQUEST_REASON.BAD_PAYLOAD, 'dev 下 200 HTML 应判成坏载荷(接口没实现)')
  235. const unreachable = await requestCockpitJson('/map/summary', {
  236. config: realConfig,
  237. fetchImpl: async () => {
  238. throw new Error('ECONNREFUSED')
  239. },
  240. })
  241. assert.equal(unreachable.reason, REQUEST_REASON.UNREACHABLE, '网络异常应归类成不可达')
  242. assert.equal(isCredentialFailure(unreachable.reason), false, '不可达不属于凭据类失败')
  243. // ---------------------------------------------------------------- 9. 顶栏视图映射
  244. const viewReady = configStatusView(injectedState)
  245. assert.equal(viewReady.dataSource, DATA_SOURCE.REAL, '视图应带上数据源')
  246. assert.equal(viewReady.tone, 'lv4', '就绪用绿色键')
  247. assert.match(viewReady.label, /real/, '标签应能读出数据源')
  248. const viewFailed = configStatusView(failedState)
  249. assert.equal(viewFailed.tone, 'lv1', '载入失败用红色键')
  250. assert.equal(viewFailed.state, CONFIG_STATE.FAILED, '失败态应透出')
  251. const viewMissing = configStatusView(missingState)
  252. assert.equal(viewMissing.tone, 'neutral', '缺失用中性键(还在用内置默认,不是故障)')
  253. assert.match(viewMissing.label, /配置缺失/, '缺失文案应可读')
  254. const viewRealViaGateway = configStatusView({ state: CONFIG_STATE.READY, credential: CREDENTIAL.NONE, config: { dataSource: DATA_SOURCE.REAL } })
  255. assert.match(viewRealViaGateway.detail, /部署层/, 'real 且无配置令牌时应说明凭据来自部署层')
  256. // 顶栏视图不得把令牌带到界面层(哪怕配置里确实有)
  257. const viewWithSecret = configStatusView({ ...readyState, config: { ...readyState.config, api: { baseUrl: '/x', token: 'super-secret' } } })
  258. assert.equal(JSON.stringify(viewWithSecret).includes('super-secret'), false, '视图模型不得携带令牌')
  259. // ---------------------------------------------------------------- 10. 静态守卫(对抗式)
  260. const moduleRoot = new URL('../../../src/modules/cockpit/', import.meta.url)
  261. const readModule = (relative) => readFileSync(new URL(relative, moduleRoot), 'utf8')
  262. // 注意:这里查的是**凭据**令牌,不是票 06 的视觉 token(`视觉 token` / `tokens.css` 是设计令牌,允许出现)。
  263. const CREDENTIAL_PATTERN = /Authorization|Bearer|api\.token|ws\.token/
  264. 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']
  265. for (const file of credentialFiles) {
  266. assert.equal(CREDENTIAL_PATTERN.test(readModule(file)), false, `${file} 不该出现凭据(页面与 store 不碰凭据)`)
  267. }
  268. const allModuleFiles = [
  269. 'CockpitShell.vue',
  270. 'components/MapCanvas.vue',
  271. 'components/CockpitPanel.vue',
  272. 'components/PanelPlaceholder.vue',
  273. 'components/StatusTag.vue',
  274. 'components/CockpitIcon.vue',
  275. 'domain/configStatus.js',
  276. 'domain/panelCatalog.js',
  277. 'domain/filterOptions.js',
  278. 'domain/statusTones.js',
  279. 'services/cockpitApi.js',
  280. 'services/cockpitConfig.js',
  281. 'services/mapService.js',
  282. 'stores/uiStore.js',
  283. 'stores/filterStore.js',
  284. ]
  285. for (const file of allModuleFiles) {
  286. const text = readModule(file)
  287. assert.equal(
  288. /location\.(assign|replace|href)\s*=|window\.location\s*=|['"`]\/login/.test(text),
  289. false,
  290. `${file} 不该有任何「送去登录页」的跳转(凭据类失败只标数据不可用)`,
  291. )
  292. }
  293. const envReaders = allModuleFiles.filter((file) => /VITE_COCKPIT_|__COCKPIT_CONFIG__/.test(readModule(file)))
  294. assert.deepEqual(envReaders, ['services/cockpitConfig.js'], `配置来源只该在配置服务里读,实际:${envReaders.join(', ')}`)
  295. const addressFiles = allModuleFiles.filter((file) => /\/api\/cockpit/.test(readModule(file)))
  296. assert.deepEqual(addressFiles, [], `页面与组件不该硬编码后端地址,实际:${addressFiles.join(', ')}`)
  297. console.log(
  298. `T04 检查通过:三态(ready/missing/failed)+ 四层来源 + 宽松校验 + 载荷判定 + mock/real 同一出口 + 凭据只在适配层(${credentialFiles.length} 个页面/store 文件零 Authorization/token,${allModuleFiles.length} 个模块文件零登录跳转)。`,
  299. )