diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 26b8c356..a503bb8b 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-432/ADR-0534(原生 Console 源码候选):修复断开/重连后的异步结果污染。每次连接使用独立内存代次,统一请求与编辑/执行链拒绝旧成功、失败和 finally;旧响应不得重开编辑器、恢复 Secret catalog、覆盖 proof 或在新连接下发出后续请求。16 项针对性回归、完整 Local API 130/130、后端 1683 pass/2 环境 skip/0 fail,静态资产合计 114,042 bytes。断开不撤销已提交的服务端写入,重连须核对 durable 状态;不新增包、后台服务或后端权限。此修复尚未通过同源远端 CI/阶段实物验收,见 [ADR-0534](./adr/ADR-0534-native-console-session-isolation.md)。 + - D-431/ADR-0533(原生 Console 源码候选):Task、Trigger、Run 列表接入既有 keyset API,开放下一页、回到首页和当前页刷新,每次最多 64 条并替换 DOM,不缓存历史页、不轮询或扩大后端查询。验证规范 cursor、页内/跨页顺序与继续边界;请求/选择代次防止旧列表、错误、详情和 Secret catalog 覆盖当前页面。客户端与生产 HTTP parser 集成 15/15、完整 Local API 114/114,静态资产共 111,857 bytes;默认 headless/Cluster 不变。远端 CI、浏览器证据和 exact 阶段实物分别核验,不借用 D-430 archive。 - D-430/ADR-0532(exact Console 双架构阶段实物已交付):在原生运行详情中开放日志下一片段、回到开头与当前片段刷新,固定 32 KiB 替换窗口,不累计全文或后台轮询。严格验证响应身份、字节范围与前进 cursor,按不含凭据的选择代次丢弃过期响应,防止切换或断开后覆盖新详情。复用既有 API/Policy/Audit,不新增包、依赖、迁移或后端 authority;默认 headless 与 Cluster 不变。提交 `12ed38b7d72fe6366ad8f1ac84aebb1bb772f3b5` 主 CI run `33793623200` 为 42 success/3 expected skip/0 fail,独立 Kubernetes run `33793623184` 成功;Console artifact run `33793687627` 为 43 success/2 expected skip/0 fail。amd64/arm64/milestone artifact `9909229721`/`9909131639`/`9909265138` 实际下载后的 checksum、bundle v8 与 milestone v7 auditor 均通过;两个 archive 内的原生 JS 都与该提交逐字节一致。该实物不包含后续 D-431 列表分页,不等同完整 3.0 或 Public Release。 diff --git a/docs/adr/ADR-0533-native-console-ledger-pagination.md b/docs/adr/ADR-0533-native-console-ledger-pagination.md index 0c83cee0..6c4df44b 100644 --- a/docs/adr/ADR-0533-native-console-ledger-pagination.md +++ b/docs/adr/ADR-0533-native-console-ledger-pagination.md @@ -1,6 +1,6 @@ # ADR-0533:原生 Console 的任务、定时与运行列表分页 -- 状态:Accepted(D-431 源码候选;远端 CI 与阶段实物待验证) +- 状态:Accepted(D-431 远端 CI 与产物构建通过;下载实物复核待完成) - 日期:2026-09-04 - 关联:QL-RFC-0001 D-431、ADR-0532 @@ -28,3 +28,5 @@ 原生 JS 为 80,336 bytes,三项静态资产共 111,857 bytes,仍小于单文件 96 KiB / 合计 192 KiB。D-430 的 `12ed38b7` 构建不含本切片;本地测试不等于 exact 双架构产物交付。 真实 Chromium 使用当前原生静态资产与合成 HTTP 数据验证了 Task、Trigger、Run 的第 65 条可达,任务详情可从第二页打开,DOM 每次只保留当前页。请求记录包含三个规范 continuation URL,浏览期间没有自动列表轮询。该浏览器证据验证交互,不冒充真实数据库、Owner 写入或双架构镜像验收。 + +提交 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 的主 CI [run 33796190607](https://github.com/whyour/qinglong/actions/runs/33796190607) 与 Console artifact [run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 均已完成并成功。上传清单包含 amd64 `9910148271`(239,038,659 bytes)、arm64 `9910153237`(234,438,339 bytes)及 milestone `9910185111`(6,797 bytes);本记录仅确认远端成功与上传存在,下载后的 checksum、离线 auditor 和 archive 源码复核尚未完成。这些归档不包含后续 ADR-0534 的客户端连接代次修复。 diff --git a/docs/adr/ADR-0534-native-console-session-isolation.md b/docs/adr/ADR-0534-native-console-session-isolation.md new file mode 100644 index 00000000..d3a5e4a1 --- /dev/null +++ b/docs/adr/ADR-0534-native-console-session-isolation.md @@ -0,0 +1,35 @@ +# ADR-0534:原生 Console 异步操作的连接代次隔离 + +- 状态:Accepted(D-432 源码候选;同源远端 CI 与阶段实物待验证) +- 日期:2026-09-04 +- 关联:QL-RFC-0001 D-432、ADR-0533 + +## 问题 + +列表的请求代次不能覆盖所有编辑器调用链。已经复现:创建 Task 时读取 Secret 目录,用户断开连接后,迟到的响应仍会填充 catalog;完整 Task authoring、presence challenge 和写入后的刷新也可能把旧连接结果带入新页面。这个问题是客户端状态隔离缺陷,不是后端认证绕过。 + +## 决策 + +- 每次连接及断开都更换一个不含 credential 的内存代次对象;即使重新使用同一个 credential,也不是同一连接。 +- 统一 `api` 在网络异常、JSON 解码异常和解码完成处检查代次;过期结果统一拒绝为 `session_changed`,不能向调用者暴露旧成功内容或旧错误。 +- Task/Trigger/Secret 编辑、authoring、presence 提交和 Run 启停链在异步边界再次检查代次。旧调用不能重新打开对话框、写入 catalog/snapshot、覆盖新 proof、发布 toast,或在新连接下继续读取/写入。 +- 断开时同步清空既有敏感状态并恢复提交按钮基线;旧 finally 不能解锁新连接正在提交的按钮。跨连接检查不替代同一连接内的列表和选择代次。 +- 不增加 package、依赖、后台计时器、轮询或后端路由。默认 headless、Cluster、旧面板兼容入口和后端 Policy/presence/audit 规则不变。 + +## 不承诺的行为 + +丢弃页面响应不等于撤销已经发送的服务端操作。断开之前已提交的 Task/Trigger/Secret 写入、Run start 或 cancellation 仍可能成功;重连后应读取 durable 状态核对,不应自动重试或把断开视为取消。此切片不引入网络取消或事务回滚,也不把 Local Console 扩展为多用户远程会话产品。 + +## 替代方案 + +只比较 token 无法区分同一 credential 的重新连接;只加 AbortController 不能撤销已完成的解码、排队回调或服务端写入。逐页临时修补则容易遗漏编辑器和 finally。因此保留统一连接代次检查,并在会产生后续副作用的调用边界复核。 + +## 验证 + +测试直接执行发布的 `console.js`,通过 `test/support/consoleClient.cjs` 复用最小 DOM 夹具,不新增生产导出。16 项回归覆盖迟到目录、JSON 解码、网络错误、相同 credential 重连、authoring 全量定义、三种草稿、按钮 finally、Trigger pin 读取后的写入禁止,以及 Run 写入后的刷新/选择隔离。初始 13 项在修复前为 12 fail / 1 pass,修复后全部通过;新增 3 项也通过。 + +最终 Local API 130/130,完整后端 1685 项(1683 pass / 2 环境条件 skip / 0 fail)。Local image 与 18-package boundary audit 均 compatible;原生 JS 82,521 bytes,三项静态资产合计 114,042 bytes,仍在单文件 96 KiB / 合计 192 KiB 预算内。 + +真实 Chromium 使用当前静态资产和仅监听本机的合成 HTTP 服务完成连接、创建 Task、确认 Secret GET 挂起、断开、放行旧响应的交互;页面保持等待凭据且没有重开编辑器。测试没有真实数据库、生产 credential 或写入 authority,不能代替镜像端到端验收。 + +上述本地验证不代表修复已进入既有下载产物。D-431 的 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 产物也不包含本修复;本切片必须以自己的 source revision、CI 和阶段归档证据验收。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 129f0363..2c3debc8 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -537,6 +537,7 @@ | [ADR-0531](./ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | 现有面板到规范 Run 日志的有界桥接 | Accepted(D-429 exact Console 双架构阶段实物已交付) | | [ADR-0532](./ADR-0532-native-console-log-window-navigation.md) | 原生 Console 日志窗口导航 | Accepted(D-430 exact Console 双架构阶段实物已交付) | | [ADR-0533](./ADR-0533-native-console-ledger-pagination.md) | 原生 Console 任务、定时与运行列表分页 | Accepted(D-431 源码候选;实物待验证) | +| [ADR-0534](./ADR-0534-native-console-session-isolation.md) | 原生 Console 异步操作的连接代次隔离 | Accepted(D-432 源码候选;同源 CI 与实物待验证) | ## 规则 diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index 9ddcc8fa..ebb5a0c8 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -83,6 +83,8 @@ ssh -L 5701:127.0.0.1:5701 router.example Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。 +断开连接不会撤销服务端已经收到的写入、启动或取消请求。重连后先核对 Task/Trigger/Secret revision 与 durable Run 状态,不要因为页面没有收到结果就直接重复提交。D-432 源码候选修复旧连接的慢响应重新打开编辑器、回填 Secret 目录或影响新提交的缺陷,见 [ADR-0534](../adr/ADR-0534-native-console-session-isolation.md);上述 D-429/D-430 归档及 D-431 构建均不包含这项修复,不能把本地回归通过当作已下载版本已更新。 + ## 当前阶段可用边界 D-424 阶段实物的原生 `/console` 可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、Task pinned Secret binding、Secret current metadata/create/rotate、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。 diff --git a/packages/ql3-local-api/assets/console/console.js b/packages/ql3-local-api/assets/console/console.js index d036cbb1..effde24c 100644 --- a/packages/ql3-local-api/assets/console/console.js +++ b/packages/ql3-local-api/assets/console/console.js @@ -140,6 +140,7 @@ const state = { token: null, + session: {}, project: 'default', view: 'tasks', selectedId: null, @@ -375,10 +376,21 @@ return Object.freeze(entries); } + function isCurrentSession(session) { + return state.session === session && Boolean(state.token); + } + + function assertCurrentSession(session) { + if (!isCurrentSession(session)) { + throw new ConsoleRequestError('session_changed', 0, null); + } + } + async function api(path, options = {}) { if (!state.token) { throw new ConsoleRequestError('authentication_required', 401, null); } + const session = state.session; const headers = { accept: 'application/json', authorization: `Bearer ${state.token}`, @@ -406,18 +418,21 @@ referrerPolicy: 'no-referrer', }); } catch { + assertCurrentSession(session); throw new ConsoleRequestError('request_unavailable', 503, null); } let value; try { value = await response.json(); } catch { + assertCurrentSession(session); throw new ConsoleRequestError( 'response_unavailable', response.status, response.headers.get('x-request-id'), ); } + assertCurrentSession(session); if (!response.ok && response.status !== options.acceptStatus) { throw new ConsoleRequestError( typeof value.code === 'string' ? value.code : 'request_unavailable', @@ -776,9 +791,11 @@ } async function saveTriggerDraft() { + const session = state.session; nodes.triggerEditorSave.disabled = true; try { const mutation = await triggerDraft(); + assertCurrentSession(session); const value = await api( `/api/v3/projects/${state.project}/triggers/${mutation.triggerId}`, { @@ -787,15 +804,16 @@ acceptStatus: 428, }, ); + assertCurrentSession(session); if (value.code === 'local_presence_required') { showPresenceChallenge({ kind: 'trigger-mutation', mutation }, value); return; } throw new ConsoleRequestError('response_unavailable', 503, null); } catch (error) { - showToast(describeError(error), 'error'); + if (isCurrentSession(session)) showToast(describeError(error), 'error'); } finally { - nodes.triggerEditorSave.disabled = false; + if (isCurrentSession(session)) nodes.triggerEditorSave.disabled = false; } } @@ -829,9 +847,11 @@ } async function loadSecretCatalog() { + const session = state.session; const value = await api( `/api/v3/projects/${state.project}/secrets?limit=64`, ); + assertCurrentSession(session); state.secretCatalog = normalizeSecretMetadata(value); return Object.freeze({ secrets: state.secretCatalog, @@ -886,6 +906,7 @@ } async function saveSecretDraft() { + const session = state.session; let body; try { body = secretDraft(); @@ -903,6 +924,7 @@ body, acceptStatus: 428, }); + assertCurrentSession(session); if (value.code === 'local_presence_required') { showPresenceChallenge({ kind: 'secret-mutation', body }, value); return; @@ -910,9 +932,9 @@ throw new ConsoleRequestError('response_unavailable', 503, null); } catch (error) { body = null; - showToast(describeError(error), 'error'); + if (isCurrentSession(session)) showToast(describeError(error), 'error'); } finally { - nodes.secretEditorSave.disabled = false; + if (isCurrentSession(session)) nodes.secretEditorSave.disabled = false; } } @@ -1051,6 +1073,7 @@ } async function saveTaskDraft() { + const session = state.session; let mutation; try { mutation = taskDraft(); @@ -1074,24 +1097,27 @@ : {}), }, ); + assertCurrentSession(session); if (value.code === 'local_presence_required') { showPresenceChallenge({ kind: 'mutation', mutation }, value); return; } throw new ConsoleRequestError('response_unavailable', 503, null); } catch (error) { - showToast(describeError(error), 'error'); + if (isCurrentSession(session)) showToast(describeError(error), 'error'); } finally { - nodes.taskEditorSave.disabled = false; + if (isCurrentSession(session)) nodes.taskEditorSave.disabled = false; } } async function beginTaskAuthoring(task) { + const session = state.session; try { const value = await api( `/api/v3/projects/${state.project}/tasks/${task.taskId}/authoring`, { method: 'POST', acceptStatus: 428 }, ); + assertCurrentSession(session); if (value.code === 'local_presence_required') { showPresenceChallenge( { kind: 'authoring', taskId: task.taskId }, @@ -1101,11 +1127,12 @@ } throw new ConsoleRequestError('response_unavailable', 503, null); } catch (error) { - showToast(describeError(error), 'error'); + if (isCurrentSession(session)) showToast(describeError(error), 'error'); } } async function completeTaskMutation() { + const session = state.session; const pending = state.pendingPresence; const proof = nodes.presenceProof.value.trim(); if (!pending || !PRESENCE_PATTERN.test(proof)) { @@ -1123,6 +1150,7 @@ `/api/v3/projects/${state.project}/tasks/${pending.taskId}/authoring`, { method: 'POST', presence: proof }, ); + assertCurrentSession(session); const snapshot = authoringSnapshot(value, pending.taskId); state.pendingPresence = null; nodes.presenceProof.value = ''; @@ -1130,12 +1158,14 @@ try { await loadSecretCatalog(); } catch { + if (!isCurrentSession(session)) return; state.secretCatalog = []; showToast( 'Task 已加载,但 Secret 目录暂不可用;已有绑定仍会保留。', 'error', ); } + assertCurrentSession(session); openTaskEditor(snapshot); showToast('完整 Task 定义已加载;保存仍需要新的本机证明。'); return; @@ -1149,6 +1179,7 @@ presence: proof, }, ); + assertCurrentSession(session); const updated = pending.mutation.body.expectedRevision !== null; state.pendingPresence = null; state.triggerSnapshot = null; @@ -1165,6 +1196,7 @@ state.selectedId = pending.mutation.triggerId; updateNavigation(); await refresh(); + assertCurrentSession(session); await selectTrigger(pending.mutation.triggerId); return; } @@ -1174,6 +1206,7 @@ body: pending.body, presence: proof, }); + assertCurrentSession(session); const rotated = pending.body.expectedCurrentVersion > 0; state.pendingPresence = null; state.secretSnapshot = null; @@ -1191,6 +1224,7 @@ state.selectedId = pending.body.name; updateNavigation(); await refresh(); + assertCurrentSession(session); selectSecret(pending.body.name); return; } @@ -1205,6 +1239,7 @@ : {}), }, ); + assertCurrentSession(session); const updated = pending.mutation.body.expectedRevision !== null; state.pendingPresence = null; state.authoringSnapshot = null; @@ -1219,13 +1254,16 @@ ); state.selectedId = pending.mutation.taskId; await refresh(); + assertCurrentSession(session); await selectTask(pending.mutation.taskId); } catch (error) { - nodes.presenceError.textContent = describeError(error); - nodes.presenceError.hidden = false; - nodes.presenceProof.select(); + if (isCurrentSession(session)) { + nodes.presenceError.textContent = describeError(error); + nodes.presenceError.hidden = false; + nodes.presenceProof.select(); + } } finally { - nodes.presenceSubmit.disabled = false; + if (isCurrentSession(session)) nodes.presenceSubmit.disabled = false; } } @@ -1455,6 +1493,7 @@ } async function startTask(task) { + const session = state.session; try { const value = await api( `/api/v3/projects/${state.project}/tasks/${task.taskId}/runs`, @@ -1468,6 +1507,7 @@ }, }, ); + assertCurrentSession(session); showToast( value.status === 'existing' ? '已找到同一启动请求。' @@ -1477,9 +1517,10 @@ state.selectedId = value.runId; updateNavigation(); await refresh(); + assertCurrentSession(session); await selectRun(value.runId); } catch (error) { - showToast(describeError(error), 'error'); + if (isCurrentSession(session)) showToast(describeError(error), 'error'); } } @@ -1602,11 +1643,12 @@ } actions.append( actionButton('查看绑定任务', async () => { + const session = state.session; state.view = 'tasks'; state.selectedId = trigger.taskId; updateNavigation(); await refresh(); - await selectTask(trigger.taskId); + if (isCurrentSession(session)) await selectTask(trigger.taskId); }), ); fragment.append(actions); @@ -1921,6 +1963,7 @@ } async function cancelRun(run) { + const session = state.session; try { const value = await api( `/api/v3/projects/${state.project}/runs/${run.id}/cancellation`, @@ -1932,6 +1975,7 @@ }, }, ); + assertCurrentSession(session); showToast( value.status === 'already_terminal' ? '运行已经结束。' @@ -1940,9 +1984,10 @@ : '取消请求已写入。', ); await refresh(); + assertCurrentSession(session); await selectRun(run.id); } catch (error) { - showToast(describeError(error), 'error'); + if (isCurrentSession(session)) showToast(describeError(error), 'error'); } } @@ -2021,6 +2066,7 @@ } function connect(token, project) { + state.session = {}; state.token = token; state.project = project; state.view = 'tasks'; @@ -2039,6 +2085,7 @@ } function disconnect() { + state.session = {}; state.token = null; state.selectedId = null; state.pendingAction = null; @@ -2047,6 +2094,10 @@ state.triggerSnapshot = null; state.secretSnapshot = null; state.secretCatalog = []; + nodes.taskEditorSave.disabled = false; + nodes.triggerEditorSave.disabled = false; + nodes.secretEditorSave.disabled = false; + nodes.presenceSubmit.disabled = false; if (nodes.taskEditor.open) nodes.taskEditor.close(); if (nodes.triggerEditor.open) nodes.triggerEditor.close(); if (nodes.secretEditor.open) nodes.secretEditor.close(); @@ -2098,11 +2149,12 @@ nodes.disconnect.addEventListener('click', disconnect); nodes.refresh.addEventListener('click', refresh); nodes.createTask.addEventListener('click', async () => { + const session = state.session; try { await loadSecretCatalog(); - openTaskEditor(); + if (isCurrentSession(session)) openTaskEditor(); } catch (error) { - showToast(describeError(error), 'error'); + if (isCurrentSession(session)) showToast(describeError(error), 'error'); } }); nodes.createTrigger.addEventListener('click', () => openTriggerEditor()); diff --git a/packages/ql3-local-api/test/consoleLedger.test.cjs b/packages/ql3-local-api/test/consoleLedger.test.cjs index 84c0f13f..bcf40e5e 100644 --- a/packages/ql3-local-api/test/consoleLedger.test.cjs +++ b/packages/ql3-local-api/test/consoleLedger.test.cjs @@ -1,114 +1,6 @@ const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -const vm = require('node:vm'); const { test } = require('node:test'); - -// A minimal DOM; all navigation, requests and validation execute the shipped JS. -class Element { - constructor(tag = 'div') { - this.tag = tag; - this.children = []; - this.dataset = {}; - this.listeners = {}; - this.isConnected = true; - } - set textContent(value) { - this.text = value; - this.replaceChildren(); - } - get textContent() { - return (this.text || '') + this.children.map((x) => x.textContent).join(''); - } - append(...children) { - for (const child of children) { - if (child.tag === 'fragment') this.append(...child.children); - else { - child.parent = this; - this.children.push(child); - } - } - } - detach() { - this.isConnected = false; - this.children.forEach((child) => child.detach()); - } - replaceChildren(...children) { - this.children.forEach((child) => child.detach()); - this.children = []; - this.append(...children); - } - setAttribute(name, value) { - this[name] = value; - } - removeAttribute(name) { - delete this[name]; - } - addEventListener(name, handler) { - this.listeners[name] = handler; - } - querySelector() { - return new Element(); - } - querySelectorAll(selector) { - return this.children.flatMap((child) => [ - ...(selector === child.tag || selector === `.${child.className}` - ? [child] - : []), - ...child.querySelectorAll(selector), - ]); - } - focus() {} -} - -function fixture(view, response) { - const nodes = new Map(); - const calls = []; - const context = vm.createContext({ - TextDecoder, - Uint8Array, - URLSearchParams, - Intl, - console, - window: { atob }, - document: { - getElementById(id) { - if (!nodes.has(id)) nodes.set(id, new Element()); - return nodes.get(id); - }, - querySelector: () => new Element(), - createElement: (tag) => new Element(tag), - createDocumentFragment: () => new Element('fragment'), - addEventListener() {}, - }, - fetch: async (url, options) => { - calls.push({ url, options }); - const body = await response(url, calls.length); - return { - ok: !body.httpStatus, - status: body.httpStatus || 200, - json: async () => body, - headers: { get: () => null }, - }; - }, - }); - const source = fs.readFileSync( - path.join(__dirname, '../assets/console/console.js'), - 'utf8', - ); - assert.ok(source.endsWith('})();\n')); - vm.runInContext( - source.slice(0, -6) + - 'globalThis.client = { state, nodes, refresh, selectTask, selectTrigger, disconnect };})();', - context, - ); - Object.assign(context.client.state, { - token: 'memory-only-test-token', - project: 'default', - view, - }); - return { ...context.client, calls }; -} +const { fixture } = require('./support/consoleClient.cjs'); const cursor = (view, row) => view === 'runs' diff --git a/packages/ql3-local-api/test/consoleSession.test.cjs b/packages/ql3-local-api/test/consoleSession.test.cjs new file mode 100644 index 00000000..031d04f6 --- /dev/null +++ b/packages/ql3-local-api/test/consoleSession.test.cjs @@ -0,0 +1,289 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); +const { fixture } = require('./support/consoleClient.cjs'); + +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; +const uuid = '12345678-1234-4234-8234-123456789abc'; +const proof = `ql3p_${uuid}_${'A'.repeat(43)}`; +const challenge = { + httpStatus: 428, + code: 'local_presence_required', + proofFileName: `${uuid}.json`, + expiresAtMs: Date.now() + 120000, +}; +const metadata = { + secrets: [ + { + name: 'old-session-only', + currentVersion: 1, + createdAtMs: 0, + secretRef: + 'qlsecret:v1:' + + Buffer.from( + JSON.stringify({ + projectId: 'default', + name: 'old-session-only', + version: 1, + }), + ).toString('base64url'), + }, + ], + truncated: false, +}; +const task = { + taskId: 'task-a', + revision: 1, + name: 'private old definition', + kind: 'command', + contentDigest: 'a'.repeat(64), + labels: {}, + enabled: true, + spec: { + schema: 'qinglong/command@v1', + config: { + command: { kind: 'argv', file: '/bin/echo', args: ['private argument'] }, + }, + }, +}; +const definition = { + task, + authoring: { + lease: `ql3a_${uuid}_${'A'.repeat(43)}`, + expiresAtMs: Date.now() + 600000, + revision: 1, + contentDigest: task.contentDigest, + }, +}; +const sessionChanged = (error) => error.code === 'session_changed'; + +test('Trigger preparation cannot send a write after its pinned Task read crosses sessions', async () => { + const response = deferred(); + const client = fixture('triggers', () => response.promise); + client.nodes.triggerId.value = 'cron-a'; + client.nodes.triggerTaskId.value = task.taskId; + client.nodes.triggerExpression.value = '* * * * *'; + client.nodes.triggerTimezone.value = 'UTC'; + client.nodes.triggerMisfire.value = 'skip'; + const saving = client.saveTriggerDraft(); + client.disconnect(); + client.state.token = 'new-session-token'; + response.resolve({ task }); + await saving; + assert.equal(client.calls.length, 1); + assert.equal(client.calls[0].options.method, 'GET'); + assert.equal(client.state.pendingPresence, null); + assert.equal(client.nodes.presenceDialog.open, false); +}); + +for (const action of ['startTask', 'cancelRun']) { + test(`${action}: reconnect during post-write refresh cannot select the old Run`, async () => { + const response = deferred(), + started = deferred(); + const client = fixture('runs', (_url, _count, options) => { + if (options.method === 'POST') + return { status: 'accepted', runId: 'run-a' }; + started.resolve(); + return response.promise; + }); + const writing = client[action]( + action === 'startTask' ? task : { id: 'run-a' }, + ); + await started.promise; + client.disconnect(); + client.state.token = 'new-session-token'; + client.state.view = 'secrets'; + response.resolve({ runs: [], hasMore: false }); + await writing; + assert.equal(client.calls.length, 2); + assert.equal(client.state.view, 'secrets'); + assert.equal(client.state.selectedId, null); + }); +} + +test('late Secret catalog cannot repopulate cleared state after disconnect', async () => { + const response = deferred(); + const client = fixture('tasks', () => response.promise); + const reading = client.loadSecretCatalog(); + client.disconnect(); + response.resolve(metadata); + await assert.rejects(reading, sessionChanged); + assert.equal(client.state.token, null); + assert.equal(client.state.secretCatalog.length, 0); +}); + +test('late response body decoding is fenced across reconnect with the same credential', async () => { + const body = deferred(), + decoding = deferred(); + const client = fixture('tasks', (url) => + url === '/probe' + ? { + json() { + decoding.resolve(); + return body.promise; + }, + } + : { tasks: [], hasMore: false }, + ); + const reading = client.api('/probe'); + await decoding.promise; + client.connect(client.state.token, 'default'); + body.resolve({ private: 'previous connection' }); + await assert.rejects(reading, sessionChanged); +}); + +test('late transport failure is also a stale session result', async () => { + const response = deferred(); + const client = fixture('tasks', () => response.promise); + const reading = client.api('/probe'); + client.disconnect(); + response.reject(new Error('old transport')); + await assert.rejects(reading, sessionChanged); +}); + +test('new Task preparation cannot reopen an editor after disconnect', async () => { + const response = deferred(); + const client = fixture('tasks', () => response.promise); + const reading = client.nodes.createTask.listeners.click(); + client.disconnect(); + const toast = client.nodes.toast.textContent; + response.resolve(metadata); + await reading; + assert.equal(client.state.secretCatalog.length, 0); + assert.equal(client.nodes.taskEditor.open, false); + assert.equal(client.nodes.toast.textContent, toast); +}); + +test('late authoring challenge cannot replace a new pending operation', async () => { + const response = deferred(); + const client = fixture('tasks', () => response.promise); + const reading = client.beginTaskAuthoring(task); + client.disconnect(); + const current = { kind: 'new-session-marker' }; + client.state.pendingPresence = current; + response.resolve(challenge); + await reading; + assert.equal(client.state.pendingPresence, current); + assert.equal(client.nodes.presenceDialog.open, false); +}); + +test('late authorized definition cannot open an editor or unlock a new proof submission', async () => { + const response = deferred(); + const client = fixture('tasks', () => response.promise); + client.state.pendingPresence = { kind: 'authoring', taskId: task.taskId }; + client.nodes.presenceProof.value = proof; + const reading = client.completeTaskMutation(); + client.disconnect(); + assert.equal(client.nodes.presenceSubmit.disabled, false); + client.state.token = 'new-session-token'; + client.nodes.presenceSubmit.disabled = true; + response.resolve(definition); + await reading; + assert.equal(client.nodes.taskEditor.open, false); + assert.equal(client.state.authoringSnapshot, null); + assert.equal(client.nodes.presenceSubmit.disabled, true); + assert.equal(client.calls.length, 1); +}); + +test('disconnect during authoring catalog enrichment discards the captured full definition', async () => { + const response = deferred(), + enrichment = deferred(); + const client = fixture('tasks', (url) => { + if (url.includes('/secrets?')) { + enrichment.resolve(); + return response.promise; + } + return definition; + }); + client.state.pendingPresence = { kind: 'authoring', taskId: task.taskId }; + client.nodes.presenceProof.value = proof; + const reading = client.completeTaskMutation(); + await enrichment.promise; + client.disconnect(); + const currentCatalog = []; + client.state.secretCatalog = currentCatalog; + response.resolve(metadata); + await reading; + assert.equal(client.state.secretCatalog, currentCatalog); + assert.equal(client.nodes.taskEditor.open, false); + assert.equal(client.state.authoringSnapshot, null); +}); + +for (const action of ['startTask', 'cancelRun']) { + test(`${action}: an already-sent write never causes follow-up reads in another session`, async () => { + const response = deferred(); + const client = fixture('tasks', () => response.promise); + const reading = client[action]( + action === 'startTask' ? task : { id: 'run-a' }, + ); + client.disconnect(); + client.state.token = 'new-session-token'; + client.state.view = 'secrets'; + const toast = client.nodes.toast.textContent; + response.resolve({ status: 'accepted', runId: 'run-a' }); + await reading; + assert.equal(client.calls.length, 1); + assert.equal(client.calls[0].options.method, 'POST'); + assert.equal(client.state.view, 'secrets'); + assert.equal(client.nodes.toast.textContent, toast); + }); +} + +for (const kind of ['task', 'trigger', 'secret']) { + test(`${kind} draft: stale challenge and finally cannot mutate a new editor`, async () => { + const response = deferred(), + sent = deferred(); + const client = fixture('tasks', (_url, _count, options) => { + if (options.method === 'GET') return { task }; + sent.resolve(); + return response.promise; + }); + Object.assign(client.nodes.taskId, { value: 'task-a' }); + client.nodes.taskName.value = 'Task'; + client.nodes.taskCommand.value = '/bin/echo'; + client.nodes.taskArgs.value = 'test'; + client.nodes.taskEnabled.checked = true; + client.nodes.triggerId.value = 'cron-a'; + client.nodes.triggerTaskId.value = 'task-a'; + client.nodes.triggerExpression.value = '* * * * *'; + client.nodes.triggerTimezone.value = 'UTC'; + client.nodes.triggerMisfire.value = 'skip'; + client.nodes.triggerEnabled.checked = true; + client.nodes.secretName.value = 'key'; + client.nodes.secretValue.value = 'synthetic-private-value'; + const action = { + task: 'saveTaskDraft', + trigger: 'saveTriggerDraft', + secret: 'saveSecretDraft', + }[kind]; + const control = client.nodes[`${kind}EditorSave`]; + const reading = client[action](); + await sent.promise; + client.disconnect(); + assert.equal(control.disabled, false); + client.state.token = 'new-session-token'; + control.disabled = true; + response.resolve(challenge); + await reading; + assert.equal(control.disabled, true); + assert.equal(client.nodes.presenceDialog.open, false); + assert.equal(client.state.pendingPresence, null); + }); +} + +test('current-session authoring and permission errors still reach the expected UI', async () => { + const client = fixture('tasks', (_url, count) => + count === 1 ? challenge : { httpStatus: 403, code: 'authorization_denied' }, + ); + await client.beginTaskAuthoring(task); + assert.equal(client.nodes.presenceDialog.open, true); + assert.equal(client.state.pendingPresence.kind, 'authoring'); + await client.beginTaskAuthoring(task); + assert.match(client.nodes.toast.textContent, /没有执行该操作的权限/); +}); diff --git a/packages/ql3-local-api/test/support/consoleClient.cjs b/packages/ql3-local-api/test/support/consoleClient.cjs new file mode 100644 index 00000000..ed3f6372 --- /dev/null +++ b/packages/ql3-local-api/test/support/consoleClient.cjs @@ -0,0 +1,137 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +// A minimal DOM; all navigation, requests and validation execute the shipped JS. +class Element { + constructor(tag = 'div') { + this.tag = tag; + this.children = []; + this.dataset = {}; + this.listeners = {}; + this.isConnected = true; + this.value = ''; + this.disabled = false; + this.open = false; + } + set textContent(value) { + this.text = value; + this.replaceChildren(); + } + get textContent() { + return (this.text || '') + this.children.map((x) => x.textContent).join(''); + } + append(...children) { + for (const child of children) { + if (child.tag === 'fragment') this.append(...child.children); + else { + child.parent = this; + this.children.push(child); + } + } + } + detach() { + this.isConnected = false; + this.children.forEach((child) => child.detach()); + } + replaceChildren(...children) { + this.children.forEach((child) => child.detach()); + this.children = []; + this.append(...children); + } + setAttribute(name, value) { + this[name] = value; + } + removeAttribute(name) { + delete this[name]; + } + addEventListener(name, handler) { + this.listeners[name] = handler; + } + querySelector() { + return new Element(); + } + querySelectorAll(selector) { + return this.children.flatMap((child) => [ + ...(selector === child.tag || selector === `.${child.className}` + ? [child] + : []), + ...child.querySelectorAll(selector), + ]); + } + focus() { + this.focusCount = (this.focusCount || 0) + 1; + } + select() { + this.selectCount = (this.selectCount || 0) + 1; + } + reset() {} + showModal() { + this.open = true; + this.openCount = (this.openCount || 0) + 1; + } + close() { + this.open = false; + } +} + +function fixture(view, response) { + const nodes = new Map(); + const calls = []; + const context = vm.createContext({ + TextDecoder, + TextEncoder, + crypto: require('node:crypto').webcrypto, + Uint8Array, + URLSearchParams, + Intl, + console, + window: { + atob, + btoa, + setTimeout() { + return 1; + }, + clearTimeout() {}, + }, + document: { + getElementById(id) { + if (!nodes.has(id)) nodes.set(id, new Element()); + return nodes.get(id); + }, + querySelector: () => new Element(), + createElement: (tag) => new Element(tag), + createDocumentFragment: () => new Element('fragment'), + addEventListener() {}, + }, + fetch: async (url, options) => { + calls.push({ url, options }); + const body = await response(url, calls.length, options); + return { + ok: !body.httpStatus, + status: body.httpStatus || 200, + json: body.json || (async () => body), + headers: { get: () => null }, + }; + }, + }); + const source = fs.readFileSync( + path.join(__dirname, '../../assets/console/console.js'), + 'utf8', + ); + assert.ok(source.endsWith('})();\n')); + vm.runInContext( + source.slice(0, -6) + + 'globalThis.client = { state, nodes, refresh, selectTask, selectTrigger, disconnect, connect, api, loadSecretCatalog, beginTaskAuthoring, completeTaskMutation, saveTaskDraft, saveTriggerDraft, saveSecretDraft, startTask, cancelRun };})();', + context, + ); + Object.assign(context.client.state, { + token: 'memory-only-test-token', + project: 'default', + view, + }); + return { ...context.client, calls }; +} + +module.exports = { fixture };