mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-28 09:02:12 +08:00
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:
@@ -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/ : /认证响应无效/,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user