Files
qinglong/cli/test/remote/subscription.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

171 lines
5.7 KiB
JavaScript

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