Files
qinglong/cli/test/internal/networkRepository.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

336 lines
12 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 http = require('node:http');
const https = require('node:https');
const net = require('node:net');
const { randomUUID } = require('node:crypto');
const { execFileSync, spawn } = require('node:child_process');
const { createContext } = require('../../dist/internal/runtime/context');
const { syncRepository } = require('../../dist/internal/subscription/subscriptionRunner');
const git = process.platform === 'darwin' ? '/usr/bin/git' : 'git';
for (const tls of [false, true])
test(
`authenticated Git ${
tls ? 'HTTPS/CONNECT' : 'HTTP/proxy'
} transport preserves checkout after access failure`,
{ timeout: 30000 },
async (t) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-git-http-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
const execPath = execFileSync(git, ['--exec-path'], {
encoding: 'utf8',
}).trim();
await fs.access(path.join(execPath, 'git-http-backend')).catch(() => {
throw new Error(
'Test prerequisite missing: git-http-backend (Alpine: git-daemon).',
);
});
const source = path.join(root, 'owner/repo.git');
await fs.mkdir(source, { recursive: true });
const run = (...args) =>
execFileSync(git, ['-C', source, ...args], { stdio: 'pipe' });
run('init', '-b', 'main');
run('config', 'user.name', 'Fixture');
run('config', 'user.email', 'fixture@example.invalid');
await fs.writeFile(path.join(source, 'wrong.js'), 'wrong branch');
run('add', '.');
run('commit', '-m', 'main');
run('checkout', '-b', 'selected');
await fs.rm(path.join(source, 'wrong.js'));
await fs.writeFile(path.join(source, 'job.js'), 'version one');
await fs.writeFile(path.join(source, 'helper.js'), 'dependency');
run('add', '-A');
run('commit', '-m', 'selected');
const authorization = `Basic ${Buffer.from(
`fixture:${randomUUID()}`,
).toString('base64')}`;
let failure = false;
const requests = [];
const children = new Set();
let certificate;
let tlsOptions;
if (tls) {
certificate = path.join(root, 'certificate.pem');
const key = path.join(root, 'key.pem');
execFileSync(
'openssl',
[
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-keyout',
key,
'-out',
certificate,
'-subj',
'/CN=127.0.0.1',
'-addext',
'subjectAltName=IP:127.0.0.1',
'-days',
'1',
],
{ stdio: 'pipe' },
);
tlsOptions = {
key: await fs.readFile(key),
cert: await fs.readFile(certificate),
};
}
const serveGit = (req, res) => {
const url = new URL(req.url, 'http://fixture.invalid');
requests.push({
method: req.method,
path: url.pathname,
proxy: req.url.startsWith('http://'),
});
if (failure) {
res.writeHead(503).end();
req.resume();
return;
}
if (req.headers.authorization !== authorization) {
res
.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
.end();
req.resume();
return;
}
const child = spawn(git, ['http-backend'], {
env: {
PATH: process.env.PATH,
GIT_PROJECT_ROOT: root,
GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: req.method,
PATH_INFO: url.pathname,
QUERY_STRING: url.search.slice(1),
CONTENT_TYPE: req.headers['content-type'] || '',
CONTENT_LENGTH: req.headers['content-length'] || '',
REMOTE_USER: 'fixture',
},
stdio: ['pipe', 'pipe', 'pipe'],
});
children.add(child);
child.on('error', () => res.destroy());
child.on('close', () => children.delete(child));
child.stderr.resume();
child.stdin.on('error', () => {});
req.pipe(child.stdin);
let pending = Buffer.alloc(0),
headersSent = false;
child.stdout.on('data', (chunk) => {
if (headersSent) {
res.write(chunk);
return;
}
pending = Buffer.concat([pending, chunk]);
const boundary = pending.indexOf('\r\n\r\n');
if (boundary < 0) return;
let status = 200;
const headers = {};
for (const line of pending
.subarray(0, boundary)
.toString()
.split('\r\n')) {
const colon = line.indexOf(':');
const key = line.slice(0, colon),
value = line.slice(colon + 1).trim();
if (key.toLowerCase() === 'status')
status = Number(value.split(' ')[0]);
else headers[key] = value;
}
res.writeHead(status, headers);
headersSent = true;
res.write(pending.subarray(boundary + 4));
});
child.stdout.on('end', () => res.end());
};
const server = tls
? https.createServer(tlsOptions, serveGit)
: http.createServer(serveGit);
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
t.after(async () => {
for (const child of children) child.kill('SIGKILL');
server.closeAllConnections();
await new Promise((resolve) => server.close(resolve));
});
const endpoint = `${tls ? 'https' : 'http'}://127.0.0.1:${
server.address().port
}`;
let proxyEndpoint = endpoint;
let tunnels = 0;
if (tls) {
const sockets = new Set();
const tunnel = http.createServer((req, res) =>
res.writeHead(405).end(),
);
tunnel.on('connect', (req, client, head) => {
if (req.url !== `127.0.0.1:${server.address().port}`) {
client.end('HTTP/1.1 403 Forbidden\r\n\r\n');
return;
}
tunnels++;
const upstream = net.connect(
server.address().port,
'127.0.0.1',
() => {
client.write('HTTP/1.1 200 Connection Established\r\n\r\n');
if (head.length) upstream.write(head);
client.pipe(upstream);
upstream.pipe(client);
},
);
for (const socket of [client, upstream]) {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
}
client.on('error', () => upstream.destroy());
upstream.on('error', () => client.destroy());
client.on('close', () => upstream.destroy());
upstream.on('close', () => client.destroy());
});
await new Promise((resolve) => tunnel.listen(0, '127.0.0.1', resolve));
proxyEndpoint = `http://127.0.0.1:${tunnel.address().port}`;
t.after(async () => {
for (const socket of sockets) socket.destroy();
await new Promise((resolve) => tunnel.close(resolve));
});
}
for (const [proxy, credentialMode] of [
[false, 'header'],
[true, 'header'],
[false, 'helper'],
[true, 'helper'],
]) {
failure = false;
const panel = path.join(
root,
`${credentialMode}-${proxy ? 'proxied-panel' : 'direct-panel'}`,
);
await fs.mkdir(panel);
const env = {
PATH: `/usr/bin:/bin:${process.env.PATH}`,
GIT_TERMINAL_PROMPT: '0',
...(tls ? { GIT_SSL_CAINFO: certificate } : {}),
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: os.devNull,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'http.extraHeader',
GIT_CONFIG_VALUE_0: `Authorization: ${authorization}`,
http_proxy: '',
https_proxy: '',
all_proxy: '',
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
NO_PROXY: '',
no_proxy: '',
};
if (credentialMode === 'helper') {
const credentialFile = path.join(panel, 'credentials');
const credential = new URL(
proxy && !tls ? 'http://repository.invalid' : endpoint,
);
const [username, password] = Buffer.from(
authorization.slice(6),
'base64',
)
.toString()
.split(':');
credential.username = username;
credential.password = password;
await fs.writeFile(credentialFile, credential.toString() + '\n', {
mode: 0o600,
});
env.GIT_CONFIG_KEY_0 = 'credential.helper';
env.GIT_CONFIG_VALUE_0 =
"store --file='" + credentialFile.replaceAll("'", "'\"'\"'") + "'";
}
const context = createContext({ root: panel }, env);
const input = {
url: `${
proxy && !tls ? 'http://repository.invalid' : endpoint
}/owner/repo.git`,
branch: 'selected',
include: '^job',
dependencies: '^helper',
autoAdd: false,
autoDelete: false,
...(proxy ? { proxy: proxyEndpoint } : {}),
};
const start = requests.length;
const previousTunnels = tunnels;
const result = await syncRepository(context, input);
const destination = path.join(
context.paths.dir_scripts,
result.repository,
);
assert.equal(
await fs.readFile(path.join(destination, 'job.js'), 'utf8'),
'version one',
);
assert.equal(
await fs.readFile(path.join(destination, 'helper.js'), 'utf8'),
'dependency',
);
await assert.rejects(fs.access(path.join(destination, 'wrong.js')));
assert.ok(
requests
.slice(start)
.some(
(row) =>
row.method === 'POST' &&
row.path.endsWith('/git-upload-pack') &&
row.proxy === (proxy && !tls),
),
);
if (tls && proxy)
assert.ok(
tunnels > previousTunnels,
'HTTPS must traverse the CONNECT proxy',
);
const checkout = path.join(context.paths.dir_repo, result.repository);
const head = execFileSync(git, [
'-C',
checkout,
'rev-parse',
'HEAD',
]).toString();
for (const kind of [
'unauthorized',
'unavailable',
...(tls ? ['untrusted'] : []),
]) {
failure = kind === 'unavailable';
const failedContext =
kind === 'unauthorized'
? createContext(
{ root: panel },
{ ...env, GIT_CONFIG_COUNT: '0' },
)
: kind === 'untrusted'
? createContext(
{ root: panel },
{ ...env, GIT_SSL_CAINFO: undefined },
)
: context;
await assert.rejects(syncRepository(failedContext, input));
assert.equal(
await fs.readFile(path.join(destination, 'job.js'), 'utf8'),
'version one',
);
assert.equal(
execFileSync(git, ['-C', checkout, 'rev-parse', 'HEAD']).toString(),
head,
);
assert.deepEqual(await fs.readdir(context.paths.dir_tmp), []);
}
}
},
);