Files
qinglong/cli/test/linux/ssh-repository.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

149 lines
5.8 KiB
JavaScript

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 net = require('node:net');
const { execFileSync, spawn } = require('node:child_process');
const { setTimeout: delay } = require('node:timers/promises');
const { createContext } = require('../../dist/internal/runtime/context');
const { syncRepository } = require('../../dist/internal/subscription/subscriptionRunner');
test(
'real SSH subscriptions require the configured identity and pinned host key',
{ skip: process.env.QL_SSH_INTEGRATION !== '1', timeout: 30000 },
async (t) => {
assert.equal(process.platform, 'linux');
assert.equal(
process.getuid(),
0,
'Use only the disposable root SSH fixture container',
);
// sshd StrictModes rejects authorized_keys below world-writable /tmp.
const root = await fs.mkdtemp('/var/lib/ql-ssh-');
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.chmod(root, 0o755);
const run = (program, args) =>
execFileSync(program, args, { stdio: 'pipe' }).toString();
run('adduser', ['-D', '-H', '-s', '/bin/sh', 'qlfixture']);
run('passwd', ['-d', 'qlfixture']);
for (const name of ['host', 'identity', 'wrong'])
run('ssh-keygen', [
'-t',
'ed25519',
'-N',
'',
'-f',
path.join(root, name),
]);
const authorized = path.join(root, 'authorized_keys');
await fs.copyFile(path.join(root, 'identity.pub'), authorized);
await fs.chmod(authorized, 0o644);
const source = path.join(root, 'owner/repo');
await fs.mkdir(source, { recursive: true });
run('git', ['-C', source, 'init', '-b', 'selected']);
run('git', ['-C', source, 'config', 'user.name', 'Fixture']);
run('git', [
'-C',
source,
'config',
'user.email',
'fixture@example.invalid',
]);
await fs.writeFile(path.join(source, 'job.js'), 'selected SSH script');
run('git', ['-C', source, 'add', '.']);
run('git', ['-C', source, 'commit', '-m', 'fixture']);
run('chown', ['-R', 'qlfixture:qlfixture', path.join(root, 'owner')]);
const port = 22022;
const serverConfig = path.join(root, 'sshd_config');
await fs.writeFile(
serverConfig,
`Port ${port}\nListenAddress 127.0.0.1\nHostKey ${root}/host\nPidFile ${root}/sshd.pid\nAuthorizedKeysFile ${authorized}\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPermitRootLogin no\nAllowUsers qlfixture\nAllowTcpForwarding no\nX11Forwarding no\nUsePAM no\n`,
);
const server = spawn('/usr/sbin/sshd', ['-D', '-e', '-f', serverConfig], {
stdio: ['ignore', 'ignore', 'pipe'],
});
let diagnostic = '';
server.stderr.on('data', (chunk) => {
diagnostic = (diagnostic + chunk).slice(-2000);
});
t.after(async () => {
t.diagnostic(diagnostic);
if (server.exitCode === null && server.signalCode === null) {
const exited = new Promise((resolve) => server.once('exit', resolve));
server.kill('SIGTERM');
await exited;
}
});
let ready = false;
for (let i = 0; i < 100; i++) {
ready = await new Promise((resolve) => {
const socket = net.connect(port, '127.0.0.1');
socket.once('connect', () => {
socket.destroy();
resolve(true);
});
socket.once('error', () => resolve(false));
});
if (ready || server.exitCode !== null) break;
await delay(20);
}
assert.ok(ready, diagnostic);
const knownHosts = path.join(root, 'known_hosts');
const trusted = `[127.0.0.1]:${port} ${await fs.readFile(
path.join(root, 'host.pub'),
'utf8',
)}`;
const config = path.join(root, 'ssh_config');
const configure = async (key = 'identity') =>
fs.writeFile(
config,
`Host fixture\n HostName 127.0.0.1\n Port ${port}\n User qlfixture\n IdentityFile ${root}/${key}\n IdentityAgent none\n IdentitiesOnly yes\n BatchMode yes\n StrictHostKeyChecking yes\n UserKnownHostsFile ${knownHosts}\n GlobalKnownHostsFile /dev/null\n ConnectTimeout 3\n`,
);
for (const url of [`ssh://fixture${source}`, `fixture:${source}`]) {
await configure();
await fs.writeFile(knownHosts, trusted);
const panel = await fs.mkdtemp(path.join(root, 'panel-'));
const env = {
PATH: process.env.PATH,
GIT_SSH_COMMAND: `ssh -F ${config}`,
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: os.devNull,
GIT_TERMINAL_PROMPT: '0',
};
const context = createContext({ root: panel }, env);
const input = {
url,
branch: 'selected',
autoAdd: false,
autoDelete: false,
};
const result = await syncRepository(context, input);
const job = path.join(
context.paths.dir_scripts,
result.repository,
'job.js',
);
const checkout = path.join(context.paths.dir_repo, result.repository);
const head = run('git', ['-C', checkout, 'rev-parse', 'HEAD']);
assert.equal(await fs.readFile(job, 'utf8'), 'selected SSH script');
for (const failure of ['identity', 'host-key']) {
await configure(failure === 'identity' ? 'wrong' : 'identity');
await fs.writeFile(
knownHosts,
failure === 'host-key'
? `[127.0.0.1]:${port} ${await fs.readFile(
path.join(root, 'wrong.pub'),
'utf8',
)}`
: trusted,
);
await assert.rejects(syncRepository(context, input));
assert.equal(await fs.readFile(job, 'utf8'), 'selected SSH script');
assert.equal(run('git', ['-C', checkout, 'rev-parse', 'HEAD']), head);
assert.deepEqual(await fs.readdir(context.paths.dir_tmp), []);
}
}
},
);