Files
qinglong/cli/test/remote/openapi.test.cjs
T
whyour 801a71d740 feat(cli): cover OpenAPI with a remote npm CLI and internal panel tools (#3074)
* 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
2026-09-25 23:24:41 +08:00

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);
});