| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343 |
- /**
- * 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 () => '<!doctype html><html><body>app</body></html>' })
- 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} 个模块文件零登录跳转)。`,
- )
|