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
This commit is contained in:
whyour
2026-09-25 23:24:41 +08:00
committed by GitHub
parent f051135fc4
commit 801a71d740
185 changed files with 22412 additions and 6 deletions
+91
View File
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { request, authenticate } = require('../../dist/remote/api/client');
test('API diagnostics preserve resource scope, uncertainty, codes and secret suppression in both languages', async (t) => {
const previous = process.env.QL_LANG;
t.after(() => {
if (previous === undefined) delete process.env.QL_LANG;
else process.env.QL_LANG = previous;
});
let response;
let calls = 0;
t.mock.method(global, 'fetch', async () => {
calls++;
if (response instanceof Error) throw response;
return response;
});
const config = {
url: 'https://fixture.invalid',
clientId: 'secret-id',
clientSecret: 'secret-value',
token: 'secret-token',
};
for (const language of ['zh', 'en']) {
process.env.QL_LANG = language;
for (const [endpoint, resource] of [
['crons/run', language === 'en' ? 'task' : '任务'],
['subscriptions/run', language === 'en' ? 'subscription' : '订阅'],
]) {
for (const failure of ['permission', 'unavailable', 'network', 'api']) {
response =
failure === 'permission'
? new Response('secret-value', { status: 403 })
: failure === 'unavailable'
? new Response('secret-value', { status: 503 })
: failure === 'network'
? new Error('secret-value')
: new Response(
JSON.stringify({ code: 401, message: 'secret-value' }),
);
const before = calls;
await assert.rejects(
request(config, endpoint, { method: 'PUT', body: [1] }),
(error) => {
assert.equal(
error.exitCode,
['permission', 'api'].includes(failure) ? 3 : 1,
);
assert.doesNotMatch(error.message, /secret-|fixture\.invalid/);
if (failure === 'unavailable' || failure === 'network') {
assert.ok(error.message.includes(resource));
assert.match(
error.message,
language === 'en' ? /outcome.*unknown/ : /结果.*未知/,
);
}
if (failure !== 'network') {
assert.ok(
error.message.includes(
endpoint.startsWith('subscriptions')
? 'subscriptions'
: 'crons',
),
);
assert.match(
error.message,
language === 'en' ? /permission/ : /权限/,
);
}
return true;
},
);
assert.equal(
calls - before,
1,
'diagnostics must not replay the mutation',
);
}
}
response = new Response(
JSON.stringify({
code: 200,
data: { token: 'secret-token', expiration: 0 },
}),
);
await assert.rejects(
authenticate(config),
language === 'en' ? /Invalid authentication response/ : /认证响应无效/,
);
}
});
+370
View File
@@ -0,0 +1,370 @@
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,
);
}
});
}
+44
View File
@@ -0,0 +1,44 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { task } = require('../../dist/remote/commands/task');
const { subscription } = require('../../dist/remote/commands/subscription');
test('task and subscription logs preserve server truncation as well as local tail limits', async (t) => {
const previous = { url: process.env.QL_URL, token: process.env.QL_ACCESS_TOKEN };
process.env.QL_URL = 'http://127.0.0.1';
process.env.QL_ACCESS_TOKEN = 'fixture';
t.after(() => {
for (const [key, value] of [['QL_URL', previous.url], ['QL_ACCESS_TOKEN', previous.token]]) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
let response;
t.mock.method(global, 'fetch', async () => new Response(JSON.stringify(response), {
headers: { 'content-type': 'application/json' },
}));
const run = async (tail) => [
await task({ kind: 'logs', id: 1, tail }),
await subscription({ name: 'subscription logs', positionals: ['1'], values: { tail: String(tail) } }),
];
// A byte-limited backend response can contain fewer lines than --tail.
response = { code: 200, data: 'partial long line\nlast line\n', offset: 262144, total: 524288, truncated: true };
for (const result of await run(200)) {
assert.equal(result.data, 'partial long line\nlast line');
assert.equal(result.truncated, true);
}
// Older backends omit truncated; complete responses may explicitly set false.
for (const metadata of [{}, { truncated: false }]) {
response = { code: 200, data: 'one\r\ntwo\r\nthree\r\n', ...metadata };
for (const result of await run(2)) {
assert.equal(result.data, 'two\nthree');
assert.equal(result.truncated, true);
}
for (const result of await run(3)) {
assert.equal(result.data, 'one\ntwo\nthree');
assert.equal(result.truncated, false);
}
}
});
+188
View File
@@ -0,0 +1,188 @@
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);
});
+85
View File
@@ -0,0 +1,85 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { randomUUID } = require('node:crypto');
const { createContext } = require('../../dist/internal/runtime/context');
const { syncRaw } = require('../../dist/internal/subscription/subscriptionRunner');
const { runProcess } = require('../../dist/internal/runtime/process');
test('raw subscriptions retain legacy netrc authentication and preserve files on denied access', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-raw-netrc-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const password = randomUUID();
const authorization =
'Basic ' + Buffer.from('fixture:' + password).toString('base64');
let denied = false;
const server = http.createServer((req, res) => {
if (req.url === '/public.js') return res.end('public script');
if (denied || req.headers.authorization !== authorization)
return res
.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
.end();
res.end('private script');
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
const env = { PATH: process.env.PATH, HOME: root, QL_LANG: 'en' };
await fs.writeFile(
path.join(root, '.netrc'),
`machine 127.0.0.1 login fixture password ${password}\n`,
{ mode: 0o600 },
);
const endpoint = `http://127.0.0.1:${server.address().port}`;
if (process.env.QL_WGET_COMPARE === '1') {
const original = await runProcess(
'wget',
['-q', '-O', path.join(root, 'original.js'), endpoint + '/private.js'],
{ env },
);
assert.equal(
original.code,
0,
'retained Shell wget authentication must work',
);
assert.equal(
await fs.readFile(path.join(root, 'original.js'), 'utf8'),
'private script',
);
}
const context = createContext({ root }, env);
const input = {
url: endpoint + '/private.js',
autoAdd: false,
autoDelete: false,
};
const result = await syncRaw(context, input);
const destination = path.join(context.paths.dir_scripts, result.file);
assert.equal(await fs.readFile(destination, 'utf8'), 'private script');
denied = true;
await assert.rejects(syncRaw(context, input));
assert.equal(await fs.readFile(destination, 'utf8'), 'private script');
assert.equal(
(await fs.readdir(context.paths.dir_raw)).filter((x) => x.endsWith('.tmp'))
.length,
0,
);
await fs.rm(path.join(root, '.netrc'));
await assert.rejects(syncRaw(context, input));
const publicResult = await syncRaw(context, {
...input,
url: endpoint + '/public.js',
});
assert.equal(
await fs.readFile(
path.join(context.paths.dir_scripts, publicResult.file),
'utf8',
),
'public script',
);
});
@@ -0,0 +1,34 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const { openOperations } = require('../../dist/remote/api/openOperations');
test('body-consuming backend routes expose a CLI request body or upload', () => {
const root = path.resolve(__dirname, '../../../back/api');
let checked = 0;
for (const filename of fs.readdirSync(root).filter(name => name.endsWith('.ts'))) {
const text = fs.readFileSync(path.join(root, filename), 'utf8');
const mount = text.match(/app\.use\(['"]([^'"]+)/)?.[1];
if (!mount) continue;
const source = ts.createSourceFile(filename, text, ts.ScriptTarget.Latest, true);
const visit = node => {
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
&& node.expression.expression.getText(source) === 'route'
&& ['post', 'put', 'delete'].includes(node.expression.name.text)
&& node.arguments[0] && ts.isStringLiteral(node.arguments[0])
&& /\breq\.body\b/.test(node.getText(source))) {
const endpoint = [mount, node.arguments[0].text].join('/').split('/').filter(Boolean).join('/');
const method = node.expression.name.text.toUpperCase();
const operation = openOperations.find(item => item.method === method && item.path === endpoint);
assert.ok(operation, `${method} ${endpoint} is missing`);
assert.ok(operation.body || operation.upload, `${operation.name} cannot provide req.body`);
checked++;
}
ts.forEachChild(node, visit);
};
visit(source);
}
assert.ok(checked > 0, 'No body-consuming routes were inspected');
});
+51
View File
@@ -0,0 +1,51 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { spawnSync } = require('node:child_process');
const { inspectPanel } = require('../../dist/internal/maintenance/check');
test('public task entries retain no-argument script discovery and help stays read-only', async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-public-inventory-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.mkdir(path.join(root, 'data/scripts'), { recursive: true });
await fs.writeFile(path.join(root, 'data/scripts/job.js'), 'throw Error("do not execute"); const $ = new Env("Job");');
for (const [entry, args] of [['task', []], ['ql', ['task']]]) {
const run = (...extra) => spawnSync(process.execPath, [path.resolve(__dirname, `../../dist/${entry}.js`), ...args, ...extra, '--json'], { env: { PATH: process.env.PATH, QL_DIR: root }, encoding: 'utf8' });
const listing = run();
assert.equal(listing.status, 0, listing.stderr);
assert.deepEqual(JSON.parse(listing.stdout).data.scripts, [{ file: 'job.js', name: 'Job' }]);
assert.equal(JSON.parse(run('--help').stdout).data.scripts, undefined);
}
});
test('removed development publication is rejected before doing any work', () => {
const result = spawnSync(process.execPath, [path.resolve(__dirname, '../../dist/ql.js'), 'dev', 'release', '--root', '/absent', '--json'], { encoding: 'utf8' });
assert.equal(result.status, 2);
assert.equal(result.stdout, '');
assert.equal(JSON.parse(result.stderr).code, 2);
});
test('health probes use the base path and reject failed or malformed scheduler health', async (t) => {
let health = { status: 503, body: { code: 503, data: { status: 'error' } } };
const seen = [];
const server = http.createServer((req, res) => {
seen.push(req.url);
if (req.url.startsWith('/ql/api/health?')) {
res.statusCode = health.status;
res.end(JSON.stringify(health.body));
} else if (req.url === '/ql/') res.end('<div id="root"></div>');
else res.end('{"code":200,"data":{"version":"2.21.0"}}');
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise(resolve => server.close(resolve)));
const context = { env: { QlPort: String(server.address().port), QlBaseUrl: '/ql/' } };
assert.equal((await inspectPanel(context)).backend.healthy, false);
health = { status: 200, body: { code: 200, data: {} } };
assert.equal((await inspectPanel(context)).backend.healthy, false);
health = { status: 200, body: { code: 200, data: { status: 'ok' } } };
assert.equal((await inspectPanel(context)).backend.healthy, true);
assert.equal(seen.some(route => route.includes('/api/system')), false);
});
+170
View File
@@ -0,0 +1,170 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const http = require('node:http');
const { spawn } = require('node:child_process');
const { parse } = require('../helpers/commands.cjs');
test('public CLI excludes local maintenance while the admin entry retains compatibility names', () => {
const help = parse(['--help']).help;
assert.match(help, /subscription list/);
assert.doesNotMatch(help, /resetpwd|local extra|rmlog/);
for (const name of ['rmlog', 'resetpwd', 'extra'])
assert.throws(() => parse([name, '1']), { exitCode: 2 });
assert.equal(parse(['resetpwd', 'example'], 'local').name, 'local resetpwd');
assert.doesNotMatch(
parse(['--help'], 'local').help,
/subscription list|auth login/,
);
assert.throws(
() => parse(['auth', 'status', '--scope', 'all']),
{ exitCode: 2 },
);
});
test('subscription management uses panel API, projects credentials out and never invokes local repo/raw', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-subscription-'));
const records = [];
const row = {
id: 7,
alias: 'sample',
name: 'sample',
status: 1,
pull_option: { password: 'private-secret' },
url: 'https://user:private-secret@example.com/repo.git',
command: 'private-secret',
sub_before: 'private-secret',
};
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');
records.push({
path: url.pathname,
query: url.searchParams,
body,
method: req.method,
});
assert.equal(req.headers.authorization, 'Bearer sub-token');
const data = url.pathname.endsWith('/log')
? 'one\ntwo\nthree\n'
: url.pathname.endsWith('/7')
? row
: [row];
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ code: 200, data }));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => {
server.closeAllConnections();
server.close();
fs.rmSync(root, { recursive: true, force: true });
});
const config = path.join(root, 'config.json');
fs.writeFileSync(
config,
JSON.stringify({
url: `http://127.0.0.1:${server.address().port}`,
clientId: 'id',
clientSecret: 'secret',
token: 'sub-token',
expiration: Date.now() / 1000 + 10000,
}),
{ mode: 0o600 },
);
const run = (args) =>
new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[path.resolve(__dirname, '../../dist/npm/ql.js'), ...args, '--json'],
{
env: {
...process.env,
QL_CLI_CONFIG: config,
QL_DIR: '/not-a-panel',
},
stdio: ['ignore', 'pipe', 'pipe'],
},
);
let out = '',
err = '';
child.stdout.on('data', (chunk) => (out += chunk));
child.stderr.on('data', (chunk) => (err += chunk));
child.once('error', reject);
child.once('close', (code) => {
assert.equal(code, 0, err);
assert.doesNotMatch(out + err, /private-secret/);
resolve(JSON.parse(out));
});
});
const status = await run(['auth', 'status', '--scope', 'subscriptions']);
assert.equal(status.data.scopeChecked, 'subscriptions');
const list = await run(['subscription', 'list', '--search', 'a & b']);
assert.deepEqual(list.data, [
{ id: 7, name: 'sample', alias: 'sample', status: 1 },
]);
assert.equal(records.at(-1).query.get('searchValue'), 'a & b');
assert.equal((await run(['subscription', 'get', '7'])).data.id, 7);
assert.deepEqual(await run(['subscription', 'logs', '7', '--tail', '2']), {
code: 200,
data: 'two\nthree',
truncated: true,
});
for (const action of ['run', 'stop', 'enable', 'disable']) {
const response = await run(['subscription', action, '7']);
assert.deepEqual(response.data, {
subscriptionId: 7,
action,
accepted: true,
});
assert.equal(records.at(-1).method, 'PUT');
assert.equal(records.at(-1).body, '[7]');
assert.equal(records.at(-1).path, `/open/subscriptions/${action}`);
}
});
test('shared API errors identify subscription permission and uncertain subscription outcomes', async (t) => {
const { request } = require('../../dist/remote/api/client');
const http = require('node:http');
let mode = 'denied',
count = 0;
const server = http.createServer((req, res) => {
count++;
if (mode === 'denied') {
res.writeHead(403);
res.end('forbidden');
} else if (mode === 'api-denied') {
res.end(JSON.stringify({ code: 403 }));
} else {
res.writeHead(503);
res.end('unavailable');
}
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(() => new Promise((resolve) => server.close(resolve)));
const config = {
url: `http://127.0.0.1:${server.address().port}`,
token: 'fixture',
clientId: 'fixture',
clientSecret: 'fixture',
expiration: Date.now() / 1000 + 1000,
};
for (const failure of ['denied', 'api-denied']) {
mode = failure;
await assert.rejects(
request(config, 'subscriptions'),
(error) =>
error.exitCode === 3 &&
/subscriptions (permission|权限)/.test(error.message) &&
!/crons/.test(error.message),
);
}
mode = 'unavailable';
await assert.rejects(
request(config, 'subscriptions/run', { method: 'PUT', body: [1] }),
/check subscription status before retrying|先检查订阅状态再考虑重试/,
);
assert.equal(count, 3);
});