mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
* feat(cli): add unified Commander CLI for QingLong 2.x * fix(cli): publish via npm and address security review feedback * ci(cli): package npm artifacts and remove evaluation collateral * test(cli): use a fixed shell fixture for log retention * refactor(cli): separate remote npm client from panel tools * feat(cli): cover active panel OpenAPI resources * docs(cli): unify authentication and skill guidance * refactor(cli): isolate internal commands and generate Commander help * refactor(cli): organize remote and internal modules by responsibility * ci(cli): publish verified npm archives from master * fix(cli): publish under the whyour npm scope * ci: use npm trusted publishing for both packages * docs: introduce the published CLI on the project homepage * fix(cli): preserve server log truncation and correct login hints * fix(cli): accept dashboard record request bodies * fix(cli): preserve stdin for local task execution * fix(cli): resolve task executables after changing directory * fix(cli): preserve shell function tasks and sanitize test failures * fix(cli): preserve shell hook state and resolve workdir after hooks * fix(cli): preserve cleanup across shared shell task timeouts * fix(cli): isolate shell control descriptors and reap timed-out descendants
371 lines
12 KiB
JavaScript
371 lines
12 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const http = require('node:http');
|
|
const { spawn } = require('node:child_process');
|
|
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
|
|
|
|
async function fixture(t) {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-ts-cli-'));
|
|
const file = path.join(dir, 'config.json');
|
|
const requests = [];
|
|
const state = { mode: 'ok', log: 'one\r\ntwo\r\nthree\r\n' };
|
|
const server = http.createServer(async (req, res) => {
|
|
let body = '';
|
|
for await (const chunk of req) body += chunk;
|
|
const url = new URL(req.url, 'http://localhost');
|
|
requests.push({
|
|
url,
|
|
method: req.method,
|
|
body,
|
|
authorization: req.headers.authorization,
|
|
});
|
|
if (state.mode === 'disconnect') {
|
|
req.socket.destroy();
|
|
return;
|
|
}
|
|
if (state.mode === 'redirect') {
|
|
res.writeHead(302, { Location: '/elsewhere' });
|
|
res.end();
|
|
return;
|
|
}
|
|
if (state.mode === 'html401') {
|
|
res.writeHead(401);
|
|
res.end('secret-body');
|
|
return;
|
|
}
|
|
if (state.mode === 'invalid') {
|
|
res.end('invalid-secret-body');
|
|
return;
|
|
}
|
|
res.setHeader('content-type', 'application/json');
|
|
const json = (data) => res.end(JSON.stringify(data));
|
|
if (state.mode === 'denied') {
|
|
res.writeHead(403);
|
|
json({ code: 403, message: 'test-secret' });
|
|
return;
|
|
}
|
|
if (state.mode === 'apiError') {
|
|
json({ code: 400, message: 'test-secret' });
|
|
return;
|
|
}
|
|
if (state.mode === 'badData') {
|
|
json({ code: 200, data: null });
|
|
return;
|
|
}
|
|
if (state.mode === 'expiredAuth') {
|
|
json({ code: 200, data: { token: 'expired', expiration: 1 } });
|
|
return;
|
|
}
|
|
if (url.pathname === '/panel/open/auth/token') {
|
|
assert.equal(url.searchParams.get('client_id'), 'test-id');
|
|
assert.equal(url.searchParams.get('client_secret'), 'test-secret');
|
|
assert.equal(req.headers.authorization, undefined);
|
|
json({
|
|
code: 200,
|
|
data: {
|
|
token: 'test-token',
|
|
expiration: Math.floor(Date.now() / 1000) + 3600,
|
|
},
|
|
});
|
|
} else {
|
|
assert.equal(req.headers.authorization, 'Bearer test-token');
|
|
if (url.pathname === '/panel/open/crons/12/log')
|
|
json({ code: 200, data: state.log, logStatus: 'completed' });
|
|
else if (url.pathname === '/panel/open/crons/12')
|
|
json({ code: 200, data: { id: 12, name: 'example', status: 1 } });
|
|
else if (url.pathname === '/panel/open/crons')
|
|
json({
|
|
code: 200,
|
|
data: { data: [{ id: 12, name: 'example' }], total: 1 },
|
|
});
|
|
else if (
|
|
['/panel/open/crons/run', '/panel/open/crons/stop'].includes(
|
|
url.pathname,
|
|
)
|
|
) {
|
|
assert.equal(req.method, 'PUT');
|
|
assert.equal(body, '[12]');
|
|
json({ code: 200 });
|
|
} else {
|
|
res.writeHead(404);
|
|
json({ code: 404 });
|
|
}
|
|
}
|
|
});
|
|
await new Promise((resolve, reject) => {
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', resolve);
|
|
});
|
|
t.after(() => {
|
|
server.closeAllConnections();
|
|
server.close();
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
const url = `http://127.0.0.1:${server.address().port}/panel`;
|
|
const invoke = (args, env = {}, executable = entry) =>
|
|
new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, [executable, ...args], {
|
|
env: {
|
|
...process.env,
|
|
QL_DIR: '/nonexistent',
|
|
QL_CLI_CONFIG: file,
|
|
QL_CLIENT_ID: 'test-id',
|
|
QL_CLIENT_SECRET: 'test-secret',
|
|
...env,
|
|
},
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
let out = '',
|
|
err = '';
|
|
child.stdout.on('data', (data) => (out += data));
|
|
child.stderr.on('data', (data) => (err += data));
|
|
child.once('error', reject);
|
|
child.once('close', (code) => resolve({ code, out, err }));
|
|
});
|
|
const login = async (prefix = ['login']) => {
|
|
const result = await invoke([...prefix, '--url', url, '--json']);
|
|
assert.equal(result.code, 0, result.err);
|
|
assert.deepEqual(JSON.parse(result.out), {
|
|
code: 200,
|
|
data: { authenticated: true, url },
|
|
});
|
|
assert.doesNotMatch(result.out + result.err, /test-secret|test-token/);
|
|
return result;
|
|
};
|
|
return { dir, file, requests, state, url, invoke, login };
|
|
}
|
|
|
|
function failure(result, code) {
|
|
assert.equal(result.code, code, result.err);
|
|
assert.equal(result.out, '');
|
|
assert.equal(JSON.parse(result.err).code, code);
|
|
assert.doesNotMatch(result.err, /test-secret|test-token|secret-body/);
|
|
}
|
|
|
|
test('compiled entry: login aliases, private storage, status, refresh and local logout', async (t) => {
|
|
const f = await fixture(t);
|
|
failure(await f.invoke(['task', 'list', '--json']), 3);
|
|
await f.login();
|
|
assert.equal(fs.statSync(f.file).mode & 0o777, 0o600);
|
|
const status = await f.invoke(['auth', 'status', '--json']);
|
|
assert.equal(status.code, 0, status.err);
|
|
assert.equal(JSON.parse(status.out).data.scopeChecked, 'crons');
|
|
assert.doesNotMatch(status.out, /test-secret|test-token/);
|
|
assert.equal(f.requests.at(-1).url.pathname, '/panel/open/crons');
|
|
const config = JSON.parse(fs.readFileSync(f.file));
|
|
config.expiration = 1;
|
|
fs.writeFileSync(f.file, JSON.stringify(config));
|
|
const get = await f.invoke(['task', 'get', '12', '--json']);
|
|
assert.equal(get.code, 0, get.err);
|
|
assert.equal(JSON.parse(get.out).data.id, 12);
|
|
assert.equal(f.requests.at(-2).url.pathname, '/panel/open/auth/token');
|
|
await f.login(['auth', 'login']);
|
|
const count = f.requests.length;
|
|
const logout = await f.invoke(['auth', 'logout', '--json']);
|
|
assert.equal(logout.code, 0, logout.err);
|
|
assert.equal(JSON.parse(logout.out).data.localOnly, true);
|
|
assert.equal(f.requests.length, count);
|
|
assert.equal(fs.existsSync(f.file), false);
|
|
assert.equal((await f.invoke(['auth', 'logout', '--json'])).code, 0);
|
|
});
|
|
|
|
test('list pagination, exact task operations and log tail preserve the 2.x API contract', async (t) => {
|
|
const f = await fixture(t);
|
|
await f.login();
|
|
const list = await f.invoke([
|
|
'task',
|
|
'list',
|
|
'--search',
|
|
'任务 & a',
|
|
'--page',
|
|
'2',
|
|
'--size',
|
|
'10',
|
|
'--json',
|
|
]);
|
|
assert.equal(list.code, 0, list.err);
|
|
assert.deepEqual(JSON.parse(list.out).data, {
|
|
data: [{ id: 12, name: 'example' }],
|
|
total: 1,
|
|
});
|
|
assert.equal(
|
|
f.requests.at(-1).url.searchParams.get('searchValue'),
|
|
'任务 & a',
|
|
);
|
|
assert.equal(f.requests.at(-1).url.searchParams.get('page'), '2');
|
|
assert.equal(f.requests.at(-1).url.searchParams.get('size'), '10');
|
|
const logs = await f.invoke(['task', 'logs', '12', '--tail', '2', '--json']);
|
|
assert.deepEqual(JSON.parse(logs.out), {
|
|
code: 200,
|
|
data: 'two\nthree',
|
|
logStatus: 'completed',
|
|
truncated: true,
|
|
});
|
|
f.state.log = '';
|
|
const empty = await f.invoke(['task', 'logs', '12', '--json']);
|
|
assert.deepEqual(JSON.parse(empty.out), {
|
|
code: 200,
|
|
data: '',
|
|
logStatus: 'completed',
|
|
truncated: false,
|
|
});
|
|
for (const action of ['run', 'stop']) {
|
|
const result = await f.invoke(['task', action, '12', '--json']);
|
|
assert.equal(result.code, 0, result.err);
|
|
assert.deepEqual(JSON.parse(result.out).data, {
|
|
taskId: 12,
|
|
action,
|
|
accepted: true,
|
|
});
|
|
assert.equal(f.requests.at(-1).url.pathname, `/panel/open/crons/${action}`);
|
|
assert.equal(f.requests.at(-1).body, '[12]');
|
|
}
|
|
});
|
|
|
|
test('invalid input is rejected before authentication or HTTP requests', async (t) => {
|
|
const f = await fixture(t);
|
|
for (const args of [
|
|
['task', 'run', '12;echo'],
|
|
['task', 'get', '-1'],
|
|
['task', 'run', '9007199254740992'],
|
|
['task', 'list', '--size', '201'],
|
|
['task', 'logs', '12', '--tail', '0'],
|
|
['task', 'list', '--page'],
|
|
['task', 'list', '--page', '1', '--page', '2'],
|
|
['task', 'stop', '12', '--unknown'],
|
|
['task', 'run'],
|
|
['task', 'run', '12', '13'],
|
|
['unknown'],
|
|
['login'],
|
|
['login', '--url', 'http://example.com'],
|
|
['login', '--url', 'https://user:password@example.com'],
|
|
['login', '--url', 'https://example.com?secret=value'],
|
|
['login', '--url', 'file:///tmp/config'],
|
|
])
|
|
failure(await f.invoke([...args, '--json']), 2);
|
|
failure(
|
|
await f.invoke(['login', '--url', f.url, '--json'], {
|
|
QL_CLIENT_ID: '',
|
|
QL_CLIENT_SECRET: '',
|
|
}),
|
|
2,
|
|
);
|
|
assert.equal(f.requests.length, 0);
|
|
});
|
|
|
|
test('authentication failures, proxy errors and uncertain mutations never replay requests or leak secrets', async (t) => {
|
|
const f = await fixture(t);
|
|
await f.login();
|
|
for (const [mode, code] of [
|
|
['denied', 3],
|
|
['html401', 3],
|
|
['apiError', 1],
|
|
['invalid', 1],
|
|
['disconnect', 1],
|
|
['redirect', 1],
|
|
]) {
|
|
f.state.mode = mode;
|
|
const count = f.requests.length;
|
|
const result = await f.invoke(['task', 'run', '12', '--json']);
|
|
failure(result, code);
|
|
assert.equal(f.requests.length, count + 1, mode);
|
|
if (['invalid', 'disconnect', 'redirect'].includes(mode))
|
|
assert.match(result.err, /outcome is unknown|执行结果未知/);
|
|
}
|
|
const prior = fs.readFileSync(f.file, 'utf8');
|
|
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
|
|
assert.equal(fs.readFileSync(f.file, 'utf8'), prior);
|
|
});
|
|
|
|
test('malformed successful responses are rejected, including expired authentication', async (t) => {
|
|
const f = await fixture(t);
|
|
await f.login();
|
|
f.state.mode = 'badData';
|
|
for (const args of [
|
|
['task', 'list'],
|
|
['task', 'get', '12'],
|
|
['task', 'logs', '12'],
|
|
['login', '--url', f.url],
|
|
]) {
|
|
failure(await f.invoke([...args, '--json']), 1);
|
|
}
|
|
f.state.mode = 'expiredAuth';
|
|
failure(await f.invoke(['login', '--url', f.url, '--json']), 1);
|
|
});
|
|
|
|
test('config permissions, malformed content and symlinks fail closed', async (t) => {
|
|
const f = await fixture(t);
|
|
await f.login();
|
|
fs.chmodSync(f.file, 0o644);
|
|
failure(await f.invoke(['task', 'list', '--json']), 1);
|
|
fs.chmodSync(f.file, 0o600);
|
|
fs.writeFileSync(f.file, '{broken');
|
|
failure(await f.invoke(['task', 'list', '--json']), 1);
|
|
fs.writeFileSync(
|
|
f.file,
|
|
JSON.stringify({ url: f.url, clientId: 123, clientSecret: 'test-secret' }),
|
|
);
|
|
failure(await f.invoke(['task', 'list', '--json']), 1);
|
|
const target = path.join(f.dir, 'target');
|
|
fs.renameSync(f.file, target);
|
|
fs.symlinkSync(target, f.file);
|
|
failure(await f.invoke(['task', 'list', '--json']), 1);
|
|
await f.login();
|
|
assert.equal(fs.lstatSync(f.file).isSymbolicLink(), false);
|
|
assert.equal(JSON.parse(fs.readFileSync(target)).clientId, 123);
|
|
});
|
|
|
|
test('standalone compiled artifact and symlink run without repo runtime dependencies', async (t) => {
|
|
const f = await fixture(t);
|
|
const dist = path.join(f.dir, 'standalone');
|
|
fs.cpSync(path.dirname(entry), dist, { recursive: true });
|
|
const link = path.join(f.dir, 'ql');
|
|
fs.symlinkSync(path.join(dist, 'ql.js'), link);
|
|
const help = await f.invoke(['--help', '--json'], {}, link);
|
|
assert.equal(help.code, 0, help.err);
|
|
assert.match(JSON.parse(help.out).data.help, /QingLong 2.x/);
|
|
const login = await f.invoke(['login', '--url', f.url, '--json'], {}, link);
|
|
assert.equal(login.code, 0, login.err);
|
|
const list = await f.invoke(['task', 'list', '--json'], {}, link);
|
|
assert.equal(list.code, 0, list.err);
|
|
assert.equal(JSON.parse(list.out).data.total, 1);
|
|
});
|
|
|
|
for (const language of ['zh', 'en', 'unsupported']) {
|
|
test(`resource response errors are localized through the compiled public CLI: ${language}`, async (t) => {
|
|
const f = await fixture(t);
|
|
await f.login();
|
|
f.state.mode = 'badData';
|
|
for (const [args, chinese, english] of [
|
|
[['task', 'list'], /任务列表响应无效/, /Invalid task list response/],
|
|
[['task', 'get', '12'], /任务响应无效/, /Invalid task response/],
|
|
[['task', 'logs', '12'], /日志响应无效/, /Invalid log response/],
|
|
[
|
|
['subscription', 'list'],
|
|
/订阅列表响应无效/,
|
|
/Invalid subscription list response/,
|
|
],
|
|
[
|
|
['subscription', 'get', '12'],
|
|
/订阅响应无效/,
|
|
/Invalid subscription response/,
|
|
],
|
|
[
|
|
['subscription', 'logs', '12'],
|
|
/订阅日志响应无效/,
|
|
/Invalid subscription log response/,
|
|
],
|
|
]) {
|
|
const result = await f.invoke([...args, '--json'], { QL_LANG: language });
|
|
failure(result, 1);
|
|
assert.match(
|
|
JSON.parse(result.err).message,
|
|
language === 'en' ? english : chinese,
|
|
);
|
|
}
|
|
});
|
|
}
|