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
189 lines
12 KiB
JavaScript
189 lines
12 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const http = require('node:http');
|
|
const { spawn } = require('node:child_process');
|
|
const { openOperations } = require('../../dist/remote/api/openOperations');
|
|
const entry = path.resolve(__dirname, '../../dist/npm/ql.js');
|
|
|
|
async function fixture(t) {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-openapi-'));
|
|
const requests = [];
|
|
let reply;
|
|
const server = http.createServer(async (req, res) => {
|
|
const chunks = [];
|
|
for await (const chunk of req) chunks.push(chunk);
|
|
const record = { method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString() };
|
|
requests.push(record);
|
|
if (reply) return reply(req, res);
|
|
if (/\/(download|data\/export|command-run)$/.test(req.url)) {
|
|
res.writeHead(200, { 'content-type': 'application/json', 'content-disposition': 'attachment; filename="fixture.json"' });
|
|
res.end('{"fixture":true}');
|
|
} else if (req.url === '/panel/open/system/log') {
|
|
res.writeHead(200, { 'content-type': 'text/plain' }); res.end('log content');
|
|
} else {
|
|
res.setHeader('content-type', 'application/json');
|
|
res.end(JSON.stringify({ code: 200, data: { id: 7, name: 'fixture', client_secret: 'SECRET-MARKER', tokens: [{ value: 'TOKEN-MARKER' }] } }));
|
|
}
|
|
});
|
|
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await fs.rm(root, { recursive: true, force: true }); });
|
|
const url = `http://127.0.0.1:${server.address().port}/panel`;
|
|
const run = (args, stdin = '', extra = {}) => new Promise((resolve, reject) => {
|
|
const child = spawn(process.execPath, [entry, ...args, '--json'], {
|
|
env: { PATH: process.env.PATH, QL_URL: url, QL_ACCESS_TOKEN: 'fixture-token', QL_LANG: 'en', ...extra },
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
});
|
|
let out = '', err = '';
|
|
const timer = setTimeout(() => { child.kill(); reject(new Error('CLI timeout')); }, 12000);
|
|
child.stdout.on('data', data => { out += data; }); child.stderr.on('data', data => { err += data; });
|
|
child.on('error', reject); child.on('close', code => { clearTimeout(timer); resolve({ code, out, err }); });
|
|
child.stdin.end(stdin);
|
|
});
|
|
return { root, requests, run, reply: value => { reply = value; } };
|
|
}
|
|
function success(result) { assert.equal(result.code, 0, result.err); return JSON.parse(result.out); }
|
|
|
|
test('dashboard record sends execution statistics and rejects a missing body before HTTP', async t => {
|
|
const f = await fixture(t);
|
|
const payload = { ref_id: 7, code: 0, elapsed: 1.5 };
|
|
success(await f.run(['dashboard', 'record', '--data', '-'], JSON.stringify(payload)));
|
|
assert.equal(f.requests.length, 1);
|
|
assert.equal(f.requests[0].method, 'POST');
|
|
assert.equal(f.requests[0].url, '/panel/open/dashboard/record');
|
|
assert.deepEqual(JSON.parse(f.requests[0].body), payload);
|
|
assert.equal((await f.run(['dashboard', 'record'])).code, 2);
|
|
assert.equal(f.requests.length, 1);
|
|
});
|
|
|
|
test('OpenAPI catalogue covers every active registered backend route; retired 410 routes are explicit', async () => {
|
|
const root = path.resolve(__dirname, '../../../back/api');
|
|
const expected = [];
|
|
const retired = new Set(['GET configs/:file', 'GET scripts/:file', 'GET logs/:file']);
|
|
const index = await fs.readFile(path.join(root, 'index.ts'), 'utf8');
|
|
for (const filename of await fs.readdir(root)) {
|
|
if (!filename.endsWith('.ts') || filename === 'index.ts') continue;
|
|
const source = await fs.readFile(path.join(root, filename), 'utf8');
|
|
assert.ok(index.includes(`'./${filename.slice(0, -3)}'`), `Unregistered API module ${filename}`);
|
|
const mount = /app\.use\(['"]([^'"]+)['"]/.exec(source);
|
|
assert.ok(mount, filename);
|
|
for (const m of source.matchAll(/route\.(get|post|put|delete|patch)\(\s*['"]([^'"]+)['"]/g)) {
|
|
const endpoint = [mount[1], m[2]].join('/').split('/').filter(Boolean).join('/');
|
|
const key = `${m[1].toUpperCase()} ${endpoint}`;
|
|
if (!retired.has(key)) expected.push(key);
|
|
else assert.match(source.slice(m.index, m.index + 200), /code: 410/);
|
|
}
|
|
}
|
|
const actual = openOperations.map(op => `${op.method} ${op.path}`);
|
|
assert.equal(new Set(actual).size, actual.length);
|
|
assert.equal(new Set(openOperations.map(op => op.name)).size, openOperations.length);
|
|
assert.deepEqual(actual.sort(), expected.sort());
|
|
});
|
|
|
|
test('task, subscription and app CRUD produce exact 2.x requests and protect app credentials', async t => {
|
|
const f = await fixture(t);
|
|
success(await f.run(['task', 'create', '--name', 'demo', '--command', 'task demo.js', '--schedule', '0 0 * * *']));
|
|
assert.deepEqual(JSON.parse(f.requests.at(-1).body), { name: 'demo', command: 'task demo.js', schedule: '0 0 * * *' });
|
|
assert.equal(f.requests.at(-1).method, 'POST'); assert.equal(f.requests.at(-1).url, '/panel/open/crons');
|
|
success(await f.run(['task', 'update', '7', '--data', '-', '--name', 'updated'], '{"command":"task demo.js","schedule":"0 1 * * *"}'));
|
|
assert.deepEqual(JSON.parse(f.requests.at(-1).body), { id: 7, name: 'updated', command: 'task demo.js', schedule: '0 1 * * *' });
|
|
success(await f.run(['task', 'delete', '7', '8'])); assert.deepEqual(JSON.parse(f.requests.at(-1).body), [7, 8]);
|
|
assert.equal(f.requests.at(-1).method, 'DELETE');
|
|
success(await f.run(['subscription', 'create', '--type', 'public-repo', '--url', 'https://example.com/repo.git', '--alias', 'demo', '--schedule-type', 'crontab', '--schedule', '0 0 * * *']));
|
|
assert.equal(f.requests.at(-1).url, '/panel/open/subscriptions');
|
|
assert.equal(JSON.parse(f.requests.at(-1).body).schedule_type, 'crontab');
|
|
const app = success(await f.run(['app', 'create', '--name', 'worker', '--scopes', 'crons,subscriptions']));
|
|
assert.deepEqual(JSON.parse(f.requests.at(-1).body), { name: 'worker', scopes: ['crons', 'subscriptions'] });
|
|
assert.equal(app.data.client_secret, undefined); assert.equal(app.data.tokens, undefined);
|
|
assert.equal(success(await f.run(['app', 'reset-secret', '7', '--show-secrets'])).data.client_secret, 'SECRET-MARKER');
|
|
assert.equal(f.requests.at(-1).url, '/panel/open/apps/7/reset-secret');
|
|
for (const req of f.requests) assert.equal(req.headers.authorization, 'Bearer fixture-token');
|
|
});
|
|
|
|
test('all added named operations reach their registered method/path, including upload/download and anonymous routes', async t => {
|
|
const f = await fixture(t);
|
|
const file = path.join(f.root, 'fixture.json'); await fs.writeFile(file, '{}');
|
|
for (const op of openOperations.filter(op => !op.existing)) {
|
|
const args = op.name.split(' ');
|
|
for (const _ of op.params ?? []) args.push('7');
|
|
if (op.body === 'ids') args.push('7', '8');
|
|
else if (op.body) {
|
|
const data = op.name.startsWith('task ') ? { command: 'echo fixture', schedule: '0 0 * * *' }
|
|
: op.name.startsWith('subscription ') ? { type: 'file', url: 'https://example.com/job.js', alias: 'job', schedule_type: 'crontab' } : {};
|
|
args.push('--data', JSON.stringify(data));
|
|
}
|
|
if (op.upload) args.push('--file', file);
|
|
if (op.download) args.push('--output', path.join(f.root, op.name.replace(' ', '-') + '.out'));
|
|
success(await f.run(args));
|
|
const last = f.requests.at(-1);
|
|
assert.equal(last.method, op.method, op.name);
|
|
assert.equal(last.url, '/panel/open/' + op.path.replace(/:[A-Za-z]+/g, '7'), op.name);
|
|
assert.equal(last.headers.authorization, op.anonymous ? undefined : 'Bearer fixture-token', op.name);
|
|
if (op.upload) assert.ok(last.body.includes(`name="${op.upload}"; filename="fixture.json"`), op.name);
|
|
}
|
|
});
|
|
|
|
test('raw route access retains query/body capabilities and rejects unsupported paths before authentication', async t => {
|
|
const f = await fixture(t);
|
|
success(await f.run(['api', 'request', 'GET', '/open/crons', '--query', '{"searchValue":"a & b","page":2}']));
|
|
assert.equal(f.requests.at(-1).url, '/panel/open/crons?searchValue=a+%26+b&page=2');
|
|
const bodyFile = path.join(f.root, 'env.json'); await fs.writeFile(bodyFile, '[{"name":"A","value":"B"}]');
|
|
success(await f.run(['env', 'create', '--data', '@' + bodyFile]));
|
|
assert.equal(f.requests.at(-1).body, '[{"name":"A","value":"B"}]');
|
|
const count = f.requests.length;
|
|
for (const args of [
|
|
['api', 'request', 'GET', 'https://example.com'], ['api', 'request', 'DELETE', 'auth/token'],
|
|
['api', 'request', 'GET', '../api/user'], ['api', 'request', 'GET', 'crons/0'], ['api', 'request', 'GET', 'crons/9007199254740993'],
|
|
['task', 'create', '--data', '{'], ['task', 'create', '--command', 'echo fixture'],
|
|
['task', 'update', '7', '--data', '{"id":8}'], ['task', 'delete', '-1'],
|
|
['env', 'list', '--query', '{"secret":{"value":1}}'],
|
|
['app', 'create', '--data', '{"name":"A"}', '--name', 'B'],
|
|
['log', 'download', '--data', '{}', '--output', bodyFile],
|
|
]) { const result = await f.run(args); assert.equal(result.code, 2, JSON.stringify(args) + result.err); assert.equal(result.out, ''); }
|
|
assert.equal(f.requests.length, count);
|
|
const output = path.join(f.root, 'download.json');
|
|
success(await f.run(['log', 'download', '--data', '{"filename":"fixture.json"}', '--output', output]));
|
|
assert.equal(await fs.readFile(output, 'utf8'), '{"fixture":true}');
|
|
assert.equal((await fs.stat(output)).mode & 0o777, 0o600);
|
|
});
|
|
|
|
test('POST/DELETE uncertainty and permission failures never retry or print server secrets', async t => {
|
|
const f = await fixture(t);
|
|
f.reply((_req, res) => { res.writeHead(503); res.end('SERVER-SECRET'); });
|
|
for (const args of [['task', 'create', '--command', 'echo fixture', '--schedule', '0 0 * * *'], ['task', 'delete', '7']]) {
|
|
const count = f.requests.length; const result = await f.run(args);
|
|
assert.equal(result.code, 1); assert.match(result.err, /unknown|retry/i);
|
|
assert.doesNotMatch(result.err, /SERVER-SECRET/); assert.equal(f.requests.length, count + 1);
|
|
}
|
|
f.reply((_req, res) => { res.writeHead(403); res.end('SERVER-SECRET'); });
|
|
const result = await f.run(['app', 'list']); assert.equal(result.code, 3); assert.match(result.err, /apps/);
|
|
assert.doesNotMatch(result.err, /SERVER-SECRET/);
|
|
});
|
|
|
|
test('failed downloads leave no partial files and timed-out mutations are not replayed', async t => {
|
|
const f = await fixture(t);
|
|
const output = path.join(f.root, 'partial.log');
|
|
f.reply((_req, res) => {
|
|
res.writeHead(200, { 'content-type': 'application/octet-stream' }); res.write('partial');
|
|
setTimeout(() => res.destroy(), 50);
|
|
});
|
|
const failed = await f.run(['log', 'download', '--data', '{"filename":"fixture.log"}', '--output', output]);
|
|
assert.equal(failed.code, 1); assert.equal(failed.out, '');
|
|
await assert.rejects(fs.stat(output), { code: 'ENOENT' });
|
|
f.reply(() => {});
|
|
const count = f.requests.length;
|
|
const timeout = await f.run(['app', 'create', '--name', 'fixture', '--scopes', 'crons', '--timeout', '1']);
|
|
assert.equal(timeout.code, 1); assert.match(timeout.err, /unknown/);
|
|
assert.equal(f.requests.length, count + 1);
|
|
});
|
|
|
|
test('owner two-factor challenge remains actionable without exposing server messages', async t => {
|
|
const f = await fixture(t);
|
|
f.reply((_req, res) => { res.setHeader('content-type', 'application/json'); res.end('{"code":420,"message":"SERVER-SECRET"}'); });
|
|
const result = await f.run(['user', 'login', '--data', '{"username":"fixture","password":"fixture"}']);
|
|
assert.equal(result.code, 3); assert.match(result.err, /two-factor-login/); assert.doesNotMatch(result.err, /SERVER-SECRET/);
|
|
assert.equal(f.requests.length, 1); assert.equal(f.requests[0].headers.authorization, undefined);
|
|
});
|