mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 03:18:09 +08:00
fix(ql3): isolate native console asynchronous sessions
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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