mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:00:24 +08:00
290 lines
10 KiB
JavaScript
290 lines
10 KiB
JavaScript
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, /没有执行该操作的权限/);
|
|
});
|