mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
fix(ql3): isolate native console asynchronous sessions
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# QingLong 3.0 Architecture RFC
|
# 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-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。
|
- 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。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ADR-0533:原生 Console 的任务、定时与运行列表分页
|
# ADR-0533:原生 Console 的任务、定时与运行列表分页
|
||||||
|
|
||||||
- 状态:Accepted(D-431 源码候选;远端 CI 与阶段实物待验证)
|
- 状态:Accepted(D-431 远端 CI 与产物构建通过;下载实物复核待完成)
|
||||||
- 日期:2026-09-04
|
- 日期:2026-09-04
|
||||||
- 关联:QL-RFC-0001 D-431、ADR-0532
|
- 关联: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 双架构产物交付。
|
原生 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 写入或双架构镜像验收。
|
真实 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 的客户端连接代次修复。
|
||||||
|
|||||||
@@ -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 和阶段归档证据验收。
|
||||||
@@ -537,6 +537,7 @@
|
|||||||
| [ADR-0531](./ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | 现有面板到规范 Run 日志的有界桥接 | Accepted(D-429 exact Console 双架构阶段实物已交付) |
|
| [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-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-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 与实物待验证) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ ssh -L 5701:127.0.0.1:5701 router.example
|
|||||||
|
|
||||||
Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。
|
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 继续使用受信管理入口。
|
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 继续使用受信管理入口。
|
||||||
|
|||||||
@@ -140,6 +140,7 @@
|
|||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
token: null,
|
token: null,
|
||||||
|
session: {},
|
||||||
project: 'default',
|
project: 'default',
|
||||||
view: 'tasks',
|
view: 'tasks',
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
@@ -375,10 +376,21 @@
|
|||||||
return Object.freeze(entries);
|
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 = {}) {
|
async function api(path, options = {}) {
|
||||||
if (!state.token) {
|
if (!state.token) {
|
||||||
throw new ConsoleRequestError('authentication_required', 401, null);
|
throw new ConsoleRequestError('authentication_required', 401, null);
|
||||||
}
|
}
|
||||||
|
const session = state.session;
|
||||||
const headers = {
|
const headers = {
|
||||||
accept: 'application/json',
|
accept: 'application/json',
|
||||||
authorization: `Bearer ${state.token}`,
|
authorization: `Bearer ${state.token}`,
|
||||||
@@ -406,18 +418,21 @@
|
|||||||
referrerPolicy: 'no-referrer',
|
referrerPolicy: 'no-referrer',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
assertCurrentSession(session);
|
||||||
throw new ConsoleRequestError('request_unavailable', 503, null);
|
throw new ConsoleRequestError('request_unavailable', 503, null);
|
||||||
}
|
}
|
||||||
let value;
|
let value;
|
||||||
try {
|
try {
|
||||||
value = await response.json();
|
value = await response.json();
|
||||||
} catch {
|
} catch {
|
||||||
|
assertCurrentSession(session);
|
||||||
throw new ConsoleRequestError(
|
throw new ConsoleRequestError(
|
||||||
'response_unavailable',
|
'response_unavailable',
|
||||||
response.status,
|
response.status,
|
||||||
response.headers.get('x-request-id'),
|
response.headers.get('x-request-id'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assertCurrentSession(session);
|
||||||
if (!response.ok && response.status !== options.acceptStatus) {
|
if (!response.ok && response.status !== options.acceptStatus) {
|
||||||
throw new ConsoleRequestError(
|
throw new ConsoleRequestError(
|
||||||
typeof value.code === 'string' ? value.code : 'request_unavailable',
|
typeof value.code === 'string' ? value.code : 'request_unavailable',
|
||||||
@@ -776,9 +791,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveTriggerDraft() {
|
async function saveTriggerDraft() {
|
||||||
|
const session = state.session;
|
||||||
nodes.triggerEditorSave.disabled = true;
|
nodes.triggerEditorSave.disabled = true;
|
||||||
try {
|
try {
|
||||||
const mutation = await triggerDraft();
|
const mutation = await triggerDraft();
|
||||||
|
assertCurrentSession(session);
|
||||||
const value = await api(
|
const value = await api(
|
||||||
`/api/v3/projects/${state.project}/triggers/${mutation.triggerId}`,
|
`/api/v3/projects/${state.project}/triggers/${mutation.triggerId}`,
|
||||||
{
|
{
|
||||||
@@ -787,15 +804,16 @@
|
|||||||
acceptStatus: 428,
|
acceptStatus: 428,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
assertCurrentSession(session);
|
||||||
if (value.code === 'local_presence_required') {
|
if (value.code === 'local_presence_required') {
|
||||||
showPresenceChallenge({ kind: 'trigger-mutation', mutation }, value);
|
showPresenceChallenge({ kind: 'trigger-mutation', mutation }, value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new ConsoleRequestError('response_unavailable', 503, null);
|
throw new ConsoleRequestError('response_unavailable', 503, null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(describeError(error), 'error');
|
if (isCurrentSession(session)) showToast(describeError(error), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
nodes.triggerEditorSave.disabled = false;
|
if (isCurrentSession(session)) nodes.triggerEditorSave.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,9 +847,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadSecretCatalog() {
|
async function loadSecretCatalog() {
|
||||||
|
const session = state.session;
|
||||||
const value = await api(
|
const value = await api(
|
||||||
`/api/v3/projects/${state.project}/secrets?limit=64`,
|
`/api/v3/projects/${state.project}/secrets?limit=64`,
|
||||||
);
|
);
|
||||||
|
assertCurrentSession(session);
|
||||||
state.secretCatalog = normalizeSecretMetadata(value);
|
state.secretCatalog = normalizeSecretMetadata(value);
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
secrets: state.secretCatalog,
|
secrets: state.secretCatalog,
|
||||||
@@ -886,6 +906,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveSecretDraft() {
|
async function saveSecretDraft() {
|
||||||
|
const session = state.session;
|
||||||
let body;
|
let body;
|
||||||
try {
|
try {
|
||||||
body = secretDraft();
|
body = secretDraft();
|
||||||
@@ -903,6 +924,7 @@
|
|||||||
body,
|
body,
|
||||||
acceptStatus: 428,
|
acceptStatus: 428,
|
||||||
});
|
});
|
||||||
|
assertCurrentSession(session);
|
||||||
if (value.code === 'local_presence_required') {
|
if (value.code === 'local_presence_required') {
|
||||||
showPresenceChallenge({ kind: 'secret-mutation', body }, value);
|
showPresenceChallenge({ kind: 'secret-mutation', body }, value);
|
||||||
return;
|
return;
|
||||||
@@ -910,9 +932,9 @@
|
|||||||
throw new ConsoleRequestError('response_unavailable', 503, null);
|
throw new ConsoleRequestError('response_unavailable', 503, null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
body = null;
|
body = null;
|
||||||
showToast(describeError(error), 'error');
|
if (isCurrentSession(session)) showToast(describeError(error), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
nodes.secretEditorSave.disabled = false;
|
if (isCurrentSession(session)) nodes.secretEditorSave.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1051,6 +1073,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveTaskDraft() {
|
async function saveTaskDraft() {
|
||||||
|
const session = state.session;
|
||||||
let mutation;
|
let mutation;
|
||||||
try {
|
try {
|
||||||
mutation = taskDraft();
|
mutation = taskDraft();
|
||||||
@@ -1074,24 +1097,27 @@
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
assertCurrentSession(session);
|
||||||
if (value.code === 'local_presence_required') {
|
if (value.code === 'local_presence_required') {
|
||||||
showPresenceChallenge({ kind: 'mutation', mutation }, value);
|
showPresenceChallenge({ kind: 'mutation', mutation }, value);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw new ConsoleRequestError('response_unavailable', 503, null);
|
throw new ConsoleRequestError('response_unavailable', 503, null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(describeError(error), 'error');
|
if (isCurrentSession(session)) showToast(describeError(error), 'error');
|
||||||
} finally {
|
} finally {
|
||||||
nodes.taskEditorSave.disabled = false;
|
if (isCurrentSession(session)) nodes.taskEditorSave.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function beginTaskAuthoring(task) {
|
async function beginTaskAuthoring(task) {
|
||||||
|
const session = state.session;
|
||||||
try {
|
try {
|
||||||
const value = await api(
|
const value = await api(
|
||||||
`/api/v3/projects/${state.project}/tasks/${task.taskId}/authoring`,
|
`/api/v3/projects/${state.project}/tasks/${task.taskId}/authoring`,
|
||||||
{ method: 'POST', acceptStatus: 428 },
|
{ method: 'POST', acceptStatus: 428 },
|
||||||
);
|
);
|
||||||
|
assertCurrentSession(session);
|
||||||
if (value.code === 'local_presence_required') {
|
if (value.code === 'local_presence_required') {
|
||||||
showPresenceChallenge(
|
showPresenceChallenge(
|
||||||
{ kind: 'authoring', taskId: task.taskId },
|
{ kind: 'authoring', taskId: task.taskId },
|
||||||
@@ -1101,11 +1127,12 @@
|
|||||||
}
|
}
|
||||||
throw new ConsoleRequestError('response_unavailable', 503, null);
|
throw new ConsoleRequestError('response_unavailable', 503, null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(describeError(error), 'error');
|
if (isCurrentSession(session)) showToast(describeError(error), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function completeTaskMutation() {
|
async function completeTaskMutation() {
|
||||||
|
const session = state.session;
|
||||||
const pending = state.pendingPresence;
|
const pending = state.pendingPresence;
|
||||||
const proof = nodes.presenceProof.value.trim();
|
const proof = nodes.presenceProof.value.trim();
|
||||||
if (!pending || !PRESENCE_PATTERN.test(proof)) {
|
if (!pending || !PRESENCE_PATTERN.test(proof)) {
|
||||||
@@ -1123,6 +1150,7 @@
|
|||||||
`/api/v3/projects/${state.project}/tasks/${pending.taskId}/authoring`,
|
`/api/v3/projects/${state.project}/tasks/${pending.taskId}/authoring`,
|
||||||
{ method: 'POST', presence: proof },
|
{ method: 'POST', presence: proof },
|
||||||
);
|
);
|
||||||
|
assertCurrentSession(session);
|
||||||
const snapshot = authoringSnapshot(value, pending.taskId);
|
const snapshot = authoringSnapshot(value, pending.taskId);
|
||||||
state.pendingPresence = null;
|
state.pendingPresence = null;
|
||||||
nodes.presenceProof.value = '';
|
nodes.presenceProof.value = '';
|
||||||
@@ -1130,12 +1158,14 @@
|
|||||||
try {
|
try {
|
||||||
await loadSecretCatalog();
|
await loadSecretCatalog();
|
||||||
} catch {
|
} catch {
|
||||||
|
if (!isCurrentSession(session)) return;
|
||||||
state.secretCatalog = [];
|
state.secretCatalog = [];
|
||||||
showToast(
|
showToast(
|
||||||
'Task 已加载,但 Secret 目录暂不可用;已有绑定仍会保留。',
|
'Task 已加载,但 Secret 目录暂不可用;已有绑定仍会保留。',
|
||||||
'error',
|
'error',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
assertCurrentSession(session);
|
||||||
openTaskEditor(snapshot);
|
openTaskEditor(snapshot);
|
||||||
showToast('完整 Task 定义已加载;保存仍需要新的本机证明。');
|
showToast('完整 Task 定义已加载;保存仍需要新的本机证明。');
|
||||||
return;
|
return;
|
||||||
@@ -1149,6 +1179,7 @@
|
|||||||
presence: proof,
|
presence: proof,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
assertCurrentSession(session);
|
||||||
const updated = pending.mutation.body.expectedRevision !== null;
|
const updated = pending.mutation.body.expectedRevision !== null;
|
||||||
state.pendingPresence = null;
|
state.pendingPresence = null;
|
||||||
state.triggerSnapshot = null;
|
state.triggerSnapshot = null;
|
||||||
@@ -1165,6 +1196,7 @@
|
|||||||
state.selectedId = pending.mutation.triggerId;
|
state.selectedId = pending.mutation.triggerId;
|
||||||
updateNavigation();
|
updateNavigation();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
assertCurrentSession(session);
|
||||||
await selectTrigger(pending.mutation.triggerId);
|
await selectTrigger(pending.mutation.triggerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1174,6 +1206,7 @@
|
|||||||
body: pending.body,
|
body: pending.body,
|
||||||
presence: proof,
|
presence: proof,
|
||||||
});
|
});
|
||||||
|
assertCurrentSession(session);
|
||||||
const rotated = pending.body.expectedCurrentVersion > 0;
|
const rotated = pending.body.expectedCurrentVersion > 0;
|
||||||
state.pendingPresence = null;
|
state.pendingPresence = null;
|
||||||
state.secretSnapshot = null;
|
state.secretSnapshot = null;
|
||||||
@@ -1191,6 +1224,7 @@
|
|||||||
state.selectedId = pending.body.name;
|
state.selectedId = pending.body.name;
|
||||||
updateNavigation();
|
updateNavigation();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
assertCurrentSession(session);
|
||||||
selectSecret(pending.body.name);
|
selectSecret(pending.body.name);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1205,6 +1239,7 @@
|
|||||||
: {}),
|
: {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
assertCurrentSession(session);
|
||||||
const updated = pending.mutation.body.expectedRevision !== null;
|
const updated = pending.mutation.body.expectedRevision !== null;
|
||||||
state.pendingPresence = null;
|
state.pendingPresence = null;
|
||||||
state.authoringSnapshot = null;
|
state.authoringSnapshot = null;
|
||||||
@@ -1219,13 +1254,16 @@
|
|||||||
);
|
);
|
||||||
state.selectedId = pending.mutation.taskId;
|
state.selectedId = pending.mutation.taskId;
|
||||||
await refresh();
|
await refresh();
|
||||||
|
assertCurrentSession(session);
|
||||||
await selectTask(pending.mutation.taskId);
|
await selectTask(pending.mutation.taskId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
nodes.presenceError.textContent = describeError(error);
|
if (isCurrentSession(session)) {
|
||||||
nodes.presenceError.hidden = false;
|
nodes.presenceError.textContent = describeError(error);
|
||||||
nodes.presenceProof.select();
|
nodes.presenceError.hidden = false;
|
||||||
|
nodes.presenceProof.select();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
nodes.presenceSubmit.disabled = false;
|
if (isCurrentSession(session)) nodes.presenceSubmit.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1455,6 +1493,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startTask(task) {
|
async function startTask(task) {
|
||||||
|
const session = state.session;
|
||||||
try {
|
try {
|
||||||
const value = await api(
|
const value = await api(
|
||||||
`/api/v3/projects/${state.project}/tasks/${task.taskId}/runs`,
|
`/api/v3/projects/${state.project}/tasks/${task.taskId}/runs`,
|
||||||
@@ -1468,6 +1507,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
assertCurrentSession(session);
|
||||||
showToast(
|
showToast(
|
||||||
value.status === 'existing'
|
value.status === 'existing'
|
||||||
? '已找到同一启动请求。'
|
? '已找到同一启动请求。'
|
||||||
@@ -1477,9 +1517,10 @@
|
|||||||
state.selectedId = value.runId;
|
state.selectedId = value.runId;
|
||||||
updateNavigation();
|
updateNavigation();
|
||||||
await refresh();
|
await refresh();
|
||||||
|
assertCurrentSession(session);
|
||||||
await selectRun(value.runId);
|
await selectRun(value.runId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(describeError(error), 'error');
|
if (isCurrentSession(session)) showToast(describeError(error), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1602,11 +1643,12 @@
|
|||||||
}
|
}
|
||||||
actions.append(
|
actions.append(
|
||||||
actionButton('查看绑定任务', async () => {
|
actionButton('查看绑定任务', async () => {
|
||||||
|
const session = state.session;
|
||||||
state.view = 'tasks';
|
state.view = 'tasks';
|
||||||
state.selectedId = trigger.taskId;
|
state.selectedId = trigger.taskId;
|
||||||
updateNavigation();
|
updateNavigation();
|
||||||
await refresh();
|
await refresh();
|
||||||
await selectTask(trigger.taskId);
|
if (isCurrentSession(session)) await selectTask(trigger.taskId);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fragment.append(actions);
|
fragment.append(actions);
|
||||||
@@ -1921,6 +1963,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cancelRun(run) {
|
async function cancelRun(run) {
|
||||||
|
const session = state.session;
|
||||||
try {
|
try {
|
||||||
const value = await api(
|
const value = await api(
|
||||||
`/api/v3/projects/${state.project}/runs/${run.id}/cancellation`,
|
`/api/v3/projects/${state.project}/runs/${run.id}/cancellation`,
|
||||||
@@ -1932,6 +1975,7 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
assertCurrentSession(session);
|
||||||
showToast(
|
showToast(
|
||||||
value.status === 'already_terminal'
|
value.status === 'already_terminal'
|
||||||
? '运行已经结束。'
|
? '运行已经结束。'
|
||||||
@@ -1940,9 +1984,10 @@
|
|||||||
: '取消请求已写入。',
|
: '取消请求已写入。',
|
||||||
);
|
);
|
||||||
await refresh();
|
await refresh();
|
||||||
|
assertCurrentSession(session);
|
||||||
await selectRun(run.id);
|
await selectRun(run.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(describeError(error), 'error');
|
if (isCurrentSession(session)) showToast(describeError(error), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2021,6 +2066,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function connect(token, project) {
|
function connect(token, project) {
|
||||||
|
state.session = {};
|
||||||
state.token = token;
|
state.token = token;
|
||||||
state.project = project;
|
state.project = project;
|
||||||
state.view = 'tasks';
|
state.view = 'tasks';
|
||||||
@@ -2039,6 +2085,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
|
state.session = {};
|
||||||
state.token = null;
|
state.token = null;
|
||||||
state.selectedId = null;
|
state.selectedId = null;
|
||||||
state.pendingAction = null;
|
state.pendingAction = null;
|
||||||
@@ -2047,6 +2094,10 @@
|
|||||||
state.triggerSnapshot = null;
|
state.triggerSnapshot = null;
|
||||||
state.secretSnapshot = null;
|
state.secretSnapshot = null;
|
||||||
state.secretCatalog = [];
|
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.taskEditor.open) nodes.taskEditor.close();
|
||||||
if (nodes.triggerEditor.open) nodes.triggerEditor.close();
|
if (nodes.triggerEditor.open) nodes.triggerEditor.close();
|
||||||
if (nodes.secretEditor.open) nodes.secretEditor.close();
|
if (nodes.secretEditor.open) nodes.secretEditor.close();
|
||||||
@@ -2098,11 +2149,12 @@
|
|||||||
nodes.disconnect.addEventListener('click', disconnect);
|
nodes.disconnect.addEventListener('click', disconnect);
|
||||||
nodes.refresh.addEventListener('click', refresh);
|
nodes.refresh.addEventListener('click', refresh);
|
||||||
nodes.createTask.addEventListener('click', async () => {
|
nodes.createTask.addEventListener('click', async () => {
|
||||||
|
const session = state.session;
|
||||||
try {
|
try {
|
||||||
await loadSecretCatalog();
|
await loadSecretCatalog();
|
||||||
openTaskEditor();
|
if (isCurrentSession(session)) openTaskEditor();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(describeError(error), 'error');
|
if (isCurrentSession(session)) showToast(describeError(error), 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
nodes.createTrigger.addEventListener('click', () => openTriggerEditor());
|
nodes.createTrigger.addEventListener('click', () => openTriggerEditor());
|
||||||
|
|||||||
@@ -1,114 +1,6 @@
|
|||||||
const assert = require('node:assert/strict');
|
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');
|
const { test } = require('node:test');
|
||||||
|
const { fixture } = require('./support/consoleClient.cjs');
|
||||||
// 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 cursor = (view, row) =>
|
const cursor = (view, row) =>
|
||||||
view === 'runs'
|
view === 'runs'
|
||||||
|
|||||||
@@ -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, /没有执行该操作的权限/);
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
Reference in New Issue
Block a user