mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
fix(python): share safe startup and honor explicit virtual environments
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const vm = require('node:vm');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const decorators = { Service: () => (x) => x, Inject: () => () => {} };
|
||||
const logger = { info() {}, warn() {}, error() {} };
|
||||
const translate = { t: (x) => x, tf: (x) => x };
|
||||
function temporary(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-advisory-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('environment values remain literal when the generated JavaScript loads', async () => {
|
||||
const output = {};
|
||||
const Env = load(path.resolve('back/services/env.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': { envFile: 'sh', jsEnvFile: 'js', pyEnvFile: 'py' },
|
||||
'../data/env': { EnvStatus: { normal: 0 } },
|
||||
'../data': {},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: async (file, text) => (output[file] = text),
|
||||
},
|
||||
}).default;
|
||||
const service = new Env(logger);
|
||||
const values = [
|
||||
'${globalThis.injected = true}',
|
||||
'quotes\'"` \\ newline\n中文\u2028',
|
||||
'${1 + 2}',
|
||||
];
|
||||
service.envs = async () =>
|
||||
values.map((value, i) => ({ name: `VALUE_${i}`, value }));
|
||||
await service.set_envs();
|
||||
const context = { process: { env: {} } };
|
||||
vm.runInNewContext(output.js, context);
|
||||
assert.equal(context.injected, undefined);
|
||||
values.forEach((value, i) =>
|
||||
assert.equal(context.process.env[`VALUE_${i}`], value),
|
||||
);
|
||||
});
|
||||
|
||||
test('subscription shell parameters round trip without expansion or command substitution', () => {
|
||||
const { formatCommand } = load(path.resolve('back/config/subscription.ts'));
|
||||
const payload =
|
||||
'a $(printf INJECTED) `printf INJECTED` " ; echo BAD; # \' 中文\nnext';
|
||||
for (const type of ['file', 'repo']) {
|
||||
const fields =
|
||||
type === 'file'
|
||||
? ['url', 'proxy']
|
||||
: [
|
||||
'url',
|
||||
'whitelist',
|
||||
'blacklist',
|
||||
'dependences',
|
||||
'branch',
|
||||
'extensions',
|
||||
'proxy',
|
||||
];
|
||||
for (const field of fields) {
|
||||
const doc = {
|
||||
id: 12,
|
||||
type,
|
||||
url: 'https://example.invalid/repo',
|
||||
[field]: payload,
|
||||
};
|
||||
const command = formatCommand(doc);
|
||||
const stdout = execFileSync('/bin/bash', [
|
||||
'-c',
|
||||
`ql() { printf '%s\\0' "$@"; }; ${command}`,
|
||||
]);
|
||||
const args = stdout.toString().split('\0').slice(0, -1);
|
||||
assert.equal(
|
||||
args[fields.indexOf(field) + 1],
|
||||
payload,
|
||||
`${type}:${field}`,
|
||||
);
|
||||
assert.equal(args[0], type === 'file' ? 'raw' : 'repo');
|
||||
assert.equal(args.length, fields.length + 3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('SSH alias traversal and symlinks cannot write or remove outside files', async (t) => {
|
||||
const base = temporary(t);
|
||||
const root = path.join(base, 'ssh');
|
||||
fs.mkdirSync(root);
|
||||
const outside = path.join(base, 'outside');
|
||||
fs.writeFileSync(outside, 'keep');
|
||||
fs.symlinkSync(outside, path.join(root, 'link'));
|
||||
const Ssh = load(path.resolve('back/services/sshKey.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': { sshdPath: root },
|
||||
'../config/util': {
|
||||
rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }),
|
||||
},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (p, value) => fs.promises.writeFile(p, value),
|
||||
},
|
||||
}).default;
|
||||
const service = Object.assign(Object.create(Ssh.prototype), {
|
||||
sshPath: root,
|
||||
logger,
|
||||
});
|
||||
for (const alias of [
|
||||
'../outside',
|
||||
'..',
|
||||
'.',
|
||||
'/tmp/outside',
|
||||
'link',
|
||||
'x\nHost *',
|
||||
]) {
|
||||
await service.generatePrivateKeyFile(alias, 'overwrite');
|
||||
await service.removePrivateKeyFile(alias);
|
||||
await assert.rejects(service.generateSingleSshConfig(alias, 'github.com'));
|
||||
assert.equal(fs.readFileSync(outside, 'utf8'), 'keep');
|
||||
}
|
||||
await service.generatePrivateKeyFile('仓库-main', 'private-key');
|
||||
await service.generateSingleSshConfig(
|
||||
'仓库-main',
|
||||
'github.com',
|
||||
'127.0.0.1:1080',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, '仓库-main'), 'utf8').trim(),
|
||||
'private-key',
|
||||
);
|
||||
await service.generatePrivateKeyFile('~global_default', 'global-key');
|
||||
await service.generateGlobalSshConfig('~global_default');
|
||||
assert.match(
|
||||
fs.readFileSync(path.join(root, '~global_default.config'), 'utf8'),
|
||||
/Host \*/,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.generateSingleSshConfig('safe', 'host\nProxyCommand bad'),
|
||||
);
|
||||
await assert.rejects(
|
||||
service.generateSingleSshConfig('safe', 'host', '$(printf BAD)'),
|
||||
);
|
||||
});
|
||||
|
||||
function cronService(config, updates = []) {
|
||||
const Cron = load(path.resolve('back/services/cron.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': config,
|
||||
'../data/cron': {
|
||||
CrontabStatus: { running: 0, idle: 1, queued: 3 },
|
||||
CrontabModel: { update: async (v) => updates.push(v) },
|
||||
},
|
||||
'../data/runningInstance': { RunningInstanceModel: {}, InstanceStatus: {} },
|
||||
'../config/util': { fileExist: async (p) => fs.existsSync(p) },
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': translate,
|
||||
'../shared/logStreamManager': {},
|
||||
'../shared/cronSchedule': {},
|
||||
}).default;
|
||||
return new Cron(logger);
|
||||
}
|
||||
function subscriptionService(config, model = {}) {
|
||||
const Subscription = load(path.resolve('back/services/subscription.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': config,
|
||||
'../data/subscription': {
|
||||
SubscriptionModel: model,
|
||||
SubscriptionStatus: {},
|
||||
},
|
||||
'../data/cron': {},
|
||||
'../config/util': { fileExist: async (p) => fs.existsSync(p) },
|
||||
'../config/const': {},
|
||||
'../shared/i18n': translate,
|
||||
'../shared/pLimit': {},
|
||||
'../shared/logStreamManager': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
return new Subscription(logger, {}, {}, {}, {});
|
||||
}
|
||||
|
||||
test('stored cron/subscription paths cannot read or enumerate outside the log root', async (t) => {
|
||||
const base = temporary(t);
|
||||
const root = path.join(base, 'log');
|
||||
fs.mkdirSync(root);
|
||||
fs.mkdirSync(path.join(base, 'outside'));
|
||||
const secret = path.join(base, 'outside', 'secret');
|
||||
fs.writeFileSync(secret, 'SECRET');
|
||||
fs.symlinkSync(path.dirname(secret), path.join(root, 'escape'));
|
||||
fs.writeFileSync(path.join(root, 'normal.log'), 'NORMAL');
|
||||
const updates = [];
|
||||
const cron = cronService({ logPath: root }, updates);
|
||||
const subscription = subscriptionService(
|
||||
{ logPath: root },
|
||||
{ update: async (v) => updates.push(v) },
|
||||
);
|
||||
for (const log_path of [secret, '../outside/secret', 'escape/secret']) {
|
||||
cron.getDb = subscription.getDb = async () => ({ log_path, status: 1 });
|
||||
await assert.rejects(cron.log(1), /outside the log directory/);
|
||||
await assert.rejects(cron.logs(1), /outside the log directory/);
|
||||
await assert.rejects(subscription.logs(1), /outside the log directory/);
|
||||
await assert.rejects(
|
||||
cron.status({ ids: [1], status: 0, log_path }),
|
||||
/outside the log directory/,
|
||||
);
|
||||
await assert.rejects(
|
||||
subscription.status({ ids: [1], status: 0, log_path }),
|
||||
/outside the log directory/,
|
||||
);
|
||||
}
|
||||
assert.equal(updates.length, 0);
|
||||
cron.getDb = subscription.getDb = async () => ({
|
||||
log_path: 'normal.log',
|
||||
status: 1,
|
||||
});
|
||||
assert.equal((await cron.log(1)).content, 'NORMAL');
|
||||
assert.ok((await cron.logs(1)).some((x) => x.filename === 'normal.log'));
|
||||
assert.ok(
|
||||
(await subscription.logs(1)).some((x) => x.filename === 'normal.log'),
|
||||
);
|
||||
});
|
||||
|
||||
test('subscription aliases are checked before persistence and forced deletion', async (t) => {
|
||||
const base = temporary(t);
|
||||
for (const dir of ['scripts', 'repo']) fs.mkdirSync(path.join(base, dir));
|
||||
let writes = 0;
|
||||
const service = subscriptionService(
|
||||
{
|
||||
scriptPath: path.join(base, 'scripts'),
|
||||
repoPath: path.join(base, 'repo'),
|
||||
},
|
||||
{
|
||||
findAll: async () => [{ alias: '../victim' }],
|
||||
destroy: async () => writes++,
|
||||
},
|
||||
);
|
||||
service.insert = service.updateDb = async () => writes++;
|
||||
await assert.rejects(
|
||||
service.create({ alias: '../victim' }),
|
||||
/Invalid subscription alias/,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.update({ alias: '../victim' }),
|
||||
/Invalid subscription alias/,
|
||||
);
|
||||
await assert.rejects(
|
||||
service.remove([1], { force: true }),
|
||||
/Invalid subscription alias/,
|
||||
);
|
||||
assert.equal(writes, 0);
|
||||
});
|
||||
|
||||
function systemService(config, overrides = {}) {
|
||||
const System = load(path.resolve('back/services/system.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': config,
|
||||
'../config/const': {},
|
||||
'../data/dependence': {
|
||||
DependenceModel: { findAll: async () => [] },
|
||||
DependenceTypes: {},
|
||||
DependenceStatus: {},
|
||||
},
|
||||
'../data/system': {},
|
||||
'../shared/pLimit': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/i18n': { ...translate, setLang() {} },
|
||||
'./notify': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'../config/util': {},
|
||||
...overrides,
|
||||
}).default;
|
||||
return new System(logger, {}, {});
|
||||
}
|
||||
|
||||
test('backup rejects unrecognized paths before spawning; valid export uses argv without a shell', async (t) => {
|
||||
const base = temporary(t);
|
||||
const calls = [];
|
||||
const service = systemService(
|
||||
{
|
||||
dataPath: path.join(base, 'data'),
|
||||
dataTgzFile: path.join(base, 'backup file.tgz'),
|
||||
},
|
||||
{
|
||||
child_process: {
|
||||
execFile: (...args) => {
|
||||
calls.push(args.slice(0, -1));
|
||||
args.at(-1)(null, '', '');
|
||||
},
|
||||
},
|
||||
'../config/util': {
|
||||
promiseExec: () => assert.fail('must not invoke shell'),
|
||||
},
|
||||
},
|
||||
);
|
||||
let downloaded = false;
|
||||
const res = {
|
||||
status() {
|
||||
return this;
|
||||
},
|
||||
send(body) {
|
||||
return body;
|
||||
},
|
||||
download() {
|
||||
downloaded = true;
|
||||
},
|
||||
};
|
||||
for (const entry of [
|
||||
'$(printf BAD)',
|
||||
'../outside',
|
||||
'/etc',
|
||||
'scripts;echo BAD',
|
||||
'--checkpoint-action=exec=bad',
|
||||
]) {
|
||||
assert.equal((await service.exportData(res, [entry])).code, 400);
|
||||
}
|
||||
assert.equal(calls.length, 0);
|
||||
await service.exportData(res, ['base', 'scripts', 'config', 'scripts']);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0][0], 'tar');
|
||||
assert.deepEqual(calls[0][1].slice(2), [
|
||||
'--',
|
||||
'data/db',
|
||||
'data/upload',
|
||||
'data/scripts',
|
||||
'data/config',
|
||||
]);
|
||||
assert.equal(calls[0][2].shell, undefined);
|
||||
assert.equal(downloaded, true);
|
||||
});
|
||||
|
||||
test('proxy, language and mirror settings stay literal at their shell boundaries', async (t) => {
|
||||
const base = temporary(t);
|
||||
const commands = [];
|
||||
const service = systemService(
|
||||
{
|
||||
dependenceProxyFile: path.join(base, 'proxy.sh'),
|
||||
langEnvFile: path.join(base, 'lang.sh'),
|
||||
},
|
||||
{
|
||||
'../config/util': {
|
||||
promiseExec: async (command) => commands.push(command),
|
||||
},
|
||||
},
|
||||
);
|
||||
service.getSystemConfig = async () => ({ info: {} });
|
||||
service.updateAuthDb = async () => {};
|
||||
service.scheduleService = { runTask: (command) => commands.push(command) };
|
||||
const value = 'x\'$(printf BAD)`printf BAD`"; echo BAD; #';
|
||||
await service.updateDependenceProxy({ dependenceProxy: value });
|
||||
await service.updateLanguage({ lang: value });
|
||||
const stdout = execFileSync('/bin/bash', [
|
||||
'-c',
|
||||
'source "$1"; source "$2"; printf "%s\\0%s" "$http_proxy" "$QL_LANG"',
|
||||
'bash',
|
||||
path.join(base, 'proxy.sh'),
|
||||
path.join(base, 'lang.sh'),
|
||||
]).toString();
|
||||
assert.equal(stdout, value + '\0' + value);
|
||||
await service.updatePythonMirror({ pythonMirror: value });
|
||||
await service.updateNodeMirror({ nodeMirror: value });
|
||||
for (const command of commands) {
|
||||
const stdout = execFileSync('/bin/bash', [
|
||||
'-c',
|
||||
`pip3() { printf '%s\\0' "$@"; }; pnpm() { printf '%s\\0' "$@"; }; ${command}`,
|
||||
]).toString();
|
||||
assert.equal(stdout.split('\0').at(-2), value);
|
||||
}
|
||||
assert.equal((await service.reloadSystem('$(printf BAD)')).code, 400);
|
||||
});
|
||||
|
||||
test('the ignored-log sentinel remains supported without exposing device files', async (t) => {
|
||||
const root = temporary(t);
|
||||
const cron = cronService({ logPath: root });
|
||||
cron.getDb = async () => ({ log_path: '/dev/null', log_name: '/dev/null' });
|
||||
assert.equal((await cron.log(1)).status, 'ignored');
|
||||
assert.deepEqual(await cron.logs(1), []);
|
||||
await cron.status({ ids: [], status: 0, log_path: '/dev/null' });
|
||||
await assert.rejects(
|
||||
cron.status({ ids: [], status: 0, log_path: '/dev/zero' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('normal backup creates an archive containing the selected data directories', async (t) => {
|
||||
const base = temporary(t);
|
||||
const dataPath = path.join(base, 'data');
|
||||
for (const name of ['db', 'upload', 'scripts'])
|
||||
fs.mkdirSync(path.join(dataPath, name), { recursive: true });
|
||||
fs.writeFileSync(path.join(dataPath, 'scripts/example.js'), 'literal script');
|
||||
const dataTgzFile = path.join(base, 'backup with spaces.tgz');
|
||||
const service = systemService({ dataPath, dataTgzFile });
|
||||
let downloaded;
|
||||
await service.exportData(
|
||||
{
|
||||
download(file) {
|
||||
downloaded = file;
|
||||
},
|
||||
send(error) {
|
||||
assert.fail(JSON.stringify(error));
|
||||
},
|
||||
},
|
||||
['scripts'],
|
||||
);
|
||||
assert.equal(downloaded, dataTgzFile);
|
||||
const content = execFileSync('tar', [
|
||||
'-xzOf',
|
||||
dataTgzFile,
|
||||
'data/scripts/example.js',
|
||||
]).toString();
|
||||
assert.equal(content, 'literal script');
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { isValidToken } = load(
|
||||
path.join(__dirname, '../../back/shared/auth.ts'),
|
||||
);
|
||||
const { hashPassword, verifyPassword } = load(
|
||||
path.join(__dirname, '../../back/shared/password.ts'),
|
||||
);
|
||||
const secret = 'security-test-secret';
|
||||
const token = (payload = {}, options = {}) =>
|
||||
jwt.sign(payload, secret, {
|
||||
algorithm: 'HS384',
|
||||
expiresIn: '1h',
|
||||
...options,
|
||||
});
|
||||
const info = (value) => ({ token: value, tokens: {} });
|
||||
|
||||
test('valid legacy and platform sessions retain compatibility', () => {
|
||||
const value = token();
|
||||
for (const auth of [
|
||||
info(value),
|
||||
{ tokens: { desktop: value } },
|
||||
{ tokens: { desktop: [{ value }] } },
|
||||
]) {
|
||||
assert.equal(isValidToken(auth, value, 'desktop', secret), true);
|
||||
}
|
||||
assert.equal(
|
||||
isValidToken({ tokens: { mobile: value } }, value, 'desktop', secret),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('session membership never bypasses JWT signature, expiry, algorithm or nbf', () => {
|
||||
const values = [
|
||||
token({}, { expiresIn: -1 }),
|
||||
token({}, { algorithm: 'HS256' }),
|
||||
token({ nbf: Math.floor(Date.now() / 1000) + 60 }),
|
||||
jwt.sign({}, 'wrong-secret', { algorithm: 'HS384', expiresIn: '1h' }),
|
||||
jwt.sign({}, secret, { algorithm: 'HS384' }),
|
||||
'not-a-token',
|
||||
];
|
||||
for (const value of values)
|
||||
assert.equal(isValidToken(info(value), value, 'desktop', secret), false);
|
||||
assert.equal(isValidToken({ tokens: {} }, token(), 'desktop', secret), false);
|
||||
assert.equal(isValidToken(null, token(), 'desktop', secret), false);
|
||||
assert.equal(isValidToken(info(''), '', 'desktop', secret), false);
|
||||
});
|
||||
|
||||
test('platform token metadata can shorten but cannot extend JWT expiration', () => {
|
||||
const value = token();
|
||||
assert.equal(
|
||||
isValidToken(
|
||||
{ tokens: { desktop: [{ value, expiration: 1 }] } },
|
||||
value,
|
||||
'desktop',
|
||||
secret,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('passwords are salted and legacy plaintext can migrate without changing the password', async () => {
|
||||
const first = await hashPassword('owner-password');
|
||||
const second = await hashPassword('owner-password');
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(await verifyPassword('owner-password', first), true);
|
||||
assert.equal(await verifyPassword('wrong-password', first), false);
|
||||
assert.equal(await verifyPassword(first, first), false);
|
||||
assert.equal(await verifyPassword('old-password', 'old-password'), true);
|
||||
assert.equal(await verifyPassword('wrong', 'old-password'), false);
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
|
||||
test('build verification accepts matching source and rejects stale or dirty artifacts', (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-build-source-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
|
||||
git('init');
|
||||
git('config', 'user.name', 'Build test');
|
||||
git('config', 'user.email', 'build-test@example.invalid');
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n');
|
||||
fs.writeFileSync(path.join(dir, '.gitignore'), 'static/\n');
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'fixture');
|
||||
fs.mkdirSync(path.join(dir, 'static/build'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'static/dist'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'static/build/app.js'), '');
|
||||
fs.writeFileSync(path.join(dir, 'static/dist/index.html'), '');
|
||||
const write = path.resolve('scripts/write-build-info.cjs');
|
||||
const verify = path.resolve('docker/verify-build.cjs');
|
||||
execFileSync(process.execPath, [write], { cwd: dir });
|
||||
execFileSync(process.execPath, [verify], { cwd: dir });
|
||||
const manifestPath = path.join(dir, 'static/build-info.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath));
|
||||
for (const invalid of [
|
||||
{ ...manifest, sourceCommit: '0'.repeat(40) },
|
||||
{ ...manifest, dirty: true },
|
||||
{ ...manifest, lockfileSha256: '0'.repeat(64) },
|
||||
]) {
|
||||
fs.writeFileSync(manifestPath, JSON.stringify(invalid));
|
||||
assert.notEqual(
|
||||
spawnSync(process.execPath, [verify], { cwd: dir }).status,
|
||||
0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('release images receive the same-run artifact and verify it before use', () => {
|
||||
const yaml = require('js-yaml');
|
||||
const workflow = yaml.load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
assert.equal(workflow.jobs['build-static'].needs, 'validate');
|
||||
const upload = workflow.jobs['build-static'].steps.find(step => step.uses?.startsWith('actions/upload-artifact@'));
|
||||
assert.equal(upload.with['include-hidden-files'], true);
|
||||
for (const name of [
|
||||
'build-alpine',
|
||||
'build-debian',
|
||||
'build-alpine310',
|
||||
'build-debian310',
|
||||
]) {
|
||||
const job = workflow.jobs[name];
|
||||
assert.equal(job.needs, 'build-static');
|
||||
const download = job.steps.find((step) =>
|
||||
step.uses?.startsWith('actions/download-artifact@'),
|
||||
);
|
||||
assert.equal(download.with.name, 'qinglong-static-${{ github.sha }}');
|
||||
assert.equal(download.with.path, 'static/');
|
||||
const build = job.steps.find((step) =>
|
||||
step.uses?.startsWith('docker/build-push-action@'),
|
||||
);
|
||||
assert.match(
|
||||
build.with['build-args'],
|
||||
/SOURCE_COMMIT=\$\{\{ github.sha \}\}/,
|
||||
);
|
||||
const dockerfile = fs.readFileSync(build.with.file, 'utf8');
|
||||
assert.doesNotMatch(dockerfile, /git clone.*qinglong-static/);
|
||||
assert.match(
|
||||
dockerfile,
|
||||
/git fetch --depth=1 origin "\$\{SOURCE_COMMIT\}"/,
|
||||
);
|
||||
assert.match(dockerfile, /node \/tmp\/verify-build.cjs/);
|
||||
}
|
||||
});
|
||||
|
||||
test('complete artifact manifests reject changed, missing, extra files and untracked build inputs', (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-build-manifest-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { cwd: dir, stdio: 'pipe' });
|
||||
git('init');
|
||||
git('config', 'user.name', 'Build test');
|
||||
git('config', 'user.email', 'build-test@example.invalid');
|
||||
fs.writeFileSync(path.join(dir, '.gitignore'), 'static/\n');
|
||||
fs.writeFileSync(path.join(dir, 'pnpm-lock.yaml'), 'lockfileVersion: 6.0\n');
|
||||
git('add', '.');
|
||||
git('commit', '-m', 'fixture');
|
||||
const artifacts = [
|
||||
'build/app.js',
|
||||
'build/services/http.js',
|
||||
'dist/index.html',
|
||||
'dist/chunks/main.js',
|
||||
'dist/assets/main.css',
|
||||
'dist/.well-known/config',
|
||||
];
|
||||
for (const file of artifacts) {
|
||||
fs.mkdirSync(path.dirname(path.join(dir, 'static', file)), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(path.join(dir, 'static', file), file);
|
||||
}
|
||||
const write = path.resolve('scripts/write-build-info.cjs');
|
||||
const verify = path.resolve('docker/verify-build.cjs');
|
||||
const run = (script) =>
|
||||
spawnSync(process.execPath, [script], { cwd: dir, encoding: 'utf8' });
|
||||
assert.equal(run(write).status, 0);
|
||||
const manifestFile = path.join(dir, 'static/build-info.json');
|
||||
const original = fs.readFileSync(manifestFile, 'utf8');
|
||||
assert.deepEqual(
|
||||
Object.keys(JSON.parse(original).files).sort(),
|
||||
artifacts.sort(),
|
||||
);
|
||||
assert.equal(run(write).status, 0);
|
||||
assert.equal(fs.readFileSync(manifestFile, 'utf8'), original);
|
||||
assert.equal(run(verify).status, 0);
|
||||
for (const name of [
|
||||
'build/services/http.js',
|
||||
'dist/chunks/main.js',
|
||||
'dist/assets/main.css',
|
||||
'dist/.well-known/config',
|
||||
]) {
|
||||
const file = path.join(dir, 'static', name);
|
||||
fs.writeFileSync(file, 'stale');
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
fs.unlinkSync(file);
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
fs.writeFileSync(file, name);
|
||||
assert.equal(run(verify).status, 0);
|
||||
}
|
||||
const extra = path.join(dir, 'static/build/stale.js');
|
||||
fs.writeFileSync(extra, 'extra');
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
fs.unlinkSync(extra);
|
||||
const source = path.join(dir, 'custom.config.js');
|
||||
fs.writeFileSync(source, 'untracked build input');
|
||||
assert.equal(run(write).status, 0);
|
||||
assert.equal(JSON.parse(fs.readFileSync(manifestFile)).dirty, true);
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
fs.unlinkSync(source);
|
||||
assert.equal(run(write).status, 0);
|
||||
assert.equal(run(verify).status, 0);
|
||||
fs.symlinkSync(path.join(dir, 'pnpm-lock.yaml'), extra);
|
||||
assert.notEqual(run(write).status, 0);
|
||||
assert.notEqual(run(verify).status, 0);
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const express = require('express');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('Python environment generation escapes backslashes and triple quotes together', async () => {
|
||||
const output = {};
|
||||
const Env = load(path.resolve('back/services/env.ts'), {
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
'../config': { envFile: 'sh', jsEnvFile: 'js', pyEnvFile: 'py' },
|
||||
'../data/env': { EnvStatus: { normal: 0 } },
|
||||
'../data': {},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: async (file, text) => (output[file] = text),
|
||||
},
|
||||
}).default;
|
||||
const values = [
|
||||
"\\'''; injected = True; #",
|
||||
"'''",
|
||||
'\\\\\\',
|
||||
'quotes\'"`\n中文\u2028',
|
||||
'${1+2}',
|
||||
];
|
||||
const service = new Env({});
|
||||
service.envs = async () =>
|
||||
values.map((value, i) => ({ name: `VALUE_${i}`, value }));
|
||||
await service.set_envs();
|
||||
const result = execFileSync(
|
||||
'python3',
|
||||
[
|
||||
'-c',
|
||||
output.py +
|
||||
'\nimport json\nprint(json.dumps([os.environ["VALUE_"+str(i)] for i in range(5)]))\nassert "injected" not in globals()',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
assert.deepEqual(JSON.parse(result), values);
|
||||
});
|
||||
|
||||
test('cron lookup treats regex and shell metacharacters as literal command text', () => {
|
||||
const { findCronId } = load(path.resolve('back/shared/cronCommand.ts'));
|
||||
const command = 'task file[1].js --value "$(touch ignored)"';
|
||||
const crontab =
|
||||
`* * * * * real_time=false no_tee=true ID=17 ${command}\n` +
|
||||
'* * * * * real_time=false no_tee=true ID=18 task other.js\n';
|
||||
assert.equal(findCronId(crontab, command), '17');
|
||||
assert.equal(findCronId(crontab, 'task file1.js'), '');
|
||||
assert.equal(findCronId(crontab, '.*'), '');
|
||||
assert.equal(findCronId(crontab, 'task other.js'), '18');
|
||||
assert.equal(findCronId('', command), '');
|
||||
assert.equal(findCronId(crontab, ''), '');
|
||||
});
|
||||
|
||||
test('command route does not execute input while looking up the cron id', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-command-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const marker = path.join(root, 'unexpected');
|
||||
const command = `task demo.js "$(touch ${marker})"`;
|
||||
const crontabFile = path.join(root, 'crontab');
|
||||
fs.writeFileSync(
|
||||
crontabFile,
|
||||
`* * * * * real_time=false no_tee=true ID=42 ${command}\n`,
|
||||
);
|
||||
const seen = [];
|
||||
const app = express.Router();
|
||||
load(path.resolve('back/api/system.ts'), {
|
||||
'../config': { crontabFile, tmpPath: root },
|
||||
'../services/system': {},
|
||||
'../services/user': {},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'../shared/logStreamManager': { logStreamManager: {} },
|
||||
typedi: {
|
||||
Container: { get: () => ({ run: async (value) => seen.push(value) }) },
|
||||
},
|
||||
'../config/util': {
|
||||
getUniqPath: async (_command, id) => {
|
||||
assert.equal(id, '42');
|
||||
return 'test';
|
||||
},
|
||||
promiseExec: async (cmd) => {
|
||||
return execFileSync('/bin/sh', ['-c', cmd], { encoding: 'utf8' });
|
||||
},
|
||||
},
|
||||
}).default(app);
|
||||
const router = app.stack.find((x) => x.name === 'router').handle;
|
||||
const route = router.stack.find(
|
||||
(x) => x.route?.path === '/command-run',
|
||||
).route;
|
||||
await route.stack
|
||||
.at(-1)
|
||||
.handle({ body: { command } }, { setHeader() {} }, (e) => {
|
||||
throw e;
|
||||
});
|
||||
assert.equal(fs.existsSync(marker), false);
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].command, command);
|
||||
});
|
||||
|
||||
test('command execution is limited before running across both API mounts', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-command-limit-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let runs = 0;
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const router = express.Router();
|
||||
load(path.resolve('back/api/system.ts'), {
|
||||
'../config': { crontabFile: path.join(root, 'missing'), tmpPath: root },
|
||||
'../services/system': {},
|
||||
'../services/user': {},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'../shared/logStreamManager': {
|
||||
logStreamManager: { closeStream: async () => {} },
|
||||
},
|
||||
'../config/util': {
|
||||
getUniqPath: async () => 'test',
|
||||
handleLogPath: async () => path.join(root, 'unused.log'),
|
||||
},
|
||||
typedi: {
|
||||
Container: {
|
||||
get: () => ({
|
||||
run: async (value, callbacks) => {
|
||||
runs++;
|
||||
await callbacks.onEnd();
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}).default(router);
|
||||
app.use('/api', router);
|
||||
app.use('/open', router);
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
for (let i = 0; i < 61; i++) {
|
||||
const response = await fetch(
|
||||
`${base}/${i % 2 ? 'api' : 'open'}/system/command-run`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command: 'echo safe' }),
|
||||
},
|
||||
);
|
||||
await response.text();
|
||||
assert.equal(response.status, i < 60 ? 200 : 429, `request ${i + 1}`);
|
||||
}
|
||||
assert.equal(runs, 60);
|
||||
});
|
||||
|
||||
test('anonymous token exchange is limited per source across both API mounts', async (t) => {
|
||||
let attempts = 0;
|
||||
const app = express();
|
||||
const router = express.Router();
|
||||
load(path.resolve('back/api/open.ts'), {
|
||||
'../services/open': {},
|
||||
typedi: {
|
||||
Container: {
|
||||
get: () => ({
|
||||
authToken: async () => {
|
||||
attempts++;
|
||||
return { code: 401 };
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}).default(router);
|
||||
app.use('/api', router);
|
||||
app.use('/open', router);
|
||||
const server = await new Promise((resolve) => {
|
||||
const s = app.listen(0, '127.0.0.1', () => resolve(s));
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
for (let i = 0; i < 101; i++) {
|
||||
const response = await fetch(
|
||||
`${base}/${
|
||||
i % 2 ? 'api' : 'open'
|
||||
}/auth/token?client_id=id&client_secret=wrong`,
|
||||
);
|
||||
await response.text();
|
||||
assert.equal(response.status, i < 100 ? 200 : 429, `request ${i + 1}`);
|
||||
}
|
||||
assert.equal(attempts, 100);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const ts = require('typescript');
|
||||
const loadModule = require('../helpers/load-security-module.cjs');
|
||||
|
||||
const { commonCronSchema } = loadModule(
|
||||
path.join(__dirname, '../../back/validation/schedule.ts'),
|
||||
{ '../config': { logPath: '/ql/data/log/' } },
|
||||
);
|
||||
|
||||
// Execute the actual form validator without mounting the entire task modal.
|
||||
const source = ts.createSourceFile(
|
||||
'modal.tsx',
|
||||
fs.readFileSync(
|
||||
path.join(__dirname, '../../src/pages/crontab/modal.tsx'),
|
||||
'utf8',
|
||||
),
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let validateForm;
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isJsxOpeningElement(node) &&
|
||||
node.attributes.properties.some(
|
||||
(attr) =>
|
||||
ts.isJsxAttribute(attr) &&
|
||||
attr.name.text === 'name' &&
|
||||
attr.initializer?.text === 'log_name',
|
||||
)
|
||||
) {
|
||||
const rules = node.attributes.properties.find(
|
||||
(attr) => ts.isJsxAttribute(attr) && attr.name.text === 'rules',
|
||||
);
|
||||
const validator = rules.initializer.expression.elements[0].properties.find(
|
||||
(property) => property.name.text === 'validator',
|
||||
).initializer;
|
||||
const { outputText } = ts.transpileModule(
|
||||
`const validate = ${validator.getText(source)};`,
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2017 } },
|
||||
);
|
||||
validateForm = new Function('intl', `${outputText}\nreturn validate;`)({
|
||||
get: (key) => key,
|
||||
});
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(source);
|
||||
assert.equal(typeof validateForm, 'function');
|
||||
|
||||
test('form and API accept Chinese log names and existing supported names', async () => {
|
||||
for (const value of [
|
||||
'',
|
||||
null,
|
||||
undefined,
|
||||
'测试',
|
||||
'任务_測試-2026.log',
|
||||
'分组/每日签到/',
|
||||
'𠮷/扩展汉字',
|
||||
'legacy_name-123.log',
|
||||
'legacy/path/',
|
||||
'/ql/data/log/中文日志',
|
||||
'/dev/null',
|
||||
'中'.repeat(100),
|
||||
]) {
|
||||
await validateForm(undefined, value);
|
||||
assert.equal(
|
||||
commonCronSchema.log_name.validate(value).error,
|
||||
undefined,
|
||||
value,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('form and API still reject unsafe relative names and excessive length', async () => {
|
||||
for (const value of [
|
||||
'.',
|
||||
'..',
|
||||
'../测试',
|
||||
'测试/../日志',
|
||||
'测试/./日志',
|
||||
'测试//日志',
|
||||
'测试\\日志',
|
||||
'测试 日志',
|
||||
'测试;echo',
|
||||
'测试$(id)',
|
||||
'测试`id`',
|
||||
'测试\n日志',
|
||||
'测试\0日志',
|
||||
'测试😀',
|
||||
'中'.repeat(101),
|
||||
]) {
|
||||
await assert.rejects(validateForm(undefined, value), undefined, value);
|
||||
assert.ok(commonCronSchema.log_name.validate(value).error, value);
|
||||
}
|
||||
});
|
||||
|
||||
test('API restricts absolute log paths to the configured directory or /dev/null', () => {
|
||||
for (const value of [
|
||||
'/tmp/测试',
|
||||
'/ql/data/log/../测试',
|
||||
'/ql/data/log-other/测试',
|
||||
]) {
|
||||
const { error } = commonCronSchema.log_name.validate(value);
|
||||
assert.equal(error.details[0].type, 'string.unsafePath', value);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
|
||||
|
||||
// node-cron caches timezone formatters; model each deployment in a fresh process.
|
||||
if (!process.env.QL_MACRO_TEST_TZ) {
|
||||
for (const tz of ['UTC', 'Asia/Shanghai']) {
|
||||
test(`macro execution in ${tz}`, () => {
|
||||
const result = require('node:child_process').spawnSync(process.execPath,
|
||||
['-r', 'ts-node/register/transpile-only', '--test', __filename],
|
||||
{ env: { ...process.env, TZ: tz, QL_MACRO_TEST_TZ: tz }, encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stdout + result.stderr);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const tz of process.env.QL_MACRO_TEST_TZ ? [process.env.QL_MACRO_TEST_TZ] : []) {
|
||||
for (const [macro, start, next] of [
|
||||
['@annually', [2026, 11, 31, 23, 59, 59], [[2027, 0, 1, 0, 0, 0], [2028, 0, 1, 0, 0, 0]]],
|
||||
['@midnight', [2026, 8, 27, 23, 59, 59], [[2026, 8, 28, 0, 0, 0], [2026, 8, 29, 0, 0, 0]]],
|
||||
['@minutely', [2026, 8, 27, 12, 34, 59], [[2026, 8, 27, 12, 35, 0], [2026, 8, 27, 12, 36, 0]]],
|
||||
]) {
|
||||
test(`${macro} fires at the expected local times in ${tz}`, async (t) => {
|
||||
const previousTZ = process.env.TZ;
|
||||
process.env.TZ = tz;
|
||||
t.after(() => { if (previousTZ === undefined) delete process.env.TZ; else process.env.TZ = previousTZ; });
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date(...start) });
|
||||
const expected = next.map((parts) => new Date(...parts).getTime());
|
||||
const actual = [];
|
||||
assert.equal(isValidCronSchedule(` ${macro} `), true);
|
||||
const job = createCronJob(macro, (date) => actual.push(date.getTime()), {
|
||||
name: macro, logger: { warn() {}, error() {} },
|
||||
});
|
||||
t.after(() => job.cancel());
|
||||
for (const time of expected) {
|
||||
t.mock.timers.tick(time - Date.now() - 1);
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(actual.includes(time), false, 'must not fire early');
|
||||
t.mock.timers.tick(1);
|
||||
await new Promise(setImmediate);
|
||||
}
|
||||
assert.deepEqual(actual, expected);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('new aliases do not enable unknown macros or raw parser extensions', () => {
|
||||
for (const schedule of ['@annuallyx', '@midnightx', '@minutelyx', '@secondly', '@reboot', 'H * * * *', '0 /5 * * * *']) {
|
||||
assert.equal(isValidCronSchedule(schedule), false, schedule);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { isValidCronSchedule } = require('../../back/shared/cronSchedule');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
const legacy = require('../fixtures/legacy-cron.json');
|
||||
const logger = { warn() {}, error() {} };
|
||||
const flush = () => new Promise(setImmediate);
|
||||
|
||||
test('validation preserves legacy syntax except the explicitly added macros', () => {
|
||||
const addedMacros = new Set(['@annually', '@midnight', '@minutely']);
|
||||
assert.ok(legacy.validation.length > 250);
|
||||
for (const { schedule, accepted } of legacy.validation) {
|
||||
assert.equal(isValidCronSchedule(schedule), accepted || addedMacros.has(schedule), schedule);
|
||||
}
|
||||
});
|
||||
|
||||
for (const sample of legacy.times) {
|
||||
test(`calendar compatibility: ${sample.schedule} from ${sample.currentDate}`, async (t) => {
|
||||
const previousTZ = process.env.TZ;
|
||||
process.env.TZ = 'UTC';
|
||||
t.after(() => { if (previousTZ === undefined) delete process.env.TZ; else process.env.TZ = previousTZ; });
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date(sample.currentDate) });
|
||||
const actual = [];
|
||||
const job = createCronJob(sample.schedule, (date) => actual.push(date.toISOString()), { name: 'calendar', logger });
|
||||
t.after(() => job.cancel());
|
||||
for (const expected of sample.next) {
|
||||
t.mock.timers.tick(new Date(expected).getTime() - Date.now());
|
||||
await flush();
|
||||
}
|
||||
assert.deepEqual(actual, sample.next);
|
||||
});
|
||||
}
|
||||
|
||||
test('late callbacks catch up exactly once and cancellation destroys native tasks', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-09-27T00:00:00Z') });
|
||||
const native = require('node-cron');
|
||||
const before = native.getTasks().size;
|
||||
const dates = [], warnings = [];
|
||||
const job = createCronJob('* * * * * *', (date) => dates.push(date.getTime()), {
|
||||
name: 'late', logger: { warn: (...args) => warnings.push(args), error() {} },
|
||||
});
|
||||
t.after(() => job.cancel());
|
||||
t.mock.timers.tick(8000);
|
||||
await flush();
|
||||
assert.equal(dates.length, 8);
|
||||
assert.equal(new Set(dates).size, 8);
|
||||
assert.ok(warnings.length > 0);
|
||||
job.cancel();
|
||||
t.mock.timers.tick(5000);
|
||||
await flush();
|
||||
assert.equal(dates.length, 8);
|
||||
assert.equal(native.getTasks().size, before);
|
||||
});
|
||||
|
||||
test('callback rejection is logged and future executions continue', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-09-27T00:00:00Z') });
|
||||
const errors = [];
|
||||
let count = 0;
|
||||
const job = createCronJob('* * * * * *', async () => { count++; throw Error('callback failed'); }, {
|
||||
name: 'failure', logger: { warn() {}, error: (...args) => errors.push(args) },
|
||||
});
|
||||
t.after(() => job.cancel());
|
||||
t.mock.timers.tick(1000);
|
||||
await flush();
|
||||
t.mock.timers.tick(1000);
|
||||
await flush();
|
||||
assert.equal(count, 2);
|
||||
assert.equal(errors.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const ts = require('typescript');
|
||||
const { ScheduleType } = require('../../back/interface/schedule');
|
||||
|
||||
test('system and node modes route extended cron once and keep portable rules in crontab', async () => {
|
||||
const source = fs.readFileSync('back/services/cron.ts', 'utf8');
|
||||
const begin = source.indexOf(' private isNodeCron(');
|
||||
const end = source.indexOf(' private async getLogName', begin);
|
||||
const saveBegin = source.indexOf(' private async setCrontab(');
|
||||
const saveEnd = source.indexOf(' public importCrontab(', saveBegin);
|
||||
const js = ts.transpileModule(`class Fixture {${source.slice(begin, end)}${source.slice(saveBegin, saveEnd)}}; module.exports = Fixture;`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
for (const mode of ['system', 'node']) {
|
||||
const module = { exports: {} };
|
||||
let file = '', installs = 0;
|
||||
new Function('module', 'process', 'execSync', 'ScheduleType', 'config', 'writeFileWithLock', 'CrontabModel', js)(
|
||||
module, { env: { QL_SCHEDULER: mode } }, () => { installs++; }, ScheduleType,
|
||||
{ crontabFile: '/unused' }, async (_, data) => { file = data; }, { update: async () => {} },
|
||||
);
|
||||
const service = new module.exports();
|
||||
service.makeCommand = (row) => `task ${row.id}.js`;
|
||||
const rows = [
|
||||
{ id: 'plain', schedule: '*/5 0-23 * * 1,3' },
|
||||
{ id: 'spaces', schedule: ' 0\t0 * * * ' },
|
||||
{ id: 'numeric-step', schedule: '0/5 * * * *' },
|
||||
{ id: 'sunday-seven', schedule: '0 0 * * 7' },
|
||||
{ id: 'day-step', schedule: '0 0 */2 * *' },
|
||||
{ id: 'restricted-days', schedule: '0 0 1-31 * 1' },
|
||||
{ id: 'restricted-week', schedule: '0 0 1 * 0-6' },
|
||||
{ id: 'annually', schedule: '@annually' },
|
||||
{ id: 'midnight', schedule: '@midnight' },
|
||||
{ id: 'minutely', schedule: '@minutely' },
|
||||
{ id: 'seconds', schedule: '0 0 * * * *' },
|
||||
{ id: 'question', schedule: '0 0 * * ?' },
|
||||
{ id: 'last', schedule: '0 0 L * *' },
|
||||
{ id: 'nth', schedule: '0 0 * * 1#2' },
|
||||
{ id: 'named', schedule: '0 0 * JAN MON' },
|
||||
{ id: 'macro', schedule: '@daily' },
|
||||
{ id: 'short', schedule: '*' },
|
||||
{ id: 'extra', schedule: '* * * * *', extra_schedules: [{ schedule: '0 * * * *' }] },
|
||||
{ id: 'once', schedule: '@once' },
|
||||
{ id: 'boot', schedule: '@boot' },
|
||||
];
|
||||
for (const row of rows) {
|
||||
const special = ['once', 'boot'].includes(row.id);
|
||||
const portable = ['plain', 'spaces'].includes(row.id);
|
||||
assert.equal(service.shouldUseCronClient(row), !special && (mode === 'node' || !portable), `${mode}: ${row.id}`);
|
||||
}
|
||||
await service.setCrontab({ data: rows, total: rows.length });
|
||||
assert.equal(installs, mode === 'system' ? 1 : 0);
|
||||
for (const id of ['numeric-step', 'sunday-seven', 'day-step', 'restricted-days', 'restricted-week', 'annually', 'midnight', 'minutely', 'seconds', 'question', 'last', 'nth', 'named', 'macro', 'short', 'extra', 'once', 'boot']) {
|
||||
const line = file.split('\n').find((line) => line.endsWith(`task ${id}.js`));
|
||||
assert.ok(line.startsWith('# '), `${mode}: ${id} cannot also run from system crontab`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const configPath = require.resolve('../../back/config');
|
||||
require.cache[configPath] = {
|
||||
id: configPath,
|
||||
filename: configPath,
|
||||
loaded: true,
|
||||
exports: { __esModule: true, default: { logPath: '/ql/data/log' } },
|
||||
children: [],
|
||||
paths: [],
|
||||
};
|
||||
|
||||
const { commonCronSchema } = require('../../back/validation/schedule');
|
||||
const { Joi } = require('celebrate');
|
||||
|
||||
const schema = Joi.object(commonCronSchema);
|
||||
|
||||
test('cron validation accepts leading-zero schedules and legacy null labels', () => {
|
||||
const result = schema.validate({
|
||||
name: 'legacy cron',
|
||||
command: 'task legacy.js',
|
||||
schedule: '01 7 * * *',
|
||||
labels: null,
|
||||
});
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
});
|
||||
|
||||
test('cron validation identifies invalid fields', () => {
|
||||
const result = schema.validate(
|
||||
{
|
||||
name: 'invalid cron',
|
||||
command: 'task invalid.js',
|
||||
schedule: '01 7 * * *',
|
||||
allow_multiple_instances: '',
|
||||
},
|
||||
{ abortEarly: false },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...new Set(result.error.details.map((detail) => detail.path.join('.')))],
|
||||
['allow_multiple_instances'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
require('ts-node/register/transpile-only');
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const dayjs = require('dayjs');
|
||||
const express = require('express');
|
||||
|
||||
test('today result lists include mixed and deleted tasks and exclude other dates', async (t) => {
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => db.close());
|
||||
const crons = db.define('Cron', {
|
||||
name: DataTypes.STRING,
|
||||
command: DataTypes.STRING,
|
||||
});
|
||||
const stats = db.define('Stat', {
|
||||
ref_id: DataTypes.INTEGER,
|
||||
date: DataTypes.STRING,
|
||||
fail_count: DataTypes.INTEGER,
|
||||
success_count: DataTypes.INTEGER,
|
||||
});
|
||||
const replacements = {
|
||||
'../../back/data/cron': { CrontabModel: crons },
|
||||
'../../back/data/cronStats': { CrontabStatModel: stats },
|
||||
'../../back/data/runningInstance': {},
|
||||
'../../back/shared/i18n': { tf: (format, id) => format.replace('%s', id) },
|
||||
};
|
||||
for (const [modulePath, exports] of Object.entries(replacements)) {
|
||||
const resolved = require.resolve(modulePath);
|
||||
const original = require.cache[resolved];
|
||||
require.cache[resolved] = {
|
||||
id: resolved,
|
||||
filename: resolved,
|
||||
loaded: true,
|
||||
exports,
|
||||
};
|
||||
t.after(() => {
|
||||
if (original) require.cache[resolved] = original;
|
||||
else delete require.cache[resolved];
|
||||
});
|
||||
}
|
||||
await db.sync();
|
||||
await crons.bulkCreate([
|
||||
{ id: 1, name: 'Recovered task', command: 'task recovered.js' },
|
||||
{ id: 2, name: '', command: 'task unnamed.js' },
|
||||
{ id: 3, name: 'Successful task', command: 'task success.js' },
|
||||
]);
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
await stats.bulkCreate([
|
||||
{ ref_id: 1, date: today, fail_count: 2, success_count: 1 },
|
||||
{ ref_id: 2, date: today, fail_count: 1, success_count: 0 },
|
||||
{ ref_id: 3, date: today, fail_count: 0, success_count: 4 },
|
||||
{ ref_id: 4, date: today, fail_count: 3, success_count: 2 },
|
||||
{
|
||||
ref_id: 3,
|
||||
date: dayjs().subtract(1, 'day').format('YYYY-MM-DD'),
|
||||
fail_count: 10,
|
||||
success_count: 20,
|
||||
},
|
||||
]);
|
||||
const router = express.Router();
|
||||
require('../../back/api/dashboard').default(router);
|
||||
const dashboard = router.stack.find(
|
||||
(layer) => layer.name === 'router',
|
||||
).handle;
|
||||
const handler = dashboard.stack.find(
|
||||
(layer) => layer.route?.path === '/failures',
|
||||
).route.stack[0].handle;
|
||||
const successHandler = dashboard.stack.find(
|
||||
(layer) => layer.route?.path === '/successes',
|
||||
).route.stack[0].handle;
|
||||
let response;
|
||||
await handler(
|
||||
{},
|
||||
{
|
||||
send: (value) => {
|
||||
response = value;
|
||||
},
|
||||
},
|
||||
(error) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(response, {
|
||||
code: 200,
|
||||
data: [
|
||||
{ id: 4, name: '任务#4', command: '', failCount: 3, deleted: true },
|
||||
{
|
||||
id: 1,
|
||||
name: 'Recovered task',
|
||||
command: 'task recovered.js',
|
||||
failCount: 2,
|
||||
deleted: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'task unnamed.js',
|
||||
command: 'task unnamed.js',
|
||||
failCount: 1,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
await successHandler(
|
||||
{},
|
||||
{
|
||||
send: (value) => {
|
||||
response = value;
|
||||
},
|
||||
},
|
||||
(error) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(response, {
|
||||
code: 200,
|
||||
data: [
|
||||
{
|
||||
id: 3,
|
||||
name: 'Successful task',
|
||||
command: 'task success.js',
|
||||
successCount: 4,
|
||||
deleted: false,
|
||||
},
|
||||
{ id: 4, name: '任务#4', command: '', successCount: 2, deleted: true },
|
||||
{
|
||||
id: 1,
|
||||
name: 'Recovered task',
|
||||
command: 'task recovered.js',
|
||||
successCount: 1,
|
||||
deleted: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
await stats.destroy({ where: {} });
|
||||
await successHandler(
|
||||
{},
|
||||
{
|
||||
send: (value) => {
|
||||
response = value;
|
||||
},
|
||||
},
|
||||
(error) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(response, { code: 200, data: [] });
|
||||
await handler(
|
||||
{},
|
||||
{
|
||||
send: (value) => {
|
||||
response = value;
|
||||
},
|
||||
},
|
||||
(error) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
assert.deepEqual(response, { code: 200, data: [] });
|
||||
await stats.drop();
|
||||
let forwarded;
|
||||
await handler(
|
||||
{},
|
||||
{ send: () => assert.fail('must forward database errors') },
|
||||
(error) => {
|
||||
forwarded = error;
|
||||
},
|
||||
);
|
||||
assert.ok(forwarded instanceof Error);
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
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 { fork } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const { Sequelize, QueryTypes } = require('sequelize');
|
||||
const ts = require('typescript');
|
||||
const { createRequire } = require('node:module');
|
||||
|
||||
const initializer = require.resolve('../../back/bootstrap/database');
|
||||
const startup = require.resolve('../../back/shared/startupProcess');
|
||||
|
||||
function fixture(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-bootstrap-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(dir, 'data/db'), { recursive: true });
|
||||
fs.mkdirSync(path.join(dir, 'data/syslog'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, '.env'), '');
|
||||
const wrapper = path.join(dir, 'parent.cjs');
|
||||
fs.writeFileSync(wrapper, `
|
||||
const cp = require('node:child_process');
|
||||
const original = cp.fork;
|
||||
cp.fork = (...args) => {
|
||||
const child = original(...args);
|
||||
process.send?.({ childPid: child.pid });
|
||||
return child;
|
||||
};
|
||||
const { runStartupProcess } = require(${JSON.stringify(startup)});
|
||||
runStartupProcess(process.argv[2]).then(() => {
|
||||
const modules = Object.keys(require.cache);
|
||||
process.send?.({ done: true, modules });
|
||||
}).catch(error => {
|
||||
console.error(error.message);
|
||||
process.exitCode = 1;
|
||||
}).finally(() => { if (process.connected) process.disconnect(); });
|
||||
`);
|
||||
return {
|
||||
dir,
|
||||
storage: path.join(dir, 'data/db/database.sqlite'),
|
||||
run(entrypoint = initializer) {
|
||||
const child = fork(wrapper, [entrypoint], {
|
||||
env: { ...process.env, QL_DIR: dir, QL_DATA_DIR: path.join(dir, 'data') },
|
||||
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
|
||||
});
|
||||
const messages = [];
|
||||
let output = '';
|
||||
child.on('message', (message) => messages.push(message));
|
||||
child.stdout.on('data', (data) => { output += data; });
|
||||
child.stderr.on('data', (data) => { output += data; });
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
});
|
||||
const done = once(child, 'close').then(([code, signal]) => ({ code, signal, output, messages }));
|
||||
return { child, done, messages };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function waitFor(predicate) {
|
||||
const deadline = Date.now() + 10000;
|
||||
while (!predicate()) {
|
||||
assert.ok(Date.now() < deadline, 'timed out waiting for child');
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
}
|
||||
|
||||
test('isolated initialization creates a fresh database and does not retain ORM modules in the parent', { timeout: 20000 }, async t => {
|
||||
const f = fixture(t);
|
||||
const result = await f.run().done;
|
||||
assert.equal(result.code, 0, result.output);
|
||||
const done = result.messages.find(message => message.done);
|
||||
assert.ok(done);
|
||||
assert.equal(done.modules.some(file => /[\\/]sequelize[\\/]|[\\/]data[\\/]index\.ts$/.test(file)), false);
|
||||
const db = new Sequelize({ dialect: 'sqlite', storage: f.storage, logging: false });
|
||||
try {
|
||||
const tables = await db.getQueryInterface().showAllTables();
|
||||
for (const table of ['Crontabs', 'Envs', 'RunningInstances', 'SchemaMigrations']) assert.ok(tables.includes(table), table);
|
||||
assert.equal((await db.query('SELECT id FROM SchemaMigrations', { type: QueryTypes.SELECT })).length, 15);
|
||||
} finally { await db.close(); }
|
||||
});
|
||||
|
||||
test('isolated initialization upgrades an existing database and remains idempotent', { timeout: 30000 }, async t => {
|
||||
const f = fixture(t);
|
||||
let db = new Sequelize({ dialect: 'sqlite', storage: f.storage, logging: false });
|
||||
await db.query('CREATE TABLE Crontabs (id INTEGER PRIMARY KEY, name TEXT)');
|
||||
await db.query("INSERT INTO Crontabs (id, name) VALUES (1, 'keep-me')");
|
||||
await db.close();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const result = await f.run().done;
|
||||
assert.equal(result.code, 0, result.output);
|
||||
}
|
||||
db = new Sequelize({ dialect: 'sqlite', storage: f.storage, logging: false });
|
||||
try {
|
||||
const rows = await db.query('SELECT * FROM Crontabs', { type: QueryTypes.SELECT });
|
||||
assert.equal(rows[0].name, 'keep-me');
|
||||
assert.ok(Object.hasOwn(rows[0], 'queued_token'));
|
||||
assert.equal((await db.query('SELECT id FROM SchemaMigrations', { type: QueryTypes.SELECT })).length, 15);
|
||||
} finally { await db.close(); }
|
||||
});
|
||||
|
||||
test('database errors and missing initializer files reject startup', { timeout: 20000 }, async t => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(f.storage, 'invalid SQLite database');
|
||||
const invalid = await f.run().done;
|
||||
assert.equal(invalid.code, 1);
|
||||
assert.match(invalid.output, /SQLITE_NOTADB/);
|
||||
assert.equal(invalid.messages.some(message => message.done), false);
|
||||
const missing = await f.run(path.join(f.dir, 'missing.cjs')).done;
|
||||
assert.equal(missing.code, 1);
|
||||
assert.match(missing.output, /MODULE_NOT_FOUND/);
|
||||
});
|
||||
|
||||
for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
test(`startup ${signal} terminates the initializer and never reports success`, { timeout: 15000 }, async t => {
|
||||
const f = fixture(t);
|
||||
const target = path.join(f.dir, 'waiting.cjs');
|
||||
const ready = path.join(f.dir, 'ready');
|
||||
fs.writeFileSync(target, `
|
||||
require('node:fs').writeFileSync(${JSON.stringify(ready)}, String(process.pid));
|
||||
setInterval(() => {}, 1000);
|
||||
`);
|
||||
const running = f.run(target);
|
||||
await waitFor(() => fs.existsSync(ready));
|
||||
const pid = Number(fs.readFileSync(ready, 'utf8'));
|
||||
running.child.kill(signal);
|
||||
const result = await running.done;
|
||||
assert.equal(result.code, 1, result.output);
|
||||
assert.match(result.output, new RegExp(signal));
|
||||
assert.equal(result.messages.some(message => message.done), false);
|
||||
assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' });
|
||||
});
|
||||
}
|
||||
|
||||
test('a database initializer blocked on SQLite exits when its parent disappears', { timeout: 20000 }, async t => {
|
||||
const f = fixture(t);
|
||||
const db = new Sequelize({ dialect: 'sqlite', storage: f.storage, logging: false });
|
||||
await db.query('BEGIN EXCLUSIVE');
|
||||
try {
|
||||
const running = f.run();
|
||||
await waitFor(() => running.messages.some(message => message.childPid));
|
||||
const pid = running.messages.find(message => message.childPid).childPid;
|
||||
// Allow the real initializer to enter the database operation before losing IPC.
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
running.child.kill('SIGKILL');
|
||||
await running.done;
|
||||
await waitFor(() => {
|
||||
try { process.kill(pid, 0); return false; }
|
||||
catch (error) { if (error.code === 'ESRCH') return true; throw error; }
|
||||
});
|
||||
} finally {
|
||||
await db.query('ROLLBACK');
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('application waits for isolated initialization and does not fork workers after a failure', async () => {
|
||||
const file = path.resolve('back/app.ts');
|
||||
const source = fs.readFileSync(file, 'utf8');
|
||||
const compiled = ts.transpileModule(source.slice(0, source.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true },
|
||||
}).outputText;
|
||||
for (const fail of [false, true]) {
|
||||
let complete;
|
||||
const pending = new Promise((resolve, reject) => { complete = () => fail ? reject(new Error('migration failed')) : resolve(); });
|
||||
const exits = [], calls = [];
|
||||
const localRequire = createRequire(file);
|
||||
const mocks = {
|
||||
cluster: { isPrimary: true },
|
||||
'./config': {},
|
||||
'./loaders/logger': { error() {} },
|
||||
'./shared/startupProcess': { runStartupProcess: entry => { assert.equal(entry, initializer); return pending; } },
|
||||
};
|
||||
const req = Object.assign(name => {
|
||||
calls.push(name);
|
||||
return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name);
|
||||
}, { resolve: localRequire.resolve });
|
||||
const module = { exports: {} };
|
||||
new Function('require', 'module', 'exports', 'process', compiled)(req, module, module.exports, { exit: code => exits.push(code) });
|
||||
const app = new module.exports();
|
||||
let started = false;
|
||||
app.startMasterProcess = () => { started = true; };
|
||||
const start = app.start();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(started, false);
|
||||
complete();
|
||||
await start;
|
||||
assert.equal(started, !fail);
|
||||
assert.deepEqual(exits, fail ? [1] : []);
|
||||
assert.equal(calls.includes('./loaders/db'), false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
test('dependency listing marks cache entries missing from disk for reinstall', async (t) => {
|
||||
const moduleStubs = new Map();
|
||||
const stubModule = (modulePath, exports) => {
|
||||
const resolved = require.resolve(modulePath);
|
||||
moduleStubs.set(resolved, require.cache[resolved]);
|
||||
require.cache[resolved] = {
|
||||
id: resolved,
|
||||
filename: resolved,
|
||||
loaded: true,
|
||||
exports,
|
||||
children: [],
|
||||
paths: [],
|
||||
};
|
||||
};
|
||||
|
||||
const DependenceStatus = {
|
||||
installing: 0,
|
||||
installed: 1,
|
||||
installFailed: 2,
|
||||
};
|
||||
const DependenceTypes = { nodejs: 0, python3: 1, linux: 2 };
|
||||
const docs = [
|
||||
{ id: 1, name: 'missing-package', type: 0, status: 1 },
|
||||
{ id: 2, name: 'present-package', type: 0, status: 1 },
|
||||
];
|
||||
const updates = [];
|
||||
const DependenceModel = {
|
||||
findAll: async ({ where }) => {
|
||||
if (where.status === DependenceStatus.installed) {
|
||||
return docs;
|
||||
}
|
||||
return docs.map((doc) => ({
|
||||
...doc,
|
||||
status: updates.some((update) => update.ids.includes(doc.id))
|
||||
? DependenceStatus.installFailed
|
||||
: doc.status,
|
||||
}));
|
||||
},
|
||||
update: async ({ status }, { where }) => {
|
||||
updates.push({ status, ids: where.id });
|
||||
},
|
||||
};
|
||||
|
||||
stubModule('../../back/config', {
|
||||
__esModule: true,
|
||||
default: {},
|
||||
});
|
||||
stubModule('../../back/data/dependence', {
|
||||
Dependence: class Dependence {},
|
||||
DependenceModel,
|
||||
DependenceStatus,
|
||||
DependenceTypes,
|
||||
versionDependenceCommandTypes: { 0: '@', 1: '==', 2: '=' },
|
||||
});
|
||||
stubModule('../../back/config/util', {
|
||||
concurrentRun: async (tasks) => Promise.all(tasks.map((task) => task())),
|
||||
detectOS: async () => 'Alpine',
|
||||
fileExist: async () => false,
|
||||
getGetCommand: (_type, name) => `check:${name}`,
|
||||
getInstallCommand: () => '',
|
||||
getPid: async () => 0,
|
||||
getUninstallCommand: () => '',
|
||||
killTask: async () => {},
|
||||
promiseExecSuccess: async (command) =>
|
||||
command === 'check:present-package' ? 'present-package 1.0.0\n' : '',
|
||||
});
|
||||
stubModule('../../back/config/const', {
|
||||
LINUX_DEPENDENCE_COMMAND: { Alpine: {} },
|
||||
});
|
||||
stubModule('../../back/shared/pLimit', {
|
||||
__esModule: true,
|
||||
default: {},
|
||||
});
|
||||
stubModule('../../back/shared/i18n', {
|
||||
t: (message) => message,
|
||||
tf: (message) => message,
|
||||
});
|
||||
stubModule('../../back/services/sock', {
|
||||
__esModule: true,
|
||||
default: class SockService {},
|
||||
});
|
||||
|
||||
const servicePath = require.resolve('../../back/services/dependence');
|
||||
const originalService = require.cache[servicePath];
|
||||
delete require.cache[servicePath];
|
||||
t.after(() => {
|
||||
if (originalService) {
|
||||
require.cache[servicePath] = originalService;
|
||||
} else {
|
||||
delete require.cache[servicePath];
|
||||
}
|
||||
for (const [resolved, original] of moduleStubs) {
|
||||
if (original) {
|
||||
require.cache[resolved] = original;
|
||||
} else {
|
||||
delete require.cache[resolved];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const DependenceService = require('../../back/services/dependence').default;
|
||||
const service = new DependenceService({}, {});
|
||||
const result = await service.dependencies({
|
||||
searchValue: '',
|
||||
type: 'nodejs',
|
||||
status: '',
|
||||
});
|
||||
|
||||
assert.deepEqual(updates, [
|
||||
{ status: DependenceStatus.installFailed, ids: [1] },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
result.map(({ id, status }) => ({ id, status })),
|
||||
[
|
||||
{ id: 1, status: DependenceStatus.installFailed },
|
||||
{ id: 2, status: DependenceStatus.installed },
|
||||
],
|
||||
);
|
||||
|
||||
await service.dependencies({ searchValue: '', type: 'linux', status: '' });
|
||||
assert.equal(updates.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const express = require('express');
|
||||
|
||||
function mockModule(modulePath, exports) {
|
||||
const filename = require.resolve(modulePath);
|
||||
require.cache[filename] = {
|
||||
id: filename,
|
||||
filename,
|
||||
loaded: true,
|
||||
exports,
|
||||
children: [],
|
||||
paths: [],
|
||||
};
|
||||
}
|
||||
|
||||
mockModule('../../back/config', {
|
||||
__esModule: true,
|
||||
default: {
|
||||
bakPath: '/tmp',
|
||||
blackFileList: [],
|
||||
configPath: '/tmp',
|
||||
logPath: '/tmp',
|
||||
logs: { level: 'info' },
|
||||
rootPath: '/tmp',
|
||||
scriptPath: '/tmp',
|
||||
systemLogPath: '/tmp',
|
||||
writePathList: ['/tmp'],
|
||||
},
|
||||
});
|
||||
mockModule('../../back/shared/i18n', {
|
||||
t: (message) => message,
|
||||
});
|
||||
mockModule('../../back/config/util', {
|
||||
fileExist: async () => false,
|
||||
readDir: async () => [],
|
||||
readDirs: async () => [],
|
||||
removeAnsi: (content) => content,
|
||||
rmPath: async () => {},
|
||||
});
|
||||
mockModule('../../back/shared/utils', {
|
||||
writeFileWithLock: async () => {},
|
||||
});
|
||||
for (const service of ['config', 'script', 'log']) {
|
||||
mockModule(`../../back/services/${service}`, {
|
||||
__esModule: true,
|
||||
default: class {},
|
||||
});
|
||||
}
|
||||
mockModule('../../back/data/runningInstance', {
|
||||
InstanceStatus: { running: 'running' },
|
||||
RunningInstanceModel: { findOne: async () => null },
|
||||
});
|
||||
|
||||
const deprecatedRoutes = [
|
||||
['config', '/configs/detail'],
|
||||
['script', '/scripts/detail'],
|
||||
['log', '/logs/detail'],
|
||||
];
|
||||
|
||||
for (const [moduleName, replacement] of deprecatedRoutes) {
|
||||
test(`${moduleName} filename route points callers to its detail API`, () => {
|
||||
const app = express.Router();
|
||||
require(`../../back/api/${moduleName}`).default(app);
|
||||
const router = app.stack.find((layer) => layer.name === 'router').handle;
|
||||
const deprecatedRoute = router.stack.find(
|
||||
(layer) => layer.route?.path === '/:file',
|
||||
);
|
||||
const handler = deprecatedRoute.route.stack.at(-1).handle;
|
||||
let responseBody;
|
||||
|
||||
handler(
|
||||
{ params: { file: 'Example.js' }, query: {} },
|
||||
{ send: (body) => (responseBody = body) },
|
||||
);
|
||||
|
||||
assert.deepEqual(responseBody, {
|
||||
code: 410,
|
||||
message: `接口已下线,请使用 ${replacement} 接口`,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
function fixture(t, api = 'script') {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-draft-path-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
for (const dir of [
|
||||
'scripts',
|
||||
'config/grpc',
|
||||
'log',
|
||||
'tmp',
|
||||
'bak',
|
||||
'outside',
|
||||
]) {
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
}
|
||||
const config = { blackFileList: ['auth.json', 'token.json', 'grpc'] };
|
||||
for (const [key, dir] of Object.entries({
|
||||
scriptPath: 'scripts',
|
||||
configPath: 'config',
|
||||
logPath: 'log',
|
||||
tmpPath: 'tmp',
|
||||
bakPath: 'bak',
|
||||
}))
|
||||
config[key] = path.join(root, dir);
|
||||
config.writePathList = [config.scriptPath, config.configPath];
|
||||
const stopped = [];
|
||||
const removed = [];
|
||||
const service = {
|
||||
stopScript: async (...args) => {
|
||||
stopped.push(args);
|
||||
return { code: 200 };
|
||||
},
|
||||
};
|
||||
const app = express.Router();
|
||||
load(path.resolve(`back/api/${api}.ts`), {
|
||||
'../config': config,
|
||||
'../config/const': {},
|
||||
'../services/script': {},
|
||||
'../services/scriptHistory': {},
|
||||
'../services/config': {},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
typedi: { Container: { get: () => service } },
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (p, s) => fs.promises.writeFile(p, s),
|
||||
},
|
||||
'../config/util': {
|
||||
fileExist: async (p) => fs.existsSync(p),
|
||||
rmPath: async (p) => {
|
||||
removed.push(p);
|
||||
await fs.promises.rm(p, { recursive: true, force: true });
|
||||
},
|
||||
},
|
||||
}).default(app);
|
||||
const router = app.stack.find((x) => x.name === 'router').handle;
|
||||
const invoke = async (method, url, body) => {
|
||||
const route = router.stack.find(
|
||||
(x) => x.route?.path === url && x.route.methods[method],
|
||||
).route;
|
||||
let result;
|
||||
await route.stack
|
||||
.at(-1)
|
||||
.handle({ body }, { send: (x) => (result = x) }, (e) => {
|
||||
throw e;
|
||||
});
|
||||
return result;
|
||||
};
|
||||
return { root, config, stopped, removed, invoke };
|
||||
}
|
||||
|
||||
test('script creation only creates the validated destination parent', async (t) => {
|
||||
const { root, config, invoke } = fixture(t);
|
||||
const outside = path.join(root, 'outside', 'unrequested');
|
||||
const result = await invoke('post', '/', {
|
||||
path: `${config.scriptPath}/../outside/unrequested`,
|
||||
filename: '../../scripts/safe.txt',
|
||||
content: 'safe',
|
||||
});
|
||||
assert.equal(result.code, 200);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(config.scriptPath, 'safe.txt'), 'utf8'),
|
||||
'safe',
|
||||
);
|
||||
assert.equal(fs.existsSync(outside), false);
|
||||
assert.equal(
|
||||
(
|
||||
await invoke('post', '/', {
|
||||
path: `${config.scriptPath}/../../escape`,
|
||||
filename: 'poc.txt',
|
||||
content: 'blocked',
|
||||
})
|
||||
).code,
|
||||
403,
|
||||
);
|
||||
});
|
||||
|
||||
test('script creation supports root and nested parents in both writable directories', async (t) => {
|
||||
const { config, invoke } = fixture(t);
|
||||
for (const root of [config.scriptPath, config.configPath]) {
|
||||
for (const filename of ['ordinary.js', 'nested/deeper/script.js']) {
|
||||
assert.equal(
|
||||
(await invoke('post', '/', {
|
||||
path: root,
|
||||
filename,
|
||||
content: 'safe',
|
||||
})).code,
|
||||
200,
|
||||
);
|
||||
assert.equal(fs.readFileSync(path.join(root, filename), 'utf8'), 'safe');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('script parent creation rejects sibling prefixes and external symlinks', async (t) => {
|
||||
const { root, config, invoke } = fixture(t);
|
||||
const sibling = config.scriptPath + '-sibling';
|
||||
const outside = path.join(root, 'outside');
|
||||
fs.mkdirSync(sibling);
|
||||
fs.symlinkSync(outside, path.join(config.scriptPath, 'escape'));
|
||||
for (const parent of [sibling, path.join(config.scriptPath, 'escape')]) {
|
||||
assert.equal(
|
||||
(await invoke('post', '/', {
|
||||
path: parent,
|
||||
filename: 'nested/poc.js',
|
||||
content: 'blocked',
|
||||
})).code,
|
||||
403,
|
||||
);
|
||||
}
|
||||
assert.equal(fs.existsSync(path.join(sibling, 'nested')), false);
|
||||
assert.equal(fs.existsSync(path.join(outside, 'nested')), false);
|
||||
});
|
||||
|
||||
test('stopping scripts rejects logs outside the log root before any side effect', async (t) => {
|
||||
const { root, config, stopped, invoke } = fixture(t);
|
||||
const callbacks = [];
|
||||
t.mock.method(global, 'setTimeout', (fn) => {
|
||||
callbacks.push(fn);
|
||||
return {};
|
||||
});
|
||||
fs.mkdirSync(path.join(config.configPath, 'victim.swap'));
|
||||
fs.mkdirSync(path.join(config.scriptPath, 'linked'));
|
||||
fs.symlinkSync(
|
||||
path.join(root, 'outside'),
|
||||
path.join(config.logPath, 'linked'),
|
||||
);
|
||||
for (const dir of ['../config', 'linked']) {
|
||||
assert.equal(
|
||||
(await invoke('put', '/stop', { filename: 'victim.js', path: dir })).code,
|
||||
403,
|
||||
);
|
||||
}
|
||||
assert.equal(stopped.length, 0);
|
||||
assert.equal(callbacks.length, 0);
|
||||
});
|
||||
|
||||
test('log cleanup revalidates delayed paths and still removes ordinary debug logs', async (t) => {
|
||||
const { root, config, removed, stopped, invoke } = fixture(t);
|
||||
const callbacks = [];
|
||||
t.mock.method(global, 'setTimeout', (fn) => {
|
||||
callbacks.push(fn);
|
||||
return {};
|
||||
});
|
||||
fs.mkdirSync(path.join(config.scriptPath, 'nested'));
|
||||
fs.mkdirSync(path.join(config.logPath, 'nested'));
|
||||
fs.mkdirSync(path.join(root, 'outside', 'victim.swap'));
|
||||
fs.mkdirSync(path.join(config.logPath, 'normal.swap'));
|
||||
assert.equal(
|
||||
(await invoke('put', '/stop', { filename: 'normal.js' })).code,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await invoke('put', '/stop', { filename: 'victim.js', path: 'nested' }))
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
fs.rmdirSync(path.join(config.logPath, 'nested'));
|
||||
fs.symlinkSync(
|
||||
path.join(root, 'outside'),
|
||||
path.join(config.logPath, 'nested'),
|
||||
);
|
||||
callbacks.forEach((fn) => fn());
|
||||
assert.equal(stopped.length, 2);
|
||||
assert.deepEqual(removed, [path.join(config.logPath, 'normal.swap')]);
|
||||
assert.equal(fs.existsSync(path.join(root, 'outside', 'victim.swap')), true);
|
||||
});
|
||||
|
||||
test('draft configuration traversal and normalized secret names cannot write files', async (t) => {
|
||||
const { config, invoke } = fixture(t, 'config');
|
||||
fs.writeFileSync(path.join(config.configPath, 'auth.json'), 'secret');
|
||||
fs.symlinkSync(
|
||||
path.join(config.configPath, 'auth.json'),
|
||||
path.join(config.configPath, 'alias'),
|
||||
);
|
||||
for (const name of [
|
||||
'auth.json',
|
||||
'sub/../auth.json',
|
||||
'sub/../token.json',
|
||||
'grpc/client.key',
|
||||
'../../shell/preload/env.sh',
|
||||
'../scripts/poc.js',
|
||||
'data/scripts/../config/auth.json',
|
||||
'alias',
|
||||
]) {
|
||||
assert.equal(
|
||||
(await invoke('post', '/save', { name, content: 'changed' })).code,
|
||||
403,
|
||||
name,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(config.configPath, 'auth.json'), 'utf8'),
|
||||
'secret',
|
||||
);
|
||||
assert.equal(
|
||||
(await invoke('post', '/save', { name: 'config.sh', content: 'normal' }))
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await invoke('post', '/save', {
|
||||
name: 'data/scripts/normal.js',
|
||||
content: 'normal',
|
||||
})
|
||||
).code,
|
||||
403,
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(config.scriptPath, 'normal.js')), false);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const configPath = require.resolve('../../ecosystem.config');
|
||||
|
||||
function loadConfig(containerValue) {
|
||||
if (containerValue === undefined) {
|
||||
delete process.env.QL_CONTAINER;
|
||||
} else {
|
||||
process.env.QL_CONTAINER = containerValue;
|
||||
}
|
||||
delete require.cache[configPath];
|
||||
return require(configPath).apps[0];
|
||||
}
|
||||
|
||||
test('container logging discards stdout and forwards startup errors without persistent copies', () => {
|
||||
const app = loadConfig('true');
|
||||
assert.equal(app.out_file, '/dev/null');
|
||||
assert.equal(app.error_file, '/proc/1/fd/2');
|
||||
assert.equal(app.time, false);
|
||||
});
|
||||
|
||||
test('non-container installs keep the PM2 logging defaults', () => {
|
||||
const app = loadConfig(undefined);
|
||||
assert.equal(app.out_file, undefined);
|
||||
assert.equal(app.error_file, undefined);
|
||||
assert.equal(app.time, true);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('environment import never writes upload filenames, including rejected files', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-import-'));
|
||||
fs.writeFileSync(path.join(root, 'sendNotify.js'), 'ORIGINAL');
|
||||
const created = [];
|
||||
const Env = class {};
|
||||
const app = express();
|
||||
load(path.resolve('back/api/env.ts'), {
|
||||
'../config': { scriptPath: root },
|
||||
'../config/util': {
|
||||
safeJSONParse: (value) => {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
},
|
||||
'../services/env': Env,
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
typedi: {
|
||||
Container: {
|
||||
get: () => ({
|
||||
create: async (values) => {
|
||||
created.push(values);
|
||||
return values;
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}).default(app);
|
||||
app.use((err, _req, res, _next) =>
|
||||
res.status(400).json({ code: 400, message: err.message }),
|
||||
);
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
t.after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.on('listening', resolve);
|
||||
server.on('error', reject);
|
||||
});
|
||||
const upload = async (content, filename = 'sendNotify.js') => {
|
||||
const form = new FormData();
|
||||
form.append('env', new Blob([content]), filename);
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}/envs/upload`,
|
||||
{ method: 'POST', body: form },
|
||||
);
|
||||
return response.json();
|
||||
};
|
||||
for (const payload of [
|
||||
'globalThis.injected=true;',
|
||||
'null',
|
||||
'[]',
|
||||
'[{"name":"BAD;CODE","value":"x"}]',
|
||||
'[{"name":"X","value":{}}]',
|
||||
]) {
|
||||
assert.equal((await upload(payload)).code, 400);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, 'sendNotify.js'), 'utf8'),
|
||||
'ORIGINAL',
|
||||
);
|
||||
}
|
||||
assert.equal((await upload('x'.repeat(5 * 1024 * 1024 + 1))).code, 400);
|
||||
assert.equal(created.length, 0);
|
||||
assert.equal(
|
||||
(
|
||||
await upload(
|
||||
'[{"name":"TOKEN","value":"literal ${value}","remarks":"ok"}]',
|
||||
'../sendNotify.js',
|
||||
)
|
||||
).code,
|
||||
200,
|
||||
);
|
||||
assert.equal(created.length, 1);
|
||||
assert.equal(created[0][0].value, 'literal ${value}');
|
||||
assert.deepEqual(fs.readdirSync(root), ['sendNotify.js']);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, 'sendNotify.js'), 'utf8'),
|
||||
'ORIGINAL',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
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 { spawnSync } = require('node:child_process');
|
||||
const source = fs.readFileSync('shell/share.sh', 'utf8');
|
||||
function extract(name) {
|
||||
const start = source.indexOf(name + '() {');
|
||||
assert.ok(start >= 0);
|
||||
return source.slice(start, source.indexOf('\n}', start) + 2);
|
||||
}
|
||||
function fixture(t, content) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-names-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, 'env file.sh');
|
||||
fs.writeFileSync(file, content);
|
||||
return {
|
||||
root,
|
||||
file,
|
||||
run(script) {
|
||||
const r = spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-ec',
|
||||
extract('get_env_array') +
|
||||
'\n' +
|
||||
extract('clear_env') +
|
||||
'\n' +
|
||||
script,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, file_env: file, FIXTURE_ROOT: root },
|
||||
},
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout;
|
||||
},
|
||||
};
|
||||
}
|
||||
test('matches legacy pipeline for whitespace, malformed lines, CRLF and final unterminated line', (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
'# comment\nexport A=one\nexport B="two words"\n export IGNORED=1\nexport\tIGNORED2=1\nexport C = spaced\nexport \nexport -x X=1\nexport D=a=b\nexport E=crlf\r\nexport F\r\nexport G=last',
|
||||
);
|
||||
assert.equal(
|
||||
f.run('get_env_array; printf "%s\\0" "${exported_variables[@]}"'),
|
||||
f.run(
|
||||
'while IFS= read -r line; do printf "%s\\0" "$line"; done < <(grep "^export " "$file_env" | awk \'{print $2}\' | cut -d= -f1)',
|
||||
),
|
||||
);
|
||||
});
|
||||
test('resets prior names for an empty file', (t) => {
|
||||
const f = fixture(t, '');
|
||||
assert.equal(
|
||||
f.run(
|
||||
'exported_variables=(OLD); get_env_array; echo "${#exported_variables[@]}"',
|
||||
),
|
||||
'0\n',
|
||||
);
|
||||
});
|
||||
test('extracting names never evaluates command substitutions or shell syntax in values', (t) => {
|
||||
const f = fixture(
|
||||
t,
|
||||
'export A=$(touch "$FIXTURE_ROOT/unsafe")\nexport B=`touch "$FIXTURE_ROOT/unsafe2"`\n',
|
||||
);
|
||||
assert.equal(
|
||||
f.run('get_env_array; printf "%s\\n" "${exported_variables[@]}"'),
|
||||
'A\nB\n',
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'unsafe')), false);
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'unsafe2')), false);
|
||||
});
|
||||
test('clear_env removes listed exports and preserves unrelated values', (t) => {
|
||||
const f = fixture(t, 'export USER_ONE=1\nexport USER_TWO=2\n');
|
||||
f.run(
|
||||
'USER_ONE=old; USER_TWO=old; UNRELATED=kept; get_env_array; clear_env; [[ ! ${USER_ONE+x} && ! ${USER_TWO+x} && "$UNRELATED" == kept ]]',
|
||||
);
|
||||
});
|
||||
test('large configuration preserves order and duplicate names', (t) => {
|
||||
const text = Array.from(
|
||||
{ length: 5000 },
|
||||
(_, i) => 'export V' + (i % 1000) + '=value with spaces',
|
||||
).join('\n');
|
||||
const f = fixture(t, text);
|
||||
const lines = f
|
||||
.run('get_env_array; printf "%s\\n" "${exported_variables[@]}"')
|
||||
.trim()
|
||||
.split('\n');
|
||||
assert.equal(lines.length, 5000);
|
||||
assert.deepEqual(
|
||||
lines,
|
||||
Array.from({ length: 5000 }, (_, i) => 'V' + (i % 1000)),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { PassThrough } = require('node:stream');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { observeChildProcess } = require('../../back/shared/childProcess');
|
||||
const { LogStreamManager } = require('../../back/shared/logStreamManager');
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const logger = { info() {}, error() {} };
|
||||
|
||||
test(
|
||||
'spawn failure settles without exit, and the next scheduled run can proceed',
|
||||
{ timeout: 3000 },
|
||||
async () => {
|
||||
let releases = 0;
|
||||
let active = 0;
|
||||
const { runCron } = load(path.resolve('back/shared/runCron.ts'), {
|
||||
'cross-spawn': {
|
||||
spawn: () => spawn('true', { shell: '/nonexistent-ql-test-shell' }),
|
||||
},
|
||||
'./pLimit': {
|
||||
runWithCronLimit: async (_cron, fn) => {
|
||||
active++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
removeQueuedCron: () => releases++,
|
||||
},
|
||||
'../loaders/logger': logger,
|
||||
'../data/cron': {
|
||||
CrontabModel: { findOne: async () => ({ isDisabled: 0 }) },
|
||||
CrontabStatus: {},
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: {},
|
||||
InstanceStatus: {},
|
||||
},
|
||||
'../config/util': { killTask: async () => {} },
|
||||
});
|
||||
await runCron('true', { id: '1' });
|
||||
await runCron('true', { id: '2' });
|
||||
assert.equal(active, 0);
|
||||
assert.equal(releases, 2);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'completion waits for slow log consumers and preserves the final output',
|
||||
{ timeout: 5000 },
|
||||
async () => {
|
||||
const chunks = [];
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
'process.stdout.write("尾行\\n"); process.stderr.write("错误\\n")',
|
||||
]);
|
||||
const observed = observeChildProcess(child, {
|
||||
onStart: () => delay(25),
|
||||
onStdout: async (message) => {
|
||||
await delay(40);
|
||||
chunks.push(message);
|
||||
},
|
||||
onStderr: async (message) => {
|
||||
await delay(30);
|
||||
chunks.push(message);
|
||||
},
|
||||
});
|
||||
const result = await observed.completed;
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(result.error, undefined);
|
||||
assert.match(chunks.join(''), /尾行/);
|
||||
assert.match(chunks.join(''), /错误/);
|
||||
},
|
||||
);
|
||||
|
||||
test('exit is not completion, and UTF-8 split across writes remains intact', async () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.pid = 123;
|
||||
const chunks = [];
|
||||
const observed = observeChildProcess(child, {
|
||||
onStdout: async (data) => chunks.push(data),
|
||||
});
|
||||
child.emit('spawn');
|
||||
let done = false;
|
||||
observed.completed.then(() => {
|
||||
done = true;
|
||||
});
|
||||
child.emit('exit', 0, null);
|
||||
const data = Buffer.from('末尾中文');
|
||||
child.stdout.write(data.subarray(0, 2));
|
||||
await delay(10);
|
||||
assert.equal(done, false);
|
||||
child.stdout.end(data.subarray(2));
|
||||
child.stderr.end();
|
||||
child.emit('close', 0, null);
|
||||
await observed.completed;
|
||||
assert.equal(chunks.join(''), '末尾中文');
|
||||
});
|
||||
|
||||
test(
|
||||
'failed log sink drains large output instead of blocking the child',
|
||||
{ timeout: 5000 },
|
||||
async () => {
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
'process.stdout.write("x".repeat(2 * 1024 * 1024))',
|
||||
]);
|
||||
const { completed } = observeChildProcess(child, {
|
||||
onStdout: async () => {
|
||||
throw new Error('ENOSPC');
|
||||
},
|
||||
});
|
||||
const result = await completed;
|
||||
assert.equal(result.code, 0);
|
||||
assert.equal(result.error.message, 'ENOSPC');
|
||||
},
|
||||
);
|
||||
|
||||
function scheduleFixture() {
|
||||
let active = 0;
|
||||
const limit = async (_params, fn) => {
|
||||
active++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
};
|
||||
const Schedule = load(path.resolve('back/services/schedule.ts'), {
|
||||
'../shared/pLimit': {
|
||||
runWithScriptLimit: limit,
|
||||
runWithSystemLimit: limit,
|
||||
},
|
||||
'cross-spawn': {
|
||||
spawn: () =>
|
||||
spawn(process.execPath, [
|
||||
'-e',
|
||||
'setTimeout(() => process.stdout.write("done"), 150)',
|
||||
]),
|
||||
},
|
||||
}).default;
|
||||
return { service: new Schedule(logger), active: () => active };
|
||||
}
|
||||
|
||||
test(
|
||||
'PID response keeps queue capacity occupied until cleanup completes',
|
||||
{ timeout: 4000 },
|
||||
async () => {
|
||||
const fixture = scheduleFixture();
|
||||
let finish;
|
||||
const finished = new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const pid = await fixture.service.runTask(
|
||||
'ignored',
|
||||
{
|
||||
onEnd: async () => {
|
||||
await delay(20);
|
||||
finish();
|
||||
},
|
||||
},
|
||||
{ id: 'script', runOrigin: 'script' },
|
||||
'start',
|
||||
);
|
||||
assert.ok(pid > 0);
|
||||
assert.equal(fixture.active(), 1);
|
||||
await finished;
|
||||
await delay(0);
|
||||
assert.equal(fixture.active(), 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'before/error/end callback failures settle and release capacity',
|
||||
{ timeout: 3000 },
|
||||
async () => {
|
||||
const fixture = scheduleFixture();
|
||||
let ended = 0;
|
||||
const result = await fixture.service.runTask(
|
||||
'ignored',
|
||||
{
|
||||
onBefore: async () => {
|
||||
throw new Error('setup failed');
|
||||
},
|
||||
onError: async () => {
|
||||
throw new Error('sink failed');
|
||||
},
|
||||
onEnd: async (child) => {
|
||||
assert.equal(child, undefined);
|
||||
ended++;
|
||||
throw new Error('cleanup failed');
|
||||
},
|
||||
},
|
||||
{ id: 'system', runOrigin: 'system' },
|
||||
);
|
||||
assert.equal(result.error.message, 'setup failed');
|
||||
assert.equal(ended, 1);
|
||||
assert.equal(fixture.active(), 0);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'concurrent writes and closes preserve all bytes, and failed files can be closed',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-lifecycle-'));
|
||||
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||
const manager = new LogStreamManager(dir);
|
||||
const file = path.join(dir, 'out.log');
|
||||
const lines = Array.from({ length: 500 }, (_, i) => `${i}:末尾\n`);
|
||||
const writes = lines.map((line) => manager.write(file, line));
|
||||
await Promise.all([
|
||||
...writes,
|
||||
manager.closeStream(file),
|
||||
manager.closeStream(file),
|
||||
]);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), lines.join(''));
|
||||
assert.equal(manager.getOpenStreamCount(), 0);
|
||||
const invalid = path.join(dir, 'missing', 'out.log');
|
||||
await assert.rejects(manager.write(invalid, 'fail'), /ENOENT/);
|
||||
await assert.rejects(manager.closeStream(invalid), /ENOENT/);
|
||||
assert.equal(manager.getOpenStreamCount(), 0);
|
||||
await manager.write(file, 'reopened');
|
||||
await manager.closeAll();
|
||||
assert.match(await fs.readFile(file, 'utf8'), /reopened$/);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,180 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
function fixture(t, customData = false) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-access-scope-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const data = path.join(root, customData ? 'custom-data' : 'data');
|
||||
const config = {
|
||||
rootPath: root,
|
||||
configPath: path.join(data, 'config'),
|
||||
scriptPath: path.join(data, 'scripts'),
|
||||
logPath: path.join(data, 'log'),
|
||||
blackFileList: ['token.json', 'auth.json', 'grpc', 'env.js'],
|
||||
};
|
||||
for (const directory of [
|
||||
config.configPath,
|
||||
config.scriptPath,
|
||||
config.logPath,
|
||||
])
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const secret = path.join(config.configPath, 'token.json');
|
||||
fs.writeFileSync(secret, 'PANEL-SECRET');
|
||||
for (const directory of [
|
||||
config.configPath,
|
||||
config.scriptPath,
|
||||
config.logPath,
|
||||
])
|
||||
fs.symlinkSync(secret, path.join(directory, 'secret-link'));
|
||||
const mocks = {
|
||||
'../config': config,
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
'../config/util': {
|
||||
getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'),
|
||||
rmPath: (p) => fs.promises.rm(p),
|
||||
removeAnsi: (s) => s,
|
||||
},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (p, s) => fs.promises.writeFile(p, s),
|
||||
},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../config/const': {},
|
||||
'../data/runningInstance': {
|
||||
InstanceStatus: { running: 'running' },
|
||||
RunningInstanceModel: { findOne: async () => null },
|
||||
},
|
||||
};
|
||||
return { config, mocks, secret };
|
||||
}
|
||||
|
||||
function api(name, service, mocks) {
|
||||
mocks.typedi = { Container: { get: () => service } };
|
||||
const app = express.Router();
|
||||
load(path.resolve(`back/api/${name}.ts`), mocks).default(app);
|
||||
const router = app.stack.find((layer) => layer.name === 'router').handle;
|
||||
return async (method, url, values) => {
|
||||
const handler = router.stack
|
||||
.find((layer) => layer.route?.path === url && layer.route.methods[method])
|
||||
.route.stack.at(-1).handle;
|
||||
let result;
|
||||
await handler(
|
||||
{ query: values, body: values },
|
||||
{
|
||||
send: (value) => {
|
||||
result = value;
|
||||
},
|
||||
download: (p) => {
|
||||
result = { code: 200, data: fs.readFileSync(p, 'utf8') };
|
||||
},
|
||||
},
|
||||
(error) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
for (const customData of [false, true]) {
|
||||
test(`config APIs reject script compatibility paths (custom=${customData})`, async (t) => {
|
||||
const { config, mocks, secret } = fixture(t, customData);
|
||||
const Config = load(path.resolve('back/services/config.ts'), mocks).default;
|
||||
mocks['../services/config'] = Config;
|
||||
const invoke = api('config', new Config(), mocks);
|
||||
const legacy = path.join(config.rootPath, 'data/scripts');
|
||||
if (customData) {
|
||||
fs.mkdirSync(legacy, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacy, 'notify.py'), 'legacy');
|
||||
}
|
||||
for (const filename of [
|
||||
'token.json',
|
||||
'auth.json',
|
||||
'grpc/env.js',
|
||||
'notify.py',
|
||||
]) {
|
||||
const file = path.join(config.scriptPath, filename);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'initial');
|
||||
const name = `data/scripts/${filename}`;
|
||||
assert.equal((await invoke('get', '/detail', { path: name })).code, 403);
|
||||
assert.equal(
|
||||
(await invoke('post', '/save', { name, content: 'updated' })).code,
|
||||
403,
|
||||
);
|
||||
assert.equal(fs.readFileSync(file, 'utf8'), 'initial');
|
||||
}
|
||||
if (customData)
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(legacy, 'notify.py'), 'utf8'),
|
||||
'legacy',
|
||||
);
|
||||
for (const name of [
|
||||
'token.json',
|
||||
'grpc/client.key',
|
||||
'secret-link',
|
||||
'data/scripts/secret-link',
|
||||
'data/scripts/../config/token.json',
|
||||
secret,
|
||||
]) {
|
||||
assert.equal(
|
||||
(await invoke('get', '/detail', { path: name })).code,
|
||||
403,
|
||||
name,
|
||||
);
|
||||
assert.equal(
|
||||
(await invoke('post', '/save', { name, content: 'overwrite' })).code,
|
||||
403,
|
||||
name,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(await invoke('post', '/save', { name: 'normal.txt', content: 'normal' }))
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await invoke('get', '/detail', { path: 'normal.txt' })).data,
|
||||
'normal',
|
||||
);
|
||||
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
|
||||
});
|
||||
}
|
||||
|
||||
test('log APIs allow configuration-like names while retaining directory boundaries', async (t) => {
|
||||
const { config, mocks, secret } = fixture(t);
|
||||
const Log = load(path.resolve('back/services/log.ts'), mocks).default;
|
||||
mocks['../services/log'] = Log;
|
||||
const invoke = api('log', new Log(), mocks);
|
||||
for (const filename of ['token.json', 'grpc/run.log', 'env.js/run.log']) {
|
||||
const file = path.join(config.logPath, filename);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, 'log content');
|
||||
const values = {
|
||||
path: path.dirname(filename) === '.' ? '' : path.dirname(filename),
|
||||
file: path.basename(filename),
|
||||
filename: path.basename(filename),
|
||||
};
|
||||
assert.equal((await invoke('get', '/detail', values)).data, 'log content');
|
||||
assert.equal(
|
||||
(await invoke('post', '/download', values)).data,
|
||||
'log content',
|
||||
);
|
||||
assert.equal((await invoke('delete', '/', values)).code, 200);
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
}
|
||||
for (const filename of ['../config/token.json', 'secret-link', secret]) {
|
||||
const values = { path: '', file: filename, filename };
|
||||
for (const [method, route] of [
|
||||
['get', '/detail'],
|
||||
['post', '/download'],
|
||||
['delete', '/'],
|
||||
])
|
||||
assert.equal((await invoke(method, route, values)).code, 403, filename);
|
||||
}
|
||||
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { resolveFileAccess } = load(
|
||||
path.join(__dirname, '../../back/shared/fileAccess.ts'),
|
||||
);
|
||||
|
||||
test('file access enforces directory boundaries, blacklist descendants and symlinks', (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-file-security-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const root = path.join(tmp, 'config');
|
||||
fs.mkdirSync(path.join(root, 'grpc'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmp, 'config-other'));
|
||||
fs.writeFileSync(path.join(root, 'normal.txt'), 'normal');
|
||||
fs.writeFileSync(path.join(root, 'grpc', 'client.key'), 'test-only');
|
||||
fs.symlinkSync(path.join(tmp, 'config-other'), path.join(root, 'outside'));
|
||||
fs.symlinkSync(path.join(root, 'grpc'), path.join(root, 'alias'));
|
||||
fs.symlinkSync(path.join(root, 'normal.txt'), path.join(root, 'safe-link'));
|
||||
fs.symlinkSync(path.join(tmp, 'missing'), path.join(root, 'dangling'));
|
||||
for (const input of [
|
||||
'../config-other/secret',
|
||||
'/etc/passwd',
|
||||
'grpc/client.key',
|
||||
'alias/client.key',
|
||||
'outside/new.txt',
|
||||
'dangling',
|
||||
'auth.json',
|
||||
'nested/auth.json',
|
||||
'',
|
||||
]) {
|
||||
assert.equal(
|
||||
resolveFileAccess(root, [input], ['grpc', 'auth.json']),
|
||||
'',
|
||||
input,
|
||||
);
|
||||
}
|
||||
for (const input of ['normal.txt', 'safe-link', 'new-dir/new.txt']) {
|
||||
assert.equal(
|
||||
resolveFileAccess(root, [input], ['grpc']),
|
||||
path.join(root, input),
|
||||
input,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { afterEach, test } = require('node:test');
|
||||
const { readDirs } = require('../../back/config/util');
|
||||
|
||||
const temporaryDirectories = [];
|
||||
|
||||
async function temporaryRoot() {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-file-tree-'));
|
||||
temporaryDirectories.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories
|
||||
.splice(0)
|
||||
.map((directory) => fs.rm(directory, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test('readDirs preserves the recursive tree contract and ignores blocked entries', async () => {
|
||||
const root = await temporaryRoot();
|
||||
await fs.mkdir(path.join(root, 'folder'));
|
||||
await fs.mkdir(path.join(root, 'node_modules'));
|
||||
await fs.writeFile(path.join(root, 'z.js'), 'z');
|
||||
await fs.writeFile(path.join(root, 'folder', 'a.js'), 'alpha');
|
||||
await fs.writeFile(path.join(root, 'node_modules', 'hidden.js'), 'hidden');
|
||||
await fs.symlink(path.join(root, 'folder'), path.join(root, 'linked-folder'));
|
||||
|
||||
const result = await readDirs(root, root, ['node_modules'], (a, b) => {
|
||||
if (a.type === b.type) return a.title.localeCompare(b.title);
|
||||
return a.type === 'directory' ? -1 : 1;
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
result.map(({ title, type, key, parent }) => ({
|
||||
title,
|
||||
type,
|
||||
key,
|
||||
parent,
|
||||
})),
|
||||
[
|
||||
{ title: 'folder', type: 'directory', key: 'folder', parent: '' },
|
||||
{ title: 'z.js', type: 'file', key: 'z.js', parent: '' },
|
||||
],
|
||||
);
|
||||
assert.equal(result[0].children.length, 1);
|
||||
assert.equal(result[0].children[0].title, 'a.js');
|
||||
assert.equal(result[0].children[0].key, path.join('folder', 'a.js'));
|
||||
assert.equal(result[0].children[0].parent, 'folder');
|
||||
assert.equal(result[0].children[0].size, 5);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const ts = require('typescript');
|
||||
const { Sequelize, DataTypes, Model } = require('sequelize');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { CronResponse } = load('back/protos/api.ts');
|
||||
const source = fs.readFileSync('back/schedule/api.ts', 'utf8');
|
||||
const start = source.indexOf('const normalizeCronData =');
|
||||
const end = source.indexOf('export const getCronDetail', start);
|
||||
const code = ts.transpileModule(source.slice(start, end), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const normalize = new Function('Model', `${code}\nreturn normalizeCronData;`)(
|
||||
Model,
|
||||
);
|
||||
|
||||
test('gRPC cron response preserves Sequelize model fields through protobuf serialization', async (t) => {
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => db.close());
|
||||
const Cron = db.define('Cron', {
|
||||
name: DataTypes.STRING,
|
||||
command: DataTypes.STRING,
|
||||
schedule: DataTypes.STRING,
|
||||
labels: DataTypes.JSON,
|
||||
extra_schedules: DataTypes.JSON,
|
||||
});
|
||||
const model = Cron.build({
|
||||
id: 7,
|
||||
name: 'smoke',
|
||||
command: 'true',
|
||||
schedule: '* * * * *',
|
||||
labels: ['test'],
|
||||
});
|
||||
const decoded = CronResponse.decode(
|
||||
CronResponse.encode({ code: 200, data: normalize(model) }).finish(),
|
||||
);
|
||||
assert.equal(decoded.data.id, 7);
|
||||
assert.equal(decoded.data.name, 'smoke');
|
||||
assert.equal(decoded.data.command, 'true');
|
||||
assert.deepEqual(decoded.data.labels, ['test']);
|
||||
assert.deepEqual(decoded.data.extra_schedules, []);
|
||||
});
|
||||
|
||||
test('legacy plain cron rows normalize absent repeated fields', () => {
|
||||
const decoded = CronResponse.decode(
|
||||
CronResponse.encode({
|
||||
code: 200,
|
||||
data: normalize({ id: 8, labels: null, extra_schedules: null }),
|
||||
}).finish(),
|
||||
);
|
||||
assert.equal(decoded.data.id, 8);
|
||||
assert.deepEqual(decoded.data.labels, []);
|
||||
assert.deepEqual(decoded.data.extra_schedules, []);
|
||||
assert.equal(normalize(null), undefined);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const childProcess = require('node:child_process');
|
||||
const grpc = require('@grpc/grpc-js');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const logger = { info() {}, debug() {}, warn() {}, error() {} };
|
||||
|
||||
function fixture(t, extra = {}) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ql-grpc's-"));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const module = load(path.resolve('back/config/grpcCerts.ts'), {
|
||||
'./index': { configPath: root },
|
||||
'./util': { fileExist: async (p) => fs.existsSync(p) },
|
||||
'../loaders/logger': logger,
|
||||
...extra,
|
||||
});
|
||||
return { root, module };
|
||||
}
|
||||
|
||||
test('gRPC keys use a private reserved workspace and clean up all OpenSSL side files', async (t) => {
|
||||
const workspaces = new Set();
|
||||
const { root, module } = fixture(t, {
|
||||
child_process: {
|
||||
...childProcess,
|
||||
execFileSync: (file, args, opts) => {
|
||||
const temporary = args.find((arg) => arg.includes('/.generate-'));
|
||||
const workspace = path.dirname(temporary);
|
||||
workspaces.add(workspace);
|
||||
assert.equal(fs.statSync(workspace).mode & 0o777, 0o700);
|
||||
return childProcess.execFileSync(file, args, opts);
|
||||
},
|
||||
},
|
||||
});
|
||||
const certs = await module.initGrpcCerts();
|
||||
assert.match(certs.serverCert, /BEGIN CERTIFICATE/);
|
||||
assert.equal(workspaces.size, 1);
|
||||
for (const dir of workspaces) assert.equal(fs.existsSync(dir), false);
|
||||
assert.deepEqual(fs.readdirSync(path.join(root, 'grpc')).sort(), [
|
||||
'ca.crt',
|
||||
'ca.key',
|
||||
'client.crt',
|
||||
'client.key',
|
||||
'server.crt',
|
||||
'server.key',
|
||||
]);
|
||||
for (const file of ['ca.key', 'client.key', 'server.key']) {
|
||||
assert.equal(
|
||||
fs.statSync(path.join(root, 'grpc', file)).mode & 0o777,
|
||||
0o600,
|
||||
);
|
||||
}
|
||||
assert.strictEqual(await module.initGrpcCerts(), certs);
|
||||
});
|
||||
|
||||
test('gRPC certificate generation cleans partial files on OpenSSL errors', async (t) => {
|
||||
const { root, module } = fixture(t, {
|
||||
child_process: {
|
||||
execFileSync: (_file, args) => {
|
||||
fs.writeFileSync(args[2], 'partial private key');
|
||||
throw new Error('openssl failed');
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(module.initGrpcCerts(), /openssl failed/);
|
||||
assert.deepEqual(fs.readdirSync(path.join(root, 'grpc')), []);
|
||||
});
|
||||
|
||||
test('real gRPC server rejects plaintext and TLS without client certificates, accepts mTLS', async (t) => {
|
||||
const { module } = fixture(t);
|
||||
const certs = await module.initGrpcCerts();
|
||||
let port;
|
||||
let checks = 0;
|
||||
const Grpc = load(path.resolve('back/services/grpc.ts'), {
|
||||
'../config': { bindHostGrpc: '127.0.0.1', grpcPort: 0 },
|
||||
'../config/grpcCerts': { initGrpcCerts: async () => certs },
|
||||
'../loaders/logger': logger,
|
||||
'./metrics': { metricsService: { record() {} } },
|
||||
typedi: { Service: () => (x) => x },
|
||||
'@grpc/grpc-js': {
|
||||
...grpc,
|
||||
Server: class extends grpc.Server {
|
||||
bindAsync(address, creds, cb) {
|
||||
super.bindAsync(address, creds, (e, boundPort) => {
|
||||
port = boundPort;
|
||||
cb(e, boundPort);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
'../schedule/addCron': {},
|
||||
'../schedule/delCron': {},
|
||||
'../schedule/setConcurrency': {},
|
||||
'../schedule/api': {},
|
||||
'../schedule/health': {
|
||||
check: (_call, cb) => {
|
||||
checks++;
|
||||
cb(null, { status: 1 });
|
||||
},
|
||||
},
|
||||
}).GrpcServerService;
|
||||
const server = new Grpc();
|
||||
await server.initialize();
|
||||
t.after(() => server.shutdown());
|
||||
const HealthClient = load(path.resolve('back/protos/health.ts')).HealthClient;
|
||||
const request = async (credentials) => {
|
||||
const client = new HealthClient(`localhost:${port}`, credentials, {
|
||||
'grpc.enable_http_proxy': 0,
|
||||
});
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
client.check({}, { deadline: Date.now() + 2000 }, (e, reply) =>
|
||||
e ? reject(e) : resolve(reply),
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
client.close();
|
||||
}
|
||||
};
|
||||
await assert.rejects(request(grpc.credentials.createInsecure()));
|
||||
await assert.rejects(
|
||||
request(grpc.credentials.createSsl(Buffer.from(certs.caCert))),
|
||||
);
|
||||
assert.equal(checks, 0);
|
||||
const reply = await request(
|
||||
grpc.credentials.createSsl(
|
||||
Buffer.from(certs.caCert),
|
||||
Buffer.from(certs.clientKey),
|
||||
Buffer.from(certs.clientCert),
|
||||
),
|
||||
);
|
||||
assert.equal(reply.status, 1);
|
||||
assert.equal(checks, 1);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const express = require('express');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const Http = load('back/services/http.ts', {
|
||||
'../config': {bindHost: '127.0.0.1'},
|
||||
'../loaders/logger': {debug(){},warn(){},error(){}},
|
||||
'./metrics': {metricsService: {record(){}}},
|
||||
typedi: {Service:()=>x=>x},
|
||||
}).HttpServerService;
|
||||
test('HTTP listener preserves private binding and releases its port after shutdown',async(t)=>{
|
||||
const app=express();app.get('/',(_q,r)=>r.send('ok'));
|
||||
const first=new Http();const server=await first.initialize(app,0);
|
||||
t.after(()=>first.shutdown());
|
||||
assert.equal(server.address().address,'127.0.0.1');
|
||||
const port=server.address().port;
|
||||
const response=await fetch(`http://127.0.0.1:${port}/`,{headers:{connection:'close'}});assert.equal(await response.text(),'ok');
|
||||
await assert.rejects(new Http().initialize(app,port),e=>e.code==='EADDRINUSE');
|
||||
await first.shutdown();const replacement=new Http();t.after(()=>replacement.shutdown());
|
||||
const next=await replacement.initialize(app,port);assert.equal(next.address().port,port);
|
||||
});
|
||||
test('custom cluster deployments can restore shared listening',async(t)=>{
|
||||
const previous=process.env.QL_HTTP_SHARED_LISTEN;
|
||||
t.after(()=>{if(previous===undefined)delete process.env.QL_HTTP_SHARED_LISTEN;else process.env.QL_HTTP_SHARED_LISTEN=previous;});
|
||||
for(const value of [undefined,'true']){
|
||||
if(value===undefined)delete process.env.QL_HTTP_SHARED_LISTEN;else process.env.QL_HTTP_SHARED_LISTEN=value;
|
||||
let options;const service=new Http();const app=express();const listen=app.listen.bind(app);
|
||||
app.listen=(opts,cb)=>{options=opts;return listen(opts,cb);};
|
||||
await service.initialize(app,0);assert.equal(options.exclusive,value!=='true');await service.shutdown();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('HTTP authentication protects init, scopes, expired sessions and config secrets', async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-http-security-'));
|
||||
for (const dir of ['config/grpc', 'scripts', 'upload', 'tmp', 'bak'])
|
||||
fs.mkdirSync(path.join(tmp, dir), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmp, 'config/grpc/client.key'), 'SENTINEL');
|
||||
fs.writeFileSync(path.join(tmp, 'config/normal.txt'), 'normal');
|
||||
const secret = 'http-security-test';
|
||||
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
|
||||
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
|
||||
const auth = {
|
||||
username: 'owner',
|
||||
password: 'configured',
|
||||
token: valid,
|
||||
tokens: { desktop: [{ value: expired }] },
|
||||
};
|
||||
const configSource = fs.readFileSync(
|
||||
path.join(__dirname, '../../back/config/index.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const whitelistSource = configSource
|
||||
.slice(configSource.indexOf('apiWhiteList:'))
|
||||
.match(/apiWhiteList:\s*\[([\s\S]*?)\]/)[1];
|
||||
const config = {
|
||||
api: { prefix: '/api' },
|
||||
apiWhiteList: [...whitelistSource.matchAll(/['"]([^'"]+)['"]/g)].map(
|
||||
(x) => x[1],
|
||||
),
|
||||
jwt: { secret },
|
||||
rootPath: tmp,
|
||||
dataPath: tmp,
|
||||
configPath: path.join(tmp, 'config/'),
|
||||
scriptPath: path.join(tmp, 'scripts/'),
|
||||
uploadPath: path.join(tmp, 'upload'),
|
||||
tmpPath: path.join(tmp, 'tmp'),
|
||||
bakPath: path.join(tmp, 'bak'),
|
||||
writePathList: [path.join(tmp, 'scripts')],
|
||||
blackFileList: ['auth.json', 'grpc'],
|
||||
baseUrl: '/panel',
|
||||
};
|
||||
const apps = [
|
||||
{
|
||||
scopes: ['configs'],
|
||||
tokens: [{ value: 'config-app', expiration: Date.now() / 1000 + 3600 }],
|
||||
},
|
||||
{
|
||||
scopes: ['configs', 'scripts'],
|
||||
tokens: [
|
||||
{ value: 'script-config-app', expiration: Date.now() / 1000 + 3600 },
|
||||
],
|
||||
},
|
||||
{
|
||||
scopes: ['scripts'],
|
||||
tokens: [{ value: 'script-app', expiration: Date.now() / 1000 + 3600 }],
|
||||
},
|
||||
{
|
||||
scopes: ['envs'],
|
||||
tokens: [{ value: 'env-app', expiration: Date.now() / 1000 + 3600 }],
|
||||
},
|
||||
];
|
||||
let initialized = 0;
|
||||
const User = class {};
|
||||
const user = {
|
||||
initializeUser: async () => {
|
||||
initialized++;
|
||||
return { code: 200 };
|
||||
},
|
||||
getAuthInfo: async () => auth,
|
||||
};
|
||||
const mocks = {
|
||||
'../config': config,
|
||||
'../config/util': {
|
||||
getToken: (r) => (r.headers.authorization || '').replace(/^Bearer /, ''),
|
||||
getPlatform: () => 'desktop',
|
||||
getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'),
|
||||
fileExist: (p) => fs.existsSync(p),
|
||||
rmPath: (p) => fs.promises.rm(p),
|
||||
},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'../shared/store': {
|
||||
shareStore: { getAuthInfo: async () => auth, getApps: async () => apps },
|
||||
},
|
||||
'../config/serverEnv': { serveEnv: (_req, res) => res.end() },
|
||||
'../services/user': User,
|
||||
'../services/script': class {},
|
||||
'../data/open': {},
|
||||
'../data/system': {},
|
||||
typedi: { Service: () => (x) => x },
|
||||
};
|
||||
const Config = load(path.join(__dirname, '../../back/services/config.ts'), {
|
||||
...mocks,
|
||||
typedi: { Service: () => (x) => x },
|
||||
}).default;
|
||||
const configService = new Config();
|
||||
mocks['../services/config'] = Config;
|
||||
const historyCache = new Map();
|
||||
const History = load(
|
||||
path.join(__dirname, '../../back/services/scriptHistory.ts'),
|
||||
mocks,
|
||||
historyCache,
|
||||
).default;
|
||||
const history = new History();
|
||||
mocks.typedi = {
|
||||
...mocks.typedi,
|
||||
Container: {
|
||||
get: (x) => (x === User ? user : x === History ? history : configService),
|
||||
},
|
||||
};
|
||||
mocks['../api'] = () => {
|
||||
const router = express.Router();
|
||||
load(path.join(__dirname, '../../back/api/user.ts'), mocks).default(router);
|
||||
load(path.join(__dirname, '../../back/api/config.ts'), mocks).default(
|
||||
router,
|
||||
);
|
||||
load(
|
||||
path.join(__dirname, '../../back/api/script.ts'),
|
||||
mocks,
|
||||
historyCache,
|
||||
).default(router);
|
||||
router.get('/envs', (_req, res) => res.json({ code: 200 }));
|
||||
return router;
|
||||
};
|
||||
const app = express();
|
||||
load(path.join(__dirname, '../../back/loaders/express.ts'), mocks).default({
|
||||
app,
|
||||
});
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
t.after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.on('listening', resolve);
|
||||
server.on('error', reject);
|
||||
});
|
||||
const request = async (url, token, method = 'GET', body) => {
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}${url}`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
},
|
||||
);
|
||||
return { status: response.status, body: await response.json() };
|
||||
};
|
||||
for (const url of [
|
||||
'/api/user/init',
|
||||
'/open/user/init',
|
||||
'/panel/api/user/init',
|
||||
'/panel/open/user/init',
|
||||
]) {
|
||||
const response = await request(url, undefined, 'PUT', {
|
||||
username: 'attacker',
|
||||
password: 'changed',
|
||||
});
|
||||
assert.equal(response.body.code, 450, url);
|
||||
}
|
||||
assert.equal(initialized, 0);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/api/user/login', undefined, 'POST', {
|
||||
username: 'x',
|
||||
password: 'x'.repeat(20000),
|
||||
})
|
||||
).status,
|
||||
413,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, 'upload/legacy.html'),
|
||||
'<script>window.test=1</script>',
|
||||
);
|
||||
const legacyUpload = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}/api/static/legacy.html`,
|
||||
);
|
||||
assert.equal(legacyUpload.headers.get('x-content-type-options'), 'nosniff');
|
||||
assert.equal(
|
||||
legacyUpload.headers.get('content-security-policy'),
|
||||
"sandbox; default-src 'none'",
|
||||
);
|
||||
for (const prefix of ['/api', '/open', '/panel/api', '/panel/open']) {
|
||||
assert.equal(
|
||||
(await request(`${prefix}/envs`, expired)).status,
|
||||
401,
|
||||
prefix,
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`${prefix}/envs`, valid)).body.code,
|
||||
200,
|
||||
prefix,
|
||||
);
|
||||
assert.equal((await request(`${prefix}/envs`)).status, 401, prefix);
|
||||
}
|
||||
assert.equal((await request('/open/envs', 'env-app')).body.code, 200);
|
||||
assert.equal(
|
||||
(await request('/open/configs/detail?path=normal.txt', 'env-app')).status,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(await request('/open/configs/detail?path=normal.txt', 'config-app')).body
|
||||
.data,
|
||||
'normal',
|
||||
);
|
||||
assert.equal(
|
||||
(await request('/open/configs/detail?path=grpc/client.key', 'config-app'))
|
||||
.body.code,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/open/configs/save', 'config-app', 'POST', {
|
||||
name: 'grpc/client.key',
|
||||
content: 'changed',
|
||||
})
|
||||
).body.code,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(tmp, 'config/grpc/client.key'), 'utf8'),
|
||||
'SENTINEL',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/Api/user/init', undefined, 'PUT', {
|
||||
username: 'x',
|
||||
password: 'y',
|
||||
})
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
const scriptName = 'data/scripts/test.js';
|
||||
for (const prefix of ['/open', '/panel/open']) {
|
||||
const denied = await request(
|
||||
`${prefix}/configs/save`,
|
||||
'config-app',
|
||||
'POST',
|
||||
{
|
||||
name: scriptName,
|
||||
content: 'overwrite',
|
||||
},
|
||||
);
|
||||
assert.equal(denied.body.code, 403);
|
||||
assert.equal(fs.existsSync(path.join(tmp, 'scripts/test.js')), false);
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await request('/open/configs/save', 'script-config-app', 'POST', {
|
||||
name: scriptName,
|
||||
content: 'authorized script',
|
||||
})
|
||||
).body.code,
|
||||
403,
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(tmp, 'scripts/test.js')), false);
|
||||
assert.equal(
|
||||
(await request(`/open/configs/detail?path=${scriptName}`, 'config-app'))
|
||||
.body.code,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/open/configs/save', 'config-app', 'POST', {
|
||||
name: 'normal.txt',
|
||||
content: 'normal config',
|
||||
})
|
||||
).body.code,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request('/api/configs/save', valid, 'POST', {
|
||||
name: scriptName,
|
||||
content: 'owner script',
|
||||
})
|
||||
).body.code,
|
||||
403,
|
||||
);
|
||||
for (const prefix of ['/open', '/panel/open']) {
|
||||
assert.equal(
|
||||
(
|
||||
await request(`${prefix}/scripts`, 'config-app', 'POST', {
|
||||
filename: 'test.js',
|
||||
content: 'unauthorized',
|
||||
})
|
||||
).status,
|
||||
401,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`${prefix}/scripts`, 'script-app', 'POST', {
|
||||
filename: 'test.js',
|
||||
content: 'authorized script',
|
||||
})
|
||||
).body.code,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(tmp, 'scripts/test.js'), 'utf8'),
|
||||
'authorized script',
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { format } = require('node:util');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||
|
||||
const { getTasks } = require('node-cron');
|
||||
|
||||
const legacySchedules = [
|
||||
'* * * * *',
|
||||
'*/5 * * * *',
|
||||
'0 */10 * * * ?',
|
||||
'0 */2 * * * ?',
|
||||
'0 */5 * * * ?',
|
||||
'0 0/30 * * * ?',
|
||||
'0 0/5 * * * ?',
|
||||
'0 0 12 ? * MON',
|
||||
];
|
||||
|
||||
test('API accepts legacy question-mark schedules but rejects malformed cron', () => {
|
||||
const { scheduleSchema } = load('back/validation/schedule.ts', {
|
||||
'../config': { logPath: '/ql/data/log' },
|
||||
});
|
||||
for (const schedule of [...legacySchedules, '@once', '@boot']) {
|
||||
assert.equal(scheduleSchema.validate(schedule).error, undefined, schedule);
|
||||
}
|
||||
for (const schedule of ['@bogus', '0 /5 * * * ?', '0 70 * * * ?', 'not a cron']) {
|
||||
assert.ok(scheduleSchema.validate(schedule).error, schedule);
|
||||
}
|
||||
});
|
||||
|
||||
test('legacy main and extra schedules restore real jobs and healthy readiness', async (t) => {
|
||||
const stacks = new Map();
|
||||
t.after(() => {
|
||||
for (const jobs of stacks.values()) for (const job of jobs) job.cancel();
|
||||
});
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/runCron': { runCron() {} },
|
||||
'../loaders/logger': { info() {}, warn() {} },
|
||||
'../shared/i18n': { tf: format },
|
||||
});
|
||||
const crons = legacySchedules.map((schedule, index) => ({
|
||||
id: `legacy-${index}`, name: 'legacy', schedule, command: 'true',
|
||||
extra_schedules: [{ schedule: '0 0/30 * * * ?' }],
|
||||
}));
|
||||
const register = (rows) => new Promise((resolve, reject) => {
|
||||
addCron({ request: { replace: true, crons: rows } }, (error) =>
|
||||
error ? reject(error) : resolve());
|
||||
});
|
||||
const readiness = new SchedulerReadiness(async () => {}, 60000);
|
||||
t.after(() => clearTimeout(readiness.retry));
|
||||
readiness.configure(() => register(crons));
|
||||
assert.equal(await readiness.recover(), true);
|
||||
assert.equal(await readiness.check(), true);
|
||||
assert.equal(stacks.size, crons.length);
|
||||
for (const jobs of stacks.values()) {
|
||||
assert.equal(jobs.length, 2);
|
||||
assert.ok(jobs.every((job) => typeof job.cancel === 'function'));
|
||||
}
|
||||
const nativeJobs = [...getTasks().values()];
|
||||
assert.equal(nativeJobs.length, crons.length * 2);
|
||||
assert.ok(nativeJobs.every((job) => job.getStatus() !== 'stopped'));
|
||||
const previousJobs = [...stacks.values()].flat();
|
||||
for (const schedule of ['@bogus', '0 /5 * * * ?', '0 70 * * * ?']) {
|
||||
for (const invalid of [
|
||||
{ ...crons[0], schedule },
|
||||
{ ...crons[0], extra_schedules: [{ schedule }] },
|
||||
]) {
|
||||
await assert.rejects(register([invalid]), (error) => error.code === 3);
|
||||
assert.deepEqual([...stacks.values()].flat(), previousJobs);
|
||||
assert.deepEqual([...getTasks().values()], nativeJobs);
|
||||
assert.ok(nativeJobs.every((job) => job.getStatus() !== 'stopped'));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { spawn, spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: 'stderr warning with exit zero',
|
||||
code: 'process.stderr.write("sudo: warning\\n")',
|
||||
saved: true,
|
||||
},
|
||||
{
|
||||
name: 'nonzero exit without stderr',
|
||||
code: 'process.exitCode = 7',
|
||||
saved: false,
|
||||
},
|
||||
{
|
||||
name: 'signal termination',
|
||||
code: 'process.kill(process.pid, "SIGTERM")',
|
||||
saved: false,
|
||||
},
|
||||
{ name: 'spawn failure', shell: '/nonexistent-ql-test-shell', saved: false },
|
||||
]) {
|
||||
test(`Linux mirror persistence follows the process result: ${scenario.name}`, async () => {
|
||||
const logger = { info() {}, error() {} };
|
||||
const ScheduleService = load('back/services/schedule.ts', {
|
||||
'../shared/cronScheduler': {},
|
||||
'../shared/pLimit': { runWithSystemLimit: async (_params, fn) => fn() },
|
||||
'cross-spawn': {
|
||||
spawn: () =>
|
||||
spawn(
|
||||
process.execPath,
|
||||
['-e', scenario.code || ''],
|
||||
scenario.shell ? { shell: scenario.shell } : {},
|
||||
),
|
||||
},
|
||||
}).default;
|
||||
const schedule = new ScheduleService(logger);
|
||||
let completion;
|
||||
const runTask = schedule.runTask.bind(schedule);
|
||||
schedule.runTask = (...args) => (completion = runTask(...args));
|
||||
const messages = [],
|
||||
writes = [];
|
||||
const SystemService = load('back/services/system.ts', {
|
||||
os: { platform: () => 'linux' },
|
||||
'../config': {},
|
||||
'../config/const': {},
|
||||
'../config/util': { updateLinuxMirrorFile: async () => 'fixture' },
|
||||
'../data/dependence': {},
|
||||
'../data/notify': {},
|
||||
'../data/system': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {},
|
||||
'../shared/i18n': {},
|
||||
'../shared/schedulerMutationLock': {},
|
||||
'./notify': class {},
|
||||
'./schedule': ScheduleService,
|
||||
'./sock': class {},
|
||||
}).default;
|
||||
const service = new SystemService(logger, schedule, {
|
||||
sendMessage: (message) => messages.push(message),
|
||||
});
|
||||
service.getSystemConfig = async () => ({ id: 1, info: { lang: 'en-US' } });
|
||||
service.updateAuthDb = async (doc) => writes.push(doc);
|
||||
let ended = 0;
|
||||
await service.updateLinuxMirror(
|
||||
{ linuxMirror: 'https://mirror.example' },
|
||||
undefined,
|
||||
() => ended++,
|
||||
);
|
||||
const result = await completion;
|
||||
assert.equal(ended, 1);
|
||||
assert.equal(writes.length, scenario.saved ? 1 : 0);
|
||||
assert.ok(messages.some((message) => message.status === 'completed'));
|
||||
if (scenario.saved) {
|
||||
assert.equal(result.code, 0);
|
||||
assert.ok(
|
||||
messages.some((message) => message.message.includes('sudo: warning')),
|
||||
);
|
||||
assert.deepEqual(writes[0].info, {
|
||||
lang: 'en-US',
|
||||
linuxMirror: 'https://mirror.example',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const hasApt = spawnSync('apt-get', ['--version']).status === 0;
|
||||
for (const distribution of ['Debian', 'Ubuntu']) {
|
||||
for (const existingMirror of [false, true]) {
|
||||
test(
|
||||
`${distribution} mirror update rejects failed index downloads with ${
|
||||
existingMirror ? 'an existing' : 'an empty'
|
||||
} source`,
|
||||
{ skip: !hasApt },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-apt-result-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const parts = path.join(root, 'parts');
|
||||
await fs.mkdir(parts);
|
||||
await fs.mkdir(path.join(root, 'lists', 'partial'), {
|
||||
recursive: true,
|
||||
});
|
||||
const source = path.join(root, 'sources.list');
|
||||
await fs.writeFile(
|
||||
source,
|
||||
'deb http://127.0.0.1:1/debian bookworm main\n',
|
||||
);
|
||||
// Load only fixture configuration, including all writable APT paths.
|
||||
const aptConfig = path.join(root, 'apt.conf');
|
||||
await fs.writeFile(
|
||||
aptConfig,
|
||||
`Dir::Etc::main "/dev/null";
|
||||
Dir::Etc::parts "${parts}";
|
||||
Dir::Etc::sourcelist "${source}";
|
||||
Dir::Etc::sourceparts "${parts}";
|
||||
Dir::State::lists "${path.join(root, 'lists')}";
|
||||
Dir::Cache "${path.join(root, 'cache')}";
|
||||
Dir::Log "${path.join(root, 'logs')}";
|
||||
Acquire::Retries "0";
|
||||
Acquire::http::Proxy "DIRECT";
|
||||
Acquire::http::Timeout "1";
|
||||
`,
|
||||
);
|
||||
const util = load('back/config/util.ts', {
|
||||
'fs/promises': {
|
||||
readFile: async (file) =>
|
||||
file === '/etc/os-release'
|
||||
? distribution
|
||||
: existingMirror
|
||||
? 'URIs: http://old.example/debian\n'
|
||||
: '',
|
||||
},
|
||||
os: { platform: () => 'linux' },
|
||||
'./index': {},
|
||||
'./const': {},
|
||||
'./share': {},
|
||||
'./container': { isInContainer: () => false },
|
||||
'../loaders/logger': {},
|
||||
'../shared/utils': {},
|
||||
'../shared/fileAccess': {},
|
||||
'../data/dependence': {},
|
||||
});
|
||||
const previousOS = process.env.QL_OS_TYPE;
|
||||
let command;
|
||||
try {
|
||||
process.env.QL_OS_TYPE = distribution.toLowerCase();
|
||||
command = await util.updateLinuxMirrorFile('http://127.0.0.1:1');
|
||||
} finally {
|
||||
if (previousOS === undefined) delete process.env.QL_OS_TYPE;
|
||||
else process.env.QL_OS_TYPE = previousOS;
|
||||
}
|
||||
// Exercise the generated updater without modifying host source files.
|
||||
const [program, ...args] = command
|
||||
.slice(command.lastIndexOf('&&') + 2)
|
||||
.trim()
|
||||
.split(/\s+/);
|
||||
assert.equal(program, 'apt-get');
|
||||
const options = {
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
env: { ...process.env, APT_CONFIG: aptConfig },
|
||||
};
|
||||
const baseline = spawnSync('apt-get', ['update'], options);
|
||||
assert.equal(baseline.status, 0, baseline.stderr);
|
||||
assert.match(baseline.stderr, /Failed to fetch/);
|
||||
const result = spawnSync(program, args, options);
|
||||
assert.equal(result.status, 100, result.stderr);
|
||||
assert.match(result.stderr, /Failed to fetch/);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const logger = { debug() {}, warn() {}, error() {} };
|
||||
|
||||
for (const host of ['127.0.0.1', '::1', '192.0.2.1', '::']) {
|
||||
test(`HTTP binding ${host} never broadens an explicit private address`, async () => {
|
||||
const Http = load(path.join(__dirname, '../../back/services/http.ts'), {
|
||||
'../config': { bindHost: host },
|
||||
'../loaders/logger': logger,
|
||||
'./metrics': { metricsService: { record() {} } },
|
||||
typedi: { Service: () => (x) => x },
|
||||
}).HttpServerService;
|
||||
const instance = new Http();
|
||||
const attempted = [];
|
||||
instance.tryListen = async (_app, _port, address) => {
|
||||
attempted.push(address);
|
||||
throw new Error('unavailable');
|
||||
};
|
||||
await assert.rejects(instance.initialize({}, 5700));
|
||||
assert.deepEqual(attempted, host === '::' ? ['::', '0.0.0.0'] : [host]);
|
||||
});
|
||||
test(`gRPC binding ${host} never broadens an explicit private address`, async () => {
|
||||
const attempted = [];
|
||||
const Grpc = load(path.join(__dirname, '../../back/services/grpc.ts'), {
|
||||
'../config': { bindHostGrpc: host, grpcPort: 5500 },
|
||||
'../loaders/logger': logger,
|
||||
'./metrics': { metricsService: { record() {} } },
|
||||
typedi: { Service: () => (x) => x },
|
||||
'@grpc/grpc-js': {
|
||||
Server: class {
|
||||
addService() {}
|
||||
bindAsync(address, _credentials, cb) {
|
||||
attempted.push(address);
|
||||
cb(new Error('unavailable'));
|
||||
}
|
||||
},
|
||||
ServerCredentials: {
|
||||
createSsl(_ca, _certs, requireClientCert) {
|
||||
assert.equal(requireClientCert, true);
|
||||
return {};
|
||||
},
|
||||
},
|
||||
},
|
||||
'../protos/cron': { CronService: {} },
|
||||
'../protos/health': { HealthService: {} },
|
||||
'../protos/api': { ApiService: {} },
|
||||
'../schedule/addCron': {},
|
||||
'../schedule/delCron': {},
|
||||
'../schedule/setConcurrency': {},
|
||||
'../schedule/health': {},
|
||||
'../schedule/api': {},
|
||||
'../config/grpcCerts': {
|
||||
initGrpcCerts: async () => ({
|
||||
caCert: 'test',
|
||||
serverCert: 'test',
|
||||
serverKey: 'test',
|
||||
}),
|
||||
},
|
||||
}).GrpcServerService;
|
||||
await assert.rejects(new Grpc().initialize());
|
||||
assert.deepEqual(
|
||||
attempted,
|
||||
host === '::' ? ['[::]:5500', '0.0.0.0:5500'] : [`${host}:5500`],
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { LogStreamManager } = require('../../back/shared/logStreamManager');
|
||||
|
||||
async function fixture(t) {
|
||||
const base = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-boundary-'));
|
||||
t.after(() => fs.rm(base, { recursive: true, force: true }));
|
||||
const root = path.join(base, 'log');
|
||||
const outside = path.join(base, 'log-other');
|
||||
await fs.mkdir(root);
|
||||
await fs.mkdir(outside);
|
||||
const victim = path.join(outside, 'victim.log');
|
||||
await fs.writeFile(victim, 'unchanged');
|
||||
await fs.symlink(outside, path.join(root, 'escape-dir'));
|
||||
await fs.symlink(victim, path.join(root, 'escape-file'));
|
||||
await fs.symlink(
|
||||
path.join(outside, 'missing.log'),
|
||||
path.join(root, 'dangling'),
|
||||
);
|
||||
const invalid = [
|
||||
path.join(root, '..', 'log-other', 'new.log'),
|
||||
path.join(root, 'escape-dir', 'new.log'),
|
||||
path.join(root, 'escape-file'),
|
||||
path.join(root, 'dangling'),
|
||||
root,
|
||||
path.join(root, 'bad\0name'),
|
||||
];
|
||||
return { root, outside, victim, invalid };
|
||||
}
|
||||
|
||||
test('log streams reject traversal, sibling prefixes and escaping symlinks before writing', async (t) => {
|
||||
const { root, outside, victim, invalid } = await fixture(t);
|
||||
const manager = new LogStreamManager(root);
|
||||
for (const target of invalid) {
|
||||
await assert.rejects(
|
||||
manager.write(target, 'overwrite'),
|
||||
/outside the log directory/,
|
||||
);
|
||||
await assert.rejects(
|
||||
manager.closeStream(target),
|
||||
/outside the log directory/,
|
||||
);
|
||||
assert.equal(manager.getOpenStreamCount(), 0);
|
||||
}
|
||||
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
|
||||
assert.deepEqual(await fs.readdir(outside), ['victim.log']);
|
||||
const folder = path.join(root, '中文 日志');
|
||||
await fs.mkdir(folder);
|
||||
const log = path.join(folder, 'task.log');
|
||||
await Promise.all([
|
||||
manager.write(log, '开始\n'),
|
||||
manager.write(log, '结束\n'),
|
||||
]);
|
||||
await manager.closeAll();
|
||||
assert.equal(await fs.readFile(log, 'utf8'), '开始\n结束\n');
|
||||
});
|
||||
|
||||
test('log initialization rejects unsafe paths before mkdir or file writes', async (t) => {
|
||||
const { root, outside, victim, invalid } = await fixture(t);
|
||||
const { handleLogPath } = load(path.resolve('back/config/util.ts'), {
|
||||
'./index': { logPath: root },
|
||||
'./share': {},
|
||||
'../loaders/logger': {},
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: (file, data) => fs.writeFile(file, data),
|
||||
},
|
||||
'../data/dependence': { DependenceTypes: {} },
|
||||
});
|
||||
for (const target of invalid) {
|
||||
await assert.rejects(
|
||||
handleLogPath(target, 'overwrite'),
|
||||
/outside the log directory/,
|
||||
);
|
||||
}
|
||||
await assert.rejects(
|
||||
handleLogPath('../log-other/new/sub/task.log', 'overwrite'),
|
||||
/outside the log directory/,
|
||||
);
|
||||
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
|
||||
assert.deepEqual(await fs.readdir(outside), ['victim.log']);
|
||||
const log = await handleLogPath('中文 日志/nested/task.log', 'initial');
|
||||
assert.equal(await fs.readFile(log, 'utf8'), 'initial');
|
||||
assert.equal(await handleLogPath(log, 'ignored'), log);
|
||||
assert.equal(await fs.readFile(log, 'utf8'), 'initial');
|
||||
});
|
||||
|
||||
test('manual execution rejects escaping log names before creating directories or spawning', async (t) => {
|
||||
const { root, outside, victim } = await fixture(t);
|
||||
let spawned = 0;
|
||||
let releases = 0;
|
||||
const errors = [];
|
||||
const CronService = load(path.resolve('back/services/cron.ts'), {
|
||||
'../config': { logPath: root },
|
||||
'../data/cron': {
|
||||
CrontabStatus: { queued: 3, idle: 1 },
|
||||
CrontabModel: { update: async () => {} },
|
||||
},
|
||||
'../data/runningInstance': { RunningInstanceModel: {}, InstanceStatus: {} },
|
||||
'../config/util': {},
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {
|
||||
manualRunWithCronLimit: async (fn) => {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
releases++;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {
|
||||
logStreamManager: { closeStream: async () => {} },
|
||||
},
|
||||
'cross-spawn': {
|
||||
spawn: () => {
|
||||
spawned++;
|
||||
throw new Error('must not spawn');
|
||||
},
|
||||
},
|
||||
}).default;
|
||||
const service = new CronService({
|
||||
info() {},
|
||||
error: (...args) => errors.push(args),
|
||||
});
|
||||
for (const log_name of [
|
||||
'../log-other/new',
|
||||
outside,
|
||||
'escape-dir/new',
|
||||
'dangling',
|
||||
'bad\0name',
|
||||
]) {
|
||||
service.getDb = async () => ({
|
||||
id: 1,
|
||||
status: 3,
|
||||
command: 'ignored',
|
||||
log_path: '',
|
||||
log_name,
|
||||
});
|
||||
await service.runSingle(1);
|
||||
}
|
||||
assert.equal(spawned, 0);
|
||||
assert.equal(releases, 5);
|
||||
assert.equal(
|
||||
errors.filter((args) =>
|
||||
args.includes('Log path is outside the log directory'),
|
||||
).length,
|
||||
5,
|
||||
);
|
||||
assert.equal(await fs.readFile(victim, 'utf8'), 'unchanged');
|
||||
assert.deepEqual(await fs.readdir(outside), ['victim.log']);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
DEFAULT_LOG_CHUNK_BYTES,
|
||||
MAX_LOG_CHUNK_BYTES,
|
||||
readLogChunk,
|
||||
} = require('../../back/shared/logReader');
|
||||
|
||||
test('log reader defaults to a bounded tail and supports incremental reads', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-'));
|
||||
const file = path.join(directory, 'task.log');
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
const initial = 'a'.repeat(DEFAULT_LOG_CHUNK_BYTES * 2);
|
||||
await fs.writeFile(file, initial);
|
||||
|
||||
const tail = await readLogChunk(file);
|
||||
assert.equal(tail.content.length, DEFAULT_LOG_CHUNK_BYTES);
|
||||
assert.equal(tail.offset, DEFAULT_LOG_CHUNK_BYTES);
|
||||
assert.equal(tail.nextOffset, initial.length);
|
||||
assert.equal(tail.truncated, true);
|
||||
|
||||
await fs.appendFile(file, 'next');
|
||||
const incremental = await readLogChunk(file, { offset: tail.nextOffset });
|
||||
assert.equal(incremental.content, 'next');
|
||||
assert.equal(incremental.nextOffset, initial.length + 4);
|
||||
});
|
||||
|
||||
test('log reader enforces the maximum chunk size', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-'));
|
||||
const file = path.join(directory, 'large.log');
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
await fs.writeFile(file, 'x'.repeat(MAX_LOG_CHUNK_BYTES + 1024));
|
||||
|
||||
const chunk = await readLogChunk(file, { limit: Number.MAX_SAFE_INTEGER });
|
||||
assert.equal(Buffer.byteLength(chunk.content), MAX_LOG_CHUNK_BYTES);
|
||||
});
|
||||
|
||||
test('log reader preserves UTF-8 characters across byte boundaries', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-log-reader-'));
|
||||
const file = path.join(directory, 'utf8.log');
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
await fs.writeFile(file, 'a'.repeat(5) + '中文日志');
|
||||
|
||||
const first = await readLogChunk(file, { offset: 0, limit: 7 });
|
||||
const second = await readLogChunk(file, {
|
||||
offset: first.nextOffset,
|
||||
limit: 7,
|
||||
});
|
||||
const third = await readLogChunk(file, {
|
||||
offset: second.nextOffset,
|
||||
limit: 7,
|
||||
});
|
||||
|
||||
assert.equal(first.content + second.content + third.content, 'aaaaa中文日志');
|
||||
assert.equal((first.content + second.content + third.content).includes('�'), false);
|
||||
});
|
||||
|
||||
test('missing logs return an empty chunk', async () => {
|
||||
const chunk = await readLogChunk('/path/that/does/not/exist.log');
|
||||
assert.deepEqual(chunk, {
|
||||
content: '',
|
||||
offset: 0,
|
||||
nextOffset: 0,
|
||||
total: 0,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { spawn } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { LogStreamManager } = require('../../back/shared/logStreamManager');
|
||||
|
||||
test(
|
||||
'manual execution captures a short child before slow status storage and flushes before release',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const logs = new LogStreamManager(root);
|
||||
const updates = [];
|
||||
let active = 0;
|
||||
let failSpawn = false;
|
||||
const CronService = load(path.resolve('back/services/cron.ts'), {
|
||||
'../config': { logPath: root },
|
||||
'../data/cron': {
|
||||
CrontabStatus: { queued: 3, running: 0, idle: 1 },
|
||||
CrontabModel: {
|
||||
update: async (data, options) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
updates.push({ data, options });
|
||||
return [1];
|
||||
},
|
||||
},
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: {},
|
||||
InstanceStatus: {},
|
||||
},
|
||||
'../config/util': { getUniqPath: async () => 'task' },
|
||||
'../config/const': { TASK_PREFIX: 'task ', QL_PREFIX: 'ql ' },
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {
|
||||
manualRunWithCronLimit: async (fn) => {
|
||||
active++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
active--;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': { logStreamManager: logs },
|
||||
'cross-spawn': {
|
||||
spawn: () =>
|
||||
failSpawn
|
||||
? spawn('true', { shell: '/nonexistent-ql-manual-shell' })
|
||||
: spawn(process.execPath, [
|
||||
'-e',
|
||||
'process.stdout.write("末尾\\n")',
|
||||
]),
|
||||
},
|
||||
}).default;
|
||||
const service = new CronService({ info() {}, error() {} });
|
||||
service.getDb = async () => ({
|
||||
id: 1,
|
||||
status: 3,
|
||||
command: 'ignored',
|
||||
log_path: '',
|
||||
});
|
||||
service.makeCommand = () => 'ignored';
|
||||
await service.runSingle(1);
|
||||
const [file] = await fs.readdir(path.join(root, 'task'));
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(root, 'task', file), 'utf8'),
|
||||
'末尾\n',
|
||||
);
|
||||
assert.equal(logs.getOpenStreamCount(), 0);
|
||||
assert.equal(active, 0);
|
||||
assert.equal(updates.at(-1).data.status, 1);
|
||||
failSpawn = true;
|
||||
await service.runSingle(1);
|
||||
assert.equal(active, 0);
|
||||
assert.equal(updates.at(-1).data.status, 1);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,220 @@
|
||||
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 { spawn } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { killTask } = require('../../back/config/util');
|
||||
const { LogStreamManager } = require('../../back/shared/logStreamManager');
|
||||
|
||||
for (const conflict of ['stop', 'newer-queue', 'stop-requeue']) {
|
||||
test(
|
||||
`manual startup loses its conditional claim after ${conflict} and terminates the late child`,
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-manual-claim-'));
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(async () => {
|
||||
await db.close();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const crons = db.define('Cron', {
|
||||
status: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
log_path: DataTypes.STRING,
|
||||
queued_token: DataTypes.STRING,
|
||||
command: DataTypes.STRING,
|
||||
});
|
||||
const instances = db.define('Instance', {
|
||||
cron_id: DataTypes.INTEGER,
|
||||
status: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
});
|
||||
await db.sync();
|
||||
await crons.create({
|
||||
id: 1,
|
||||
status: 3,
|
||||
pid: null,
|
||||
log_path: 'previous.log',
|
||||
command: 'ignored',
|
||||
});
|
||||
let release,
|
||||
entered,
|
||||
child,
|
||||
released = 0;
|
||||
const pending = new Promise((resolve) => (entered = resolve));
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const logs = new LogStreamManager(root);
|
||||
t.after(() => logs.closeAll());
|
||||
const CronService = load('back/services/cron.ts', {
|
||||
'../config': { logPath: root },
|
||||
'../data/cron': {
|
||||
CrontabModel: crons,
|
||||
CrontabStatus: { queued: 3, running: 0, idle: 1 },
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: instances,
|
||||
InstanceStatus: { running: 0, stopped: 2 },
|
||||
},
|
||||
'../config/util': {
|
||||
getUniqPath: async () => {
|
||||
entered();
|
||||
await gate;
|
||||
return 'task';
|
||||
},
|
||||
killTask,
|
||||
},
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {
|
||||
manualRunWithCronLimit: async (fn) => {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
released++;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': { logStreamManager: logs },
|
||||
'cross-spawn': {
|
||||
spawn: () => {
|
||||
child = spawn(process.execPath, [
|
||||
'-e',
|
||||
'setInterval(() => {}, 1000)',
|
||||
]);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
});
|
||||
return child;
|
||||
},
|
||||
},
|
||||
}).default;
|
||||
const service = new CronService({ info() {}, error() {} });
|
||||
service.getDb = async () =>
|
||||
(await crons.findByPk(1)).get({ plain: true });
|
||||
service.makeCommand = () => 'ignored';
|
||||
const running = service.runSingle(1);
|
||||
await pending;
|
||||
if (conflict === 'stop-requeue') {
|
||||
await service.stop([1]);
|
||||
// Queue again through the real API; leave the new runner pending.
|
||||
const pendingRuns = [];
|
||||
service.runSingle = (id, token) => pendingRuns.push({ id, token });
|
||||
await service.run([1]);
|
||||
assert.equal(pendingRuns.length, 1);
|
||||
assert.equal(
|
||||
pendingRuns[0].token,
|
||||
(await crons.findByPk(1)).queued_token,
|
||||
);
|
||||
} else if (conflict === 'stop') await service.stop([1]);
|
||||
else await crons.update({ log_path: 'newer.log' }, { where: { id: 1 } });
|
||||
release();
|
||||
const result = await running;
|
||||
assert.match(result.error.message, /stopped or superseded/);
|
||||
assert.ok(child.signalCode || child.exitCode !== null);
|
||||
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
|
||||
const row = await crons.findByPk(1);
|
||||
assert.equal(row.status, conflict === 'stop' ? 1 : 3);
|
||||
assert.equal(
|
||||
row.log_path,
|
||||
conflict === 'newer-queue' ? 'newer.log' : 'previous.log',
|
||||
);
|
||||
assert.equal(row.pid, null);
|
||||
assert.equal(released, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test(
|
||||
'verified termination waits for a SIGTERM-resistant target to exit',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const child = spawn(process.execPath, [
|
||||
'-e',
|
||||
'process.on("SIGTERM",()=>{}); process.stdout.write("ready"); setInterval(()=>{},1000)',
|
||||
]);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
});
|
||||
const exited = once(child, 'exit');
|
||||
await new Promise((resolve) => child.stdout.once('data', resolve));
|
||||
await killTask(child.pid, true);
|
||||
// Linux may report an exited zombie before Node reaps our child. Wait for
|
||||
// that separate event before requiring the PID to disappear.
|
||||
await exited;
|
||||
assert.throws(() => process.kill(child.pid, 0), { code: 'ESRCH' });
|
||||
assert.equal(child.signalCode, 'SIGKILL');
|
||||
},
|
||||
);
|
||||
|
||||
test('a runner waiting for a concurrency slot cannot adopt a newer queued generation', async (t) => {
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => db.close());
|
||||
const crons = db.define('Cron', {
|
||||
status: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
log_path: DataTypes.STRING,
|
||||
queued_token: DataTypes.STRING,
|
||||
});
|
||||
await db.sync();
|
||||
await crons.create({ id: 1, status: 1, log_path: 'same.log' });
|
||||
const waiting = [];
|
||||
const CronService = load('back/services/cron.ts', {
|
||||
'../config': {},
|
||||
'../data/cron': {
|
||||
CrontabModel: crons,
|
||||
CrontabStatus: { queued: 3, idle: 1, running: 0 },
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: { findAll: async () => [] },
|
||||
InstanceStatus: { running: 0 },
|
||||
},
|
||||
'../config/util': {
|
||||
getUniqPath: () => assert.fail('stale runner must not prepare a child'),
|
||||
},
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {
|
||||
manualRunWithCronLimit: (fn) => {
|
||||
waiting.push(fn);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': {},
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {},
|
||||
'cross-spawn': { spawn: () => assert.fail('stale runner must not spawn') },
|
||||
}).default;
|
||||
const errors = [];
|
||||
const service = new CronService({ error: (...args) => errors.push(args) });
|
||||
await service.run([1]);
|
||||
const first = (await crons.findByPk(1)).queued_token;
|
||||
await service.stop([1]);
|
||||
await service.run([1]);
|
||||
const second = (await crons.findByPk(1)).queued_token;
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(waiting.length, 2);
|
||||
await waiting[0]();
|
||||
const row = await crons.findByPk(1);
|
||||
assert.equal(row.status, 3);
|
||||
assert.equal(row.queued_token, second);
|
||||
assert.equal(row.log_path, 'same.log');
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
MAX_METRIC_SAMPLES,
|
||||
metricsService,
|
||||
} = require('../../back/services/metrics');
|
||||
|
||||
test('metrics keep only the newest bounded samples', () => {
|
||||
const sampleCount = MAX_METRIC_SAMPLES * 50;
|
||||
for (let index = 0; index < sampleCount; index++) {
|
||||
metricsService.record('http_request', index, {
|
||||
path: `/health/${index}`,
|
||||
});
|
||||
}
|
||||
|
||||
const result = metricsService.getMetrics('http_request');
|
||||
assert.equal(result.count, MAX_METRIC_SAMPLES);
|
||||
assert.equal(result.metrics[0].value, sampleCount - MAX_METRIC_SAMPLES);
|
||||
assert.equal(result.metrics.at(-1).value, sampleCount - 1);
|
||||
});
|
||||
|
||||
test('empty metric queries return finite aggregates', () => {
|
||||
const result = metricsService.getMetrics('missing_metric');
|
||||
assert.deepEqual(
|
||||
{
|
||||
count: result.count,
|
||||
average: result.average,
|
||||
min: result.min,
|
||||
max: result.max,
|
||||
},
|
||||
{ count: 0, average: 0, min: 0, max: 0 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
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 { execFileSync, spawn, spawnSync } = require('node:child_process');
|
||||
|
||||
const script = path.resolve('scripts/wait-for-mirror.sh');
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
const options = (env = {}) => ({
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
QL_MIRROR_WAIT_SECONDS: '3',
|
||||
QL_MIRROR_POLL_INTERVAL: '1',
|
||||
QL_MIRROR_QUERY_TIMEOUT: '1',
|
||||
...env,
|
||||
},
|
||||
});
|
||||
|
||||
function fixture(t) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mirror-status-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const source = path.join(tmp, 'source');
|
||||
const mirror = path.join(tmp, 'mirror.git');
|
||||
git('init', source);
|
||||
git('init', '--bare', mirror);
|
||||
git('-C', source, 'config', 'user.name', 'Fixture');
|
||||
git('-C', source, 'config', 'user.email', 'fixture@example.invalid');
|
||||
git('-C', source, 'checkout', '-b', 'develop');
|
||||
fs.writeFileSync(path.join(source, 'snapshot'), 'first');
|
||||
git('-C', source, 'add', '.');
|
||||
git('-C', source, 'commit', '-m', 'first');
|
||||
return { tmp, source, mirror };
|
||||
}
|
||||
|
||||
test('mirror verification reads matching refs without changing either repository', (t) => {
|
||||
const { source, mirror } = fixture(t);
|
||||
git('-C', source, 'push', mirror, 'develop');
|
||||
const before = git('ls-remote', '--refs', mirror);
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[script, source, mirror, 'develop'],
|
||||
options(),
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
assert.match(result.stdout, /Mirror matches the latest source refs/);
|
||||
assert.equal(git('ls-remote', '--refs', mirror), before);
|
||||
assert.equal(git('-C', source, 'status', '--porcelain'), '');
|
||||
});
|
||||
|
||||
test('mirror polling follows a source that advances while waiting', async (t) => {
|
||||
const { source, mirror } = fixture(t);
|
||||
git('-C', source, 'push', mirror, 'develop');
|
||||
fs.writeFileSync(path.join(source, 'snapshot'), 'second');
|
||||
git('-C', source, 'commit', '-am', 'second');
|
||||
const child = spawn(
|
||||
'bash',
|
||||
[script, source, mirror, 'develop'],
|
||||
options({
|
||||
QL_MIRROR_WAIT_SECONDS: '6',
|
||||
}),
|
||||
);
|
||||
let output = '';
|
||||
let error = '';
|
||||
let moved = false;
|
||||
child.stderr.on('data', (data) => {
|
||||
error += data;
|
||||
});
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data;
|
||||
if (!moved && output.includes('Waiting for mirror')) {
|
||||
moved = true;
|
||||
fs.writeFileSync(path.join(source, 'snapshot'), 'third');
|
||||
git('-C', source, 'commit', '-am', 'third');
|
||||
git('-C', source, 'push', mirror, 'develop');
|
||||
}
|
||||
});
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', resolve);
|
||||
});
|
||||
assert.equal(status, 0, error + output);
|
||||
assert.ok(moved);
|
||||
assert.match(output, new RegExp(git('-C', source, 'rev-parse', 'develop')));
|
||||
});
|
||||
|
||||
test('empty or missing refs never count as a synchronized mirror', (t) => {
|
||||
const { source, mirror } = fixture(t);
|
||||
for (const ref of ['develop', 'missing']) {
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[script, source, mirror, ref],
|
||||
// Leave time to read both refs before checking the missing-ref diagnostic.
|
||||
options(),
|
||||
);
|
||||
assert.equal(result.status, 1, result.stderr + result.stdout);
|
||||
assert.match(result.stdout, /is missing refs\/heads\//);
|
||||
assert.match(result.stdout, /::error::Mirror did not match/);
|
||||
}
|
||||
});
|
||||
|
||||
test('static releases require both the version branch and annotated tag object', (t) => {
|
||||
const { source, mirror } = fixture(t);
|
||||
git('-C', source, 'branch', 'v2.22.0');
|
||||
git('-C', source, 'tag', '-a', 'v2.22.0', '-m', 'release');
|
||||
git('-C', source, 'push', mirror, 'refs/tags/v2.22.0');
|
||||
const args = [script, source, mirror, 'v2.22.0', 'tag'];
|
||||
const env = {
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true',
|
||||
QL_MIRROR_WAIT_SECONDS: '3',
|
||||
};
|
||||
const partial = spawnSync('bash', args, options(env));
|
||||
assert.equal(partial.status, 1, partial.stderr + partial.stdout);
|
||||
assert.match(partial.stdout, /mirror is missing refs\/heads\/v2.22.0/);
|
||||
git('-C', source, 'push', mirror, 'refs/heads/v2.22.0');
|
||||
const complete = spawnSync('bash', args, options(env));
|
||||
assert.equal(complete.status, 0, complete.stderr + complete.stdout);
|
||||
assert.match(
|
||||
complete.stdout,
|
||||
new RegExp(git('-C', source, 'rev-parse', 'refs/tags/v2.22.0')),
|
||||
);
|
||||
// A tag pointing to the same commit with different annotation is still stale.
|
||||
git('-C', source, 'tag', '-fa', 'v2.22.0', '-m', 'updated annotation');
|
||||
const stale = spawnSync('bash', args, options(env));
|
||||
assert.equal(stale.status, 1, stale.stderr + stale.stdout);
|
||||
});
|
||||
|
||||
test('network failures and hung ref queries stop within the configured deadline', (t) => {
|
||||
const { tmp, source, mirror } = fixture(t);
|
||||
const failure = spawnSync(
|
||||
'bash',
|
||||
[script, source, path.join(tmp, 'absent'), 'develop'],
|
||||
options({
|
||||
QL_MIRROR_WAIT_SECONDS: '3',
|
||||
}),
|
||||
);
|
||||
assert.equal(failure.status, 1, failure.stderr + failure.stdout);
|
||||
assert.match(failure.stdout, /Could not read mirror refs/);
|
||||
|
||||
const bin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
const realGit = execFileSync('which', ['git'], { encoding: 'utf8' }).trim();
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'git'),
|
||||
'#!/bin/sh\nif [ "$1" = ls-remote ]; then sleep 30; fi\nexec "$QL_TEST_GIT" "$@"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const started = Date.now();
|
||||
const hung = spawnSync('bash', [script, source, mirror, 'develop'], {
|
||||
...options({
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
||||
QL_TEST_GIT: realGit,
|
||||
QL_MIRROR_WAIT_SECONDS: '2',
|
||||
QL_MIRROR_QUERY_TIMEOUT: '20',
|
||||
}),
|
||||
timeout: 5000,
|
||||
});
|
||||
assert.equal(hung.status, 1, hung.stderr + hung.stdout);
|
||||
assert.ok(Date.now() - started < 4500);
|
||||
assert.match(hung.stdout, /within 2s/);
|
||||
});
|
||||
|
||||
test('invalid mirror parameters fail before polling', (t) => {
|
||||
const { source, mirror } = fixture(t);
|
||||
for (const args of [
|
||||
[],
|
||||
[source, mirror, 'develop', 'other'],
|
||||
[source, mirror, 'bad ref'],
|
||||
]) {
|
||||
assert.notEqual(spawnSync('bash', [script, ...args], options()).status, 0);
|
||||
}
|
||||
for (const value of ['0', '-1', '1.5', '1+2', '9999999999999']) {
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[script, source, mirror, 'develop'],
|
||||
options({
|
||||
QL_MIRROR_WAIT_SECONDS: value,
|
||||
}),
|
||||
);
|
||||
assert.equal(result.status, 2, result.stderr + result.stdout);
|
||||
}
|
||||
});
|
||||
|
||||
test('Gitee workflows verify both mirrors and do not gate image publication', () => {
|
||||
const workflow = require('js-yaml').load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
assert.equal(workflow.jobs.code_gitee, undefined);
|
||||
assert.equal(workflow.jobs.static_gitee, undefined);
|
||||
const job = workflow.jobs['verify-gitee-mirrors'];
|
||||
assert.equal(job.needs, 'build-static');
|
||||
assert.equal(job['timeout-minutes'], 14);
|
||||
assert.deepEqual(job.permissions, { contents: 'write' });
|
||||
assert.deepEqual(job.strategy.matrix.repository, [
|
||||
'qinglong',
|
||||
'qinglong-static',
|
||||
]);
|
||||
const verify = job.steps.find((step) => step.run);
|
||||
assert.equal(verify.env.MIRROR_REF, '${{ github.ref_name }}');
|
||||
assert.equal(verify.env.MIRROR_REF_TYPE, '${{ github.ref_type }}');
|
||||
assert.equal(
|
||||
verify.env.QL_MIRROR_INCLUDE_VERSION_BRANCH,
|
||||
"${{ matrix.repository == 'qinglong-static' }}",
|
||||
);
|
||||
assert.match(verify.run, /bash scripts\/wait-for-mirror.sh/);
|
||||
assert.equal(verify.if, "${{ matrix.repository == 'qinglong' }}");
|
||||
assert.equal(verify.env.GH_TOKEN, '${{ github.token }}');
|
||||
assert.equal(verify.env.QL_MIRROR_NOTIFY_ON_START, 'false');
|
||||
assert.equal(verify.env.QL_MIRROR_NOTIFY_REPO, 'whyour/qinglong');
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(verify),
|
||||
/secrets\.|git push|sync-static|ssh-keyscan/,
|
||||
);
|
||||
const notify = job.steps.find((step) =>
|
||||
/Notify and wait/.test(step.name || ''),
|
||||
);
|
||||
assert.equal(notify.if, "${{ matrix.repository == 'qinglong-static' }}");
|
||||
assert.equal(notify.env.GH_TOKEN, '${{ secrets.API_TOKEN }}');
|
||||
assert.match(notify.run, /bash scripts\/wait-for-mirror.sh/);
|
||||
assert.equal(notify.env.QL_MIRROR_NOTIFY_REPO, 'whyour/qinglong-static');
|
||||
assert.equal(notify.env.QL_MIRROR_INCLUDE_VERSION_BRANCH, 'true');
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(job),
|
||||
/GITEE_TOKEN|git@gitee|sync-static|ssh-keyscan/,
|
||||
);
|
||||
const backfill = workflow.jobs['verify-existing-static-gitee'];
|
||||
assert.match(backfill.if, /workflow_dispatch.*inputs.static_sync_only/);
|
||||
assert.equal(backfill.needs, undefined);
|
||||
const verifyExisting = backfill.steps.find((step) => step.run);
|
||||
assert.equal(verifyExisting.env.MIRROR_REF, '${{ inputs.static_ref }}');
|
||||
assert.equal(
|
||||
verifyExisting.env.MIRROR_REF_TYPE,
|
||||
'${{ inputs.static_ref_type }}',
|
||||
);
|
||||
assert.equal(verifyExisting.env.GH_TOKEN, '${{ secrets.API_TOKEN }}');
|
||||
assert.match(verifyExisting.run, /wait-for-mirror.sh/);
|
||||
assert.equal(
|
||||
verifyExisting.env.QL_MIRROR_NOTIFY_REPO,
|
||||
'whyour/qinglong-static',
|
||||
);
|
||||
assert.equal(verifyExisting.env.QL_MIRROR_INCLUDE_VERSION_BRANCH, 'true');
|
||||
assert.deepEqual(workflow.on.push.branches, ['master', 'develop']);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(backfill),
|
||||
/GITEE_TOKEN|git@gitee|sync-static|ssh-keyscan/,
|
||||
);
|
||||
for (const name of [
|
||||
'build-alpine',
|
||||
'build-debian',
|
||||
'build-alpine310',
|
||||
'build-debian310',
|
||||
]) {
|
||||
assert.equal(workflow.jobs[name].needs, 'build-static');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { monitoringMiddleware } = require('../../back/middlewares/monitoring');
|
||||
const { metricsService } = require('../../back/services/metrics');
|
||||
|
||||
function createResponse() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
ended: false,
|
||||
end() {
|
||||
this.ended = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('health probes bypass request metric retention with or without base URL', () => {
|
||||
const before = metricsService.getMetrics('http_request', {
|
||||
path: '/api/health',
|
||||
}).count;
|
||||
let nextCalled = false;
|
||||
|
||||
for (const path of ['/api/health', '/ql/api/health']) {
|
||||
const response = createResponse();
|
||||
monitoringMiddleware({ method: 'GET', path }, response, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
response.end();
|
||||
assert.equal(response.ended, true);
|
||||
}
|
||||
|
||||
const after = metricsService.getMetrics('http_request', {
|
||||
path: '/api/health',
|
||||
}).count;
|
||||
assert.equal(nextCalled, true);
|
||||
assert.equal(after, before);
|
||||
});
|
||||
|
||||
test('non-health requests keep bounded service metrics', () => {
|
||||
const path = '/api/monitoring-test';
|
||||
const before = metricsService.getMetrics('http_request', { path }).count;
|
||||
const response = createResponse();
|
||||
|
||||
monitoringMiddleware(
|
||||
{ method: 'GET', path, platform: 'desktop' },
|
||||
response,
|
||||
() => {},
|
||||
);
|
||||
response.end();
|
||||
|
||||
const after = metricsService.getMetrics('http_request', { path }).count;
|
||||
assert.equal(response.ended, true);
|
||||
assert.equal(after, before + 1);
|
||||
});
|
||||
|
||||
test('ordinary request metrics are sampled instead of retained per request', () => {
|
||||
const path = '/api/monitoring-sampling-test';
|
||||
const before = metricsService.getMetrics('http_request', { path }).count;
|
||||
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
const response = createResponse();
|
||||
monitoringMiddleware(
|
||||
{ method: 'GET', path, platform: 'desktop' },
|
||||
response,
|
||||
() => {},
|
||||
);
|
||||
response.end();
|
||||
}
|
||||
|
||||
const recorded =
|
||||
metricsService.getMetrics('http_request', { path }).count - before;
|
||||
assert.ok(recorded >= 2 && recorded <= 3);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { createCronJob } = require('../../back/shared/cronScheduler');
|
||||
const logger = { info() {}, warn() {}, error() {} };
|
||||
|
||||
test('native calendar skips nonexistent spring time and duplicate fall hour', async (t) => {
|
||||
const previous = process.env.TZ;
|
||||
process.env.TZ = 'America/New_York';
|
||||
t.after(() => { if (previous === undefined) delete process.env.TZ; else process.env.TZ = previous; });
|
||||
t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: new Date('2026-03-08T05:00:00Z') });
|
||||
const springRuns = [];
|
||||
const spring = createCronJob('0 30 2 * * *', (date) => springRuns.push(date.toISOString()), { name: 'spring', logger });
|
||||
t.after(() => spring.cancel());
|
||||
t.mock.timers.tick(new Date('2026-03-09T06:30:00Z') - Date.now());
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(springRuns, ['2026-03-09T06:30:00.000Z']);
|
||||
spring.cancel();
|
||||
t.mock.timers.setTime(new Date('2026-11-01T05:30:00Z').getTime());
|
||||
const fallRuns = [];
|
||||
const fall = createCronJob('0 30 1 * * *', (date) => fallRuns.push(date.toISOString()), { name: 'fall', logger });
|
||||
t.after(() => fall.cancel());
|
||||
t.mock.timers.tick(new Date('2026-11-02T06:30:00Z') - Date.now());
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(fallRuns, ['2026-11-02T06:30:00.000Z']);
|
||||
});
|
||||
|
||||
test('construction failure cleans staged jobs and preserves the running snapshot', async () => {
|
||||
const cancelled = [];
|
||||
const stacks = new Map([['old', [{ cancel: () => cancelled.push('old') }]]]);
|
||||
let count = 0;
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/cronScheduler': { createCronJob: () => {
|
||||
if (++count === 2) throw Error('construction failed');
|
||||
return { start() { throw Error('must not start'); }, cancel: () => cancelled.push('staged') };
|
||||
} },
|
||||
'../shared/runCron': {}, '../loaders/logger': logger, '../shared/i18n': { tf: require('node:util').format },
|
||||
});
|
||||
await assert.rejects(new Promise((resolve, reject) => addCron({ request: {
|
||||
replace: true, crons: [{ id: 'new', schedule: '* * * * *', extra_schedules: [{ schedule: '0 * * * *' }] }],
|
||||
} }, (error) => error ? reject(error) : resolve())), /construction failed/);
|
||||
assert.deepEqual([...stacks.keys()], ['old']);
|
||||
assert.deepEqual(cancelled, ['staged']);
|
||||
});
|
||||
|
||||
test('invalid subscription schedules are isolated and immediate runs without a schedule survive', async () => {
|
||||
const warnings = [], runs = [];
|
||||
const Service = load('back/services/schedule.ts', {
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
'../shared/pLimit': {},
|
||||
}).default;
|
||||
const service = new Service({ ...logger, warn: (...args) => warnings.push(args) });
|
||||
service.runTask = async (...args) => { runs.push(args); };
|
||||
const item = { id: 123, name: 'subscription', command: 'true', runOrigin: 'subscription' };
|
||||
await service.createCronTask({ ...item, schedule: 'not a cron' });
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.equal(service.scheduleStacks.size, 0);
|
||||
await service.createCronTask(item, {}, true);
|
||||
assert.equal(runs.length, 1);
|
||||
await service.createCronTask({ ...item, schedule: '* * * * *' });
|
||||
assert.equal(service.scheduleStacks.size, 1);
|
||||
await service.cancelCronTask(item);
|
||||
assert.equal(service.scheduleStacks.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
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 { spawnSync, spawn } = require('node:child_process');
|
||||
const helper = path.resolve('shell/node_path_cache.sh');
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-node-path-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.symlinkSync(process.execPath, path.join(bin, 'node'));
|
||||
const calls = path.join(root, 'calls');
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'pnpm'),
|
||||
'#!/bin/bash\necho call >> "$CALLS"\n[[ "${FAIL:-0}" == 1 ]] && exit 1\nprintf "%s\\n" "${ANSWER:-/test/global/node_modules}"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
PATH: `${bin}:${process.env.PATH}`,
|
||||
dir_tmp: path.join(root, 'cache'),
|
||||
CALLS: calls,
|
||||
HELPER: helper,
|
||||
};
|
||||
const command = '. "$HELPER"; ql_get_node_global_path';
|
||||
const run = (extra = {}, code = command) =>
|
||||
spawnSync('/bin/bash', ['-uc', code], {
|
||||
cwd: root,
|
||||
env: { ...env, ...extra },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const count = () =>
|
||||
fs.existsSync(calls)
|
||||
? fs.readFileSync(calls, 'utf8').trim().split('\n').length
|
||||
: 0;
|
||||
return { root, bin, env, command, run, count };
|
||||
}
|
||||
test('warm cache avoids pnpm, preserves spaces, and never evaluates cached text', (t) => {
|
||||
const f = fixture(t);
|
||||
const answer = '/test/path with spaces/$(touch injected)';
|
||||
assert.equal(f.run({ ANSWER: answer }).stdout.trim(), answer);
|
||||
assert.equal(f.run({ ANSWER: '/different' }).stdout.trim(), answer);
|
||||
assert.equal(f.count(), 1);
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'injected')), false);
|
||||
});
|
||||
test('config, environment, cwd, and executable changes invalidate discovery', (t) => {
|
||||
const f = fixture(t);
|
||||
f.run();
|
||||
fs.writeFileSync(path.join(f.root, '.npmrc'), 'global-dir=/other\n');
|
||||
f.run();
|
||||
assert.equal(f.count(), 2);
|
||||
f.run({ npm_config_global_dir: '/third' });
|
||||
assert.equal(f.count(), 3);
|
||||
f.run({ PNPM_HOME: '/fourth' });
|
||||
assert.equal(f.count(), 4);
|
||||
fs.mkdirSync(path.join(f.root, 'child'));
|
||||
f.run({}, 'cd child; ' + f.command);
|
||||
assert.equal(f.count(), 5);
|
||||
f.run();
|
||||
const before = f.count();
|
||||
fs.appendFileSync(path.join(f.bin, 'pnpm'), '# upgrade\n');
|
||||
f.run();
|
||||
assert.equal(f.count(), before + 1);
|
||||
});
|
||||
test('alternating Node/crond working directories retain independent cached roots', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(path.join(f.root, 'child'));
|
||||
fs.writeFileSync(path.join(f.root, 'child', '.npmrc'), 'global-dir=/child\n');
|
||||
for (let i = 0; i < 4; i++) {
|
||||
assert.equal(f.run({ ANSWER: '/parent/modules' }).stdout.trim(), '/parent/modules');
|
||||
assert.equal(
|
||||
f.run({ ANSWER: '/child/modules' }, 'cd child; ' + f.command).stdout.trim(),
|
||||
'/child/modules',
|
||||
);
|
||||
}
|
||||
assert.equal(f.count(), 2, 'each working directory should discover only once');
|
||||
assert.equal(fs.readdirSync(f.env.dir_tmp).filter((x) => x.endsWith('.cache')).length, 2);
|
||||
});
|
||||
test('refresh retires old per-environment records without removing locks or unrelated files', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(f.env.dir_tmp);
|
||||
const prefix = `pnpm-root-${process.getuid()}`;
|
||||
const names = [`${prefix}-1-2.cache`, `${prefix}.lock`, 'unrelated.cache'];
|
||||
const old = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
for (const name of names) {
|
||||
const file = path.join(f.env.dir_tmp, name);
|
||||
fs.writeFileSync(file, '');
|
||||
fs.utimesSync(file, old, old);
|
||||
}
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(fs.existsSync(path.join(f.env.dir_tmp, names[0])), false);
|
||||
for (const name of names.slice(1)) assert.ok(fs.existsSync(path.join(f.env.dir_tmp, name)));
|
||||
});
|
||||
test('expired or malformed records refresh and failed lookups are not cached', (t) => {
|
||||
const f = fixture(t);
|
||||
f.run();
|
||||
const file = path.join(
|
||||
f.env.dir_tmp,
|
||||
fs.readdirSync(f.env.dir_tmp).find((name) => name.endsWith('.cache')),
|
||||
);
|
||||
let lines = fs.readFileSync(file, 'utf8').split('\n');
|
||||
lines[1] = '0';
|
||||
fs.writeFileSync(file, lines.join('\n'));
|
||||
f.run();
|
||||
assert.equal(f.count(), 2);
|
||||
fs.writeFileSync(file, lines[0] + '\n08\n/incorrect\n');
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(f.count(), 3);
|
||||
fs.writeFileSync(file, lines[0]);
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(f.count(), 4);
|
||||
fs.rmSync(file);
|
||||
assert.equal(f.run({ FAIL: '1' }).status, 1);
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(f.count(), 6);
|
||||
});
|
||||
test('disabled or unavailable cache falls back without changing discovery output', (t) => {
|
||||
const f = fixture(t);
|
||||
for (let i = 0; i < 2; i++)
|
||||
assert.equal(
|
||||
f.run({ QL_NODE_PATH_CACHE: '0' }).stdout.trim(),
|
||||
'/test/global/node_modules',
|
||||
);
|
||||
assert.equal(f.count(), 2);
|
||||
assert.equal(fs.existsSync(f.env.dir_tmp), false);
|
||||
const blocked = path.join(f.root, 'file');
|
||||
fs.writeFileSync(blocked, '');
|
||||
assert.equal(
|
||||
f.run({ dir_tmp: blocked }).stdout.trim(),
|
||||
'/test/global/node_modules',
|
||||
);
|
||||
assert.equal(f.count(), 3);
|
||||
});
|
||||
test('concurrent refreshes publish complete records and do not overwrite symlink targets', async (t) => {
|
||||
const f = fixture(t);
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: 8 },
|
||||
() =>
|
||||
new Promise((resolve, reject) => {
|
||||
const p = spawn('/bin/bash', ['-uc', f.command], {
|
||||
cwd: f.root,
|
||||
env: f.env,
|
||||
});
|
||||
let out = '';
|
||||
p.stdout.on('data', (c) => (out += c));
|
||||
p.once('error', reject);
|
||||
p.once('close', (code) => {
|
||||
try {
|
||||
assert.equal(code, 0);
|
||||
assert.equal(out.trim(), '/test/global/node_modules');
|
||||
resolve();
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
const before = f.count();
|
||||
f.run();
|
||||
assert.equal(f.count(), before);
|
||||
const files = fs
|
||||
.readdirSync(f.env.dir_tmp)
|
||||
.filter((file) => file.endsWith('.cache'));
|
||||
assert.equal(files.length, 1);
|
||||
const file = path.join(f.env.dir_tmp, files[0]);
|
||||
fs.rmSync(file);
|
||||
const victim = path.join(f.root, 'victim');
|
||||
fs.writeFileSync(victim, 'unchanged');
|
||||
fs.symlinkSync(victim, file);
|
||||
assert.equal(f.run().status, 0);
|
||||
assert.equal(fs.readFileSync(victim, 'utf8'), 'unchanged');
|
||||
assert.equal(fs.lstatSync(file).isSymbolicLink(), false);
|
||||
});
|
||||
|
||||
test('task entry tolerates discovery failure with errexit enabled', (t) => {
|
||||
const f = fixture(t);
|
||||
const source = fs.readFileSync(path.resolve('shell/otask.sh'), 'utf8');
|
||||
const start = source.indexOf('append_node_dependency_path() {');
|
||||
const end = source.indexOf('\nenter_script_workdir()', start);
|
||||
const script =
|
||||
source.slice(start, end) +
|
||||
'\nappend_node_dependency_path; printf "continued:%s" "$NODE_PATH"';
|
||||
for (const dir of [path.dirname(helper), f.root]) {
|
||||
const r = f.run(
|
||||
{
|
||||
FAIL: '1',
|
||||
dir_shell: dir,
|
||||
dir_dep: '/legacy/deps',
|
||||
NODE_PATH: '/existing',
|
||||
},
|
||||
'set -e; ' + script,
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, 'continued:/existing:/legacy/deps');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
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 { spawn, spawnSync } = require('node:child_process');
|
||||
const helper = path.resolve('shell/node_path_cache.sh');
|
||||
const hasFlock = spawnSync('/bin/bash', ['-c', 'type -P flock']).status === 0;
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-path-lock-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.symlinkSync(process.execPath, path.join(bin, 'node'));
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'pnpm'),
|
||||
'#!/bin/bash\nprintf "call\\n" >> "$CALLS"\n[[ -n "${UMASK_FILE:-}" ]] && umask > "$UMASK_FILE"\nsleep "${LOOKUP_DELAY:-0.2}"\n[[ "${FAIL:-0}" == 1 ]] && exit 17\nprintf "%s\\n" "${npm_config_global_dir:-/test/global/node_modules}"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: root,
|
||||
PATH: bin + ':' + process.env.PATH,
|
||||
dir_tmp: path.join(root, 'cache'),
|
||||
CALLS: path.join(root, 'calls'),
|
||||
HELPER: helper,
|
||||
};
|
||||
const command = '. "$HELPER"; ql_get_node_global_path';
|
||||
const run = (extra = {}, code = command) =>
|
||||
spawnSync('/bin/bash', ['-euc', code], {
|
||||
cwd: root,
|
||||
env: { ...env, ...extra },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const asyncRun = (extra = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn('/bin/bash', ['-euc', command], {
|
||||
cwd: root,
|
||||
env: { ...env, ...extra },
|
||||
});
|
||||
let out = '',
|
||||
err = '';
|
||||
child.stdout.on('data', (c) => (out += c));
|
||||
child.stderr.on('data', (c) => (err += c));
|
||||
child.on('error', reject);
|
||||
child.on('close', (status) =>
|
||||
resolve({ status, stdout: out, stderr: err }),
|
||||
);
|
||||
});
|
||||
const count = () =>
|
||||
fs.existsSync(env.CALLS)
|
||||
? fs.readFileSync(env.CALLS, 'utf8').trim().split('\n').length
|
||||
: 0;
|
||||
const key = run({}, '. "$HELPER"; ql_node_path_cache_key').stdout.trim();
|
||||
const cache = path.join(
|
||||
env.dir_tmp,
|
||||
`pnpm-root-${process.getuid()}-${key.replace(' ', '-')}.cache`,
|
||||
);
|
||||
return {
|
||||
root,
|
||||
bin,
|
||||
env,
|
||||
command,
|
||||
run,
|
||||
asyncRun,
|
||||
count,
|
||||
cache,
|
||||
lock: path.join(env.dir_tmp, `pnpm-root-${process.getuid()}.lock`),
|
||||
};
|
||||
}
|
||||
function check(r, output = '/test/global/node_modules') {
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout.trim(), output);
|
||||
}
|
||||
test(
|
||||
'simultaneous cold and expired lookups share one successful refresh',
|
||||
{ skip: !hasFlock },
|
||||
async (t) => {
|
||||
const f = fixture(t);
|
||||
for (let batch = 0; batch < 2; batch++) {
|
||||
if (batch) {
|
||||
const lines = fs.readFileSync(f.cache, 'utf8').split('\n');
|
||||
lines[1] = '0';
|
||||
fs.writeFileSync(f.cache, lines.join('\n'));
|
||||
}
|
||||
const rows = await Promise.all(
|
||||
Array.from({ length: 8 }, () => f.asyncRun({ LOOKUP_DELAY: '0.5' })),
|
||||
);
|
||||
rows.forEach((r) => check(r));
|
||||
assert.equal(f.count(), batch + 1);
|
||||
}
|
||||
assert.equal(fs.statSync(f.lock).mode & 0o777, 0o600);
|
||||
check(f.run());
|
||||
assert.equal(f.count(), 2);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'waiters with different configuration do not reuse another key',
|
||||
{ skip: !hasFlock },
|
||||
async (t) => {
|
||||
const f = fixture(t);
|
||||
const rows = await Promise.all(
|
||||
['/first', '/second'].map((v) =>
|
||||
f.asyncRun({ npm_config_global_dir: v }),
|
||||
),
|
||||
);
|
||||
rows.forEach((r, i) => check(r, ['/first', '/second'][i]));
|
||||
assert.equal(f.count(), 2);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'lock timeout falls back while a holder is still alive',
|
||||
{ skip: !hasFlock },
|
||||
async (t) => {
|
||||
const f = fixture(t);
|
||||
fs.mkdirSync(f.env.dir_tmp);
|
||||
const holder = spawn(
|
||||
'/bin/bash',
|
||||
['-c', 'exec 9>> "$LOCK"; flock 9; echo ready; sleep 30'],
|
||||
{ env: { ...f.env, LOCK: f.lock }, detached: true },
|
||||
);
|
||||
t.after(() => {
|
||||
try {
|
||||
process.kill(-holder.pid, 'SIGKILL');
|
||||
} catch {}
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
holder.stdout.once('data', resolve);
|
||||
holder.once('error', reject);
|
||||
});
|
||||
const start = Date.now();
|
||||
check(f.run());
|
||||
assert.ok(Date.now() - start >= 1800);
|
||||
assert.equal(holder.exitCode, null);
|
||||
assert.equal(f.count(), 1);
|
||||
},
|
||||
);
|
||||
test('absent or unsupported flock and unsafe lock paths preserve discovery', (t) => {
|
||||
const f = fixture(t);
|
||||
check(
|
||||
f.run(
|
||||
{},
|
||||
'type(){ if [[ "$*" == "-P flock" ]]; then return 1; fi; builtin type "$@"; }; ' +
|
||||
f.command,
|
||||
),
|
||||
);
|
||||
fs.rmSync(f.cache);
|
||||
fs.writeFileSync(path.join(f.bin, 'flock'), '#!/bin/bash\nexit 64\n', {
|
||||
mode: 0o755,
|
||||
});
|
||||
check(f.run());
|
||||
fs.rmSync(f.cache);
|
||||
fs.rmSync(f.lock, { force: true });
|
||||
const victim = path.join(f.root, 'victim');
|
||||
fs.writeFileSync(victim, 'untouched');
|
||||
fs.symlinkSync(victim, f.lock);
|
||||
check(f.run());
|
||||
assert.equal(fs.readFileSync(victim, 'utf8'), 'untouched');
|
||||
assert.equal(f.count(), 3);
|
||||
});
|
||||
test('failed refresh releases lock and does not publish a success', (t) => {
|
||||
const f = fixture(t);
|
||||
assert.equal(f.run({ FAIL: '1' }).status, 17);
|
||||
assert.equal(fs.existsSync(f.cache), false);
|
||||
check(f.run());
|
||||
assert.equal(f.count(), 2);
|
||||
});
|
||||
test('refresh leaves the caller file descriptor and umask unchanged', (t) => {
|
||||
const f = fixture(t);
|
||||
const sentinel = path.join(f.root, 'fd');
|
||||
const mask = path.join(f.root, 'mask');
|
||||
const r = f.run(
|
||||
{ SENTINEL: sentinel, UMASK_FILE: mask },
|
||||
'umask 022; exec 9> "$SENTINEL"; ' +
|
||||
f.command +
|
||||
' >/dev/null; printf preserved >&9; umask',
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout.trim(), '0022');
|
||||
assert.equal(fs.readFileSync(sentinel, 'utf8'), 'preserved');
|
||||
assert.equal(fs.readFileSync(mask, 'utf8').trim(), '0022');
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const yaml = require('js-yaml');
|
||||
const workflow = yaml.load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
const publication = workflow.jobs.publish.steps.find(
|
||||
(step) => step.name === 'Publish npm package with OIDC',
|
||||
).run;
|
||||
|
||||
test('npm publication waits for every release image, including Python 3.10', () => {
|
||||
for (const name of [
|
||||
'build-alpine',
|
||||
'build-debian',
|
||||
'build-alpine310',
|
||||
'build-debian310',
|
||||
])
|
||||
assert.ok(workflow.jobs.publish.needs.includes(name), name);
|
||||
});
|
||||
|
||||
for (const [scenario, expectedStatus, published] of [
|
||||
['existing', 0, false],
|
||||
['missing', 0, true],
|
||||
['unauthorized', 1, false],
|
||||
['network', 1, false],
|
||||
['unexpected-version', 1, false],
|
||||
]) {
|
||||
test(`npm publication handles ${scenario} without publishing an unverified version`, (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-npm-publication-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'package.json'),
|
||||
JSON.stringify({ name: '@fixture/qinglong', version: '2.22.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'npm'),
|
||||
`#!/usr/bin/env node
|
||||
const fs = require('node:fs');
|
||||
if (process.argv[2] === 'publish') { fs.writeFileSync('published', 'yes'); process.exit(0); }
|
||||
if (process.argv[2] !== 'view') process.exit(99);
|
||||
switch (process.env.QL_PUBLISH_SCENARIO) {
|
||||
case 'existing': console.log(JSON.stringify('2.22.0')); break;
|
||||
case 'unexpected-version': console.log(JSON.stringify('2.21.0')); break;
|
||||
case 'missing': console.log(JSON.stringify({error:{code:'E404'}})); process.exit(1);
|
||||
case 'unauthorized': console.log(JSON.stringify({error:{code:'E401'}})); process.exit(1);
|
||||
case 'network': console.log('upstream unavailable'); process.exit(1);
|
||||
}
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const result = spawnSync('bash', ['-c', publication], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
||||
RUNNER_TEMP: root,
|
||||
QL_PUBLISH_SCENARIO: scenario,
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, expectedStatus, result.stdout + result.stderr);
|
||||
assert.equal(fs.existsSync(path.join(root, 'published')), published);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
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 { spawnSync } = require('node:child_process');
|
||||
|
||||
function fixture(t, language) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-lazy-notify-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const extension = language === 'python' ? 'py' : 'js';
|
||||
fs.copyFileSync(`shell/preload/sitecustomize.${extension}`, path.join(root, `sitecustomize.${extension}`));
|
||||
fs.writeFileSync(path.join(root, `env.${extension}`), '');
|
||||
fs.writeFileSync(path.join(root, 'before.sh'), 'export FROM_SHELL=before\n');
|
||||
fs.writeFileSync(path.join(root, 'before.js'), 'process.env.FROM_LANGUAGE = "javascript";\n');
|
||||
fs.writeFileSync(path.join(root, 'task_before.py'), 'import os\nos.environ["FROM_LANGUAGE"] = "python"\n');
|
||||
fs.writeFileSync(path.join(root, 'client.js'), 'module.exports = { getEnvs: () => "client-ready" };\n');
|
||||
fs.copyFileSync('shell/preload/client.py', path.join(root, 'client.py'));
|
||||
fs.copyFileSync('shell/preload/esm-loader.mjs', path.join(root, 'esm-loader.mjs'));
|
||||
const env = {
|
||||
...process.env,
|
||||
PYTHONPATH: root,
|
||||
PREV_PYTHONPATH: '',
|
||||
NODE_OPTIONS: '',
|
||||
PREV_NODE_OPTIONS: '',
|
||||
QL_NODE_GLOBAL_PATH: '',
|
||||
file_task_before: path.join(root, 'before.sh'),
|
||||
file_task_before_js: path.join(root, 'before.js'),
|
||||
dir_scripts: root,
|
||||
task_before: 'export FROM_COMMAND=command',
|
||||
envParam: 'ACCOUNTS',
|
||||
numParam: '2-3',
|
||||
ACCOUNTS: 'first&second&third',
|
||||
};
|
||||
const write = (name, text) => fs.writeFileSync(path.join(root, name), text);
|
||||
const run = (body, extensionOverride = extension) => {
|
||||
const entry = path.join(root, `entry.${extensionOverride}`);
|
||||
write(path.basename(entry), body);
|
||||
const python = language === 'python';
|
||||
const result = spawnSync(python ? 'python3' : process.execPath,
|
||||
python ? [entry] : ['--require', path.join(root, 'sitecustomize.js'), entry],
|
||||
{ cwd: root, env, encoding: 'utf8', timeout: 15000 });
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
return result.stdout;
|
||||
};
|
||||
return { root, env, write, run };
|
||||
}
|
||||
|
||||
for (const extension of ['js', 'mjs']) {
|
||||
test(`${extension} startup preserves hooks, environment selection and client without importing notifications`, (t) => {
|
||||
const f = fixture(t, 'javascript');
|
||||
f.write('__ql_notify__.js', 'throw Error("notification dependencies must not load at startup");');
|
||||
f.run(`
|
||||
const assert = ${extension === 'mjs' ? '(await import("node:assert/strict")).default' : 'require("node:assert/strict")'};
|
||||
assert.equal(process.env.FROM_SHELL, 'before');
|
||||
assert.equal(process.env.FROM_LANGUAGE, 'javascript');
|
||||
assert.equal(process.env.FROM_COMMAND, 'command');
|
||||
assert.equal(process.env.ACCOUNTS, 'second&third');
|
||||
assert.equal(QLAPI.getEnvs(), 'client-ready');
|
||||
assert.equal(typeof QLAPI.notify, 'function');
|
||||
`, extension);
|
||||
});
|
||||
}
|
||||
|
||||
test('JavaScript first notification imports once and preserves arguments, this, promises and errors', (t) => {
|
||||
const f = fixture(t, 'javascript');
|
||||
f.write('__ql_notify__.js', `
|
||||
global.notificationLoads = (global.notificationLoads || 0) + 1;
|
||||
exports.sendNotify = function (...args) {
|
||||
if (args[0] === 'fail') throw new Error('send failed');
|
||||
global.notificationResult = Promise.resolve({ args, receiver: this });
|
||||
return global.notificationResult;
|
||||
};
|
||||
`);
|
||||
f.run(`
|
||||
const assert = require('node:assert/strict');
|
||||
assert.equal(global.notificationLoads, undefined);
|
||||
(async () => {
|
||||
const options = { channel: 'fixture' };
|
||||
const promise = QLAPI.notify('title', 'content', options);
|
||||
assert.equal(promise, global.notificationResult);
|
||||
const result = await promise;
|
||||
assert.deepEqual(result.args, ['title', 'content', options]);
|
||||
assert.equal(result.receiver, QLAPI);
|
||||
await QLAPI.notify('second');
|
||||
assert.equal(global.notificationLoads, 1);
|
||||
assert.throws(() => QLAPI.notify('fail'), /send failed/);
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
`);
|
||||
});
|
||||
|
||||
test('JavaScript reports import failures on notify and permits a later retry', (t) => {
|
||||
const f = fixture(t, 'javascript');
|
||||
f.write('__ql_notify__.js', `
|
||||
if (!process.env.NOTIFY_READY) throw Error('notification dependency unavailable');
|
||||
exports.sendNotify = (...args) => args;
|
||||
`);
|
||||
f.run(`
|
||||
const assert = require('node:assert/strict');
|
||||
assert.equal(QLAPI.getEnvs(), 'client-ready');
|
||||
assert.throws(() => QLAPI.notify('first'), /notification dependency unavailable/);
|
||||
process.env.NOTIFY_READY = '1';
|
||||
assert.deepEqual(QLAPI.notify('retry'), ['retry']);
|
||||
`);
|
||||
});
|
||||
|
||||
test('Python startup preserves hooks, environment selection and client without importing notifications', (t) => {
|
||||
const f = fixture(t, 'python');
|
||||
f.write('__ql_notify__.py', 'raise RuntimeError("notification dependencies must not load at startup")\n');
|
||||
f.run(`
|
||||
import os, sys
|
||||
assert os.environ['FROM_SHELL'] == 'before'
|
||||
assert os.environ['FROM_LANGUAGE'] == 'python'
|
||||
assert os.environ['FROM_COMMAND'] == 'command'
|
||||
assert os.environ['ACCOUNTS'] == 'second&third'
|
||||
assert '__ql_notify__' not in sys.modules
|
||||
assert callable(QLAPI.notify)
|
||||
assert callable(QLAPI.getEnvs)
|
||||
`);
|
||||
});
|
||||
|
||||
test('Python first notification imports once and preserves args, kwargs, return values and errors', (t) => {
|
||||
const f = fixture(t, 'python');
|
||||
f.write('__ql_notify__.py', `
|
||||
import builtins
|
||||
builtins.notification_loads = getattr(builtins, 'notification_loads', 0) + 1
|
||||
def send(*args, **kwargs):
|
||||
if args[0] == 'fail':
|
||||
raise ValueError('send failed')
|
||||
return args, kwargs
|
||||
`);
|
||||
f.run(`
|
||||
import builtins, sys
|
||||
assert '__ql_notify__' not in sys.modules
|
||||
assert QLAPI.notify('title', 'content', channel='fixture') == (('title', 'content'), {'channel': 'fixture'})
|
||||
assert QLAPI.notify('second') == (('second',), {})
|
||||
assert builtins.notification_loads == 1
|
||||
try:
|
||||
QLAPI.notify('fail')
|
||||
except ValueError as error:
|
||||
assert str(error) == 'send failed'
|
||||
else:
|
||||
raise AssertionError('notification exception was swallowed')
|
||||
`);
|
||||
});
|
||||
|
||||
test('Python reports import failures on notify and permits a later retry', (t) => {
|
||||
const f = fixture(t, 'python');
|
||||
f.write('__ql_notify__.py', `
|
||||
import os
|
||||
if not os.environ.get('NOTIFY_READY'):
|
||||
raise RuntimeError('notification dependency unavailable')
|
||||
def send(*args, **kwargs):
|
||||
return args
|
||||
`);
|
||||
f.run(`
|
||||
import os, sys
|
||||
assert '__ql_notify__' not in sys.modules
|
||||
try:
|
||||
QLAPI.notify('first')
|
||||
except RuntimeError as error:
|
||||
assert str(error) == 'notification dependency unavailable'
|
||||
else:
|
||||
raise AssertionError('import exception was swallowed')
|
||||
os.environ['NOTIFY_READY'] = '1'
|
||||
assert QLAPI.notify('retry') == ('retry',)
|
||||
`);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const vm = require('node:vm');
|
||||
const fs = require('node:fs');
|
||||
const source = fs.readFileSync('ecosystem.config.js', 'utf8');
|
||||
const appSource = fs.readFileSync('back/app.ts', 'utf8');
|
||||
const ts = require('typescript');
|
||||
const compiled = ts.transpileModule(appSource.slice(0, appSource.indexOf('\nconst app = new Application();')) + '\nmodule.exports = Application;', {compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,esModuleInterop:true}}).outputText;
|
||||
function config(env) {const module={exports:{}};vm.runInNewContext(source,{module,process:{env}});return module.exports.apps[0];}
|
||||
test('container APM policy keeps worker isolation and can restore all inherited probes', () => {
|
||||
for (const primary of [undefined, 'true']) for (const workers of [undefined, 'true']) {
|
||||
const env={QL_CONTAINER:'true',QL_PRIMARY_APM:primary,QL_WORKER_APM:workers};
|
||||
const conf=config(env);env.pm_id='0';env.pmx=String(conf.pmx);
|
||||
const calls=[],module={exports:{}};
|
||||
vm.runInNewContext(compiled,{module,exports:module.exports,process:{env},require(name){if(name==='cluster')return {fork(options){calls.push({...env,...options});return {id:1,process:{pid:123}}}};if(name==='express')return ()=>({use(){}});return {};}});
|
||||
new module.exports().forkWorker('http');
|
||||
assert.equal(conf.pmx,primary==='true');
|
||||
assert.equal(calls[0].pmx,primary==='true'&&workers==='true'?'true':'false');
|
||||
assert.equal(conf.max_restarts,5);assert.equal(conf.script,'static/build/app.js');
|
||||
}
|
||||
});
|
||||
test('standalone PM2 monitoring stays enabled', () => {
|
||||
for(const QL_PRIMARY_APM of [undefined,'true','false']) assert.equal(config({QL_PRIMARY_APM}).pmx,true);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const ts = require('typescript');
|
||||
|
||||
// Isolate OS probes while exercising the production termination algorithm.
|
||||
function terminationFixture(readStat, signalProbe = () => {}) {
|
||||
const source = fs.readFileSync('back/config/util.ts', 'utf8');
|
||||
const start = source.indexOf('export async function killTask(');
|
||||
const end = source.indexOf('export async function getPid(', start);
|
||||
const code = ts.transpileModule(source.slice(start, end), {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
}).outputText;
|
||||
const signals = [];
|
||||
const exports = {};
|
||||
new Function('exports', 'process', 'psTree', 'fs', 'setTimeout', code)(
|
||||
exports,
|
||||
{
|
||||
platform: 'linux',
|
||||
kill: (pid, signal) => {
|
||||
signals.push([pid, signal]);
|
||||
signalProbe(pid, signal);
|
||||
},
|
||||
},
|
||||
async () => [],
|
||||
{ readFile: readStat },
|
||||
setTimeout,
|
||||
);
|
||||
return { killTask: exports.killTask, signals };
|
||||
}
|
||||
|
||||
for (const state of ['Z', 'X', 'x']) {
|
||||
test(`termination accepts Linux exited state ${state} before PID reaping`, async () => {
|
||||
const { killTask, signals } = terminationFixture(
|
||||
async () => `42 (name with ) parentheses) ${state} 1 0 0`,
|
||||
);
|
||||
await killTask(42, true);
|
||||
assert.equal(
|
||||
signals.some(([, signal]) => signal === 'SIGKILL'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('termination keeps waiting while the process is running', async () => {
|
||||
let reads = 0;
|
||||
const { killTask } = terminationFixture(
|
||||
async () => `42 (worker) ${++reads === 1 ? 'S' : 'Z'} 1 0 0`,
|
||||
);
|
||||
await killTask(42, true);
|
||||
assert.equal(reads, 2);
|
||||
});
|
||||
|
||||
test('a process disappearing during the procfs read is accepted', async () => {
|
||||
let probes = 0;
|
||||
const { killTask } = terminationFixture(
|
||||
async () => {
|
||||
throw Object.assign(Error('gone'), { code: 'ENOENT' });
|
||||
},
|
||||
(_pid, signal) => {
|
||||
if (signal === 0 && ++probes > 1)
|
||||
throw Object.assign(Error('gone'), { code: 'ESRCH' });
|
||||
},
|
||||
);
|
||||
await killTask(42, true);
|
||||
assert.equal(probes, 2);
|
||||
});
|
||||
|
||||
test('permission failures are not reported as successful termination', async () => {
|
||||
const denied = Object.assign(Error('denied'), { code: 'EPERM' });
|
||||
const { killTask } = terminationFixture(
|
||||
async () => '',
|
||||
() => {
|
||||
throw denied;
|
||||
},
|
||||
);
|
||||
await assert.rejects(killTask(42, true), (error) => error === denied);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const ts = require('typescript');
|
||||
|
||||
// Exercise the production function with a deterministic process-tree race.
|
||||
function fixture(descendants, probe = () => {}) {
|
||||
const source = fs.readFileSync('back/config/util.ts', 'utf8');
|
||||
const start = source.indexOf('export async function killTask(');
|
||||
const end = source.indexOf('export async function getPid(', start);
|
||||
const code = ts.transpileModule(source.slice(start, end), {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
}).outputText;
|
||||
const signals = [];
|
||||
const exports = {};
|
||||
new Function('exports', 'process', 'psTree', 'fs', 'setTimeout', code)(
|
||||
exports,
|
||||
{
|
||||
platform: 'linux',
|
||||
kill: (pid, signal) => {
|
||||
const normalized = { 15: 'SIGTERM', 2: 'SIGINT' }[signal] || signal;
|
||||
signals.push([pid, normalized]);
|
||||
probe(pid, normalized);
|
||||
},
|
||||
},
|
||||
async () => [...descendants],
|
||||
{},
|
||||
setTimeout,
|
||||
);
|
||||
return { killTask: exports.killTask, signals };
|
||||
}
|
||||
|
||||
test('an exited descendant does not prevent stopping its siblings and parent', async () => {
|
||||
const { killTask, signals } = fixture([101, 102], (pid) => {
|
||||
if (pid === 102) throw Object.assign(Error('gone'), { code: 'ESRCH' });
|
||||
});
|
||||
await killTask(100);
|
||||
assert.deepEqual(signals, [
|
||||
[102, 'SIGTERM'],
|
||||
[101, 'SIGTERM'],
|
||||
[100, 'SIGTERM'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('an already exited leaf can be stopped repeatedly', async () => {
|
||||
const { killTask } = fixture([], () => {
|
||||
throw Object.assign(Error('gone'), { code: 'ESRCH' });
|
||||
});
|
||||
await killTask(100);
|
||||
await killTask(100);
|
||||
});
|
||||
|
||||
for (const descendants of [[], [101]]) {
|
||||
test(`signal permission failure is surfaced with ${descendants.length} descendants`, async () => {
|
||||
const denied = Object.assign(Error('denied'), { code: 'EPERM' });
|
||||
const { killTask } = fixture(descendants, () => {
|
||||
throw denied;
|
||||
});
|
||||
await assert.rejects(killTask(100), (error) => error === denied);
|
||||
});
|
||||
}
|
||||
|
||||
test('a leaf retains SIGINT and a process tree retains child-first SIGTERM', async () => {
|
||||
const leaf = fixture([]);
|
||||
await leaf.killTask(100);
|
||||
assert.deepEqual(leaf.signals, [[100, 'SIGINT']]);
|
||||
const tree = fixture([101, 102]);
|
||||
await tree.killTask(100);
|
||||
assert.deepEqual(tree.signals, [
|
||||
[102, 'SIGTERM'],
|
||||
[101, 'SIGTERM'],
|
||||
[100, 'SIGTERM'],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const protectedPathCase = require(
|
||||
'../../back/middlewares/protectedPathCase',
|
||||
).default;
|
||||
|
||||
function runMiddleware(path) {
|
||||
let status;
|
||||
let body;
|
||||
let nextCalled = false;
|
||||
const response = {
|
||||
status(value) {
|
||||
status = value;
|
||||
return this;
|
||||
},
|
||||
json(value) {
|
||||
body = value;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
||||
protectedPathCase({ path }, response, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
return { status, body, nextCalled };
|
||||
}
|
||||
|
||||
test('rejects case variations under protected API namespaces', () => {
|
||||
for (const path of [
|
||||
'/Api/configs/detail',
|
||||
'/api/Configs/detail',
|
||||
'/OPEN/scripts/detail',
|
||||
'/open/scripts/Detail',
|
||||
]) {
|
||||
assert.deepEqual(runMiddleware(path), {
|
||||
status: 400,
|
||||
body: { code: 400, message: 'Invalid path format' },
|
||||
nextCalled: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('allows normalized protected paths and unrelated paths', () => {
|
||||
for (const path of [
|
||||
'/api/configs/detail',
|
||||
'/open/scripts/detail',
|
||||
'/OpenApi/status',
|
||||
'/assets/AppBundle.js',
|
||||
]) {
|
||||
assert.deepEqual(runMiddleware(path), {
|
||||
status: undefined,
|
||||
body: undefined,
|
||||
nextCalled: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
const test = require('node:test'),
|
||||
assert = require('node:assert/strict'),
|
||||
fs = require('node:fs'),
|
||||
os = require('node:os'),
|
||||
path = require('node:path'),
|
||||
{ execFileSync } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
test('venv installs, uninstalls and probes use the selected Python, including spaces', () => {
|
||||
const methods = load('back/config/util.ts', {
|
||||
'./index': { rootPath: '/application' },
|
||||
'./const': {
|
||||
PYTHON_VENV_DIR: 'virtual env',
|
||||
PYTHON_INSTALL_DIR: '/legacy',
|
||||
},
|
||||
'../loaders/logger': {},
|
||||
'../shared/utils': {},
|
||||
'../data/dependence': {
|
||||
DependenceTypes: { nodejs: 0, python3: 1, linux: 2 },
|
||||
},
|
||||
});
|
||||
for (const name of [
|
||||
'getInstallCommand',
|
||||
'getUninstallCommand',
|
||||
'getGetCommand',
|
||||
]) {
|
||||
const cmd = methods[name](1, 'requests');
|
||||
assert.match(cmd, /'\/application\/virtual env\/bin\/python3'/);
|
||||
assert.doesNotMatch(cmd, /--prefix=/);
|
||||
}
|
||||
});
|
||||
test('venv setup keeps Python packages isolated and retains existing startup configuration', (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-venv-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const ql = path.join(dir, 'application with spaces');
|
||||
fs.mkdirSync(ql);
|
||||
const venv = path.join(dir, 'venv with spaces');
|
||||
execFileSync('python3', ['-m', 'venv', venv]);
|
||||
const source = fs.readFileSync('shell/start-simplify.sh', 'utf8');
|
||||
const snippet = source.slice(
|
||||
source.indexOf('export PNPM_HOME='),
|
||||
source.indexOf('\ncd "${QL_DIR}"'),
|
||||
);
|
||||
const result = execFileSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-c',
|
||||
snippet +
|
||||
'\npython3 -c "import sys; print(sys.prefix); print(sys.base_prefix)"',
|
||||
],
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
QL_DIR: ql,
|
||||
QL_DATA_DIR: path.join(dir, 'data'),
|
||||
PYTHON_VENV_DIR: venv,
|
||||
PYTHONPATH: '/legacy/modules',
|
||||
PYTHONUSERBASE: '/legacy',
|
||||
PATH: process.env.PATH,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
},
|
||||
)
|
||||
.trim()
|
||||
.split('\n');
|
||||
assert.equal(fs.realpathSync(result[0]), fs.realpathSync(venv));
|
||||
assert.notEqual(result[1], venv);
|
||||
assert.match(source, /set -C; cat .env.example > .env/);
|
||||
assert.doesNotMatch(source, /npm install|pip3 install|chmod 777/);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { after, test } = require('node:test');
|
||||
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-log-command-'));
|
||||
|
||||
after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true }));
|
||||
|
||||
function writeExecutable(filePath, content) {
|
||||
fs.writeFileSync(filePath, content, { mode: 0o755 });
|
||||
}
|
||||
|
||||
function prepareFixture(name) {
|
||||
const testRoot = path.join(fixtureRoot, name);
|
||||
const shellDir = path.join(testRoot, 'shell');
|
||||
const binDir = path.join(testRoot, 'bin');
|
||||
fs.mkdirSync(shellDir, { recursive: true });
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
fs.copyFileSync(
|
||||
path.join(root, 'shell/update.sh'),
|
||||
path.join(shellDir, 'update.sh'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(shellDir, 'share.sh'),
|
||||
[
|
||||
'dir_data="${QL_DATA_DIR:-$QL_DIR/data}"',
|
||||
'load_ql_envs() { :; }',
|
||||
'import_config() { :; }',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.writeFileSync(path.join(shellDir, 'api.sh'), '');
|
||||
fs.writeFileSync(path.join(shellDir, 'env.sh'), '');
|
||||
writeExecutable(
|
||||
path.join(binDir, 'pm2'),
|
||||
'#!/usr/bin/env bash\necho "pm2 must not be called" >&2\nexit 99\n',
|
||||
);
|
||||
|
||||
return { binDir, shellDir, testRoot };
|
||||
}
|
||||
|
||||
test('ql log reads the requested system log history without PM2', () => {
|
||||
const { binDir, shellDir, testRoot } = prepareFixture('host');
|
||||
const systemLogDir = path.join(testRoot, 'data/syslog');
|
||||
fs.mkdirSync(systemLogDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(systemLogDir, '2026-08-20.log'),
|
||||
'system-one\nsystem-two\nsystem-three\n',
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
'/bin/bash',
|
||||
[path.join(shellDir, 'update.sh'), 'log', '--lines', '2', '--nostream'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
QL_CONTAINER: 'false',
|
||||
QL_DIR: testRoot,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, 'system-two\nsystem-three\n');
|
||||
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
|
||||
});
|
||||
|
||||
test('ql log follows the current Winston system log without PM2', () => {
|
||||
const { binDir, shellDir, testRoot } = prepareFixture('container');
|
||||
const systemLogDir = path.join(testRoot, 'data/syslog');
|
||||
const currentDate = spawnSync('date', ['+%F'], { encoding: 'utf8' }).stdout.trim();
|
||||
const currentLog = path.join(systemLogDir, `${currentDate}.log`);
|
||||
fs.mkdirSync(systemLogDir, { recursive: true });
|
||||
fs.writeFileSync(currentLog, 'system-one\n');
|
||||
writeExecutable(
|
||||
path.join(binDir, 'tail'),
|
||||
'#!/usr/bin/env bash\nprintf "tail"\nprintf " <%s>" "$@"\nprintf "\\n"\n',
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
'/bin/bash',
|
||||
[path.join(shellDir, 'update.sh'), 'log', '--lines', '0'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
QL_CONTAINER: 'true',
|
||||
QL_DIR: testRoot,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, `tail <-n> <0> <--retry> <-F> <${currentLog}>\n`);
|
||||
assert.equal(fs.existsSync(path.join(testRoot, 'data/log/log')), false);
|
||||
});
|
||||
|
||||
test('ql log reports when no persisted system log exists', () => {
|
||||
const { binDir, shellDir, testRoot } = prepareFixture('container-nostream');
|
||||
|
||||
const result = spawnSync(
|
||||
'/bin/bash',
|
||||
[path.join(shellDir, 'update.sh'), 'log', '--nostream'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
QL_CONTAINER: 'true',
|
||||
QL_DIR: testRoot,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.match(result.stderr, /no system log found/);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const tick = () => new Promise(setImmediate);
|
||||
function gate() { let release; const promise = new Promise(r => release = r); return { promise, release }; }
|
||||
|
||||
async function fixture() {
|
||||
const logger = { info() {}, error() {}, warn() {} }, notices = [];
|
||||
const limit = load('back/shared/pLimit.ts', {
|
||||
'../data/system': { AuthDataType: { systemConfig: 'systemConfig' }, SystemModel: { sync: async () => {}, findOne: async () => ({ info: { cronConcurrency: 1 } }) } },
|
||||
'../loaders/logger': logger,
|
||||
'../services/notify': class {},
|
||||
'../shared/i18n': { t: s => s, tf: s => s },
|
||||
'../config': { grpcPort: 1 },
|
||||
'../protos/api': { ApiClient: class { systemNotify(value, cb) { notices.push(value); cb(); } } },
|
||||
'../config/grpcCerts': { getGrpcCerts: () => null },
|
||||
}).default;
|
||||
await tick();
|
||||
const spawned = [], records = new Map(), jobs = new Map();
|
||||
let readGate, killGate;
|
||||
const { runCron } = load('back/shared/runCron.ts', {
|
||||
'cross-spawn': { spawn: command => { spawned.push(command); return {}; } },
|
||||
'./pLimit': limit,
|
||||
'../loaders/logger': logger,
|
||||
'../data/cron': { CrontabModel: { findOne: async ({ where }) => { const row = records.get(String(where.id)); if (readGate) await readGate.promise; return row; }, update: async () => {} }, CrontabStatus: { running: 0, queued: 3, idle: 1 } },
|
||||
'../data/runningInstance': { RunningInstanceModel: { update: async () => {} }, InstanceStatus: { running: 0, stopped: 2 } },
|
||||
'../config/util': { killTask: async () => { if (killGate) await killGate.promise; } },
|
||||
'./childProcess': { observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }), asError: e => e },
|
||||
});
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'../shared/cronScheduler': { createCronJob: (schedule, callback) => ({ start() {}, cancel() {}, fire: callback }) },
|
||||
'../shared/cronSchedule': { isValidCronSchedule: x => x !== 'invalid' },
|
||||
'./data': { scheduleStacks: jobs }, '../shared/runCron': { runCron }, '../loaders/logger': logger,
|
||||
'../shared/i18n': { tf: s => s },
|
||||
});
|
||||
const { delCron } = load('back/schedule/delCron.ts', { './data': { scheduleStacks: jobs }, '../loaders/logger': logger });
|
||||
function add(id = '1', command = 'old', replace = false) {
|
||||
records.set(id, { isDisabled: 0, allow_multiple_instances: 1 });
|
||||
addCron({ request: { crons: [{ id, command, schedule: '* * * * * *' }], replace } }, err => assert.ifError(err));
|
||||
return jobs.get(id)[0];
|
||||
}
|
||||
function remove(id = '1') { delCron({ request: { ids: [id] } }, err => assert.ifError(err)); }
|
||||
async function block() { const g = gate(); const done = limit.runWithCronLimit({ id: 'blocker' }, async () => { await g.promise; }); await tick(); return { release: async () => { g.release(); await done; } }; }
|
||||
return { limit, spawned, records, jobs, add, remove, block, notices, setReadGate: g => readGate = g, setKillGate: g => killGate = g };
|
||||
}
|
||||
|
||||
for (const operation of ['disable', 'delete', 'update', 'replace', 'disable-enable']) {
|
||||
test(`queued scheduled execution is invalidated by ${operation}`, async () => {
|
||||
const f = await fixture(), old = f.add(), blocker = await f.block();
|
||||
const queued = old.fire();
|
||||
if (operation === 'disable' || operation === 'disable-enable') { f.records.get('1').isDisabled = 1; f.remove(); }
|
||||
if (operation === 'delete') { f.records.delete('1'); f.remove(); }
|
||||
if (operation === 'update') f.add('1', 'new');
|
||||
if (operation === 'replace') f.add('1', 'new', true);
|
||||
if (operation === 'disable-enable') f.add('1', 'old');
|
||||
const next = f.jobs.get('1')?.[0].fire();
|
||||
await blocker.release(); await queued; await next;
|
||||
assert.deepEqual(f.spawned, ['update', 'replace'].includes(operation) ? ['new'] : operation === 'disable-enable' ? ['old'] : []);
|
||||
assert.equal(f.limit.cronLimitPendingCount, 0);
|
||||
assert.equal(f.limit.cronLimitActiveCount, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test('stale callbacks cannot re-enqueue after cancellation', async () => {
|
||||
const f = await fixture(), old = f.add(); f.remove(); await old.fire();
|
||||
assert.deepEqual(f.spawned, []);
|
||||
});
|
||||
|
||||
test('invalidation during an asynchronous database read prevents spawning', async () => {
|
||||
const f = await fixture(), old = f.add(), pending = gate(); f.setReadGate(pending);
|
||||
const run = old.fire(); await tick(); f.remove(); pending.release(); await run;
|
||||
assert.deepEqual(f.spawned, []);
|
||||
});
|
||||
|
||||
test('invalidation while replacing a running instance prevents the old spawn', async () => {
|
||||
const f = await fixture(), old = f.add(), pending = gate();
|
||||
f.records.set('1', { isDisabled: 0, allow_multiple_instances: 0, pid: 10, status: 0 }); f.setKillGate(pending);
|
||||
const run = old.fire(); await tick(); f.remove(); pending.release(); await run;
|
||||
assert.deepEqual(f.spawned, []);
|
||||
});
|
||||
|
||||
test('database removal or disable is respected before scheduler RPC arrives', async () => {
|
||||
for (const disabled of [true, false]) {
|
||||
const f = await fixture(), old = f.add(), blocker = await f.block(); const run = old.fire();
|
||||
if (disabled) f.records.get('1').isDisabled = 1; else f.records.delete('1');
|
||||
await blocker.release(); await run; assert.deepEqual(f.spawned, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('five stale runs do not suppress the new revision or erase its repeat accounting', async () => {
|
||||
const f = await fixture(), old = f.add(), blocker = await f.block();
|
||||
const pending = Array.from({ length: 5 }, () => old.fire());
|
||||
const current = f.add('1', 'new'); pending.push(...Array.from({ length: 5 }, () => current.fire()));
|
||||
assert.equal(f.notices.length, 0);
|
||||
await blocker.release(); await Promise.all(pending);
|
||||
assert.deepEqual(f.spawned, Array(5).fill('new'));
|
||||
await current.fire(); assert.equal(f.spawned.length, 6);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('startup closes interrupted instances without inventing exit codes or rewriting history', async (t) => {
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => db.close());
|
||||
const instances = db.define('Instance', {
|
||||
status: DataTypes.INTEGER,
|
||||
started_at: DataTypes.INTEGER,
|
||||
finished_at: DataTypes.INTEGER,
|
||||
exit_code: DataTypes.INTEGER,
|
||||
});
|
||||
await db.sync();
|
||||
await instances.bulkCreate([
|
||||
{ id: 1, status: 0, started_at: 100 },
|
||||
{ id: 2, status: 1, started_at: 100, finished_at: 110, exit_code: 0 },
|
||||
{ id: 3, status: 2, started_at: 100, finished_at: 120, exit_code: 143 },
|
||||
{ id: 4, status: 3, started_at: 100, finished_at: 130, exit_code: 7 },
|
||||
]);
|
||||
const systemConfig = { info: { lang: 'en-US' } };
|
||||
const service = {
|
||||
set_envs: async () => {},
|
||||
getAuthInfo: async () => ({}),
|
||||
initializeSystemApp: async () => {},
|
||||
refreshApps: async () => {},
|
||||
};
|
||||
const initData = load('back/loaders/initData.ts', {
|
||||
typedi: { Container: { get: () => service } },
|
||||
'../services/dependence': class {},
|
||||
'../services/cron': class {},
|
||||
'../services/env': class {},
|
||||
'../services/system': class {},
|
||||
'../services/user': class {},
|
||||
'../services/open': class {},
|
||||
'../data/cron': {
|
||||
CrontabModel: { update: async () => {}, findAll: async () => [] },
|
||||
CrontabStatus: { idle: 1 },
|
||||
},
|
||||
'../data/dependence': {
|
||||
DependenceModel: { findAll: async () => [], update: async () => {} },
|
||||
DependenceStatus: { queued: 0 },
|
||||
},
|
||||
'../data/cronView': {
|
||||
CrontabViewModel: { findAll: async () => [{}] },
|
||||
CronViewType: { 系统: 0 },
|
||||
},
|
||||
'../data/env': {},
|
||||
'../data/system': {
|
||||
SystemModel: { findOrCreate: async () => [systemConfig] },
|
||||
AuthDataType: {},
|
||||
},
|
||||
'../data/open': {
|
||||
AppModel: {
|
||||
findOne: async () => ({ get: () => ({ scopes: ['dashboard'] }) }),
|
||||
},
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: instances,
|
||||
InstanceStatus: { running: 0, stopped: 2 },
|
||||
},
|
||||
'../config': {},
|
||||
'../config/util': { fileExist: async () => true, isDemoEnv: () => false },
|
||||
'../shared/store': { shareStore: { updateAuthInfo: async () => {} } },
|
||||
'../shared/i18n': { setLang() {} },
|
||||
'./logger': {},
|
||||
'../schedule/client': {
|
||||
readiness: { configure() {}, recover: async () => {} },
|
||||
},
|
||||
}).default;
|
||||
// Dependency installation is unrelated to startup reconciliation.
|
||||
const originalTimeout = global.setTimeout;
|
||||
global.setTimeout = () => 0;
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
await initData();
|
||||
} finally {
|
||||
global.setTimeout = originalTimeout;
|
||||
}
|
||||
const rows = await instances.findAll({ order: [['id', 'ASC']], raw: true });
|
||||
assert.equal(rows[0].status, 2);
|
||||
assert.ok(
|
||||
rows[0].finished_at >= before &&
|
||||
rows[0].finished_at <= Math.floor(Date.now() / 1000),
|
||||
);
|
||||
assert.equal(rows[0].exit_code, null);
|
||||
assert.deepEqual(
|
||||
rows
|
||||
.slice(1)
|
||||
.map(({ status, finished_at, exit_code }) => [
|
||||
status,
|
||||
finished_at,
|
||||
exit_code,
|
||||
]),
|
||||
[
|
||||
[1, 110, 0],
|
||||
[2, 120, 143],
|
||||
[3, 130, 7],
|
||||
],
|
||||
);
|
||||
global.setTimeout = () => 0;
|
||||
try {
|
||||
await initData();
|
||||
} finally {
|
||||
global.setTimeout = originalTimeout;
|
||||
}
|
||||
assert.deepEqual(
|
||||
await instances.findAll({ order: [['id', 'ASC']], raw: true }),
|
||||
rows,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const {
|
||||
getDirectorySize,
|
||||
normalizeRetentionPolicy,
|
||||
} = require('../../back/shared/retention');
|
||||
|
||||
test('retention policy defaults to disabled and clamps invalid values', () => {
|
||||
assert.deepEqual(normalizeRetentionPolicy({}), {
|
||||
runningInstanceRetentionDays: 0,
|
||||
cronStatRetentionDays: 0,
|
||||
});
|
||||
assert.deepEqual(
|
||||
normalizeRetentionPolicy({
|
||||
runningInstanceRetentionDays: -10,
|
||||
cronStatRetentionDays: 99999,
|
||||
}),
|
||||
{
|
||||
runningInstanceRetentionDays: 0,
|
||||
cronStatRetentionDays: 3650,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('directory preview counts files recursively and ignores symlinks', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-retention-'));
|
||||
const nested = path.join(directory, 'nested');
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
await fs.mkdir(nested);
|
||||
await fs.writeFile(path.join(directory, 'one'), Buffer.alloc(7));
|
||||
await fs.writeFile(path.join(nested, 'two'), Buffer.alloc(11));
|
||||
await fs.symlink(path.join(nested, 'two'), path.join(directory, 'link'));
|
||||
|
||||
assert.equal(await getDirectorySize(directory), 18);
|
||||
assert.equal(await getDirectorySize(path.join(directory, 'missing')), 0);
|
||||
});
|
||||
|
||||
test('cleanup previews first, protects running instances, and uses explicit options', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-retention-'));
|
||||
const nodeCache = path.join(directory, 'node');
|
||||
await fs.mkdir(nodeCache);
|
||||
await fs.writeFile(path.join(nodeCache, 'cache'), Buffer.alloc(13));
|
||||
|
||||
const config = require('../../back/config').default;
|
||||
const originalCachePath = config.dependenceCachePath;
|
||||
const RunningInstanceModel = {};
|
||||
const CrontabStatModel = {};
|
||||
const InstanceStatus = { running: 0 };
|
||||
const sequelize = {};
|
||||
const stubModule = (modulePath, exports) => {
|
||||
require.cache[require.resolve(modulePath)] = {
|
||||
id: require.resolve(modulePath),
|
||||
filename: require.resolve(modulePath),
|
||||
loaded: true,
|
||||
exports,
|
||||
children: [],
|
||||
paths: [],
|
||||
};
|
||||
};
|
||||
stubModule('../../back/data', { sequelize });
|
||||
stubModule('../../back/data/cronStats', { CrontabStatModel });
|
||||
stubModule('../../back/data/runningInstance', {
|
||||
InstanceStatus,
|
||||
RunningInstanceModel,
|
||||
});
|
||||
stubModule('../../back/data/system', {
|
||||
AuthDataType: { systemConfig: 'systemConfig' },
|
||||
SystemModel: {},
|
||||
});
|
||||
delete require.cache[require.resolve('../../back/services/retention')];
|
||||
const RetentionService = require('../../back/services/retention').default;
|
||||
|
||||
t.after(async () => {
|
||||
config.dependenceCachePath = originalCachePath;
|
||||
await fs.rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
let instanceWhere;
|
||||
let vacuumed = false;
|
||||
let instanceCountCalls = 0;
|
||||
let statCountCalls = 0;
|
||||
config.dependenceCachePath = directory;
|
||||
RunningInstanceModel.count = async ({ where }) => {
|
||||
instanceCountCalls++;
|
||||
instanceWhere = where;
|
||||
return 2;
|
||||
};
|
||||
RunningInstanceModel.destroy = async ({ where }) => {
|
||||
instanceWhere = where;
|
||||
return 2;
|
||||
};
|
||||
CrontabStatModel.count = async () => {
|
||||
statCountCalls++;
|
||||
return 3;
|
||||
};
|
||||
CrontabStatModel.destroy = async () => 3;
|
||||
sequelize.transaction = async (callback) => callback({});
|
||||
sequelize.query = async (query) => {
|
||||
vacuumed = query === 'VACUUM';
|
||||
return [];
|
||||
};
|
||||
|
||||
const service = new RetentionService();
|
||||
const disabledPreview = await service.preview({
|
||||
runningInstanceRetentionDays: 0,
|
||||
cronStatRetentionDays: 0,
|
||||
});
|
||||
assert.equal(disabledPreview.runningInstances, 0);
|
||||
assert.equal(disabledPreview.cronStats, 0);
|
||||
assert.equal(instanceCountCalls, 0);
|
||||
assert.equal(statCountCalls, 0);
|
||||
|
||||
const result = await service.cleanup({
|
||||
runningInstanceRetentionDays: 30,
|
||||
cronStatRetentionDays: 90,
|
||||
dependenceCacheTypes: ['node'],
|
||||
compactDatabase: true,
|
||||
});
|
||||
|
||||
assert.equal(instanceWhere.status[Op.ne], InstanceStatus.running);
|
||||
assert.equal(result.deleted.runningInstances, 2);
|
||||
assert.equal(result.deleted.cronStats, 3);
|
||||
assert.equal(result.deleted.dependenceCacheBytes, 13);
|
||||
assert.equal(vacuumed, true);
|
||||
await assert.rejects(fs.stat(nodeCache), { code: 'ENOENT' });
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const { createRequire } = require('node:module');
|
||||
const path = require('node:path');
|
||||
const ts = require('typescript');
|
||||
const source = fs.readFileSync('back/app.ts', 'utf8');
|
||||
const compiled = ts.transpileModule(
|
||||
source.slice(0, source.indexOf('\nconst app = new Application();')) +
|
||||
'\nmodule.exports = Application;',
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
|
||||
function fixture() {
|
||||
const required = [],
|
||||
listeners = new Map();
|
||||
let invalidations = 0;
|
||||
const module = { exports: {} };
|
||||
const localRequire = createRequire(path.resolve('back/app.ts'));
|
||||
const mocks = {
|
||||
'./config': { cors: { origin: ['http://localhost'] } },
|
||||
'./loaders/logger': { error() {}, warn() {}, info() {} },
|
||||
'./middlewares/monitoring': {
|
||||
monitoringMiddleware: (_req, _res, next) => next(),
|
||||
},
|
||||
'./schedule/client': {
|
||||
default: { readiness: { invalidate: () => invalidations++ } },
|
||||
__esModule: true,
|
||||
},
|
||||
};
|
||||
new Function('require', 'module', 'exports', 'process', compiled)(
|
||||
(name) => {
|
||||
required.push(name);
|
||||
return Object.hasOwn(mocks, name) ? mocks[name] : localRequire(name);
|
||||
},
|
||||
module,
|
||||
module.exports,
|
||||
{ env: {}, on: (name, fn) => listeners.set(name, fn) },
|
||||
);
|
||||
return {
|
||||
app: new module.exports(),
|
||||
required,
|
||||
listeners,
|
||||
invalidations: () => invalidations,
|
||||
};
|
||||
}
|
||||
|
||||
test('primary construction does not load HTTP middleware, metrics or the gRPC client', () => {
|
||||
const { required } = fixture();
|
||||
for (const name of [
|
||||
'express',
|
||||
'helmet',
|
||||
'cors',
|
||||
'compression',
|
||||
'./middlewares/monitoring',
|
||||
'./schedule/client',
|
||||
'./config/util',
|
||||
]) {
|
||||
assert.equal(required.includes(name), false, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('lazy HTTP initialization preserves query filtering, security headers, CORS and compression', async (t) => {
|
||||
const { app, required } = fixture();
|
||||
const http = await app.setupMiddlewares();
|
||||
http.get('/test', (req, res) =>
|
||||
res.json({ query: req.query, data: 'x'.repeat(2048) }),
|
||||
);
|
||||
const server = await new Promise((resolve) => {
|
||||
const server = http.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
t.after(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
server.closeAllConnections();
|
||||
}),
|
||||
);
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${server.address().port}/test?t=123&keep=yes`,
|
||||
{
|
||||
headers: { Origin: 'http://localhost', 'Accept-Encoding': 'gzip' },
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get('x-content-type-options'), 'nosniff');
|
||||
assert.equal(
|
||||
response.headers.get('access-control-allow-origin'),
|
||||
'http://localhost',
|
||||
);
|
||||
assert.equal(response.headers.get('content-encoding'), 'gzip');
|
||||
assert.deepEqual((await response.json()).query, { keep: 'yes' });
|
||||
assert.ok(required.includes('express'));
|
||||
assert.equal(required.includes('./schedule/client'), false);
|
||||
});
|
||||
|
||||
test('worker recovery messages still invalidate HTTP readiness, but gRPC shutdown setup does not load its client', async () => {
|
||||
const grpc = fixture();
|
||||
grpc.app.setupWorkerShutdown('grpc');
|
||||
await grpc.listeners.get('message')('scheduler-unavailable');
|
||||
assert.equal(grpc.required.includes('./schedule/client'), false);
|
||||
const http = fixture();
|
||||
http.app.setupWorkerShutdown('http');
|
||||
await http.listeners.get('message')('scheduler-unavailable');
|
||||
await http.listeners.get('message')('reregister-crons');
|
||||
assert.equal(http.invalidations(), 2);
|
||||
const shutdowns = [];
|
||||
http.app.gracefulShutdown = (role) => shutdowns.push(role);
|
||||
await http.listeners.get('message')('shutdown');
|
||||
http.listeners.get('SIGTERM')();
|
||||
assert.deepEqual(shutdowns, ['http', 'http']);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const http = require('node:http');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
const config = require('../../back/config').default;
|
||||
const { check } = require('../../back/schedule/health');
|
||||
|
||||
function runCheck(service = 'cron') {
|
||||
return new Promise((resolve) => {
|
||||
check({ request: { service } }, (error, response) => {
|
||||
resolve({ error, response });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('schedule health check uses the HTTP health endpoint and system logs', async (t) => {
|
||||
const originalBaseUrl = config.baseUrl;
|
||||
const originalPort = config.port;
|
||||
const originalSystemLogPath = config.systemLogPath;
|
||||
const originalContainer = process.env.QL_CONTAINER;
|
||||
|
||||
t.after(() => {
|
||||
config.baseUrl = originalBaseUrl;
|
||||
config.port = originalPort;
|
||||
config.systemLogPath = originalSystemLogPath;
|
||||
if (originalContainer === undefined) {
|
||||
delete process.env.QL_CONTAINER;
|
||||
} else {
|
||||
process.env.QL_CONTAINER = originalContainer;
|
||||
}
|
||||
});
|
||||
|
||||
await t.test(
|
||||
'returns serving for a healthy prefixed HTTP service',
|
||||
async () => {
|
||||
let requestedUrl = '';
|
||||
const server = http.createServer((request, response) => {
|
||||
requestedUrl = request.url;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify({ code: 200, data: { status: 'ok' } }));
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
config.baseUrl = '/ql';
|
||||
config.port = server.address().port;
|
||||
|
||||
const result = await runCheck();
|
||||
assert.equal(result.error, null);
|
||||
assert.deepEqual(result.response, { status: 1 });
|
||||
assert.equal(requestedUrl, '/ql/api/health');
|
||||
},
|
||||
);
|
||||
|
||||
await t.test(
|
||||
'reports recent system logs when HTTP startup fails',
|
||||
async () => {
|
||||
const logDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-health-'));
|
||||
t.after(() => fs.rm(logDir, { recursive: true, force: true }));
|
||||
const lines = Array.from(
|
||||
{ length: 305 },
|
||||
(_, index) => `line-${index + 1}`,
|
||||
);
|
||||
await fs.writeFile(path.join(logDir, '2026-08-16.log'), 'older-log');
|
||||
await fs.writeFile(path.join(logDir, '2026-08-17.log'), lines.join('\n'));
|
||||
config.systemLogPath = logDir;
|
||||
config.port = 1;
|
||||
process.env.QL_CONTAINER = 'true';
|
||||
|
||||
const result = await runCheck();
|
||||
assert.equal(result.response, undefined);
|
||||
assert.match(result.error.message, /ECONNREFUSED|connect/);
|
||||
assert.match(
|
||||
result.error.message,
|
||||
/http:\/\/localhost:1\/ql\/api\/health/,
|
||||
);
|
||||
assert.match(result.error.message, /docker logs <container>/);
|
||||
assert.match(result.error.message, /line-305/);
|
||||
assert.doesNotMatch(result.error.message, /line-1\n/);
|
||||
assert.doesNotMatch(result.error.message, /qinglong-error\.log/);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const grpc = require('@grpc/grpc-js');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||
|
||||
for (const [name, rows, delay, succeeds] of [
|
||||
['large distinct snapshot', Array.from({length:10000}, (_, i) => ({id:String(i), schedule:`${i%60} ${Math.floor(i/60)%60} ${Math.floor(i/3600)} * * *`})), 10000, true],
|
||||
['extra schedules', [{id:'extra', schedule:'* * * * *', extra_schedules:Array.from({length:2000},()=>({schedule:'0 * * * *'}))}], 10000, true],
|
||||
['unresponsive small write', [{id:'small', schedule:'* * * * *'}], 30000, false],
|
||||
['unresponsive large write remains bounded', Array.from({length:30000},()=>({schedule:'* * * * *'})), 180000, false],
|
||||
]) {
|
||||
test(`recovery deadline: ${name}`, async (t) => {
|
||||
t.mock.timers.enable({apis:['Date','setTimeout'], now:0});
|
||||
let calls=0, deadline;
|
||||
const fake={waitForReady:(_,cb)=>cb(), addCron:(_req,_meta,options,cb)=>{
|
||||
calls++; deadline=options.deadline;
|
||||
const budget=deadline-Date.now();
|
||||
setTimeout(()=>cb(delay<=budget ? null : Object.assign(Error('deadline'),{code:grpc.status.DEADLINE_EXCEEDED}), {}), Math.min(delay,budget));
|
||||
}};
|
||||
const client=load('back/schedule/client.ts', {
|
||||
'../protos/cron':{CronClient:class{constructor(){return fake}}},
|
||||
'../config':{grpcPort:5500},
|
||||
'../config/grpcCerts':{getGrpcCerts:()=>({caCert:'ca',clientKey:'key',clientCert:'cert'})},
|
||||
'@grpc/grpc-js':{...grpc,credentials:{createSsl:()=>({})}},
|
||||
}).default;
|
||||
const readiness=new SchedulerReadiness(async()=>{}, 60000);
|
||||
t.after(()=>clearTimeout(readiness.retry));
|
||||
client.readiness=readiness;
|
||||
readiness.configure(()=>client.addCron(rows,true));
|
||||
const recovering=readiness.recover();
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(calls,1);
|
||||
assert.ok(deadline<=120000);
|
||||
t.mock.timers.tick(Math.min(delay,deadline));
|
||||
assert.equal(await recovering,succeeds);
|
||||
assert.equal(calls,1,'uncertain writes are not replayed inline');
|
||||
assert.equal(await readiness.check(),succeeds);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
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 grpc = require('@grpc/grpc-js');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const logger = { info() {}, debug() {}, warn() {}, error() {} };
|
||||
const tick = () => new Promise(setImmediate);
|
||||
function createLimit() {
|
||||
return load('back/shared/pLimit.ts', {
|
||||
'../data/system': {
|
||||
AuthDataType: { systemConfig: 'systemConfig' },
|
||||
SystemModel: {
|
||||
sync: async () => {},
|
||||
findOne: async () => ({ info: { cronConcurrency: 1 } }),
|
||||
},
|
||||
},
|
||||
'../loaders/logger': logger,
|
||||
'../services/notify': class {},
|
||||
'../shared/i18n': { t: (x) => x, tf: (x) => x },
|
||||
'../config': {},
|
||||
'../config/grpcCerts': { getGrpcCerts: () => null },
|
||||
'../protos/api': {},
|
||||
}).default;
|
||||
}
|
||||
function createSystem(client, limit, options = {}) {
|
||||
const store = options.store || {
|
||||
state: {
|
||||
id: 1,
|
||||
type: 'systemConfig',
|
||||
info: { cronConcurrency: 1, timezone: 'Asia/Shanghai' },
|
||||
},
|
||||
};
|
||||
const writes = [];
|
||||
const Service = load('back/services/system.ts', {
|
||||
'../config': options.config || {},
|
||||
'../shared/schedulerMutationLock': options.config
|
||||
? load('back/shared/schedulerMutationLock.ts', {
|
||||
'../config': options.config,
|
||||
})
|
||||
: { withSchedulerMutation: (operation) => operation() },
|
||||
'../config/const': {},
|
||||
'../config/util': {},
|
||||
'../data/dependence': {},
|
||||
'../data/notify': {},
|
||||
'../data/system': {
|
||||
AuthDataType: { systemConfig: 'systemConfig' },
|
||||
SystemModel: {
|
||||
findOne: async () => ({
|
||||
...store.state,
|
||||
get: () => ({ ...store.state }),
|
||||
}),
|
||||
update: async (values) => {
|
||||
writes.push(values);
|
||||
store.state = { ...store.state, ...values };
|
||||
},
|
||||
},
|
||||
},
|
||||
'../shared/pLimit': limit,
|
||||
'../schedule/client': client,
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'./notify': class {},
|
||||
'./schedule': class {},
|
||||
'./sock': class {},
|
||||
}).default;
|
||||
return {
|
||||
service: new Service(logger, {}, {}),
|
||||
writes,
|
||||
state: () => store.state,
|
||||
};
|
||||
}
|
||||
async function rpcFixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-concurrency-rpc-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const certs = await load('back/config/grpcCerts.ts', {
|
||||
'./index': { configPath: root },
|
||||
'./util': { fileExist: async (p) => fs.existsSync(p) },
|
||||
'../loaders/logger': logger,
|
||||
}).initGrpcCerts();
|
||||
const schedulerLimit = createLimit(),
|
||||
apiLimit = createLimit();
|
||||
await tick();
|
||||
const handler = load('back/schedule/setConcurrency.ts', {
|
||||
'../shared/pLimit': schedulerLimit,
|
||||
});
|
||||
let port;
|
||||
const Grpc = load('back/services/grpc.ts', {
|
||||
'../config': { bindHostGrpc: '127.0.0.1', grpcPort: 0 },
|
||||
'../config/grpcCerts': { initGrpcCerts: async () => certs },
|
||||
'../loaders/logger': logger,
|
||||
'./metrics': { metricsService: { record() {} } },
|
||||
'../schedule/addCron': {},
|
||||
'../schedule/delCron': {},
|
||||
'../schedule/api': {},
|
||||
'../schedule/health': { check: (_call, cb) => cb(null, { status: 1 }) },
|
||||
'../schedule/setConcurrency': handler,
|
||||
'@grpc/grpc-js': {
|
||||
...grpc,
|
||||
Server: class extends grpc.Server {
|
||||
bindAsync(address, creds, cb) {
|
||||
super.bindAsync(address, creds, (error, boundPort) => {
|
||||
port = boundPort;
|
||||
cb(error, boundPort);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
}).GrpcServerService;
|
||||
const server = new Grpc();
|
||||
await server.initialize();
|
||||
const client = load('back/schedule/client.ts', {
|
||||
'../config': { grpcPort: port },
|
||||
'../config/grpcCerts': { getGrpcCerts: () => certs },
|
||||
}).default;
|
||||
t.after(async () => {
|
||||
client._client?.close();
|
||||
await server.shutdown();
|
||||
});
|
||||
return {
|
||||
...createSystem(client, apiLimit),
|
||||
schedulerLimit,
|
||||
apiLimit,
|
||||
client,
|
||||
};
|
||||
}
|
||||
function enqueue(t, limit, count) {
|
||||
const releases = [],
|
||||
runs = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
releases.push(release);
|
||||
runs.push(limit.runWithCronLimit({ id: String(i) }, () => gate));
|
||||
}
|
||||
t.after(async () => {
|
||||
releases.forEach((release) => release());
|
||||
await Promise.all(runs);
|
||||
});
|
||||
return { releases, runs };
|
||||
}
|
||||
|
||||
test('concurrency changes reach the independent scheduler queue over mTLS immediately and preserve running jobs', async (t) => {
|
||||
const f = await rpcFixture(t);
|
||||
const { releases, runs } = enqueue(t, f.schedulerLimit, 3);
|
||||
await tick();
|
||||
assert.equal(f.schedulerLimit.cronLimitActiveCount, 1);
|
||||
assert.equal(f.schedulerLimit.cronLimitPendingCount, 2);
|
||||
assert.equal(
|
||||
(await f.service.updateCronConcurrency({ cronConcurrency: 2 })).code,
|
||||
200,
|
||||
);
|
||||
await tick();
|
||||
assert.equal(f.schedulerLimit.cronLimitActiveCount, 2);
|
||||
assert.equal(f.schedulerLimit.cronLimitPendingCount, 1);
|
||||
await f.service.updateCronConcurrency({ cronConcurrency: 1 });
|
||||
assert.equal(
|
||||
f.schedulerLimit.cronLimitActiveCount,
|
||||
2,
|
||||
'lowering the limit must not stop active jobs',
|
||||
);
|
||||
releases[0]();
|
||||
await runs[0];
|
||||
await tick();
|
||||
assert.equal(f.schedulerLimit.cronLimitActiveCount, 1);
|
||||
assert.equal(f.schedulerLimit.cronLimitPendingCount, 1);
|
||||
releases[1]();
|
||||
await runs[1];
|
||||
await tick();
|
||||
assert.equal(f.schedulerLimit.cronLimitActiveCount, 1);
|
||||
assert.equal(f.schedulerLimit.cronLimitPendingCount, 0);
|
||||
releases[2]();
|
||||
await runs[2];
|
||||
assert.equal(f.state().info.cronConcurrency, 1);
|
||||
});
|
||||
|
||||
test('zero, null and omitted concurrency restore the default in both API and scheduler queues without a restart', async (t) => {
|
||||
const f = await rpcFixture(t);
|
||||
for (const value of [0, null, undefined]) {
|
||||
await f.service.updateCronConcurrency({ cronConcurrency: 1 });
|
||||
const scheduler = enqueue(t, f.schedulerLimit, 4),
|
||||
manual = enqueue(t, f.apiLimit, 4);
|
||||
await tick();
|
||||
assert.equal(f.schedulerLimit.cronLimitActiveCount, 1);
|
||||
assert.equal(f.apiLimit.cronLimitActiveCount, 1);
|
||||
await f.service.updateCronConcurrency(
|
||||
value === undefined ? {} : { cronConcurrency: value },
|
||||
);
|
||||
await tick();
|
||||
assert.equal(
|
||||
f.state().info.cronConcurrency,
|
||||
0,
|
||||
'persist the same default that both queues apply',
|
||||
);
|
||||
assert.equal(f.schedulerLimit.cronLimitActiveCount, 4);
|
||||
assert.equal(f.apiLimit.cronLimitActiveCount, 4);
|
||||
[...scheduler.releases, ...manual.releases].forEach((release) => release());
|
||||
await Promise.all([...scheduler.runs, ...manual.runs]);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid concurrency is rejected before configuration writes or queue changes', async () => {
|
||||
const mutations = [];
|
||||
const f = createSystem(
|
||||
{ setConcurrency: async (value) => mutations.push(value) },
|
||||
{ setCustomLimit: async (value) => mutations.push(value) },
|
||||
);
|
||||
for (const value of [-1, 1.5, NaN, Infinity, 2147483648])
|
||||
await assert.rejects(
|
||||
f.service.updateCronConcurrency({ cronConcurrency: value }),
|
||||
{ status: 400 },
|
||||
);
|
||||
assert.deepEqual(f.writes, []);
|
||||
assert.deepEqual(mutations, []);
|
||||
const handler = load('back/schedule/setConcurrency.ts', {
|
||||
'../shared/pLimit': {
|
||||
setCustomLimit: async (value) => mutations.push(value),
|
||||
},
|
||||
}).setConcurrency;
|
||||
for (const value of [-1, 0.5, 2147483648])
|
||||
await handler({ request: { concurrency: value } }, (error) =>
|
||||
assert.equal(error.code, grpc.status.INVALID_ARGUMENT),
|
||||
);
|
||||
assert.deepEqual(mutations, []);
|
||||
});
|
||||
|
||||
for (const code of [grpc.status.UNAVAILABLE, grpc.status.DEADLINE_EXCEEDED]) {
|
||||
test(`concurrency RPC failure ${code} invalidates readiness and is not reported as success or retried`, async () => {
|
||||
let writes = 0,
|
||||
invalidations = 0;
|
||||
const fake = {
|
||||
waitForReady: (_deadline, callback) => callback(),
|
||||
setConcurrency: (_request, _metadata, options, callback) => {
|
||||
assert.ok(options.deadline > Date.now());
|
||||
writes++;
|
||||
callback(Object.assign(new Error('scheduler unavailable'), { code }));
|
||||
},
|
||||
};
|
||||
const client = load('back/schedule/client.ts', {
|
||||
'../protos/cron': {
|
||||
CronClient: class {
|
||||
constructor() {
|
||||
return fake;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../config': { grpcPort: 1 },
|
||||
'../config/grpcCerts': {
|
||||
getGrpcCerts: () => ({
|
||||
caCert: 'ca',
|
||||
clientKey: 'key',
|
||||
clientCert: 'cert',
|
||||
}),
|
||||
},
|
||||
'@grpc/grpc-js': { ...grpc, credentials: { createSsl: () => ({}) } },
|
||||
}).default;
|
||||
client.readiness.invalidate = () => invalidations++;
|
||||
const f = createSystem(client, { setCustomLimit: async () => {} });
|
||||
await assert.rejects(
|
||||
f.service.updateCronConcurrency({ cronConcurrency: 2 }),
|
||||
{ status: 503 },
|
||||
);
|
||||
assert.equal(writes, 1);
|
||||
assert.equal(invalidations, 1);
|
||||
});
|
||||
}
|
||||
|
||||
function mutationConfig(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-concurrency-lock-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return { crontabFile: path.join(root, 'crontab.list') };
|
||||
}
|
||||
|
||||
test('independent API service instances serialize database and scheduler updates with the shared filesystem lock', async (t) => {
|
||||
const config = mutationConfig(t);
|
||||
const store = { state: { id: 1, info: { cronConcurrency: 1 } } };
|
||||
let release, entered;
|
||||
const firstEntered = new Promise((resolve) => (entered = resolve));
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
t.after(() => release());
|
||||
let scheduler = 1,
|
||||
api = 1;
|
||||
const client = {
|
||||
setConcurrency: async (value) => {
|
||||
if (value === 10) {
|
||||
entered();
|
||||
await gate;
|
||||
}
|
||||
scheduler = value;
|
||||
},
|
||||
};
|
||||
const limit = { setCustomLimit: async (value) => (api = value) };
|
||||
const a = createSystem(client, limit, { config, store });
|
||||
const b = createSystem(client, limit, { config, store });
|
||||
const first = a.service.updateCronConcurrency({ cronConcurrency: 10 });
|
||||
await firstEntered;
|
||||
const second = b.service.updateCronConcurrency({ cronConcurrency: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
assert.equal(
|
||||
b.writes.length,
|
||||
0,
|
||||
'a second writer cannot persist ahead of the first RPC',
|
||||
);
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(
|
||||
[store.state.info.cronConcurrency, api, scheduler],
|
||||
[1, 1, 1],
|
||||
);
|
||||
});
|
||||
|
||||
for (const newerLimit of [undefined, 2]) {
|
||||
test(`production recovery restores persisted concurrency before health becomes ready (newer limit: ${newerLimit})`, async (t) => {
|
||||
const config = mutationConfig(t);
|
||||
const store = { state: { id: 1, info: { cronConcurrency: 1 } } };
|
||||
let scheduler = 1,
|
||||
api = 1,
|
||||
fail = false,
|
||||
restoreJobs = 0;
|
||||
let unblockRecovery, recoveryEntered;
|
||||
const entered = new Promise((resolve) => (recoveryEntered = resolve));
|
||||
const gate = new Promise((resolve) => (unblockRecovery = resolve));
|
||||
t.after(() => unblockRecovery());
|
||||
const fake = {
|
||||
waitForReady: (_deadline, callback) => callback(),
|
||||
makeUnaryRequest: (...args) => args.at(-1)(null, { status: 1 }),
|
||||
setConcurrency: ({ concurrency }, _metadata, _options, callback) => {
|
||||
if (fail) {
|
||||
fail = false;
|
||||
callback(
|
||||
Object.assign(new Error('lost RPC'), {
|
||||
code: grpc.status.UNAVAILABLE,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
scheduler = concurrency;
|
||||
callback(null, {});
|
||||
}
|
||||
},
|
||||
};
|
||||
const client = load('back/schedule/client.ts', {
|
||||
'../protos/cron': {
|
||||
CronClient: class {
|
||||
constructor() {
|
||||
return fake;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../config': { grpcPort: 1 },
|
||||
'../config/grpcCerts': {
|
||||
getGrpcCerts: () => ({
|
||||
caCert: 'ca',
|
||||
clientKey: 'key',
|
||||
clientCert: 'cert',
|
||||
}),
|
||||
},
|
||||
'@grpc/grpc-js': { ...grpc, credentials: { createSsl: () => ({}) } },
|
||||
}).default;
|
||||
const f = createSystem(
|
||||
client,
|
||||
{ setCustomLimit: async (value) => (api = value) },
|
||||
{ config, store },
|
||||
);
|
||||
const source = fs.readFileSync('back/loaders/initData.ts', 'utf8');
|
||||
const callback = source.match(
|
||||
/cronClient\.readiness\.configure\((async \(\) => \{[\s\S]*?\n \})\);/,
|
||||
)[1];
|
||||
const restore = new Function(
|
||||
'cronService',
|
||||
'systemService',
|
||||
`return ${callback}`,
|
||||
)(
|
||||
{
|
||||
autosave_crontab: async (strict) => {
|
||||
assert.equal(strict, true);
|
||||
restoreJobs++;
|
||||
if (restoreJobs > 1) {
|
||||
recoveryEntered();
|
||||
await gate;
|
||||
}
|
||||
},
|
||||
},
|
||||
f.service,
|
||||
);
|
||||
client.readiness.configure(restore);
|
||||
assert.equal(await client.readiness.recover(), true);
|
||||
fail = true;
|
||||
await assert.rejects(
|
||||
f.service.updateCronConcurrency({ cronConcurrency: 10 }),
|
||||
{ status: 503 },
|
||||
);
|
||||
await entered;
|
||||
assert.equal(await client.readiness.check(), false);
|
||||
assert.equal(scheduler, 1);
|
||||
// A newer successful request must win over the failed request being recovered.
|
||||
if (newerLimit !== undefined)
|
||||
await f.service.updateCronConcurrency({ cronConcurrency: newerLimit });
|
||||
scheduler = 1; // Simulate the scheduler restarting while recovery is in progress.
|
||||
const recovered = client.readiness.recover();
|
||||
unblockRecovery();
|
||||
assert.equal(await recovered, true);
|
||||
const expected = newerLimit ?? 10;
|
||||
assert.deepEqual(
|
||||
[store.state.info.cronConcurrency, api, scheduler],
|
||||
[expected, expected, expected],
|
||||
);
|
||||
assert.equal(await client.readiness.check(), true);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
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 load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
for (const failOperation of [false, true]) {
|
||||
test(`scheduler lock survives nested file writes and releases after ${
|
||||
failOperation ? 'failure' : 'success'
|
||||
}`, async (t) => {
|
||||
// Match production's canonical /ql path even when macOS temp is a symlink.
|
||||
const root = await fs.realpath(
|
||||
await fs.mkdtemp(path.join(os.tmpdir(), 'ql-nested-lock-')),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const config = { crontabFile: path.join(root, 'crontab.list') };
|
||||
await fs.writeFile(config.crontabFile, '');
|
||||
const { withSchedulerMutation } = load(
|
||||
'back/shared/schedulerMutationLock.ts',
|
||||
{
|
||||
'../config': config,
|
||||
},
|
||||
);
|
||||
const { writeFileWithLock } = load('back/shared/utils.ts', {
|
||||
'../config/util': {
|
||||
fileExist: async (file) =>
|
||||
fs.access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
},
|
||||
});
|
||||
const failure = new Error('registration failed');
|
||||
const operation = withSchedulerMutation(async () => {
|
||||
await writeFileWithLock(config.crontabFile, 'first');
|
||||
await writeFileWithLock(config.crontabFile, 'second');
|
||||
if (failOperation) throw failure;
|
||||
});
|
||||
if (failOperation)
|
||||
await assert.rejects(operation, (error) => error === failure);
|
||||
else await operation;
|
||||
await assert.rejects(fs.stat(`${config.crontabFile}.scheduler.lock`), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
await withSchedulerMutation(() =>
|
||||
writeFileWithLock(config.crontabFile, 'recovered'),
|
||||
);
|
||||
assert.equal(await fs.readFile(config.crontabFile, 'utf8'), 'recovered');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
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 { spawn } = require('node:child_process');
|
||||
const { once } = require('node:events');
|
||||
const { setTimeout: delay } = require('node:timers/promises');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const { status } = require('@grpc/grpc-js');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
function gate() {
|
||||
let release;
|
||||
const promise = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return { promise, release };
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-scheduler-mutation-'),
|
||||
);
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
const crons = db.define('Cron', {
|
||||
name: DataTypes.STRING,
|
||||
command: DataTypes.STRING,
|
||||
schedule: DataTypes.STRING,
|
||||
isDisabled: DataTypes.INTEGER,
|
||||
saved: DataTypes.BOOLEAN,
|
||||
queued_token: DataTypes.STRING,
|
||||
});
|
||||
await db.sync();
|
||||
t.after(async () => {
|
||||
await db.close();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const jobs = new Map();
|
||||
const client = {
|
||||
addCron: async (items, replace) => {
|
||||
if (replace) jobs.clear();
|
||||
items.forEach((item) => jobs.set(item.id, item.command));
|
||||
},
|
||||
delCron: async (ids) => ids.forEach((id) => jobs.delete(id)),
|
||||
};
|
||||
const CronService = load('back/services/cron.ts', {
|
||||
'../config': {
|
||||
crontabFile: path.join(root, 'crontab.list'),
|
||||
logPath: root,
|
||||
},
|
||||
'../data/cron': {
|
||||
CrontabModel: crons,
|
||||
Crontab: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, { isDisabled: 0 }, options);
|
||||
}
|
||||
},
|
||||
},
|
||||
'../data/runningInstance': {},
|
||||
'../config/util': { isDemoEnv: () => false },
|
||||
'../config/const': {},
|
||||
'../schedule/client': client,
|
||||
'../shared/pLimit': {},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {},
|
||||
}).default;
|
||||
const service = new CronService({ error() {}, warn() {}, info() {} });
|
||||
service.getLogName = async () => 'task';
|
||||
service.shouldUseCronClient = () => true;
|
||||
service.makeCommand = (doc) => doc.command;
|
||||
service.crontabs = async () => ({ data: await crons.findAll({ raw: true }) });
|
||||
service.setCrontab = async () => {};
|
||||
return { service, crons, jobs, client, root };
|
||||
}
|
||||
|
||||
for (const operation of ['create', 'update', 'remove', 'disabled', 'enabled']) {
|
||||
test(`recovery snapshot cannot overwrite concurrent ${operation}`, async (t) => {
|
||||
const { service, crons, jobs } = await fixture(t);
|
||||
await crons.create({
|
||||
id: 1,
|
||||
command: 'old',
|
||||
schedule: '* * * * *',
|
||||
isDisabled: operation === 'enabled' ? 1 : 0,
|
||||
});
|
||||
const entered = gate(),
|
||||
release = gate();
|
||||
let first = true;
|
||||
service.setCrontab = async () => {
|
||||
if (first) {
|
||||
first = false;
|
||||
entered.release();
|
||||
await release.promise;
|
||||
}
|
||||
};
|
||||
const recovery = service.autosave_crontab(true);
|
||||
await entered.promise;
|
||||
const before = await crons.findAll({ raw: true });
|
||||
const mutation =
|
||||
operation === 'create'
|
||||
? service.create({ command: 'new', schedule: '* * * * *' })
|
||||
: operation === 'update'
|
||||
? service.update({ id: 1, command: 'new' })
|
||||
: service[operation]([1]);
|
||||
await delay(50);
|
||||
assert.deepEqual(
|
||||
await crons.findAll({ raw: true }),
|
||||
before,
|
||||
'DB writes must also wait for recovery',
|
||||
);
|
||||
release.release();
|
||||
await Promise.all([recovery, mutation]);
|
||||
const rows = await crons.findAll({ raw: true });
|
||||
assert.deepEqual(
|
||||
[...jobs.entries()].sort(),
|
||||
rows
|
||||
.filter((row) => !row.isDisabled)
|
||||
.map((row) => [String(row.id), row.command])
|
||||
.sort(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('recovery waits for an admitted mutation to finish registration before reading the DB', async (t) => {
|
||||
const { service, client, jobs } = await fixture(t);
|
||||
const entered = gate(),
|
||||
release = gate();
|
||||
const add = client.addCron;
|
||||
client.addCron = async (items, replace) => {
|
||||
if (!replace) {
|
||||
entered.release();
|
||||
await release.promise;
|
||||
}
|
||||
return add(items, replace);
|
||||
};
|
||||
const mutation = service.create({ command: 'new', schedule: '* * * * *' });
|
||||
await entered.promise;
|
||||
let reads = 0;
|
||||
const read = service.crontabs;
|
||||
service.crontabs = async () => {
|
||||
reads++;
|
||||
return read();
|
||||
};
|
||||
const recovery = service.autosave_crontab(true);
|
||||
await delay(50);
|
||||
assert.equal(reads, 0);
|
||||
release.release();
|
||||
await Promise.all([mutation, recovery]);
|
||||
assert.deepEqual([...jobs.values()], ['new']);
|
||||
});
|
||||
|
||||
for (const operation of ['create', 'update', 'enabled']) {
|
||||
test(`${operation} preserves scheduler 503 after rollback and releases its lock`, async (t) => {
|
||||
const { service, crons, client } = await fixture(t);
|
||||
if (operation !== 'create')
|
||||
await crons.create({
|
||||
id: 1,
|
||||
command: 'old',
|
||||
schedule: '* * * * *',
|
||||
isDisabled: operation === 'enabled' ? 1 : 0,
|
||||
});
|
||||
const cause = Object.assign(Error('unavailable'), { status: 503 });
|
||||
client.addCron = async () => {
|
||||
throw cause;
|
||||
};
|
||||
const mutation =
|
||||
operation === 'create'
|
||||
? service.create({ command: 'new', schedule: '* * * * *' })
|
||||
: operation === 'update'
|
||||
? service.update({ id: 1, command: 'new' })
|
||||
: service.enabled([1]);
|
||||
await assert.rejects(
|
||||
mutation,
|
||||
(err) =>
|
||||
err.status === 503 && err.cause === cause && /回滚/.test(err.message),
|
||||
);
|
||||
if (operation === 'create') assert.equal(await crons.count(), 0);
|
||||
else {
|
||||
const row = await crons.findByPk(1);
|
||||
assert.equal(row.command, 'old');
|
||||
assert.equal(row.isDisabled, operation === 'enabled' ? 1 : 0);
|
||||
}
|
||||
client.addCron = async () => {};
|
||||
await service.autosave_crontab(true);
|
||||
});
|
||||
}
|
||||
|
||||
test(
|
||||
'scheduler lock excludes a second OS process and leaves no lock after completion',
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-scheduler-process-'),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const config = { crontabFile: path.join(root, 'crontab.list') };
|
||||
const { withSchedulerMutation } = load(
|
||||
'back/shared/schedulerMutationLock.ts',
|
||||
{ '../config': config },
|
||||
);
|
||||
const entered = gate(),
|
||||
release = gate();
|
||||
const owner = withSchedulerMutation(async () => {
|
||||
entered.release();
|
||||
await release.promise;
|
||||
});
|
||||
await entered.promise;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
`
|
||||
const load = require('./test/helpers/load-security-module.cjs');
|
||||
const { withSchedulerMutation } = load('back/shared/schedulerMutationLock.ts', {'../config': JSON.parse(process.argv[1])});
|
||||
process.send('attempting');
|
||||
withSchedulerMutation(async () => process.send('entered')).then(() => process.disconnect()).catch(err => { console.error(err); process.exit(1); });
|
||||
`,
|
||||
JSON.stringify(config),
|
||||
],
|
||||
{ stdio: ['ignore', 'pipe', 'pipe', 'ipc'] },
|
||||
);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null) child.kill('SIGKILL');
|
||||
release.release();
|
||||
});
|
||||
const closed = once(child, 'close');
|
||||
assert.equal((await once(child, 'message'))[0], 'attempting');
|
||||
let acquired = false;
|
||||
const acquisition = once(child, 'message').then(([message]) => {
|
||||
acquired = true;
|
||||
assert.equal(message, 'entered');
|
||||
});
|
||||
await delay(150);
|
||||
assert.equal(acquired, false);
|
||||
release.release();
|
||||
await owner;
|
||||
await acquisition;
|
||||
assert.equal((await closed)[0], 0);
|
||||
await assert.rejects(fs.stat(`${config.crontabFile}.scheduler.lock`), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
for (const operation of ['update', 'remove', 'disabled']) {
|
||||
for (const code of [status.DEADLINE_EXCEEDED, status.UNAVAILABLE]) {
|
||||
for (const applied of [false, true]) {
|
||||
test(`${operation} aborts on RPC ${code} with deletion applied=${applied} and reconciles the original DB`, async (t) => {
|
||||
const { service, crons, client, jobs } = await fixture(t);
|
||||
await crons.create({
|
||||
id: 1, command: 'old', schedule: '* * * * *', isDisabled: 0,
|
||||
});
|
||||
jobs.set('1', 'old');
|
||||
const before = await crons.findAll({ raw: true });
|
||||
const failure = Object.assign(Error('uncertain deletion'), { code, status: 503 });
|
||||
let deletes = 0;
|
||||
client.delCron = async (ids) => {
|
||||
deletes++;
|
||||
if (applied) ids.forEach((id) => jobs.delete(id));
|
||||
throw failure;
|
||||
};
|
||||
const add = client.addCron;
|
||||
client.addCron = async () => assert.fail('must not register after failed deletion');
|
||||
service.setCrontab = async () => assert.fail('must not publish after failed deletion');
|
||||
await assert.rejects(
|
||||
operation === 'update'
|
||||
? service.update({ id: 1, command: 'new' })
|
||||
: service[operation]([1]),
|
||||
(error) => error === failure && error.status === 503,
|
||||
);
|
||||
assert.equal(deletes, 1, 'must not replay an uncertain deletion');
|
||||
assert.deepEqual(await crons.findAll({ raw: true }), before);
|
||||
client.addCron = add;
|
||||
service.setCrontab = async () => {};
|
||||
await service.autosave_crontab(true);
|
||||
assert.deepEqual([...jobs.entries()], [['1', 'old']]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('configuration rollback cannot restore an obsolete manual queue token', async (t) => {
|
||||
const { service, crons, client } = await fixture(t);
|
||||
await crons.create({
|
||||
id: 1,
|
||||
command: 'old',
|
||||
schedule: '* * * * *',
|
||||
isDisabled: 0,
|
||||
queued_token: 'old-token',
|
||||
});
|
||||
let first = true;
|
||||
client.addCron = async () => {
|
||||
if (first) {
|
||||
first = false;
|
||||
await crons.update({ queued_token: 'new-token' }, { where: { id: 1 } });
|
||||
throw Object.assign(Error('unavailable'), { status: 503 });
|
||||
}
|
||||
};
|
||||
await assert.rejects(
|
||||
service.update({ id: 1, command: 'new' }),
|
||||
(err) => err.status === 503,
|
||||
);
|
||||
const row = await crons.findByPk(1);
|
||||
assert.equal(row.command, 'old');
|
||||
assert.equal(row.queued_token, 'new-token');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
const test=require('node:test');
|
||||
const assert=require('node:assert/strict');
|
||||
const {setTimeout:sleep}=require('node:timers/promises');
|
||||
const {SchedulerReadiness}=require('../../back/shared/schedulerReadiness');
|
||||
const load=require('../helpers/load-security-module.cjs');
|
||||
const fs=require('node:fs');
|
||||
const ts=require('typescript');
|
||||
|
||||
test('readiness stays false through failed restoration and retries without idle polling',async()=>{
|
||||
let probes=0,restores=0,fail=true;
|
||||
const state=new SchedulerReadiness(async()=>{probes++;},10);
|
||||
state.configure(async()=>{restores++;if(fail)throw Error('registration failed');});
|
||||
assert.equal(await state.recover(),false);assert.equal(await state.check(),false);
|
||||
fail=false;await sleep(40);assert.equal(await state.check(),true);assert.ok(restores>=2);
|
||||
const before=probes;await sleep(40);assert.equal(probes,before);
|
||||
});
|
||||
test('single-flight recovery cannot mark an invalidated generation ready',async()=>{
|
||||
let release,restores=0;
|
||||
const state=new SchedulerReadiness(async()=>{},10);
|
||||
state.configure(async()=>{if(++restores===1)await new Promise(r=>release=r);});
|
||||
const a=state.recover();assert.equal(state.recover(),a);await sleep(1);
|
||||
state.invalidate();release();assert.equal(await a,false);assert.equal(await state.check(),false);
|
||||
await sleep(30);assert.equal(await state.check(),true);assert.equal(restores,2);
|
||||
});
|
||||
test('mutation waiting is bounded, a later recovery can succeed',async()=>{
|
||||
let release;
|
||||
const state=new SchedulerReadiness(async()=>{},10);state.configure(()=>new Promise(r=>release=r));
|
||||
await assert.rejects(state.ensureReady(15),e=>e.status===503);
|
||||
release();await sleep(1);assert.equal(await state.check(),true);
|
||||
});
|
||||
test('probe failure immediately invalidates a previously ready scheduler',async()=>{
|
||||
let fail=false;
|
||||
const state=new SchedulerReadiness(async()=>{if(fail)throw Error('unavailable');},10);
|
||||
state.configure(async()=>{});assert.equal(await state.recover(),true);
|
||||
fail=true;assert.equal(await state.check(),false);
|
||||
fail=false;await sleep(40);assert.equal(await state.check(),true);
|
||||
});
|
||||
test('health uses actual readiness and returns HTTP 503 until recovery',async(t)=>{
|
||||
let ready=false;const express=require('express');
|
||||
const {HealthService}=load('back/services/health.ts',{
|
||||
typedi:{Service:()=>x=>x},'../loaders/logger':{error(){}},'./http':{},
|
||||
'../schedule/client':{readiness:{check:async()=>ready}},
|
||||
});
|
||||
const service=new HealthService({getServer:()=>({})});
|
||||
const app=express();load('back/api/health.ts',{
|
||||
typedi:{get:()=>service},'../services/health':{HealthService},'../loaders/logger':{error(){}},
|
||||
}).default(app);
|
||||
const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
|
||||
t.after(()=>new Promise(r=>{server.close(r);server.closeAllConnections();}));
|
||||
for(const expected of [503,200,503]){
|
||||
ready=expected===200;const r=await fetch(`http://127.0.0.1:${server.address().port}/health`);const b=await r.json();
|
||||
assert.equal(r.status,expected);assert.equal(b.code,expected);assert.equal(b.data.services.grpc,ready);
|
||||
}
|
||||
});
|
||||
test('recovery registration errors propagate while ordinary autosave retains file synchronization',async()=>{
|
||||
const source=fs.readFileSync('back/services/cron.ts','utf8');const a=source.indexOf(' public async autosave_crontab('),z=source.indexOf(' public async bootTask',a);
|
||||
const js=ts.transpileModule('class Fixture {\n'+source.slice(a,z)+'}\nmodule.exports=Fixture;', {compilerOptions:{target:ts.ScriptTarget.ES2020}}).outputText;
|
||||
const module={exports:{}};let files=0;
|
||||
new Function('module','isDemoEnv','cronClient','withSchedulerMutation',js)(module,()=>false,{addCron:async()=>{throw Error('registration unavailable');}},fn=>fn());
|
||||
const fixture=new module.exports();fixture.crontabs=async()=>({data:[]});fixture.setCrontab=async()=>{files++;};fixture.logger={warn(){}};
|
||||
await fixture.autosave_crontab();assert.equal(files,1);
|
||||
await assert.rejects(fixture.autosave_crontab(true),/registration unavailable/);assert.equal(files,2);
|
||||
});
|
||||
test('scheduler probe uses the cron channel and failed writes are not replayed',async()=>{
|
||||
const calls=[];const fake={
|
||||
waitForReady(deadline,cb){calls.push(['wait',deadline]);cb();},
|
||||
makeUnaryRequest(path,serialize,deserialize,request,options,cb){calls.push(['probe',path,request,options]);cb(null,{status:1});},
|
||||
addCron(request,metadata,options,cb){calls.push(['add',request,options]);cb(Object.assign(Error('invalid'),{code:3}));},
|
||||
};
|
||||
const client=load('back/schedule/client.ts',{
|
||||
'../protos/cron':{CronClient:class{constructor(){return fake;}}},
|
||||
'../config':{grpcPort:5500},'../config/grpcCerts':{getGrpcCerts:()=>({caCert:'ca',clientKey:'key',clientCert:'cert'})},
|
||||
'@grpc/grpc-js':{...require('@grpc/grpc-js'),credentials:{createSsl:()=>({})},status:{UNAVAILABLE:14},Metadata:class{}},
|
||||
}).default;
|
||||
client.readiness.configure(async()=>{});assert.equal(await client.readiness.recover(),true);
|
||||
assert.ok(calls.filter(x=>x[0]==='probe').every(x=>x[1]==='/com.ql.health.Health/Check'&&x[2].service==='scheduler'&&x[3].deadline>Date.now()));
|
||||
await assert.rejects(client.addCron([]),/invalid/);assert.equal(calls.filter(x=>x[0]==='add').length,1);
|
||||
});
|
||||
test('scheduler health probe never calls back into HTTP health',async()=>{
|
||||
const {check}=load('back/schedule/health.ts',{
|
||||
'../config':{},undici:{request:()=>{throw Error('recursive HTTP health call');}},
|
||||
});
|
||||
const result=await new Promise(resolve=>check({request:{service:'scheduler'}},(error,response)=>resolve({error,response})));
|
||||
assert.equal(result.error,null);assert.deepEqual(result.response,{status:1});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const ts = require('typescript');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { SchedulerReadiness } = require('../../back/shared/schedulerReadiness');
|
||||
const { AddCronRequest } = require('../../back/protos/cron');
|
||||
const { getInvalidCronSchedules } = require('../../back/shared/cronSchedule');
|
||||
|
||||
function fixture(client) {
|
||||
const source = fs.readFileSync('back/services/cron.ts', 'utf8');
|
||||
const a = source.indexOf(' public async autosave_crontab(');
|
||||
const z = source.indexOf(' public async bootTask', a);
|
||||
const js = ts.transpileModule(
|
||||
'class Fixture {\n' + source.slice(a, z) + '}\nmodule.exports=Fixture;',
|
||||
{ compilerOptions: { target: ts.ScriptTarget.ES2020 } },
|
||||
).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('module', 'isDemoEnv', 'cronClient', 'withSchedulerMutation', 'getInvalidCronSchedules', js)(
|
||||
module,
|
||||
() => false,
|
||||
client,
|
||||
(fn) => fn(),
|
||||
getInvalidCronSchedules,
|
||||
);
|
||||
const service = new module.exports();
|
||||
service.setCrontab = async () => {};
|
||||
service.logger = { warn() {} };
|
||||
service.shouldUseCronClient = () => true;
|
||||
service.makeCommand = () => 'true';
|
||||
return service;
|
||||
}
|
||||
|
||||
test('recovery reconciles a surviving scheduler after missed deletes and disables, including an empty DB', async () => {
|
||||
const cancelled = [];
|
||||
const stacks = new Map(
|
||||
['removed', 'disabled', 'kept'].map((id) => [
|
||||
id,
|
||||
[{ cancel: () => cancelled.push(id) }],
|
||||
]),
|
||||
);
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/cronScheduler': {
|
||||
createCronJob: (_schedule, _callback, options) => ({
|
||||
start() {}, cancel: () => cancelled.push(options.name.split(':')[0]),
|
||||
}),
|
||||
},
|
||||
'../shared/runCron': {},
|
||||
'../loaders/logger': { info() {}, warn() {} },
|
||||
'../shared/i18n': { tf: (s) => s },
|
||||
});
|
||||
const client = {
|
||||
addCron: async (crons, replace) => {
|
||||
// Exercise the real protobuf field, not just a JavaScript-only flag.
|
||||
const request = AddCronRequest.decode(
|
||||
AddCronRequest.encode({ crons, replace }).finish(),
|
||||
);
|
||||
await new Promise((resolve, reject) =>
|
||||
addCron({ request }, (err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
},
|
||||
};
|
||||
const service = fixture(client);
|
||||
let rows = [
|
||||
{ id: 'kept', schedule: '* * * * *', isDisabled: 0 },
|
||||
{ id: 'disabled', schedule: '* * * * *', isDisabled: 1 },
|
||||
];
|
||||
service.crontabs = async () => ({ data: rows });
|
||||
const state = new SchedulerReadiness(async () => {});
|
||||
state.configure(() => service.autosave_crontab(true));
|
||||
assert.equal(await state.recover(), true);
|
||||
assert.equal(await state.check(), true);
|
||||
assert.deepEqual([...stacks.keys()], ['kept']);
|
||||
assert.deepEqual(cancelled.sort(), ['disabled', 'kept', 'removed']);
|
||||
rows = [];
|
||||
state.invalidate();
|
||||
assert.equal(await state.recover(), true);
|
||||
assert.equal(stacks.size, 0);
|
||||
assert.equal(await state.check(), true);
|
||||
});
|
||||
|
||||
test('invalid replacement leaves the previous schedule intact', async () => {
|
||||
const stacks = new Map([
|
||||
[
|
||||
'old',
|
||||
[
|
||||
{
|
||||
cancel: () => {
|
||||
throw Error('must not cancel');
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/runCron': {},
|
||||
'../loaders/logger': {},
|
||||
'../shared/i18n': { tf: (s) => s },
|
||||
});
|
||||
await assert.rejects(
|
||||
new Promise((resolve, reject) =>
|
||||
addCron(
|
||||
{
|
||||
request: {
|
||||
replace: true,
|
||||
crons: [{ id: 'bad', schedule: 'not a cron', extra_schedules: [] }],
|
||||
},
|
||||
},
|
||||
(err) => (err ? reject(err) : resolve()),
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.deepEqual([...stacks.keys()], ['old']);
|
||||
});
|
||||
|
||||
test('invalid persisted schedules cannot block recovery, valid jobs or HTTP health', async (t) => {
|
||||
const stacks = new Map();
|
||||
const warnings = [];
|
||||
const { addCron } = load('back/schedule/addCron.ts', {
|
||||
'./data': { scheduleStacks: stacks },
|
||||
'../shared/runCron': {},
|
||||
'../loaders/logger': { info() {}, warn() {} },
|
||||
'../shared/i18n': { tf: require('node:util').format },
|
||||
});
|
||||
const client = { addCron: (crons, replace) => new Promise((resolve, reject) => {
|
||||
addCron({ request: { crons, replace } }, (error) => error ? reject(error) : resolve());
|
||||
}) };
|
||||
const service = fixture(client);
|
||||
let snapshot;
|
||||
service.setCrontab = async (tabs) => { snapshot = tabs.data; };
|
||||
service.logger.warn = (...args) => warnings.push(require('node:util').format(...args));
|
||||
let rows = [
|
||||
{ id: 'good', name: 'valid', schedule: '0 0/30 * * * ?', isDisabled: 0 },
|
||||
{ id: 'bad-main', name: 'bad main', schedule: 'not a cron', isDisabled: 0 },
|
||||
{ id: 'bad-extra', name: 'bad extra', schedule: '* * * * *', extra_schedules: [{ schedule: '0 70 * * * ?' }], isDisabled: 0 },
|
||||
{ id: 'unsupported', name: 'unsupported', schedule: 'H * * * *', isDisabled: 0 },
|
||||
{ id: 'disabled', name: 'disabled', schedule: 'bad', isDisabled: 1 },
|
||||
];
|
||||
service.crontabs = async () => ({ data: rows });
|
||||
const state = new SchedulerReadiness(async () => {}, 60000);
|
||||
t.after(() => {
|
||||
clearTimeout(state.retry);
|
||||
for (const jobs of stacks.values()) for (const job of jobs) job.cancel();
|
||||
});
|
||||
state.configure(() => service.autosave_crontab(true));
|
||||
assert.equal(await state.recover(), true);
|
||||
assert.deepEqual([...stacks.keys()], ['good']);
|
||||
assert.deepEqual(snapshot.map((x) => x.id), ['good', 'disabled']);
|
||||
assert.equal(rows.length, 5, 'persisted rows are preserved for editing');
|
||||
assert.equal(rows[1].isDisabled, 0);
|
||||
assert.equal(warnings.length, 3);
|
||||
assert.match(warnings.join('\n'), /bad-main.*bad main.*not a cron/);
|
||||
assert.match(warnings.join('\n'), /bad-extra.*0 70/);
|
||||
const { HealthService } = load('back/services/health.ts', {
|
||||
typedi: { Service: () => (x) => x }, './http': {},
|
||||
'../schedule/client': { readiness: state }, '../loaders/logger': { error() {} },
|
||||
});
|
||||
const health = new HealthService({ getServer: () => ({}) });
|
||||
assert.equal((await health.check()).status, 'ok');
|
||||
rows = rows.filter((x) => x.id !== 'good');
|
||||
assert.equal(await state.recover(), true, 'even all-invalid snapshots can recover');
|
||||
assert.equal(stacks.size, 0);
|
||||
assert.equal((await health.check()).status, 'ok');
|
||||
});
|
||||
|
||||
test('pre-RPC channel failures invalidate readiness and return 503 without executing or replaying writes', async () => {
|
||||
for (const method of ['addCron', 'delCron']) {
|
||||
let invalidations = 0,
|
||||
writes = 0;
|
||||
const fake = {
|
||||
waitForReady: (_deadline, cb) => cb(Error('channel unavailable')),
|
||||
addCron: () => writes++,
|
||||
delCron: () => writes++,
|
||||
};
|
||||
const client = load('back/schedule/client.ts', {
|
||||
'../protos/cron': {
|
||||
CronClient: class {
|
||||
constructor() {
|
||||
return fake;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../config': { grpcPort: 5500 },
|
||||
'../config/grpcCerts': {
|
||||
getGrpcCerts: () => ({
|
||||
caCert: 'ca',
|
||||
clientKey: 'key',
|
||||
clientCert: 'cert',
|
||||
}),
|
||||
},
|
||||
'@grpc/grpc-js': {
|
||||
...require('@grpc/grpc-js'),
|
||||
credentials: { createSsl: () => ({}) },
|
||||
},
|
||||
}).default;
|
||||
client.readiness.invalidate = () => invalidations++;
|
||||
await assert.rejects(
|
||||
client[method]([]),
|
||||
(err) => err.status === 503 && /channel unavailable/.test(err.message),
|
||||
);
|
||||
assert.equal(invalidations, 1);
|
||||
assert.equal(writes, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const grpc = require('@grpc/grpc-js');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
for (const method of ['addCron', 'delCron']) {
|
||||
test(`${method} invalidates an uncertain timed-out write without replaying it`, async () => {
|
||||
let writes = 0;
|
||||
let invalidations = 0;
|
||||
const timeout = Object.assign(new Error('response deadline exceeded'), {
|
||||
code: grpc.status.DEADLINE_EXCEEDED,
|
||||
});
|
||||
const fake = {
|
||||
waitForReady: (_deadline, callback) => callback(),
|
||||
[method]: (_request, _metadata, _options, callback) => {
|
||||
// The server may have applied the write before the response was lost.
|
||||
writes++;
|
||||
callback(timeout);
|
||||
},
|
||||
};
|
||||
const client = load('back/schedule/client.ts', {
|
||||
'../protos/cron': {
|
||||
CronClient: class {
|
||||
constructor() {
|
||||
return fake;
|
||||
}
|
||||
},
|
||||
},
|
||||
'../config': { grpcPort: 5500 },
|
||||
'../config/grpcCerts': {
|
||||
getGrpcCerts: () => ({
|
||||
caCert: 'ca',
|
||||
clientKey: 'key',
|
||||
clientCert: 'cert',
|
||||
}),
|
||||
},
|
||||
'@grpc/grpc-js': { ...grpc, credentials: { createSsl: () => ({}) } },
|
||||
}).default;
|
||||
client.readiness.invalidate = () => invalidations++;
|
||||
await assert.rejects(client[method]([]), (error) => error === timeout);
|
||||
assert.equal(
|
||||
invalidations,
|
||||
1,
|
||||
'reconcile from the DB after an uncertain RPC result',
|
||||
);
|
||||
assert.equal(timeout.status, 503);
|
||||
assert.equal(writes, 1, 'do not replay an uncertain mutation');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { Sequelize, QueryTypes } = require('sequelize');
|
||||
const { migrateSchema } = require('../../back/shared/schemaMigrations');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
async function createLegacy(storage = ':memory:', omitEnvs = false) {
|
||||
const database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage,
|
||||
logging: false,
|
||||
});
|
||||
for (const table of [
|
||||
'CrontabViews',
|
||||
'Subscriptions',
|
||||
'Crontabs',
|
||||
...(omitEnvs ? [] : ['Envs']),
|
||||
]) {
|
||||
await database.query(
|
||||
`CREATE TABLE "${table}" (id INTEGER PRIMARY KEY, name TEXT)`,
|
||||
);
|
||||
}
|
||||
await database.query(
|
||||
'INSERT INTO "Crontabs" (id, name) VALUES (1, \'keep-me\')',
|
||||
);
|
||||
return database;
|
||||
}
|
||||
|
||||
test('legacy database upgrades without losing rows and repeated migration is idempotent', async (t) => {
|
||||
const database = await createLegacy();
|
||||
t.after(() => database.close());
|
||||
await migrateSchema(database);
|
||||
await migrateSchema(database);
|
||||
const rows = await database.query('SELECT * FROM "Crontabs"', {
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
assert.equal(rows[0].name, 'keep-me');
|
||||
assert.equal(rows[0].queued_token, null);
|
||||
assert.ok(Object.hasOwn(rows[0], 'allow_multiple_instances'));
|
||||
const applied = await database.query('SELECT id FROM "SchemaMigrations"', {
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
assert.equal(applied.length, 15);
|
||||
});
|
||||
|
||||
test('migration failure rolls back added columns and can be retried after repair', async (t) => {
|
||||
const database = await createLegacy(':memory:', true);
|
||||
t.after(() => database.close());
|
||||
await assert.rejects(migrateSchema(database));
|
||||
const columns = await database.getQueryInterface().describeTable('Crontabs');
|
||||
assert.equal(Object.hasOwn(columns, 'work_dir'), false);
|
||||
await database.query(
|
||||
'CREATE TABLE "Envs" (id INTEGER PRIMARY KEY, name TEXT)',
|
||||
);
|
||||
await migrateSchema(database);
|
||||
assert.ok(
|
||||
Object.hasOwn(
|
||||
await database.getQueryInterface().describeTable('Crontabs'),
|
||||
'work_dir',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('offline pre-upgrade backup restores the legacy schema and data', async (t) => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-migration-'));
|
||||
t.after(() => fs.rm(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, 'database.sqlite');
|
||||
const backup = path.join(dir, 'before.sqlite');
|
||||
let database = await createLegacy(file);
|
||||
await database.close();
|
||||
await fs.copyFile(file, backup);
|
||||
database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: file,
|
||||
logging: false,
|
||||
});
|
||||
await migrateSchema(database);
|
||||
await database.close();
|
||||
await fs.copyFile(backup, file);
|
||||
database = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: file,
|
||||
logging: false,
|
||||
});
|
||||
try {
|
||||
assert.equal(
|
||||
Object.hasOwn(
|
||||
await database.getQueryInterface().describeTable('Crontabs'),
|
||||
'work_dir',
|
||||
),
|
||||
false,
|
||||
);
|
||||
const rows = await database.query('SELECT name FROM "Crontabs"', {
|
||||
type: QueryTypes.SELECT,
|
||||
});
|
||||
assert.equal(rows[0].name, 'keep-me');
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('database loader rejects initialization failure rather than allowing workers to start', async () => {
|
||||
const mocks = { './logger': { error() {}, info() {} } };
|
||||
for (const [file, model] of [
|
||||
['env', 'EnvModel'],
|
||||
['cron', 'CrontabModel'],
|
||||
['dependence', 'DependenceModel'],
|
||||
['open', 'AppModel'],
|
||||
['system', 'SystemModel'],
|
||||
['subscription', 'SubscriptionModel'],
|
||||
['cronView', 'CrontabViewModel'],
|
||||
['cronStats', 'CrontabStatModel'],
|
||||
['runningInstance', 'RunningInstanceModel'],
|
||||
])
|
||||
mocks[`../data/${file}`] = { [model]: { sync: async () => {} } };
|
||||
mocks['../data'] = { sequelize: {} };
|
||||
mocks['../shared/schemaMigrations'] = {
|
||||
migrateSchema: async () => {
|
||||
throw new Error('SQLITE_FULL');
|
||||
},
|
||||
};
|
||||
const initialize = load(path.resolve('back/loaders/db.ts'), mocks).default;
|
||||
await assert.rejects(initialize(), /SQLITE_FULL/);
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('script file operations allow token.json while protecting panel configuration', async (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-script-access-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
for (const dir of [
|
||||
'config/grpc',
|
||||
'scripts/ZaiZaiCat-Checkin',
|
||||
'scripts/token.json',
|
||||
'bak',
|
||||
'tmp',
|
||||
])
|
||||
fs.mkdirSync(path.join(root, dir), { recursive: true });
|
||||
const config = {
|
||||
dataPath: root,
|
||||
scriptPath: path.join(root, 'scripts'),
|
||||
configPath: path.join(root, 'config'),
|
||||
tmpPath: path.join(root, 'tmp'),
|
||||
bakPath: path.join(root, 'bak'),
|
||||
blackFileList: ['token.json', 'auth.json', 'grpc'],
|
||||
};
|
||||
config.writePathList = [config.configPath, config.scriptPath];
|
||||
const secret = path.join(config.configPath, 'token.json');
|
||||
fs.writeFileSync(secret, 'PANEL-SECRET');
|
||||
fs.symlinkSync(secret, path.join(config.scriptPath, 'secret-link'));
|
||||
fs.symlinkSync(
|
||||
config.configPath,
|
||||
path.join(config.scriptPath, 'config-link'),
|
||||
);
|
||||
fs.symlinkSync(secret, path.join(config.configPath, 'secret-alias'));
|
||||
const mocks = {
|
||||
'../config': config,
|
||||
'../config/const': {},
|
||||
'../config/util': {
|
||||
getFileContentByName: (p) => fs.promises.readFile(p, 'utf8'),
|
||||
fileExist: async (p) => fs.existsSync(p),
|
||||
rmPath: (p) => fs.promises.rm(p, { recursive: true }),
|
||||
readDir: async () => [],
|
||||
},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'./sock': {},
|
||||
'./cron': {},
|
||||
'./schedule': {},
|
||||
'../shared/pLimit': {},
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
};
|
||||
const cache = new Map();
|
||||
const Script = load(
|
||||
path.join(__dirname, '../../back/services/script.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
).default;
|
||||
const service = new Script();
|
||||
mocks['../services/script'] = Script;
|
||||
const History = load(
|
||||
path.join(__dirname, '../../back/services/scriptHistory.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
).default;
|
||||
const history = new History();
|
||||
mocks.typedi = {
|
||||
...mocks.typedi,
|
||||
Container: { get: (type) => (type === History ? history : service) },
|
||||
};
|
||||
const app = express.Router();
|
||||
load(path.join(__dirname, '../../back/api/script.ts'), mocks, cache).default(
|
||||
app,
|
||||
);
|
||||
const router = app.stack.find((layer) => layer.name === 'router').handle;
|
||||
const invoke = async (method, url, body = {}, query = {}, file) => {
|
||||
const route = router.stack.find(
|
||||
(layer) => layer.route?.path === url && layer.route.methods[method],
|
||||
).route;
|
||||
let result;
|
||||
await route.stack.at(-1).handle(
|
||||
{ body, query, file },
|
||||
{
|
||||
send: (value) => {
|
||||
result = value;
|
||||
},
|
||||
download: (p) => {
|
||||
result = { code: 200, data: fs.readFileSync(p, 'utf8') };
|
||||
},
|
||||
},
|
||||
(error) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
};
|
||||
for (const directory of ['', 'ZaiZaiCat-Checkin']) {
|
||||
const filename = directory ? 'token.json' : 'auth.json';
|
||||
const body = {
|
||||
path: directory,
|
||||
filename,
|
||||
content: '{"account":"initial"}',
|
||||
};
|
||||
assert.equal((await invoke('post', '/', body)).code, 200);
|
||||
assert.deepEqual(
|
||||
await invoke('get', '/detail', {}, { path: directory, file: filename }),
|
||||
{ code: 200, data: body.content },
|
||||
);
|
||||
assert.equal(
|
||||
(await invoke('put', '/', { ...body, content: '{"account":"updated"}' }))
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
assert.deepEqual(await invoke('post', '/download', body), {
|
||||
code: 200,
|
||||
data: '{"account":"updated"}',
|
||||
});
|
||||
assert.equal(
|
||||
(await invoke('put', '/rename', { ...body, newFilename: 'renamed.json' }))
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await invoke('put', '/rename', {
|
||||
...body,
|
||||
filename: 'renamed.json',
|
||||
newFilename: filename,
|
||||
})
|
||||
).code,
|
||||
200,
|
||||
);
|
||||
assert.equal((await invoke('delete', '/', body)).code, 200);
|
||||
}
|
||||
assert.equal(
|
||||
(await invoke('get', '/', {}, { path: 'token.json' })).code,
|
||||
200,
|
||||
);
|
||||
const upload = path.join(config.tmpPath, 'upload');
|
||||
fs.writeFileSync(upload, 'uploaded');
|
||||
assert.equal(
|
||||
(
|
||||
await invoke(
|
||||
'post',
|
||||
'/',
|
||||
{ filename: 'token.json', path: 'ZaiZaiCat-Checkin' },
|
||||
{},
|
||||
{ path: upload },
|
||||
)
|
||||
).code,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
await service.getFile('ZaiZaiCat-Checkin', 'token.json'),
|
||||
'uploaded',
|
||||
);
|
||||
for (const filename of [
|
||||
'../config/token.json',
|
||||
secret,
|
||||
'secret-link',
|
||||
'config-link/token.json',
|
||||
]) {
|
||||
assert.equal(service.checkFilePath('', filename), '', filename);
|
||||
assert.equal(
|
||||
(await invoke('post', '/download', { filename })).code,
|
||||
403,
|
||||
filename,
|
||||
);
|
||||
assert.equal(
|
||||
(await invoke('put', '/', { filename, content: 'changed' })).code,
|
||||
403,
|
||||
filename,
|
||||
);
|
||||
if (!path.isAbsolute(filename))
|
||||
assert.equal(
|
||||
(await invoke('post', '/', { filename, content: 'changed' })).code,
|
||||
403,
|
||||
filename,
|
||||
);
|
||||
}
|
||||
for (const filename of [
|
||||
'token.json',
|
||||
'auth.json',
|
||||
'grpc/client.key',
|
||||
'secret-alias',
|
||||
]) {
|
||||
assert.equal(
|
||||
(
|
||||
await invoke('post', '/', {
|
||||
path: config.configPath,
|
||||
filename,
|
||||
content: 'changed',
|
||||
})
|
||||
).code,
|
||||
403,
|
||||
filename,
|
||||
);
|
||||
}
|
||||
assert.equal(fs.readFileSync(secret, 'utf8'), 'PANEL-SECRET');
|
||||
assert.equal(
|
||||
(
|
||||
await invoke('post', '/', {
|
||||
path: config.configPath,
|
||||
filename: 'normal.txt',
|
||||
content: 'normal',
|
||||
})
|
||||
).code,
|
||||
200,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,551 @@
|
||||
require('reflect-metadata');
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const { errors } = require('celebrate');
|
||||
const { Container } = require('typedi');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { resolveFileAccess } = load(
|
||||
path.join(__dirname, '../../back/shared/fileAccess.ts'),
|
||||
);
|
||||
|
||||
test('script routes save, list, preview and restore, with validation and conflict responses', async (t) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-api-'));
|
||||
const scripts = path.join(root, 'scripts');
|
||||
await fs.mkdir(scripts);
|
||||
await fs.writeFile(path.join(scripts, 'test.js'), 'before');
|
||||
const config = {
|
||||
scriptPath: scripts,
|
||||
configPath: path.join(root, 'config'),
|
||||
dataPath: root,
|
||||
bakPath: path.join(root, 'bak'),
|
||||
blackFileList: ['auth.json'],
|
||||
writePathList: [scripts, path.join(root, 'config')],
|
||||
};
|
||||
await fs.mkdir(config.bakPath);
|
||||
await fs.mkdir(config.configPath);
|
||||
let failure = '';
|
||||
let beforeRealpath;
|
||||
class ScriptService {
|
||||
checkFilePath(dir, name) {
|
||||
return resolveFileAccess(scripts, [dir || '', name]);
|
||||
}
|
||||
}
|
||||
const mocks = {
|
||||
'../config': { default: config, __esModule: true },
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
realpath: async (file, ...args) => {
|
||||
if (beforeRealpath) await beforeRealpath(file);
|
||||
return fs.realpath(file, ...args);
|
||||
},
|
||||
copyFile: async (from, to) => {
|
||||
if (failure === 'backup' && path.dirname(to) === config.bakPath)
|
||||
throw Object.assign(new Error('backup denied'), { code: 'EACCES' });
|
||||
return fs.copyFile(from, to);
|
||||
},
|
||||
writeFile: async (file, ...args) => {
|
||||
if (failure === 'history' && String(file).endsWith('.pending.tmp'))
|
||||
throw Object.assign(new Error('history full'), { code: 'ENOSPC' });
|
||||
return fs.writeFile(file, ...args);
|
||||
},
|
||||
},
|
||||
'../config/util': {
|
||||
rmPath: (file) => fs.rm(file, { force: true, recursive: true }),
|
||||
fileExist: async (file) =>
|
||||
fs.access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
},
|
||||
'../services/script': { default: ScriptService, __esModule: true },
|
||||
'../shared/i18n': { t: (value) => value },
|
||||
};
|
||||
const cache = new Map();
|
||||
const api = load(
|
||||
path.join(__dirname, '../../back/api/script.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
);
|
||||
const { default: ScriptHistoryService } = load(
|
||||
path.join(__dirname, '../../back/services/scriptHistory.ts'),
|
||||
mocks,
|
||||
cache,
|
||||
);
|
||||
Container.set(ScriptService, new ScriptService());
|
||||
t.after(() => {
|
||||
Container.remove(ScriptHistoryService);
|
||||
Container.remove(ScriptService);
|
||||
});
|
||||
assert.ok(Container.has(ScriptHistoryService));
|
||||
const historyService = Container.get(ScriptHistoryService);
|
||||
assert.strictEqual(Container.get(ScriptHistoryService), historyService);
|
||||
// An empty history read must not create storage or other persistent resources.
|
||||
assert.deepEqual((await historyService.list('', 'test.js')).versions, []);
|
||||
await assert.rejects(fs.stat(path.join(root, 'script-history')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
api.default(app);
|
||||
app.use(errors());
|
||||
app.use((error, _req, res, _next) =>
|
||||
res.status(500).json({ error: error.message }),
|
||||
);
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((resolve) => server.once('listening', resolve));
|
||||
t.after(async () => {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const call = async (url, body, method = 'PUT') => {
|
||||
const res = await fetch(
|
||||
base + url,
|
||||
body && {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
assert.equal(
|
||||
(await call('/scripts', { filename: 'test.js', content: 'after' })).body
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
const list = await call('/scripts/history?filename=test.js');
|
||||
assert.equal(list.status, 200);
|
||||
assert.equal(list.body.data.versions.length, 1);
|
||||
const id = list.body.data.versions[0].id;
|
||||
const preview = await call(
|
||||
`/scripts/history/detail?filename=test.js&id=${id}`,
|
||||
);
|
||||
assert.equal(preview.body.data.version.content, 'before');
|
||||
assert.equal(preview.body.data.current, 'after');
|
||||
assert.equal(
|
||||
(await call('/scripts/history/restore', { filename: 'test.js', id }))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/scripts/history/restore', {
|
||||
filename: 'test.js',
|
||||
id,
|
||||
expectedHash: '0'.repeat(64),
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call('/scripts/history/restore', {
|
||||
filename: 'test.js',
|
||||
id,
|
||||
expectedHash: preview.body.data.currentHash,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'test.js'), 'utf8'),
|
||||
'before',
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/scripts/history?filename=..%2Foutside')).status,
|
||||
403,
|
||||
);
|
||||
assert.equal((await call('/scripts/history?filename=missing')).status, 404);
|
||||
for (const content of ['debug-C', 'debug-D']) {
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{ filename: 'test.js', originFilename: 'test.js', path: '', content },
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.data.historyRecorded, true);
|
||||
}
|
||||
const debugList = await call('/scripts/history?filename=test.js');
|
||||
const newest = await call(
|
||||
`/scripts/history/detail?filename=test.js&id=${debugList.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(newest.body.data.version.content, 'debug-C');
|
||||
assert.equal(newest.body.data.current, 'debug-D');
|
||||
|
||||
// develop allows script names that are protected only in the config root.
|
||||
await fs.writeFile(path.join(scripts, 'auth.json'), 'script-original');
|
||||
for (const method of ['PUT', 'POST']) {
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{ filename: 'auth.json', path: '', content: `script-${method}` },
|
||||
method,
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.data.historyRecorded, true);
|
||||
}
|
||||
const namedHistory = await call('/scripts/history?filename=auth.json');
|
||||
assert.equal(namedHistory.body.data.versions.length, 2);
|
||||
const namedPreview = await call(
|
||||
`/scripts/history/detail?filename=auth.json&id=${namedHistory.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(namedPreview.body.data.version.content, 'script-PUT');
|
||||
assert.equal(namedPreview.body.data.current, 'script-POST');
|
||||
assert.equal(
|
||||
(
|
||||
await call('/scripts/history/restore', {
|
||||
filename: 'auth.json',
|
||||
id: namedHistory.body.data.versions[0].id,
|
||||
expectedHash: namedPreview.body.data.currentHash,
|
||||
})
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'auth.json'), 'utf8'),
|
||||
'script-PUT',
|
||||
);
|
||||
|
||||
// Both editor PUT and debugger POST support the same explicit opt-out.
|
||||
for (const method of ['PUT', 'POST']) {
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'large.js'),
|
||||
'x'.repeat(1024 * 1024 + 1),
|
||||
);
|
||||
const payload = { filename: 'large.js', path: '', content: 'small' };
|
||||
const rejected = await call('/scripts', payload, method);
|
||||
assert.equal(rejected.status, 413);
|
||||
assert.equal(rejected.body.historyUnavailable, true);
|
||||
assert.equal(
|
||||
(await call('/scripts', { ...payload, skipHistory: true }, method))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{
|
||||
...payload,
|
||||
skipHistory: true,
|
||||
expectedHash: rejected.body.currentHash,
|
||||
},
|
||||
method,
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.data.historyRecorded, false);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'large.js'), 'utf8'),
|
||||
'small',
|
||||
);
|
||||
}
|
||||
await t.test(
|
||||
'save-as rejects paths outside writable roots before backup or history changes',
|
||||
async () => {
|
||||
const outside = path.join(root, 'outside');
|
||||
const sibling = `${scripts}-sibling`;
|
||||
for (const dir of [outside, sibling]) {
|
||||
await fs.mkdir(dir);
|
||||
await fs.writeFile(path.join(dir, 'source.js'), 'outside-source');
|
||||
await fs.writeFile(path.join(dir, 'destination.js'), 'outside-target');
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'boundary-source.js'),
|
||||
'safe-source',
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'boundary-target.js'),
|
||||
'safe-target',
|
||||
);
|
||||
await fs.symlink(outside, path.join(scripts, 'outside-link'));
|
||||
for (const escape of [
|
||||
'../outside',
|
||||
'../scripts-sibling',
|
||||
'outside-link',
|
||||
]) {
|
||||
for (const escapedSide of ['source', 'target']) {
|
||||
const result = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename:
|
||||
escapedSide === 'target'
|
||||
? `${escape}/destination.js`
|
||||
: 'boundary-target.js',
|
||||
originFilename:
|
||||
escapedSide === 'source'
|
||||
? `${escape}/source.js`
|
||||
: 'boundary-source.js',
|
||||
content: 'changed',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(result.body.code, 403);
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'boundary-source.js'), 'utf8'),
|
||||
'safe-source',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'boundary-target.js'), 'utf8'),
|
||||
'safe-target',
|
||||
);
|
||||
for (const dir of [outside, sibling]) {
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(dir, 'source.js'), 'utf8'),
|
||||
'outside-source',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(dir, 'destination.js'), 'utf8'),
|
||||
'outside-target',
|
||||
);
|
||||
}
|
||||
assert.deepEqual(await fs.readdir(config.bakPath), []);
|
||||
assert.equal(
|
||||
(await call('/scripts/history?filename=boundary-target.js')).body.data
|
||||
.versions.length,
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
for (const swappedSide of ['source', 'target']) {
|
||||
await t.test(
|
||||
`save-as rejects a ${swappedSide} symlink changed during real-path resolution`,
|
||||
async () => {
|
||||
const source = path.join(scripts, `${swappedSide}-race-source.js`);
|
||||
const destination = path.join(scripts, `${swappedSide}-race-target.js`);
|
||||
const outside = path.join(root, `${swappedSide}-race-outside.js`);
|
||||
await fs.writeFile(source, 'safe-source');
|
||||
await fs.writeFile(destination, 'safe-target');
|
||||
await fs.writeFile(outside, 'outside-sentinel');
|
||||
const swapped = swappedSide === 'source' ? source : destination;
|
||||
let swappedOnce = false;
|
||||
beforeRealpath = async (file) => {
|
||||
if (file === swapped && !swappedOnce) {
|
||||
swappedOnce = true;
|
||||
await fs.unlink(swapped);
|
||||
await fs.symlink(outside, swapped);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: path.basename(destination),
|
||||
originFilename: path.basename(source),
|
||||
content: 'changed',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(swappedOnce, true);
|
||||
assert.equal(result.status, 403);
|
||||
assert.equal(await fs.readFile(outside, 'utf8'), 'outside-sentinel');
|
||||
assert.equal(
|
||||
await fs.readFile(
|
||||
swappedSide === 'source' ? destination : source,
|
||||
'utf8',
|
||||
),
|
||||
swappedSide === 'source' ? 'safe-target' : 'safe-source',
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(config.bakPath), []);
|
||||
await fs.unlink(swapped);
|
||||
await fs.writeFile(
|
||||
swapped,
|
||||
swappedSide === 'source' ? 'safe-source' : 'safe-target',
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
`/scripts/history?filename=${path.basename(destination)}`,
|
||||
)
|
||||
).body.data.versions.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
beforeRealpath = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
await t.test(
|
||||
'save-as rechecks configuration secret names after resolving a changed source link',
|
||||
async () => {
|
||||
const secret = path.join(config.configPath, 'auth.json');
|
||||
const source = path.join(config.configPath, 'ordinary.js');
|
||||
const destination = path.join(scripts, 'config-race-target.js');
|
||||
await fs.writeFile(secret, 'config-secret');
|
||||
await fs.writeFile(source, 'ordinary-config');
|
||||
await fs.writeFile(destination, 'safe-target');
|
||||
let swapped = false;
|
||||
beforeRealpath = async (file) => {
|
||||
if (file === source && !swapped) {
|
||||
swapped = true;
|
||||
await fs.unlink(source);
|
||||
await fs.symlink(secret, source);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: path.basename(destination),
|
||||
originFilename: '../config/ordinary.js',
|
||||
content: 'changed',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(swapped, true);
|
||||
assert.equal(result.status, 403);
|
||||
assert.equal(await fs.readFile(secret, 'utf8'), 'config-secret');
|
||||
assert.equal(await fs.readFile(destination, 'utf8'), 'safe-target');
|
||||
assert.deepEqual(await fs.readdir(config.bakPath), []);
|
||||
assert.equal(
|
||||
(await call('/scripts/history?filename=config-race-target.js')).body
|
||||
.data.versions.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
beforeRealpath = undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
for (const scenario of ['target-link', 'source-link', 'normalized-path']) {
|
||||
await t.test(
|
||||
`save-as preserves aliases of the same file: ${scenario}`,
|
||||
async () => {
|
||||
const sourceName = `${scenario}-source.js`;
|
||||
const aliasName = `${scenario}-alias.js`;
|
||||
const sourcePath = path.join(scripts, sourceName);
|
||||
await fs.writeFile(sourcePath, 'source-original');
|
||||
if (scenario !== 'normalized-path')
|
||||
await fs.symlink(sourceName, path.join(scripts, aliasName));
|
||||
const filename =
|
||||
scenario === 'target-link'
|
||||
? aliasName
|
||||
: scenario === 'normalized-path'
|
||||
? `./${sourceName}`
|
||||
: sourceName;
|
||||
const originFilename =
|
||||
scenario === 'source-link' ? aliasName : sourceName;
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{ filename, originFilename, path: '', content: 'source-edited' },
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
assert.equal(saved.body.code, 200);
|
||||
assert.equal(await fs.readFile(sourcePath, 'utf8'), 'source-edited');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, filename), 'utf8'),
|
||||
'source-edited',
|
||||
);
|
||||
if (scenario !== 'normalized-path') {
|
||||
assert.equal(
|
||||
await fs.readlink(path.join(scripts, aliasName)),
|
||||
sourceName,
|
||||
);
|
||||
const sourceHistory = await call(
|
||||
`/scripts/history?filename=${sourceName}`,
|
||||
);
|
||||
const aliasHistory = await call(
|
||||
`/scripts/history?filename=${aliasName}`,
|
||||
);
|
||||
assert.deepEqual(aliasHistory.body.data, sourceHistory.body.data);
|
||||
}
|
||||
const list = await call(
|
||||
`/scripts/history?filename=${encodeURIComponent(filename)}`,
|
||||
);
|
||||
assert.equal(list.body.data.versions.length, 1);
|
||||
const preview = await call(
|
||||
`/scripts/history/detail?filename=${encodeURIComponent(
|
||||
filename,
|
||||
)}&id=${list.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(preview.body.data.version.content, 'source-original');
|
||||
assert.equal(preview.body.data.current, 'source-edited');
|
||||
},
|
||||
);
|
||||
}
|
||||
await t.test(
|
||||
'save-as backs up the source and retains destination history before removing the source',
|
||||
async () => {
|
||||
await fs.writeFile(path.join(scripts, 'source.js'), 'source-original');
|
||||
await fs.writeFile(
|
||||
path.join(scripts, 'destination.js'),
|
||||
'destination-original',
|
||||
);
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: 'destination.js',
|
||||
originFilename: 'source.js',
|
||||
path: '',
|
||||
content: 'source-edited',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 200);
|
||||
await assert.rejects(fs.access(path.join(scripts, 'source.js')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(config.bakPath, 'source.js'), 'utf8'),
|
||||
'source-original',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'destination.js'), 'utf8'),
|
||||
'source-edited',
|
||||
);
|
||||
const list = await call('/scripts/history?filename=destination.js');
|
||||
const preview = await call(
|
||||
`/scripts/history/detail?filename=destination.js&id=${list.body.data.versions[0].id}`,
|
||||
);
|
||||
assert.equal(preview.body.data.version.content, 'destination-original');
|
||||
},
|
||||
);
|
||||
for (const step of ['backup', 'history']) {
|
||||
await t.test(
|
||||
`save-as ${step} failure preserves both original scripts`,
|
||||
async () => {
|
||||
const sourceName = `${step}-source.js`,
|
||||
destinationName = `${step}-destination.js`;
|
||||
await fs.writeFile(path.join(scripts, sourceName), 'source-original');
|
||||
await fs.writeFile(
|
||||
path.join(scripts, destinationName),
|
||||
'destination-original',
|
||||
);
|
||||
failure = step;
|
||||
try {
|
||||
const saved = await call(
|
||||
'/scripts',
|
||||
{
|
||||
filename: destinationName,
|
||||
originFilename: sourceName,
|
||||
path: '',
|
||||
content: 'source-edited',
|
||||
},
|
||||
'POST',
|
||||
);
|
||||
assert.equal(saved.status, 500);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, sourceName), 'utf8'),
|
||||
'source-original',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, destinationName), 'utf8'),
|
||||
'destination-original',
|
||||
);
|
||||
assert.equal(
|
||||
(await call(`/scripts/history?filename=${destinationName}`)).body
|
||||
.data.versions.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
failure = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,383 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const source = path.join(__dirname, '../../back/shared/scriptHistory.ts');
|
||||
const mocks = {
|
||||
'../config/util': {
|
||||
fileExist: async (file) =>
|
||||
fs.access(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
},
|
||||
};
|
||||
const { ScriptHistory, SCRIPT_HISTORY_LIMITS } = load(source, mocks);
|
||||
|
||||
async function fixture(t, limits = {}, History = ScriptHistory) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-'));
|
||||
t.after(() => fs.rm(root, { force: true, recursive: true }));
|
||||
const scripts = path.join(root, 'scripts');
|
||||
const history = path.join(root, 'history');
|
||||
await fs.mkdir(scripts);
|
||||
const file = path.join(scripts, 'test.js');
|
||||
await fs.writeFile(file, 'original\n');
|
||||
const service = new History(scripts, history, ['auth.json'], {
|
||||
...SCRIPT_HISTORY_LIMITS,
|
||||
...limits,
|
||||
});
|
||||
return { root, scripts, history, file, service };
|
||||
}
|
||||
|
||||
test('first save, unchanged save, restore and undo restore preserve exact content', async (t) => {
|
||||
const { service, file, history } = await fixture(t);
|
||||
assert.deepEqual((await service.list('', 'test.js')).versions, []);
|
||||
await assert.rejects(fs.stat(history), { code: 'ENOENT' });
|
||||
await service.save('', 'test.js', 'second\r\n中文');
|
||||
let list = await service.list('', 'test.js');
|
||||
assert.equal(list.versions.length, 1);
|
||||
assert.equal(list.versions[0].source, 'baseline');
|
||||
assert.equal(
|
||||
(await service.detail('', 'test.js', list.versions[0].id)).version.content,
|
||||
'original\n',
|
||||
);
|
||||
const archive = path.join(history, (await fs.readdir(history))[0]);
|
||||
const before = await fs.readFile(archive);
|
||||
await service.save('', 'test.js', 'second\r\n中文');
|
||||
assert.deepEqual(
|
||||
await fs.readFile(archive),
|
||||
before,
|
||||
'unchanged save performs no archive write',
|
||||
);
|
||||
await service.restore('', 'test.js', list.versions[0].id, list.currentHash);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
|
||||
list = await service.list('', 'test.js');
|
||||
assert.equal(list.versions[0].source, 'save');
|
||||
await service.restore('', 'test.js', list.versions[0].id, list.currentHash);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'second\r\n中文');
|
||||
assert.equal(
|
||||
(await service.list('', 'test.js')).versions[0].source,
|
||||
'restore',
|
||||
);
|
||||
});
|
||||
|
||||
test('conflict check refuses to overwrite a file changed after preview, including external edits', async (t) => {
|
||||
const { service, file } = await fixture(t);
|
||||
await service.save('', 'test.js', 'saved');
|
||||
const list = await service.list('', 'test.js');
|
||||
await fs.writeFile(file, 'external');
|
||||
await assert.rejects(
|
||||
service.restore('', 'test.js', list.versions[0].id, list.currentHash),
|
||||
{ status: 409 },
|
||||
);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'external');
|
||||
await service.save('', 'test.js', 'after external');
|
||||
const current = await service.list('', 'test.js');
|
||||
assert.equal(current.versions[0].source, 'external');
|
||||
assert.equal(
|
||||
(await service.detail('', 'test.js', current.versions[0].id)).version
|
||||
.content,
|
||||
'external',
|
||||
);
|
||||
});
|
||||
|
||||
test('same-name files are isolated, traversal, blacklist and escaping symlinks are forbidden', async (t) => {
|
||||
const { service, scripts, root } = await fixture(t);
|
||||
await fs.mkdir(path.join(scripts, 'nested'));
|
||||
await fs.writeFile(path.join(scripts, 'nested', 'test.js'), 'nested');
|
||||
await service.save('nested', 'test.js', 'changed');
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 0);
|
||||
await fs.writeFile(path.join(root, 'outside.js'), 'secret');
|
||||
await fs.symlink(
|
||||
path.join(root, 'outside.js'),
|
||||
path.join(scripts, 'escape.js'),
|
||||
);
|
||||
for (const filename of ['../outside.js', 'auth.json', 'escape.js']) {
|
||||
await assert.rejects(service.save('', filename, 'bad'), { status: 403 });
|
||||
await assert.rejects(service.list('', filename), { status: 403 });
|
||||
}
|
||||
await fs.symlink(
|
||||
path.join(scripts, 'test.js'),
|
||||
path.join(scripts, 'alias.js'),
|
||||
);
|
||||
await service.save('', 'alias.js', 'via alias');
|
||||
assert.equal(
|
||||
(await service.list('', 'test.js')).versions.length,
|
||||
1,
|
||||
'safe aliases share one canonical history',
|
||||
);
|
||||
});
|
||||
|
||||
test('concurrent saves across service instances serialize snapshots without losing content', async (t) => {
|
||||
const { service, scripts, history, file } = await fixture(t);
|
||||
const other = new ScriptHistory(scripts, history);
|
||||
await Promise.all([
|
||||
service.save('', 'test.js', 'A'),
|
||||
other.save('', 'test.js', 'B'),
|
||||
]);
|
||||
const list = await service.list('', 'test.js');
|
||||
const contents = await Promise.all(
|
||||
list.versions.map((v) =>
|
||||
service.detail('', 'test.js', v.id).then((d) => d.version.content),
|
||||
),
|
||||
);
|
||||
contents.push(await fs.readFile(file, 'utf8'));
|
||||
assert.deepEqual(contents.sort(), ['A', 'B', 'original\n'].sort());
|
||||
});
|
||||
|
||||
test('retention trims old revisions and bounds file count and global compressed storage', async (t) => {
|
||||
const { service, scripts, history } = await fixture(t, {
|
||||
versions: 3,
|
||||
files: 2,
|
||||
totalBytes: 1800,
|
||||
});
|
||||
for (let i = 0; i < 6; i++) await service.save('', 'test.js', `version ${i}`);
|
||||
const list = await service.list('', 'test.js');
|
||||
assert.equal(list.versions.length, 3);
|
||||
assert.equal(
|
||||
(await service.detail('', 'test.js', list.versions.at(-1).id)).version
|
||||
.content,
|
||||
'version 2',
|
||||
);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const name = `${i}.js`;
|
||||
await fs.writeFile(
|
||||
path.join(scripts, name),
|
||||
randomBytes(300).toString('hex'),
|
||||
);
|
||||
await service.save('', name, 'new');
|
||||
}
|
||||
const files = await fs.readdir(history);
|
||||
assert.ok(files.length <= 2);
|
||||
let total = 0;
|
||||
for (const name of files)
|
||||
total += (await fs.stat(path.join(history, name))).size;
|
||||
assert.ok(total <= 1800);
|
||||
assert.equal((await service.list('', '4.js')).versions.length, 1);
|
||||
});
|
||||
|
||||
test('invalid text, oversized files and corrupted archives fail without overwriting scripts', async (t) => {
|
||||
const { service, file, history } = await fixture(t, { fileBytes: 100 });
|
||||
await assert.rejects(service.save('', 'test.js', 'a'.repeat(101)), {
|
||||
status: 413,
|
||||
});
|
||||
await fs.writeFile(file, Buffer.from([0xff, 0xfe]));
|
||||
await assert.rejects(service.save('', 'test.js', 'new'), { status: 413 });
|
||||
await fs.writeFile(file, 'before');
|
||||
await service.save('', 'test.js', 'after');
|
||||
await fs.writeFile(
|
||||
path.join(history, (await fs.readdir(history))[0]),
|
||||
'corrupt',
|
||||
);
|
||||
await assert.rejects(service.save('', 'test.js', 'replacement'), {
|
||||
status: 500,
|
||||
});
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'after');
|
||||
});
|
||||
|
||||
test('archive failure prevents overwrite; file-write failure retains snapshot and releases locks', async (t) => {
|
||||
let fail = 'archive';
|
||||
const { ScriptHistory: FailingHistory } = load(source, {
|
||||
...mocks,
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
writeFile: async (file, ...args) => {
|
||||
if (fail === 'archive' && String(file).endsWith('.pending.tmp')) {
|
||||
throw Object.assign(new Error('disk full'), { code: 'ENOSPC' });
|
||||
}
|
||||
return fs.writeFile(file, ...args);
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
if (fail === 'script' && String(to).endsWith('test.js'))
|
||||
throw Object.assign(new Error('commit failed'), { code: 'ENOSPC' });
|
||||
return fs.rename(from, to);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { service, file } = await fixture(t, {}, FailingHistory);
|
||||
await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' });
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
|
||||
fail = 'script';
|
||||
await assert.rejects(service.save('', 'test.js', 'new'), { code: 'ENOSPC' });
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 0);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'original\n');
|
||||
fail = '';
|
||||
await service.save('', 'test.js', 'success');
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'success');
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 1);
|
||||
});
|
||||
|
||||
test('empty content is a valid recoverable version and missing versions return 404', async (t) => {
|
||||
const { service, file } = await fixture(t);
|
||||
await service.save('', 'test.js', '');
|
||||
await service.save('', 'test.js', 'again');
|
||||
const list = await service.list('', 'test.js');
|
||||
const empty = list.versions[0];
|
||||
assert.equal(empty.size, 0);
|
||||
await service.restore('', 'test.js', empty.id, list.currentHash);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), '');
|
||||
await assert.rejects(service.detail('', 'test.js', 'unknown'), {
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
|
||||
test('quota eviction never happens on failed preparation or failed commit', async (t) => {
|
||||
let failure = '';
|
||||
const { ScriptHistory: Faulty } = load(source, {
|
||||
...mocks,
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
writeFile: async (file, ...args) => {
|
||||
if (
|
||||
failure === 'archive-write' &&
|
||||
String(file).endsWith('.pending.tmp')
|
||||
)
|
||||
throw Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
if (
|
||||
failure === 'script-write' &&
|
||||
path.basename(file).startsWith('.ql-save-')
|
||||
)
|
||||
throw Object.assign(new Error('full'), { code: 'ENOSPC' });
|
||||
return fs.writeFile(file, ...args);
|
||||
},
|
||||
rename: async (from, to) => {
|
||||
if (failure === 'archive-commit' && String(to).endsWith('.br'))
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
if (failure === 'script-commit' && String(to).endsWith('.js'))
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return fs.rename(from, to);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty);
|
||||
await service.save('', 'test.js', 'saved');
|
||||
const archive = path.join(history, (await fs.readdir(history))[0]);
|
||||
const original = await fs.readFile(archive);
|
||||
await fs.writeFile(path.join(scripts, 'other.js'), 'other-original');
|
||||
for (const step of [
|
||||
'archive-write',
|
||||
'script-write',
|
||||
'archive-commit',
|
||||
'script-commit',
|
||||
]) {
|
||||
failure = step;
|
||||
await assert.rejects(service.save('', 'other.js', 'other-changed'), {
|
||||
code: step.endsWith('write') ? 'ENOSPC' : 'EACCES',
|
||||
});
|
||||
assert.deepEqual(await fs.readFile(archive), original, step);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'other.js'), 'utf8'),
|
||||
'other-original',
|
||||
);
|
||||
assert.equal((await fs.readdir(history)).length, 1);
|
||||
assert.equal(
|
||||
(await fs.readdir(scripts)).length,
|
||||
2,
|
||||
'no script staging leftovers',
|
||||
);
|
||||
}
|
||||
// Updating an existing history also restores its exact prior bytes if the
|
||||
// script rename fails, including revisions that retention would have pruned.
|
||||
failure = 'script-commit';
|
||||
await assert.rejects(service.save('', 'test.js', 'changed'), {
|
||||
code: 'EACCES',
|
||||
});
|
||||
assert.deepEqual(await fs.readFile(archive), original);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'test.js'), 'utf8'),
|
||||
'saved',
|
||||
);
|
||||
failure = '';
|
||||
await service.save('', 'other.js', 'other-changed');
|
||||
assert.equal((await service.list('', 'test.js')).versions.length, 0);
|
||||
});
|
||||
|
||||
test('cleanup errors report a successful save with a warning and are retried on the next save', async (t) => {
|
||||
let failure = false;
|
||||
const { ScriptHistory: Faulty } = load(source, {
|
||||
...mocks,
|
||||
'fs/promises': {
|
||||
...fs,
|
||||
unlink: async (file) => {
|
||||
if (failure && String(file).endsWith('.br'))
|
||||
throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return fs.unlink(file);
|
||||
},
|
||||
},
|
||||
});
|
||||
const { service, scripts, history } = await fixture(t, { files: 1 }, Faulty);
|
||||
await service.save('', 'test.js', 'saved');
|
||||
await fs.writeFile(path.join(scripts, 'other.js'), 'old');
|
||||
failure = true;
|
||||
const result = await service.save('', 'other.js', 'new');
|
||||
assert.equal(result.cleanupPending, true);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(scripts, 'other.js'), 'utf8'),
|
||||
'new',
|
||||
);
|
||||
failure = false;
|
||||
await service.save('', 'other.js', 'next');
|
||||
assert.equal((await fs.readdir(history)).length, 1);
|
||||
});
|
||||
|
||||
test('large files can be reduced only after explicit historyless confirmation with a matching hash', async (t) => {
|
||||
const { service, file } = await fixture(t, { fileBytes: 100 });
|
||||
await fs.writeFile(file, 'x'.repeat(101));
|
||||
let confirmation;
|
||||
try {
|
||||
await service.save('', 'test.js', 'small');
|
||||
} catch (error) {
|
||||
confirmation = error;
|
||||
}
|
||||
assert.equal(confirmation.status, 413);
|
||||
assert.match(confirmation.currentHash, /^[a-f0-9]{64}$/);
|
||||
assert.equal((await fs.stat(file)).size, 101);
|
||||
await assert.rejects(
|
||||
service.save('', 'test.js', 'small', {
|
||||
skipHistory: true,
|
||||
expectedHash: '0'.repeat(64),
|
||||
}),
|
||||
{ status: 409 },
|
||||
);
|
||||
const result = await service.save('', 'test.js', 'small', {
|
||||
skipHistory: true,
|
||||
expectedHash: confirmation.currentHash,
|
||||
});
|
||||
assert.equal(result.historyRecorded, false);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'small');
|
||||
await service.save('', 'test.js', 'normal');
|
||||
assert.equal(
|
||||
(await service.list('', 'test.js')).versions.length,
|
||||
1,
|
||||
'normal history resumes',
|
||||
);
|
||||
try {
|
||||
await service.save('', 'test.js', 'x'.repeat(101));
|
||||
} catch (error) {
|
||||
confirmation = error;
|
||||
}
|
||||
await fs.writeFile(file, 'someone else changed it');
|
||||
await assert.rejects(
|
||||
service.save('', 'test.js', 'x'.repeat(101), {
|
||||
skipHistory: true,
|
||||
expectedHash: confirmation.currentHash,
|
||||
}),
|
||||
{ status: 409 },
|
||||
);
|
||||
});
|
||||
|
||||
test('atomic saves preserve executable mode and canonical symlink targets', async (t) => {
|
||||
const { service, scripts, file } = await fixture(t);
|
||||
await fs.chmod(file, 0o755);
|
||||
await fs.symlink(file, path.join(scripts, 'alias.js'));
|
||||
await service.save('', 'alias.js', 'new');
|
||||
assert.equal((await fs.stat(file)).mode & 0o777, 0o755);
|
||||
assert.equal(
|
||||
(await fs.lstat(path.join(scripts, 'alias.js'))).isSymbolicLink(),
|
||||
true,
|
||||
);
|
||||
assert.equal(await fs.readFile(file, 'utf8'), 'new');
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
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 { spawnSync } = require('node:child_process');
|
||||
const source = fs
|
||||
.readFileSync(path.resolve('shell/api.sh'), 'utf8')
|
||||
.replace(/\nget_token\s*$/, '\n');
|
||||
const realJq = spawnSync('which', ['jq'], { encoding: 'utf8' }).stdout.trim();
|
||||
function fixture(t) {
|
||||
assert.ok(realJq, 'jq is required');
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-api-parse-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const bin = path.join(root, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'jq'),
|
||||
'#!/bin/bash\nprintf "call\\n" >> "$JQ_CALLS"\nexec "$REAL_JQ" "$@"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const token = path.join(root, 'token.json'),
|
||||
response = path.join(root, 'response.json'),
|
||||
calls = path.join(root, 'calls');
|
||||
const run = (code) =>
|
||||
spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-euc',
|
||||
source +
|
||||
'\ncreate_token(){ __ql_token__=generated; generated=1; }; curl(){ cat "$RESPONSE_FILE"; };\n' +
|
||||
code,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: bin + ':' + process.env.PATH,
|
||||
REAL_JQ: realJq,
|
||||
JQ_CALLS: calls,
|
||||
file_auth_token: token,
|
||||
RESPONSE_FILE: response,
|
||||
ql_port: '5700',
|
||||
__ql_token__: 'test-token',
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
token,
|
||||
response,
|
||||
run,
|
||||
count: () =>
|
||||
fs.existsSync(calls)
|
||||
? fs.readFileSync(calls, 'utf8').trim().split('\n').length
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
test('valid token cache is read with one jq and reused without generation', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(
|
||||
f.token,
|
||||
JSON.stringify({
|
||||
value: 'header.payload.signature',
|
||||
expiration: 4102444800,
|
||||
}),
|
||||
);
|
||||
const r = f.run(
|
||||
'get_token; printf "%s:%s" "$__ql_token__" "${generated:-0}"',
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, 'header.payload.signature:0');
|
||||
assert.equal(f.count(), 1);
|
||||
});
|
||||
test('expired, corrupt, missing or header-unsafe token cache refreshes under errexit', (t) => {
|
||||
const f = fixture(t);
|
||||
for (const content of [
|
||||
undefined,
|
||||
'broken',
|
||||
'{}',
|
||||
JSON.stringify({ value: 'old', expiration: 1 }),
|
||||
JSON.stringify({ value: 'old', expiration: '4102444800' }),
|
||||
JSON.stringify({ value: '', expiration: 4102444800 }),
|
||||
JSON.stringify({ value: 'bad\ninjected', expiration: 4102444800 }),
|
||||
JSON.stringify({ value: 'bad\n', expiration: 4102444800 }),
|
||||
JSON.stringify({ value: 'bad\rinjected', expiration: 4102444800 }),
|
||||
]) {
|
||||
if (content === undefined) fs.rmSync(f.token, { force: true });
|
||||
else fs.writeFileSync(f.token, content);
|
||||
const r = f.run('get_token; printf "%s:%s" "$__ql_token__" "$generated"');
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, 'generated:1');
|
||||
}
|
||||
});
|
||||
test('success with a message checks authentication and stays silent', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(f.response, JSON.stringify({ code: 200, message: 'ok' }));
|
||||
const r = f.run('update_cron 1 0 123 log 1');
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, '');
|
||||
assert.equal(f.count(), 2);
|
||||
});
|
||||
test('status and statistics errors preserve multiline Unicode messages', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(
|
||||
f.response,
|
||||
JSON.stringify({ code: 500, message: '写入失败\n请重试 "原任务"' }),
|
||||
);
|
||||
for (const command of [
|
||||
'update_cron 1 1 123 log 1 2 7',
|
||||
'record_cron_stat 1 7 2',
|
||||
]) {
|
||||
const r = f.run(command);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, '写入失败\n请重试 "原任务"\n');
|
||||
}
|
||||
assert.equal(f.count(), 4);
|
||||
});
|
||||
test('non-JSON errors fall back to raw text and absent messages retain legacy null', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(f.response, 'upstream unavailable');
|
||||
let r = f.run('update_cron 1 0 123 log 1');
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(r.stdout, 'upstream unavailable\n');
|
||||
fs.writeFileSync(f.response, '{"code":500}');
|
||||
r = f.run('record_cron_stat 1 7 2');
|
||||
assert.equal(r.status, 0);
|
||||
assert.equal(r.stdout, 'null\n');
|
||||
});
|
||||
test('statistics without a task id perform no JSON parsing', (t) => {
|
||||
const f = fixture(t);
|
||||
const r = f.run('record_cron_stat "" 0 1 || true');
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(f.count(), 0);
|
||||
});
|
||||
|
||||
test('canonical success resets prior errors and matches jq missing-message semantics', (t) => {
|
||||
const f = fixture(t);
|
||||
fs.writeFileSync(f.response, '{"code":200}');
|
||||
const r = f.run(
|
||||
'code=500; message=old; ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"; record_cron_stat 1 0 1',
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, '200|null');
|
||||
assert.equal(f.count(), 0);
|
||||
});
|
||||
|
||||
test('noncanonical JSON always uses jq, including whitespace, duplicate keys and embedded success text', (t) => {
|
||||
const f = fixture(t);
|
||||
const cases = [
|
||||
[' {"code":200} ', '200|null'],
|
||||
['{"code": 200}', '200|null'],
|
||||
['{"code":200,"message":"完成\\n第二行"}', '200|完成\n第二行'],
|
||||
['{"code":200,"data":{"code":500}}', '200|null'],
|
||||
['{"code":"200"}', '200|null'],
|
||||
['{"code":200,"code":500,"message":"error"}', '500|error'],
|
||||
['{"code":500,"message":"{\\"code\\":200}"}', '500|{"code":200}'],
|
||||
['{"code":500}', '500|null'],
|
||||
];
|
||||
for (const [body, expected] of cases) {
|
||||
fs.writeFileSync(f.response, body);
|
||||
const r = f.run(
|
||||
'ql_parse_status_response "$(cat "$RESPONSE_FILE")"; printf "%s|%s" "$code" "$message"',
|
||||
);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, expected);
|
||||
}
|
||||
assert.equal(f.count(), cases.length);
|
||||
});
|
||||
|
||||
test('success-shaped invalid documents retain the raw-response error fallback', (t) => {
|
||||
const f = fixture(t);
|
||||
const cases = [
|
||||
'{"code":200}trailing',
|
||||
'{"code":200',
|
||||
'<html>{"code":200}</html>',
|
||||
];
|
||||
for (const body of cases) {
|
||||
fs.writeFileSync(f.response, body);
|
||||
const r = f.run('update_cron 1 0 123 log 1');
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, body + '\n');
|
||||
}
|
||||
assert.equal(f.count(), cases.length * 2);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
|
||||
test('shell translations print leading dashes, placeholders and newlines', () => {
|
||||
const result = spawnSync('bash', ['-c', String.raw`
|
||||
source shell/share.sh
|
||||
t '---> 服务诊断信息'
|
||||
t '---> 最近的系统日志: %s' '/tmp/test.log'
|
||||
t 'first\nsecond'
|
||||
`], { encoding: 'utf8', env: { ...process.env, QL_DIR: process.cwd() } });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(result.stdout,
|
||||
'---> 服务诊断信息\n---> 最近的系统日志: /tmp/test.log\nfirst\nsecond\n');
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
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, execFileSync } = require('node:child_process');
|
||||
const {
|
||||
writeManifest,
|
||||
sourceCommit,
|
||||
} = require('../helpers/upgrade-fixture.cjs');
|
||||
const { waitForHealth, withUpgradeLock } = require('../../shell/upgrade.cjs');
|
||||
|
||||
const share = `
|
||||
export dir_root="$QL_DIR" dir_shell="$QL_DIR/shell" dir_tmp="$QL_DIR/.tmp"
|
||||
export dir_data="$QL_DIR/data" dir_static="$QL_DIR/static" dir_log="$QL_DIR/data/log" dir_config="$QL_DIR/data/config"
|
||||
load_ql_envs() { :; }
|
||||
import_config() { :; }
|
||||
fix_config() { :; }
|
||||
make_dir() { mkdir -p "$1"; }
|
||||
format_timestamp() { date +%s; }
|
||||
format_time() { printf '%s' "$2"; }
|
||||
t() { printf "$@"; }
|
||||
npm_install_2() {
|
||||
printf installed > "$QL_DIR/installed"
|
||||
exit_status="$DEP_STATUS"
|
||||
mkdir -p "$1/node_modules"
|
||||
printf new > "$1/node_modules/dependency"
|
||||
}
|
||||
delete_pm2() { printf stopped >> "$QL_DIR/stops"; }
|
||||
reload_pm2() {
|
||||
printf started >> "$QL_DIR/starts"
|
||||
if [[ "$FAIL_START" == 'true' ]] && [[ ! -f "$QL_DIR/failed-once" ]]; then
|
||||
touch "$QL_DIR/failed-once"; return 1
|
||||
fi
|
||||
}
|
||||
`;
|
||||
|
||||
function write(root, name, content) {
|
||||
const filename = path.join(root, name);
|
||||
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
||||
fs.writeFileSync(filename, content);
|
||||
return filename;
|
||||
}
|
||||
|
||||
function run(program, args, options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cp = spawn(program, args, {
|
||||
...options,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '',
|
||||
stderr = '';
|
||||
cp.stdout.on('data', (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
cp.stderr.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
cp.on('error', reject);
|
||||
cp.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
async function fixture(t, options = {}) {
|
||||
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-shell-upgrade-'));
|
||||
t.after(() => fs.rmSync(base, { recursive: true, force: true }));
|
||||
const root = path.join(base, 'panel'),
|
||||
payload = path.join(base, 'payload'),
|
||||
archives = path.join(base, 'archives');
|
||||
const branch = ['develop', 'debian-dev'].includes(options.branch)
|
||||
? 'develop'
|
||||
: 'master';
|
||||
const source = path.join(payload, `qinglong-${sourceCommit}`),
|
||||
staticRoot = path.join(payload, `qinglong-static-${branch}`);
|
||||
const packageJson = '{"version":"2.22.0"}';
|
||||
for (const target of [root, source]) {
|
||||
write(target, 'shell/update.sh', fs.readFileSync('shell/update.sh'));
|
||||
write(target, 'shell/upgrade.cjs', fs.readFileSync('shell/upgrade.cjs'));
|
||||
write(
|
||||
target,
|
||||
'cli/src/internal/maintenance/upgradeArtifacts.cjs',
|
||||
fs.readFileSync('cli/src/internal/maintenance/upgradeArtifacts.cjs'),
|
||||
);
|
||||
write(target, 'shell/share.sh', share);
|
||||
write(target, 'shell/api.sh', '');
|
||||
write(target, 'shell/env.sh', '');
|
||||
write(target, 'sample/config.sample.sh', 'sample');
|
||||
write(target, 'package.json', packageJson);
|
||||
write(
|
||||
target,
|
||||
'pnpm-lock.yaml',
|
||||
target === source && options.lockChange ? 'new lock' : 'old lock',
|
||||
);
|
||||
write(target, 'back/app.ts', target === source ? 'new' : 'old');
|
||||
write(
|
||||
target,
|
||||
'version.yaml',
|
||||
target === source ? 'version: 2.22.0' : 'version: 2.21.0',
|
||||
);
|
||||
}
|
||||
write(root, 'node_modules/dependency', 'old');
|
||||
write(root, '.env', 'private setting');
|
||||
write(root, 'data/config/config.sh', 'custom');
|
||||
write(root, 'static/build/app.js', 'old backend');
|
||||
write(root, 'static/dist/index.html', 'old frontend');
|
||||
write(root, '.tmp/import-in-progress', 'keep');
|
||||
write(staticRoot, 'build/app.js', 'new backend');
|
||||
write(staticRoot, 'dist/index.html', '<script src="umi.js"></script>');
|
||||
write(staticRoot, 'dist/umi.js', 'dashboard');
|
||||
write(source, '.env.example', 'new example');
|
||||
writeManifest(source, staticRoot);
|
||||
const manifestFile = path.join(staticRoot, 'build-info.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestFile));
|
||||
if (options.manifest === 'missing') fs.unlinkSync(manifestFile);
|
||||
else if (options.manifest === 'dirty')
|
||||
fs.writeFileSync(
|
||||
manifestFile,
|
||||
JSON.stringify({ ...manifest, dirty: true }),
|
||||
);
|
||||
else if (options.manifest === 'lock')
|
||||
fs.writeFileSync(
|
||||
manifestFile,
|
||||
JSON.stringify({ ...manifest, lockfileSha256: '0'.repeat(64) }),
|
||||
);
|
||||
else if (options.manifest === 'commit')
|
||||
fs.writeFileSync(
|
||||
manifestFile,
|
||||
JSON.stringify({ ...manifest, sourceCommit: 'b'.repeat(40) }),
|
||||
);
|
||||
else if (options.manifest === 'checksum')
|
||||
write(staticRoot, 'dist/umi.js', 'stale frontend');
|
||||
else if (options.manifest === 'extra')
|
||||
write(staticRoot, 'build/stale.js', 'stale backend');
|
||||
else if (options.manifest === 'frontend')
|
||||
fs.unlinkSync(path.join(staticRoot, 'dist/index.html'));
|
||||
fs.mkdirSync(archives);
|
||||
for (const repo of ['qinglong', 'qinglong-static']) {
|
||||
const archive = path.join(archives, `${repo}.zip`);
|
||||
execFileSync(
|
||||
'zip',
|
||||
[
|
||||
'-qr',
|
||||
archive,
|
||||
`${repo}-${repo === 'qinglong' ? sourceCommit : branch}`,
|
||||
],
|
||||
{ cwd: payload },
|
||||
);
|
||||
if (options.corrupt === repo) fs.writeFileSync(archive, 'invalid zip');
|
||||
}
|
||||
const curl = write(
|
||||
base,
|
||||
'bin/curl',
|
||||
`#!${process.execPath}
|
||||
const fs=require('node:fs'),path=require('node:path'),args=process.argv.slice(2);
|
||||
if(args.includes('--head')) process.exit(7);
|
||||
const repo=args.at(-1).includes('/qinglong-static/')?'qinglong-static':'qinglong';
|
||||
fs.appendFileSync(process.env.TRACE,args.at(-1)+'\\n');
|
||||
if(process.env.FAIL_DOWNLOAD===repo) process.exit(22);
|
||||
fs.copyFileSync(path.join(process.env.ARCHIVES,repo+'.zip'),args[args.indexOf('--output')+1]);
|
||||
`,
|
||||
);
|
||||
fs.chmodSync(curl, 0o755);
|
||||
const server = http.createServer((req, res) =>
|
||||
res.end('{"code":200,"data":{"status":"ok"}}'),
|
||||
);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
const env = {
|
||||
...process.env,
|
||||
QL_DIR: root,
|
||||
QL_BRANCH: options.branch || 'master',
|
||||
PATH: `${path.join(base, 'bin')}:${process.env.PATH}`,
|
||||
QlPort: String(server.address().port),
|
||||
ARCHIVES: archives,
|
||||
TRACE: path.join(base, 'trace'),
|
||||
real_time: options.tee ? 'false' : 'true',
|
||||
FAIL_DOWNLOAD: options.download || '',
|
||||
DEP_STATUS: options.depFail ? '1' : '0',
|
||||
FAIL_START: options.failStart ? 'true' : 'false',
|
||||
};
|
||||
return {
|
||||
root,
|
||||
env,
|
||||
execute: (...args) =>
|
||||
run('bash', [path.join(root, 'shell/update.sh'), ...args], { env }),
|
||||
read: (name) => fs.readFileSync(path.join(root, name), 'utf8'),
|
||||
};
|
||||
}
|
||||
|
||||
for (const options of [
|
||||
{ download: 'qinglong' },
|
||||
{ download: 'qinglong-static', tee: true },
|
||||
{ corrupt: 'qinglong' },
|
||||
{ corrupt: 'qinglong-static', tee: true },
|
||||
...[
|
||||
'missing',
|
||||
'dirty',
|
||||
'lock',
|
||||
'commit',
|
||||
'checksum',
|
||||
'extra',
|
||||
'frontend',
|
||||
].map((manifest) => ({ manifest })),
|
||||
{ lockChange: true, depFail: true, tee: true },
|
||||
])
|
||||
test(`failed update preserves the running panel: ${JSON.stringify(
|
||||
options,
|
||||
)}`, async (t) => {
|
||||
const f = await fixture(t, options);
|
||||
const result = await f.execute('update');
|
||||
assert.notEqual(result.code, 0, result.stdout + result.stderr);
|
||||
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
|
||||
assert.equal(f.read('node_modules/dependency'), 'old');
|
||||
assert.equal(f.read('static/dist/index.html'), 'old frontend');
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.equal(f.read('.tmp/import-in-progress'), 'keep');
|
||||
assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [
|
||||
'import-in-progress',
|
||||
]);
|
||||
});
|
||||
|
||||
for (const branch of ['master', 'develop', 'debian', 'debian-dev'])
|
||||
test(`download-only then reload installs matching artifacts and lockfile dependencies: ${branch}`, async (t) => {
|
||||
const f = await fixture(t, { branch, lockChange: true, tee: true });
|
||||
let result = await f.execute('update', 'false');
|
||||
assert.equal(result.code, 0, result.stdout + result.stderr);
|
||||
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
|
||||
assert.match(
|
||||
fs.readFileSync(f.env.TRACE, 'utf8'),
|
||||
new RegExp(`qinglong/repository/archive/${sourceCommit}\\.zip`),
|
||||
);
|
||||
result = await f.execute('reload', 'system');
|
||||
assert.equal(result.code, 0, result.stdout + result.stderr);
|
||||
assert.equal(f.read('node_modules/dependency'), 'new');
|
||||
assert.equal(f.read('pnpm-lock.yaml'), 'new lock');
|
||||
assert.equal(f.read('static/dist/umi.js'), 'dashboard');
|
||||
assert.equal(f.read('back/app.ts'), 'new');
|
||||
assert.equal(f.read('.env'), 'private setting');
|
||||
assert.equal(f.read('.env.example'), 'new example');
|
||||
assert.equal(f.read('data/config/config.sh'), 'custom');
|
||||
assert.deepEqual(fs.readdirSync(path.join(f.root, '.tmp')), [
|
||||
'import-in-progress',
|
||||
]);
|
||||
assert.ok(
|
||||
!fs.readdirSync(f.root).some((name) => name.includes('ql-backup')),
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed start rolls code, frontend and node_modules back and exits nonzero through tee', async (t) => {
|
||||
const f = await fixture(t, { lockChange: true, failStart: true, tee: true });
|
||||
const result = await f.execute('update');
|
||||
assert.notEqual(result.code, 0);
|
||||
assert.equal(f.read('version.yaml'), 'version: 2.21.0');
|
||||
assert.equal(f.read('node_modules/dependency'), 'old');
|
||||
assert.equal(f.read('static/dist/index.html'), 'old frontend');
|
||||
assert.equal(f.read('starts'), 'startedstarted');
|
||||
assert.ok(!fs.readdirSync(f.root).some((name) => name.includes('ql-backup')));
|
||||
});
|
||||
|
||||
test('reload revalidates staged files before stopping the old panel', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await f.execute('update', 'false')).code, 0);
|
||||
const directory = JSON.parse(
|
||||
f.read('.tmp/upgrade-ready-master.json'),
|
||||
).directory;
|
||||
write(
|
||||
f.root,
|
||||
`.tmp/${directory}/qinglong-static-master/dist/umi.js`,
|
||||
'corrupt',
|
||||
);
|
||||
assert.notEqual((await f.execute('reload', 'system')).code, 0);
|
||||
assert.equal(fs.existsSync(path.join(f.root, 'stops')), false);
|
||||
});
|
||||
|
||||
test('a new successful stage retires only the previous ready workspace', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal((await f.execute('update', 'false')).code, 0);
|
||||
const previous = JSON.parse(
|
||||
f.read('.tmp/upgrade-ready-master.json'),
|
||||
).directory;
|
||||
assert.equal((await f.execute('update', 'false')).code, 0);
|
||||
const current = JSON.parse(
|
||||
f.read('.tmp/upgrade-ready-master.json'),
|
||||
).directory;
|
||||
assert.notEqual(previous, current);
|
||||
assert.equal(fs.existsSync(path.join(f.root, '.tmp', previous)), false);
|
||||
assert.equal(f.read('.tmp/import-in-progress'), 'keep');
|
||||
});
|
||||
|
||||
test('failed health checks and concurrent upgrades are rejected', async (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-upgrade-lock-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
await withUpgradeLock(tmp, async () => {
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, async () => {}),
|
||||
/Another upgrade/,
|
||||
);
|
||||
});
|
||||
assert.deepEqual(fs.readdirSync(tmp), []);
|
||||
const server = http.createServer((req, res) => {
|
||||
res.statusCode = 503;
|
||||
res.end('{"code":503}');
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
await assert.rejects(
|
||||
waitForHealth({ QlPort: String(server.address().port) }, 20),
|
||||
/health check/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const apiFile = path.resolve(__dirname, '../../shell/api.sh');
|
||||
|
||||
const endpoints = {
|
||||
status: {
|
||||
method: 'PUT', url: /\/open\/crons\/status\?t=\d+$/,
|
||||
body: { ids: ['37'], status: '1', pid: '4321', log_path: 'task/run.log',
|
||||
last_execution_time: 1787004000, last_running_time: 90, exit_code: 0 },
|
||||
success: '', failure: (message) => message,
|
||||
},
|
||||
statistics: {
|
||||
method: 'POST', url: /\/open\/dashboard\/record$/,
|
||||
body: { ref_id: 37, code: 0, elapsed: 90 },
|
||||
success: '', failure: (message) => message,
|
||||
},
|
||||
add: {
|
||||
method: 'POST', url: /\/open\/crons\?t=\d+$/,
|
||||
body: { name: 'sample job', command: 'task job.py', schedule: '0 * * * *', sub_id: null },
|
||||
success: 'sample job -> 添加成功', failure: (message) => `sample job -> 添加失败(${message})`,
|
||||
},
|
||||
update: {
|
||||
method: 'PUT', url: /\/open\/crons\?t=\d+$/,
|
||||
body: { name: 'sample job', command: 'task job.py', schedule: '0 * * * *', id: '37' },
|
||||
success: 'sample job -> 更新成功', failure: (message) => `sample job -> 更新失败(${message})`,
|
||||
},
|
||||
command: {
|
||||
method: 'PUT', url: /\/open\/crons\?t=\d+$/,
|
||||
body: { command: 'task job.py', id: '37' },
|
||||
success: 'task job.py -> 更新成功', failure: (message) => `task job.py -> 更新失败(${message})`,
|
||||
},
|
||||
delete: {
|
||||
method: 'DELETE', url: /\/open\/crons\?t=\d+$/,
|
||||
body: [37, 38], success: '成功', failure: (message) => `失败(${message})`,
|
||||
},
|
||||
notify: {
|
||||
method: 'PUT', url: /\/open\/system\/notify\?t=\d+$/,
|
||||
body: { title: 'title', content: 'body' },
|
||||
success: '通知发送成功🎉', failure: (message) => `通知失败(${message})`,
|
||||
},
|
||||
find: {
|
||||
method: 'GET', url: /\/open\/crons\/detail\?log_path=task\/run.log&t=\d+$/,
|
||||
body: null, success: 'sample job', failure: () => '',
|
||||
},
|
||||
auth: {
|
||||
method: 'PUT', url: /\/open\/system\/auth\/reset\?t=\d+$/,
|
||||
body: { retries: 0 }, success: '重置登录错误次数成功🎉',
|
||||
failure: (message) => `重置登录错误次数失败(${message})`,
|
||||
},
|
||||
};
|
||||
|
||||
function runCallback(endpoint, scenario) {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-api-token-'));
|
||||
try {
|
||||
const tokenFile = path.join(directory, 'token.json');
|
||||
fs.writeFileSync(tokenFile, JSON.stringify({
|
||||
value: 'initial-token', expiration: Math.floor(Date.now() / 1000) + 3600,
|
||||
}));
|
||||
const child = spawnSync('bash', ['-c', `
|
||||
. "$API_FILE"
|
||||
create_token() {
|
||||
printf 'refresh\\n' >> "$CAPTURE"
|
||||
__ql_token__=fresh-token
|
||||
printf '{"value":"fresh-token","expiration":4102444800}' > "$file_auth_token"
|
||||
}
|
||||
case "$SCENARIO" in
|
||||
expired) printf '{"value":"initial-token","expiration":1}' > "$file_auth_token" ;;
|
||||
replaced) printf '{"value":"fresh-token","expiration":4102444800}' > "$file_auth_token" ;;
|
||||
esac
|
||||
curl() {
|
||||
local auth body url method arg
|
||||
while [[ $# -gt 0 ]]; do
|
||||
arg="$1"; shift
|
||||
case "$arg" in
|
||||
-H) [[ "$1" == Authorization:* ]] && auth="$1"; shift ;;
|
||||
--data-raw) body="$1"; shift ;;
|
||||
-X) method="$1"; shift ;;
|
||||
http:*) url="$arg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\\n%s\\n%s\\n%s\\n' "$auth" "$method" "$url" "\${body:-null}" >> "$CAPTURE"
|
||||
if [[ "$SCENARIO" == forbidden ]]; then
|
||||
printf '{"code":403,"message":"forbidden"}'
|
||||
elif [[ "$SCENARIO" == unavailable ]]; then
|
||||
printf '{"code":500,"message":"unavailable"}'
|
||||
elif [[ "$SCENARIO" == persistent || ( "$SCENARIO" != valid && "$auth" != 'Authorization: Bearer fresh-token' ) ]]; then
|
||||
printf '{"code":401,"message":"Token 已失效"}'
|
||||
else
|
||||
printf '{"code":200,"data":{"name":"sample job"}}'
|
||||
fi
|
||||
}
|
||||
t() { printf "$@"; }
|
||||
case "$ENDPOINT" in
|
||||
status) update_cron '"37"' 1 4321 'task/run.log' 1787004000 90 0 execution-37 ;;
|
||||
statistics) record_cron_stat 37 0 90 ;;
|
||||
add) add_cron_api '0 * * * *' 'task job.py' 'sample job' ;;
|
||||
update) update_cron_api '0 * * * *' 'task job.py' 'sample job' 37 ;;
|
||||
command) update_cron_command_api 'task job.py' 37 ;;
|
||||
delete) del_cron_api '37,38' ;;
|
||||
notify) notify_api title body ;;
|
||||
find) find_cron_api 'log_path=task/run.log' ;;
|
||||
auth) update_auth_config '"retries":0' '重置登录错误次数' ;;
|
||||
esac
|
||||
`], {
|
||||
env: { ...process.env, PATH: `/usr/bin:/bin:${process.env.PATH}`, API_FILE: apiFile, file_auth_token: tokenFile,
|
||||
CAPTURE: path.join(directory, 'requests'), SCENARIO: scenario,
|
||||
ENDPOINT: endpoint, ql_port: '5700' },
|
||||
encoding: 'utf8', timeout: 10_000,
|
||||
});
|
||||
assert.equal(child.status, 0, child.stderr);
|
||||
assert.equal(child.stderr, '');
|
||||
const lines = fs.readFileSync(path.join(directory, 'requests'), 'utf8').trim().split('\n');
|
||||
const refreshes = lines.filter((line) => line === 'refresh').length;
|
||||
const requests = lines.filter((line) => line !== 'refresh');
|
||||
const captures = [];
|
||||
for (let i = 0; i < requests.length; i += 4) {
|
||||
captures.push({ auth: requests[i], method: requests[i + 1],
|
||||
url: requests[i + 2], body: JSON.parse(requests[i + 3]) });
|
||||
}
|
||||
return { output: child.stdout, refreshes, captures };
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [endpoint, expected] of Object.entries(endpoints)) {
|
||||
for (const scenario of ['valid', 'expired', 'replaced', 'rejected', 'persistent', 'forbidden', 'unavailable']) {
|
||||
test(`${endpoint} API handles ${scenario} token without unbounded retries`, () => {
|
||||
const { output, refreshes, captures } = runCallback(endpoint, scenario);
|
||||
const retries = ['rejected', 'persistent'].includes(scenario);
|
||||
assert.equal(captures.length, retries ? 2 : 1);
|
||||
assert.equal(refreshes, ['expired', 'rejected', 'persistent'].includes(scenario) ? 1 : 0);
|
||||
for (const capture of captures) {
|
||||
assert.deepEqual(capture.body, expected.body);
|
||||
assert.equal(capture.method, expected.method);
|
||||
assert.match(capture.url, expected.url);
|
||||
}
|
||||
if (['expired', 'replaced', 'rejected'].includes(scenario)) {
|
||||
assert.equal(captures.at(-1).auth, 'Authorization: Bearer fresh-token');
|
||||
}
|
||||
const error = scenario === 'persistent' ? 'Token 已失效'
|
||||
: scenario === 'forbidden' ? 'forbidden' : scenario === 'unavailable' ? 'unavailable' : '';
|
||||
assert.equal(output.trim(), error ? expected.failure(error) : expected.success);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const ts = require('typescript');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { isValidToken } = require('../../back/shared/auth');
|
||||
|
||||
async function setup() {
|
||||
const secret = 'polling-test';
|
||||
const token = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
|
||||
let auth = { token };
|
||||
let read = async () => auth;
|
||||
let reads = 0;
|
||||
const validations = [];
|
||||
let onConnection;
|
||||
const clients = new Set();
|
||||
const timers = new Set();
|
||||
const mocks = {
|
||||
sockjs: {
|
||||
createServer: () => ({
|
||||
on: (_, fn) => {
|
||||
onConnection = fn;
|
||||
},
|
||||
installHandlers() {},
|
||||
}),
|
||||
},
|
||||
typedi: {
|
||||
Container: {
|
||||
get: () => ({
|
||||
addClient: (c) => clients.add(c),
|
||||
removeClient: (c) => clients.delete(c),
|
||||
}),
|
||||
},
|
||||
},
|
||||
'../services/sock': class {},
|
||||
'../config/util': { getPlatform: (agent) => agent || 'desktop' },
|
||||
'../shared/store': {
|
||||
shareStore: {
|
||||
getAuthInfo: () => {
|
||||
reads++;
|
||||
return read();
|
||||
},
|
||||
},
|
||||
},
|
||||
'../shared/auth': {
|
||||
isValidToken: (...args) => {
|
||||
validations.push({ token: args[1], platform: args[2] });
|
||||
return isValidToken(...args);
|
||||
},
|
||||
},
|
||||
'../config': { baseUrl: '', jwt: { secret } },
|
||||
};
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '../../back/loaders/sock.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const { outputText } = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
});
|
||||
const module = { exports: {} };
|
||||
new Function(
|
||||
'require',
|
||||
'module',
|
||||
'exports',
|
||||
'setInterval',
|
||||
'clearInterval',
|
||||
outputText,
|
||||
)(
|
||||
(name) => {
|
||||
assert.ok(Object.hasOwn(mocks, name), name);
|
||||
return mocks[name];
|
||||
},
|
||||
module,
|
||||
module.exports,
|
||||
(callback, delay) => {
|
||||
assert.equal(delay, 1000);
|
||||
const timer = { callback, unref() {} };
|
||||
timers.add(timer);
|
||||
return timer;
|
||||
},
|
||||
(timer) => timers.delete(timer),
|
||||
);
|
||||
await module.exports.default({ server: {} });
|
||||
function connection(value = token) {
|
||||
const conn = new EventEmitter();
|
||||
Object.assign(conn, {
|
||||
headers: {},
|
||||
pathname: '/api/ws/a/b/websocket',
|
||||
url: `/api/ws/a/b/websocket?token=${value}`,
|
||||
write() {},
|
||||
close(code) {
|
||||
this.closeCode = code;
|
||||
this.emit('close');
|
||||
},
|
||||
});
|
||||
return conn;
|
||||
}
|
||||
return {
|
||||
token,
|
||||
secret,
|
||||
clients,
|
||||
timers,
|
||||
validations,
|
||||
connection,
|
||||
connect: (c) => onConnection(c),
|
||||
get reads() {
|
||||
return reads;
|
||||
},
|
||||
setAuth(value) {
|
||||
auth = value;
|
||||
},
|
||||
setRead(fn) {
|
||||
read = fn;
|
||||
},
|
||||
tick: () => Promise.all([...timers].map((t) => t.callback())),
|
||||
};
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve, reject;
|
||||
const promise = new Promise((a, b) => {
|
||||
resolve = a;
|
||||
reject = b;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
test('multiple sockets share one periodic read, revoke together, and stop polling when empty', async () => {
|
||||
const h = await setup();
|
||||
assert.equal(h.timers.size, 0);
|
||||
const sockets = Array.from({ length: 20 }, () => h.connection());
|
||||
await Promise.all(sockets.map(h.connect));
|
||||
assert.equal(h.reads, 20, 'initial authentication remains per connection');
|
||||
assert.equal(h.timers.size, 1);
|
||||
await h.tick();
|
||||
assert.equal(h.reads, 21);
|
||||
assert.equal(h.clients.size, 20);
|
||||
h.setAuth({ token: '' });
|
||||
await h.tick();
|
||||
assert.ok(sockets.every((c) => c.closeCode === '401'));
|
||||
assert.equal(h.clients.size, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
const c = h.connection();
|
||||
h.setAuth({ token: h.token });
|
||||
await h.connect(c);
|
||||
assert.equal(h.timers.size, 1);
|
||||
c.close();
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('slow reads do not overlap and old snapshots do not reject newly accepted sockets', async () => {
|
||||
const h = await setup();
|
||||
const old = h.connection();
|
||||
await h.connect(old);
|
||||
const pending = deferred();
|
||||
h.setRead(() => pending.promise);
|
||||
const tick = h.tick();
|
||||
await h.tick();
|
||||
assert.equal(h.reads, 2);
|
||||
old.close();
|
||||
h.setRead(async () => ({ token: h.token }));
|
||||
const fresh = h.connection();
|
||||
await h.connect(fresh);
|
||||
pending.resolve({ token: '' });
|
||||
await tick;
|
||||
assert.equal(h.clients.has(fresh), true);
|
||||
assert.equal(fresh.closeCode, undefined);
|
||||
await h.tick();
|
||||
assert.equal(h.reads, 4);
|
||||
fresh.close();
|
||||
});
|
||||
|
||||
test('periodic store failures disconnect the checked sessions', async () => {
|
||||
const h = await setup();
|
||||
const c = h.connection();
|
||||
await h.connect(c);
|
||||
h.setRead(async () => {
|
||||
throw new Error('database unavailable');
|
||||
});
|
||||
await h.tick();
|
||||
assert.equal(c.closeCode, '401');
|
||||
assert.equal(h.clients.size, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('closed connections cannot be added after initial authentication finishes', async () => {
|
||||
const h = await setup();
|
||||
const pending = deferred();
|
||||
h.setRead(() => pending.promise);
|
||||
const c = h.connection();
|
||||
const connecting = h.connect(c);
|
||||
c.close();
|
||||
pending.resolve({ token: h.token });
|
||||
await connecting;
|
||||
assert.equal(h.clients.size, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('initial store errors reject the connection without creating a timer', async () => {
|
||||
const h = await setup();
|
||||
h.setRead(async () => {
|
||||
throw new Error('database unavailable');
|
||||
});
|
||||
const c = h.connection();
|
||||
await h.connect(c);
|
||||
assert.equal(c.closeCode, '401');
|
||||
assert.equal(h.clients.size, 0);
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('each periodic check still validates JWT expiry and platform session membership', async () => {
|
||||
const h = await setup();
|
||||
const expired = h.connection(
|
||||
jwt.sign({}, h.secret, { algorithm: 'HS384', expiresIn: -1 }),
|
||||
);
|
||||
await h.connect(expired);
|
||||
assert.equal(expired.closeCode, '404');
|
||||
const c = h.connection();
|
||||
await h.connect(c);
|
||||
h.setAuth({ tokens: { desktop: [{ value: h.token, expiration: 1 }] } });
|
||||
await h.tick();
|
||||
assert.equal(c.closeCode, '401');
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('an accepted JWT is disconnected when it expires without a store change', async () => {
|
||||
const h = await setup();
|
||||
const c = h.connection();
|
||||
await h.connect(c);
|
||||
const now = Date.now;
|
||||
try {
|
||||
Date.now = () => now() + 2 * 60 * 60 * 1000;
|
||||
await h.tick();
|
||||
} finally {
|
||||
Date.now = now;
|
||||
}
|
||||
assert.equal(c.closeCode, '401');
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('same token and platform share validation only within the current round', async () => {
|
||||
const h = await setup();
|
||||
const sockets = Array.from({ length: 20 }, () => h.connection());
|
||||
await Promise.all(sockets.map(h.connect));
|
||||
assert.equal(
|
||||
h.validations.length,
|
||||
20,
|
||||
'initial authentication stays independent',
|
||||
);
|
||||
h.validations.length = 0;
|
||||
await h.tick();
|
||||
assert.equal(h.validations.length, 1);
|
||||
await h.tick();
|
||||
assert.equal(h.validations.length, 2, 'new rounds revalidate');
|
||||
h.setAuth({ token: '' });
|
||||
await h.tick();
|
||||
assert.equal(
|
||||
h.validations.length,
|
||||
3,
|
||||
'invalid results are also shared for this round',
|
||||
);
|
||||
assert.ok(sockets.every((c) => c.closeCode === '401'));
|
||||
assert.equal(h.timers.size, 0);
|
||||
});
|
||||
|
||||
test('validation results never cross tokens or platforms', async () => {
|
||||
const h = await setup();
|
||||
const other = jwt.sign({ account: 'other' }, h.secret, {
|
||||
algorithm: 'HS384',
|
||||
expiresIn: '1h',
|
||||
});
|
||||
h.setAuth({
|
||||
tokens: {
|
||||
desktop: [{ value: h.token }, { value: other }],
|
||||
mobile: [{ value: h.token }],
|
||||
},
|
||||
});
|
||||
const desktop = h.connection();
|
||||
const mobile = h.connection();
|
||||
mobile.headers['user-agent'] = 'mobile';
|
||||
const separate = h.connection(other);
|
||||
await Promise.all([desktop, mobile, separate].map(h.connect));
|
||||
h.validations.length = 0;
|
||||
h.setAuth({ tokens: { desktop: [{ value: h.token }], mobile: [] } });
|
||||
await h.tick();
|
||||
assert.equal(h.validations.length, 3);
|
||||
assert.equal(desktop.closeCode, undefined);
|
||||
assert.equal(mobile.closeCode, '401');
|
||||
assert.equal(separate.closeCode, '401');
|
||||
desktop.close();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('WebSocket connections reject expired tokens and close when sessions are revoked', async () => {
|
||||
const secret = 'sock-test';
|
||||
const valid = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: '1h' });
|
||||
const expired = jwt.sign({}, secret, { algorithm: 'HS384', expiresIn: -1 });
|
||||
let auth = { token: valid, tokens: { desktop: [{ value: expired }] } };
|
||||
let onConnection;
|
||||
const clients = new Set();
|
||||
const Sock = class {};
|
||||
const loader = load(path.join(__dirname, '../../back/loaders/sock.ts'), {
|
||||
sockjs: {
|
||||
createServer: () => ({
|
||||
on: (_event, fn) => {
|
||||
onConnection = fn;
|
||||
},
|
||||
installHandlers() {},
|
||||
}),
|
||||
},
|
||||
typedi: {
|
||||
Container: {
|
||||
get: () => ({
|
||||
addClient: (c) => clients.add(c),
|
||||
removeClient: (c) => clients.delete(c),
|
||||
}),
|
||||
},
|
||||
},
|
||||
'../services/sock': Sock,
|
||||
'../config': { jwt: { secret }, baseUrl: '' },
|
||||
'../config/util': { getPlatform: () => 'desktop' },
|
||||
'../shared/store': { shareStore: { getAuthInfo: async () => auth } },
|
||||
}).default;
|
||||
await loader({ server: {} });
|
||||
const connection = (token) => {
|
||||
const c = new EventEmitter();
|
||||
Object.assign(c, {
|
||||
headers: {},
|
||||
pathname: '/api/ws/a/b/websocket',
|
||||
url: `/api/ws/a/b/websocket?token=${token}`,
|
||||
close() {
|
||||
this.closed = true;
|
||||
this.emit('close');
|
||||
},
|
||||
write() {},
|
||||
});
|
||||
return c;
|
||||
};
|
||||
const rejected = connection(expired);
|
||||
await onConnection(rejected);
|
||||
assert.equal(rejected.closed, true);
|
||||
assert.equal(clients.size, 0);
|
||||
const accepted = connection(valid);
|
||||
await onConnection(accepted);
|
||||
assert.equal(clients.size, 1);
|
||||
auth = { token: '', tokens: {} };
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
accepted.close();
|
||||
reject(new Error('revoked socket remained open'));
|
||||
}, 3000);
|
||||
accepted.once('close', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
assert.equal(clients.size, 0);
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
async function fixture(t, sameHome = false, missingUser = false) {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-ssh-home-'));
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
const userHome = path.join(directory, 'user');
|
||||
const envHome = sameHome ? userHome : path.join(directory, 'env');
|
||||
const sshdPath = path.join(directory, 'ssh.d');
|
||||
await fs.mkdir(sshdPath);
|
||||
const util = {
|
||||
fileExist: async (file) =>
|
||||
fs.stat(file).then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
rmPath: async (file) => fs.rm(file, { force: true }),
|
||||
};
|
||||
const { writeFileWithLock } = load('back/shared/utils.ts', {
|
||||
'../config/util': util,
|
||||
});
|
||||
let initialized;
|
||||
const ready = new Promise((resolve) => (initialized = resolve));
|
||||
const SshKeyService = load('back/services/sshKey.ts', {
|
||||
os: {
|
||||
...os,
|
||||
homedir: () => envHome,
|
||||
userInfo: () => {
|
||||
if (missingUser) throw Error('No passwd entry');
|
||||
return { homedir: userHome };
|
||||
},
|
||||
},
|
||||
'../config': { sshdPath },
|
||||
'../config/util': util,
|
||||
'../shared/utils': {
|
||||
writeFileWithLock: async (file, content, options) => {
|
||||
await writeFileWithLock(file, content, options);
|
||||
if (content.includes('Include ')) {
|
||||
initialized();
|
||||
}
|
||||
},
|
||||
},
|
||||
}).default;
|
||||
const service = new SshKeyService({ error: assert.fail, warn: assert.fail });
|
||||
await ready;
|
||||
return { service, userHome, envHome, sshdPath };
|
||||
}
|
||||
|
||||
for (const sameHome of [false, true]) {
|
||||
test(`SSH configuration uses the effective user home with ${
|
||||
sameHome ? 'matching' : 'overridden'
|
||||
} HOME`, async (t) => {
|
||||
const { service, userHome, envHome, sshdPath } = await fixture(t, sameHome);
|
||||
const configFile = path.join(userHome, '.ssh/config');
|
||||
assert.equal(
|
||||
await fs.readFile(configFile, 'utf8'),
|
||||
`Include ${sshdPath}/*.config\n\n`,
|
||||
);
|
||||
assert.equal((await fs.stat(path.dirname(configFile))).mode & 0o777, 0o700);
|
||||
assert.equal((await fs.stat(configFile)).mode & 0o777, 0o600);
|
||||
if (!sameHome)
|
||||
await assert.rejects(fs.stat(path.join(envHome, '.ssh/config')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
await fs.appendFile(
|
||||
configFile,
|
||||
'Host existing\n Hostname example.com\n',
|
||||
);
|
||||
await service.initSshConfigFile();
|
||||
const content = await fs.readFile(configFile, 'utf8');
|
||||
assert.equal(content.split('Include ').length, 2);
|
||||
assert.match(content, /Host existing/);
|
||||
});
|
||||
}
|
||||
|
||||
test('SSH initialization still works for a custom UID without a passwd entry', async (t) => {
|
||||
const { envHome, sshdPath } = await fixture(t, true, true);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(envHome, '.ssh/config'), 'utf8'),
|
||||
`Include ${sshdPath}/*.config\n\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test('SSH alias changes retain private key permissions and remove old credentials', async (t) => {
|
||||
const { service, sshdPath } = await fixture(t);
|
||||
const {
|
||||
getSubscriptionSshAlias,
|
||||
} = require('../../back/shared/subscriptionPath');
|
||||
const original = 'private/中文@v1';
|
||||
const renamed = 'renamed/中文@v2';
|
||||
await service.addSSHKey('fixture-private-key', original, '127.0.0.1');
|
||||
const first = getSubscriptionSshAlias(original);
|
||||
assert.equal((await fs.stat(path.join(sshdPath, first))).mode & 0o777, 0o400);
|
||||
assert.match(
|
||||
await fs.readFile(path.join(sshdPath, `${first}.config`), 'utf8'),
|
||||
/Hostname 127\.0\.0\.1/,
|
||||
);
|
||||
await service.removeSSHKey(original, '127.0.0.1');
|
||||
await service.addSSHKey('fixture-private-key', renamed, '127.0.0.1');
|
||||
await assert.rejects(fs.stat(path.join(sshdPath, first)), { code: 'ENOENT' });
|
||||
await assert.rejects(fs.stat(path.join(sshdPath, `${first}.config`)), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
assert.equal(
|
||||
(await fs.stat(path.join(sshdPath, getSubscriptionSshAlias(renamed))))
|
||||
.mode & 0o777,
|
||||
0o400,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
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 { spawnSync } = require('node:child_process');
|
||||
|
||||
// Run the actual startup prefix, stopping before service initialization.
|
||||
const source = fs
|
||||
.readFileSync('shell/start-simplify.sh', 'utf8')
|
||||
.split('. "${QL_DIR}/shell/share.sh"')[0];
|
||||
for (const command of ['start', 'reload']) {
|
||||
for (const existing of [false, true]) {
|
||||
test(`${command} ${
|
||||
existing ? 'preserves existing' : 'initializes missing'
|
||||
} .env`, (t) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-env-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
fs.mkdirSync(path.join(root, 'shell'));
|
||||
fs.writeFileSync(path.join(root, 'shell/test.sh'), '# fixture');
|
||||
fs.writeFileSync(path.join(root, '.env.example'), 'BACK_PORT=5700\n');
|
||||
const custom = 'BACK_PORT=5800\nJWT_SECRET=synthetic-test-only\n';
|
||||
if (existing)
|
||||
fs.writeFileSync(path.join(root, '.env'), custom, { mode: 0o600 });
|
||||
const result = spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-c',
|
||||
'npm(){ :; }; pip3(){ :; }; apk(){ :; }; sudo(){ "$@"; }; python3(){ printf 3.11; };\n' +
|
||||
source,
|
||||
'startup-test',
|
||||
command,
|
||||
],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
QL_DIR: root,
|
||||
QL_DATA_DIR: root + '/data',
|
||||
QL_OS_TYPE: 'alpine',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, '.env'), 'utf8'),
|
||||
existing ? custom : 'BACK_PORT=5700\n',
|
||||
);
|
||||
if (existing)
|
||||
assert.equal(fs.statSync(path.join(root, '.env')).mode & 0o777, 0o600);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
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 { execFileSync, spawn, spawnSync } = require('node:child_process');
|
||||
const notify = path.resolve('scripts/wait-for-mirror.sh');
|
||||
const verify = notify;
|
||||
const triggerRef = 'refs/heads/gitee-sync-trigger';
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
|
||||
function fixture(t) {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-mirror-trigger-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const work = path.join(tmp, 'work');
|
||||
const source = path.join(tmp, 'source.git');
|
||||
const mirror = path.join(tmp, 'mirror.git');
|
||||
for (const repo of [source, mirror]) git('init', '--bare', repo);
|
||||
git('init', work);
|
||||
for (const repo of [source, work]) {
|
||||
git('-C', repo, 'config', 'user.name', 'Fixture');
|
||||
git('-C', repo, 'config', 'user.email', 'fixture@example.invalid');
|
||||
}
|
||||
git('-C', work, 'checkout', '-b', 'develop');
|
||||
fs.writeFileSync(path.join(work, 'snapshot'), 'static artifact');
|
||||
git('-C', work, 'add', '.');
|
||||
git('-C', work, 'commit', '-m', 'snapshot');
|
||||
git('-C', work, 'push', source, 'develop');
|
||||
const bin = path.join(tmp, 'bin');
|
||||
fs.mkdirSync(bin);
|
||||
// Model GitHub's Git Data API with real Git objects and non-forced ref updates.
|
||||
// The fixture's post-receive hook simulates Gitee receiving the push event.
|
||||
fs.writeFileSync(
|
||||
path.join(bin, 'gh'),
|
||||
`#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { execFileSync, spawnSync } = require('node:child_process');
|
||||
const args = process.argv.slice(2);
|
||||
const endpoint = args.find((arg) => arg.startsWith('repos/'));
|
||||
const method = args.includes('--method') ? args[args.indexOf('--method') + 1] : 'GET';
|
||||
const fields = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '-f' || args[i] === '-F') {
|
||||
const field = args[++i];
|
||||
const separator = field.indexOf('=');
|
||||
fields[field.slice(0, separator)] = field.slice(separator + 1);
|
||||
}
|
||||
}
|
||||
fs.appendFileSync(process.env.QL_TEST_API_LOG, JSON.stringify({method, endpoint, fields, time: Date.now()}) + '\\n');
|
||||
const source = process.env.QL_TEST_SOURCE;
|
||||
const git = (...args) => execFileSync('git', ['-C', source, ...args], {encoding: 'utf8', stdio: 'pipe'}).trim();
|
||||
const fail = (status) => { console.error('gh: API failure (HTTP ' + status + ')'); process.exit(1); };
|
||||
const ref = 'refs/heads/gitee-sync-trigger';
|
||||
const isUpdate = method === 'PATCH' || endpoint.endsWith('/git/refs');
|
||||
if (process.env.QL_TEST_API_HANG) spawnSync('sleep', ['30'], {stdio: 'ignore'});
|
||||
if (process.env.QL_TEST_API_REJECT) fail(500);
|
||||
if (isUpdate && process.env.QL_TEST_COLLISION && !fs.existsSync(process.env.QL_TEST_COLLISION)) {
|
||||
const current = git('rev-parse', ref);
|
||||
const tree = git('rev-parse', current + '^{tree}');
|
||||
const peer = git('commit-tree', tree, '-p', current, '-m', 'Concurrent notifier');
|
||||
git('update-ref', ref, peer);
|
||||
fs.writeFileSync(process.env.QL_TEST_COLLISION, peer);
|
||||
}
|
||||
if (isUpdate && process.env.QL_TEST_UPDATE_FAILURE && !fs.existsSync(process.env.QL_TEST_UPDATE_FAILURE)) {
|
||||
fs.writeFileSync(process.env.QL_TEST_UPDATE_FAILURE, 'failed');
|
||||
fail(500);
|
||||
}
|
||||
try {
|
||||
if (method === 'GET' && endpoint.includes('/git/ref/')) {
|
||||
console.log(git('rev-parse', '--verify', 'refs/' + endpoint.split('/git/ref/')[1]));
|
||||
} else if (method === 'GET' && endpoint.includes('/git/commits/')) {
|
||||
console.log(git('rev-parse', endpoint.split('/git/commits/')[1] + '^{tree}'));
|
||||
} else if (endpoint.endsWith('/git/trees')) {
|
||||
const body = JSON.parse(fs.readFileSync(0, 'utf8'));
|
||||
const entries = body.tree.map((entry) => {
|
||||
const blob = execFileSync('git', ['-C', source, 'hash-object', '-w', '--stdin'], {encoding: 'utf8', input: entry.content}).trim();
|
||||
return entry.mode + ' blob ' + blob + '\\t' + entry.path + '\\n';
|
||||
}).join('');
|
||||
console.log(execFileSync('git', ['-C', source, 'mktree'], {encoding: 'utf8', input: entries}).trim());
|
||||
} else if (endpoint.endsWith('/git/commits')) {
|
||||
const parents = fields['parents[]'] ? ['-p', fields['parents[]']] : [];
|
||||
console.log(git('commit-tree', fields.tree, ...parents, '-m', fields.message));
|
||||
} else if (isUpdate) {
|
||||
const current = spawnSync('git', ['-C', source, 'rev-parse', '--verify', ref], {encoding: 'utf8'});
|
||||
const old = current.status === 0 ? current.stdout.trim() : '0'.repeat(40);
|
||||
if (method === 'PATCH') {
|
||||
if (fields.force !== 'false' || current.status !== 0 || spawnSync('git', ['-C', source, 'merge-base', '--is-ancestor', old, fields.sha]).status !== 0) fail(422);
|
||||
} else if (current.status === 0 || fields.ref !== ref) fail(422);
|
||||
git('update-ref', ref, fields.sha, old);
|
||||
const hook = path.join(source, 'hooks/post-receive');
|
||||
if (fs.existsSync(hook)) execFileSync(hook, {cwd: source, input: old + ' ' + fields.sha + ' ' + ref + '\\n', env: {...process.env, GIT_DIR: source}, stdio: ['pipe', 'pipe', 'pipe']});
|
||||
} else fail(400);
|
||||
} catch (error) { fail(method === 'GET' ? 404 : 422); }
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${bin}${path.delimiter}${process.env.PATH}`,
|
||||
QL_TEST_SOURCE: source,
|
||||
QL_TEST_API_LOG: path.join(tmp, 'api.log'),
|
||||
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'true',
|
||||
QL_MIRROR_WAIT_SECONDS: '4',
|
||||
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
|
||||
QL_MIRROR_POLL_INTERVAL: '1',
|
||||
QL_MIRROR_QUERY_TIMEOUT: '1',
|
||||
};
|
||||
return { tmp, work, source, mirror, env };
|
||||
}
|
||||
|
||||
test('static notifications use a tiny REST branch with unchanged trees and preserve published refs', (t) => {
|
||||
const { source, env } = fixture(t);
|
||||
const snapshot = git('-C', source, 'rev-parse', 'develop');
|
||||
execFileSync('bash', [notify, source, source, 'develop', 'branch'], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
const first = git('-C', source, 'rev-parse', triggerRef);
|
||||
execFileSync('bash', [notify, source, source, 'develop', 'branch'], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
const second = git('-C', source, 'rev-parse', triggerRef);
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(git('-C', source, 'rev-parse', `${second}^`), first);
|
||||
assert.equal(
|
||||
git('-C', source, 'ls-tree', '-r', '--name-only', second),
|
||||
'.gitee-sync-trigger',
|
||||
);
|
||||
assert.equal(git('-C', source, 'diff', '--name-only', first, second), '');
|
||||
assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot);
|
||||
assert.match(
|
||||
git('-C', source, 'log', '-1', '--format=%s', second),
|
||||
new RegExp(snapshot),
|
||||
);
|
||||
});
|
||||
|
||||
test('REST notifications retry a newer parent and GitHub 500s with backoff', (t) => {
|
||||
const { tmp, source, env } = fixture(t);
|
||||
execFileSync('bash', [notify, source, source, 'develop'], {
|
||||
stdio: 'pipe',
|
||||
env,
|
||||
});
|
||||
const parent = git('-C', source, 'rev-parse', triggerRef);
|
||||
const flag = path.join(tmp, 'collision');
|
||||
const collision = spawnSync('bash', [notify, source, source, 'develop'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...env, QL_TEST_COLLISION: flag },
|
||||
});
|
||||
assert.equal(collision.status, 0, collision.stderr + collision.stdout);
|
||||
assert.match(collision.stdout, /notification update failed \(attempt 1\)/);
|
||||
const peer = fs.readFileSync(flag, 'utf8');
|
||||
assert.equal(git('-C', source, 'rev-parse', `${peer}^`), parent);
|
||||
assert.equal(git('-C', source, 'rev-parse', `${triggerRef}^`), peer);
|
||||
const failed = path.join(tmp, 'failed');
|
||||
const recovery = spawnSync('bash', [notify, source, source, 'develop'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...env, QL_TEST_UPDATE_FAILURE: failed },
|
||||
});
|
||||
assert.equal(recovery.status, 0, recovery.stderr + recovery.stdout);
|
||||
assert.match(recovery.stderr, /HTTP 500/);
|
||||
const calls = fs
|
||||
.readFileSync(env.QL_TEST_API_LOG, 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(JSON.parse);
|
||||
const updates = calls.filter((call) => call.method === 'PATCH');
|
||||
assert.ok(updates.every((call) => call.fields.force === 'false'));
|
||||
const last = updates.slice(-2);
|
||||
assert.ok(
|
||||
last[1].time - last[0].time >= 1900,
|
||||
'failed API updates must back off',
|
||||
);
|
||||
});
|
||||
|
||||
test('missing snapshots fail before sending any notification', (t) => {
|
||||
const { source, env } = fixture(t);
|
||||
const missing = spawnSync('bash', [notify, source, source, 'missing'], {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
});
|
||||
assert.equal(missing.status, 1, missing.stderr + missing.stdout);
|
||||
assert.match(missing.stdout, /Published source ref.*does not exist/);
|
||||
assert.equal(fs.existsSync(env.QL_TEST_API_LOG), false);
|
||||
});
|
||||
|
||||
test('notification failure does not prevent an already synchronized mirror from passing', (t) => {
|
||||
const { source, mirror, env } = fixture(t);
|
||||
git('-C', source, 'push', mirror, 'develop');
|
||||
const result = spawnSync('bash', [verify, source, mirror, 'develop'], {
|
||||
encoding: 'utf8',
|
||||
env: { ...env, QL_TEST_API_REJECT: 'true' },
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
assert.match(
|
||||
result.stdout,
|
||||
/Could not send the mirror notification; continuing/,
|
||||
);
|
||||
assert.match(result.stdout, /Mirror matches the latest source refs/);
|
||||
});
|
||||
|
||||
test('a failed delayed notification keeps polling until the mirror updates', async (t) => {
|
||||
const { source, mirror, env } = fixture(t);
|
||||
const child = spawn('bash', [verify, source, mirror, 'develop'], {
|
||||
env: {
|
||||
...env,
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false',
|
||||
QL_MIRROR_WAIT_SECONDS: '10',
|
||||
QL_TEST_API_REJECT: 'true',
|
||||
},
|
||||
});
|
||||
let output = '';
|
||||
let error = '';
|
||||
let synchronized = false;
|
||||
child.stderr.on('data', (data) => {
|
||||
error += data;
|
||||
});
|
||||
child.stdout.on('data', (data) => {
|
||||
output += data;
|
||||
if (
|
||||
!synchronized &&
|
||||
output.includes('Could not read notification ref (attempt 1)')
|
||||
) {
|
||||
synchronized = true;
|
||||
git('-C', source, 'push', mirror, 'develop');
|
||||
}
|
||||
});
|
||||
const status = await new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('close', resolve);
|
||||
});
|
||||
assert.ok(synchronized);
|
||||
assert.equal(status, 0, error + output);
|
||||
assert.match(output, /Mirror matches the latest source refs/);
|
||||
});
|
||||
|
||||
test('failed or hung notifications preserve the mirror polling deadline', (t) => {
|
||||
const { source, mirror, env } = fixture(t);
|
||||
for (const failure of ['QL_TEST_API_REJECT', 'QL_TEST_API_HANG']) {
|
||||
const started = Date.now();
|
||||
const result = spawnSync('bash', [verify, source, mirror, 'develop'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 6000,
|
||||
env: {
|
||||
...env,
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false',
|
||||
QL_MIRROR_WAIT_SECONDS: '3',
|
||||
[failure]: 'true',
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 1, result.stderr + result.stdout);
|
||||
assert.ok(Date.now() - started < 5000);
|
||||
assert.match(result.stdout, /::error::Mirror did not match.*within 3s/);
|
||||
}
|
||||
});
|
||||
|
||||
test('static verification retries a small GitHub notification after a missed sync interval', (t) => {
|
||||
const { tmp, source, mirror, env } = fixture(t);
|
||||
const snapshot = git('-C', source, 'rev-parse', 'develop');
|
||||
git('-C', source, 'update-ref', 'refs/heads/v2.22.0', snapshot);
|
||||
git('-C', source, 'update-ref', 'refs/tags/v2.22.0', snapshot);
|
||||
const countFile = path.join(tmp, 'requests');
|
||||
fs.writeFileSync(
|
||||
path.join(source, 'hooks/post-receive'),
|
||||
`#!/bin/sh
|
||||
while read before after ref; do
|
||||
if [ "$ref" = refs/heads/gitee-sync-trigger ]; then
|
||||
count=0
|
||||
if [ -f "$QL_TEST_COUNT" ]; then count=$(cat "$QL_TEST_COUNT"); fi
|
||||
count=$((count + 1))
|
||||
printf '%s' "$count" > "$QL_TEST_COUNT"
|
||||
if [ "$count" -ge 2 ]; then
|
||||
git push --quiet "$QL_TEST_MIRROR" refs/heads/v2.22.0:refs/heads/v2.22.0 refs/tags/v2.22.0:refs/tags/v2.22.0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const result = spawnSync('bash', [verify, source, mirror, 'v2.22.0', 'tag'], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...env,
|
||||
QL_TEST_COUNT: countFile,
|
||||
QL_TEST_MIRROR: mirror,
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
assert.equal(fs.readFileSync(countFile, 'utf8'), '2');
|
||||
assert.match(result.stdout, /Retrying the GitHub notification/);
|
||||
assert.match(result.stdout, /Mirror matches the latest source refs/);
|
||||
assert.doesNotMatch(result.stdout, /::error::/);
|
||||
for (const ref of ['refs/heads/v2.22.0', 'refs/tags/v2.22.0']) {
|
||||
assert.equal(git('-C', mirror, 'rev-parse', ref), snapshot);
|
||||
}
|
||||
});
|
||||
|
||||
test('static verification still fails when fresh notifications never synchronize the mirror', (t) => {
|
||||
const { source, mirror, env } = fixture(t);
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[verify, source, mirror, 'develop', 'branch'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...env,
|
||||
QL_MIRROR_WAIT_SECONDS: '2',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 1, result.stderr + result.stdout);
|
||||
// The retry contract is checked above. A short polling deadline can expire
|
||||
// while the REST fixture is still sending its second notification.
|
||||
const notifications = Number(
|
||||
git('-C', source, 'rev-list', '--count', triggerRef),
|
||||
);
|
||||
assert.ok(notifications >= 1 && notifications <= 2);
|
||||
assert.match(result.stdout, /::error::Mirror did not match/);
|
||||
const invalid = spawnSync(
|
||||
'bash',
|
||||
[verify, source, mirror, 'develop', 'branch'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...env, QL_MIRROR_WAIT_SECONDS: '0' },
|
||||
},
|
||||
);
|
||||
assert.equal(invalid.status, 2);
|
||||
});
|
||||
|
||||
test('code mirrors that already match do not send another notification', (t) => {
|
||||
const { source, mirror, env } = fixture(t);
|
||||
git('-C', source, 'push', mirror, 'develop');
|
||||
const codeEnv = {
|
||||
...env,
|
||||
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false',
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false',
|
||||
};
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[verify, source, mirror, 'develop', 'branch'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: codeEnv,
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
assert.match(result.stdout, /Mirror matches the latest source refs/);
|
||||
assert.doesNotMatch(result.stdout, /Sent a small GitHub push event via REST/);
|
||||
assert.notEqual(
|
||||
spawnSync('git', ['-C', source, 'rev-parse', '--verify', triggerRef])
|
||||
.status,
|
||||
0,
|
||||
);
|
||||
const invalid = spawnSync(
|
||||
'bash',
|
||||
[verify, source, mirror, 'develop', 'branch'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...codeEnv, QL_MIRROR_NOTIFY_ON_START: 'later' },
|
||||
},
|
||||
);
|
||||
assert.equal(invalid.status, 2);
|
||||
});
|
||||
|
||||
test('code mirrors notify after the sync interval and verify tags without version branches', (t) => {
|
||||
const { tmp, work, source, mirror, env } = fixture(t);
|
||||
const snapshot = git('-C', source, 'rev-parse', 'develop');
|
||||
git('-C', work, 'tag', '-a', 'v2.22.0', '-m', 'release');
|
||||
git('-C', work, 'push', source, 'refs/tags/v2.22.0');
|
||||
const tag = git('-C', source, 'rev-parse', 'refs/tags/v2.22.0');
|
||||
const countFile = path.join(tmp, 'requests');
|
||||
fs.writeFileSync(
|
||||
path.join(source, 'hooks/post-receive'),
|
||||
`#!/bin/sh
|
||||
while read before after ref; do
|
||||
if [ "$ref" = refs/heads/gitee-sync-trigger ]; then
|
||||
printf 'notification\n' >> "$QL_TEST_COUNT"
|
||||
git push --quiet "$QL_TEST_MIRROR" refs/tags/v2.22.0:refs/tags/v2.22.0
|
||||
fi
|
||||
done
|
||||
`,
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const started = Date.now();
|
||||
const result = spawnSync('bash', [verify, source, mirror, 'v2.22.0', 'tag'], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...env,
|
||||
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false',
|
||||
QL_MIRROR_INCLUDE_VERSION_BRANCH: 'false',
|
||||
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
|
||||
QL_TEST_COUNT: countFile,
|
||||
QL_TEST_MIRROR: mirror,
|
||||
},
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
assert.ok(
|
||||
Date.now() - started >= 900,
|
||||
'notification should wait for the sync interval',
|
||||
);
|
||||
assert.equal(fs.readFileSync(countFile, 'utf8'), 'notification\n');
|
||||
assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1');
|
||||
assert.equal(git('-C', source, 'rev-parse', 'develop'), snapshot);
|
||||
assert.equal(git('-C', mirror, 'rev-parse', 'refs/tags/v2.22.0'), tag);
|
||||
assert.notEqual(
|
||||
spawnSync('git', [
|
||||
'-C',
|
||||
mirror,
|
||||
'rev-parse',
|
||||
'--verify',
|
||||
'refs/heads/v2.22.0',
|
||||
]).status,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('code mirrors that never synchronize fail after one delayed notification', (t) => {
|
||||
const { source, mirror, env } = fixture(t);
|
||||
const result = spawnSync(
|
||||
'bash',
|
||||
[verify, source, mirror, 'develop', 'branch'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...env,
|
||||
QL_MIRROR_NOTIFY_REPO: 'fixture/source',
|
||||
QL_MIRROR_NOTIFY_ON_START: 'false',
|
||||
QL_MIRROR_WAIT_SECONDS: '3',
|
||||
QL_MIRROR_RETRY_AFTER_SECONDS: '1',
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.equal(result.status, 1, result.stderr + result.stdout);
|
||||
assert.equal(git('-C', source, 'rev-list', '--count', triggerRef), '1');
|
||||
assert.match(result.stdout, /::error::Mirror did not match/);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
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 { execFileSync, spawnSync } = require('node:child_process');
|
||||
const { writeManifest } = require('../helpers/upgrade-fixture.cjs');
|
||||
|
||||
test('static mirror receives complete orphan snapshots of each requested branch and reports rejection', (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-static-mirror-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const source = path.join(tmp, 'source'),
|
||||
target = path.join(tmp, 'target.git');
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
git('init', source);
|
||||
git('init', '--bare', target);
|
||||
git('-C', source, 'config', 'user.name', 'Fixture');
|
||||
git('-C', source, 'config', 'user.email', 'fixture@example.invalid');
|
||||
const script = path.resolve('scripts/sync-static.sh');
|
||||
const env = { ...process.env, QL_STATIC_SOURCE: source };
|
||||
for (const branch of ['master', 'develop']) {
|
||||
git('-C', source, 'checkout', '--orphan', branch);
|
||||
fs.writeFileSync(path.join(source, 'build-info.json'), branch);
|
||||
git('-C', source, 'add', '.');
|
||||
git('-C', source, 'commit', '-m', branch);
|
||||
execFileSync('bash', [script, target, branch], { env, stdio: 'pipe' });
|
||||
assert.equal(
|
||||
git('-C', target, 'rev-parse', branch),
|
||||
git('-C', source, 'rev-parse', branch),
|
||||
);
|
||||
assert.equal(
|
||||
git('-C', target, 'rev-parse', '--is-shallow-repository'),
|
||||
'false',
|
||||
);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(target, 'hooks/pre-receive'),
|
||||
'#!/bin/sh\nexit 1\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
fs.writeFileSync(path.join(source, 'build-info.json'), 'next');
|
||||
git('-C', source, 'add', '.');
|
||||
git('-C', source, 'commit', '-m', 'next');
|
||||
assert.notEqual(
|
||||
spawnSync('bash', [script, target, 'develop'], { env }).status,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('mirror jobs select the built branch, retain hidden artifacts and propagate failures', () => {
|
||||
const yaml = require('js-yaml');
|
||||
const workflow = yaml.load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
for (const name of ['static_gitlab']) {
|
||||
const job = workflow.jobs[name],
|
||||
step = job.steps.find((step) => step.run);
|
||||
assert.equal(job.needs, 'build-static');
|
||||
assert.equal(step.env.STATIC_BRANCH, '${{ github.ref_name }}');
|
||||
assert.equal(step.env.STATIC_REF_TYPE, '${{ github.ref_type }}');
|
||||
assert.match(step.run, /set -euo pipefail/);
|
||||
assert.match(step.run, /bash scripts\/sync-static.sh.*"\$STATIC_BRANCH"/);
|
||||
assert.doesNotMatch(step.run, /warning|set \+e|--depth/);
|
||||
}
|
||||
const publish = workflow.jobs['build-static'].steps.find(
|
||||
(step) => step.name === 'copy to static repo',
|
||||
);
|
||||
assert.equal(publish.env.GITHUB_REF_TYPE, '${{ github.ref_type }}');
|
||||
assert.match(publish.run, /bash scripts\/publish-static.sh/);
|
||||
assert.match(
|
||||
fs.readFileSync('scripts/publish-static.sh', 'utf8'),
|
||||
/cp -a static\/\./,
|
||||
);
|
||||
});
|
||||
|
||||
test('version static snapshots retain a matching branch and Git tag on all three remotes', (t) => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-version-static-'));
|
||||
t.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
const source = path.join(tmp, 'source');
|
||||
const git = (...args) =>
|
||||
execFileSync('git', args, { encoding: 'utf8', stdio: 'pipe' }).trim();
|
||||
git('init', source);
|
||||
git('-C', source, 'config', 'user.name', 'Fixture');
|
||||
git('-C', source, 'config', 'user.email', 'fixture@example.invalid');
|
||||
fs.writeFileSync(path.join(source, 'version.yaml'), 'version: 2.22.0\n');
|
||||
fs.writeFileSync(path.join(source, '.gitignore'), 'static/\n');
|
||||
fs.writeFileSync(path.join(source, 'pnpm-lock.yaml'), 'fixture lock\n');
|
||||
git('-C', source, 'add', '.');
|
||||
git('-C', source, 'commit', '-m', 'source');
|
||||
const staticRoot = path.join(source, 'static');
|
||||
for (const filename of [
|
||||
'build/app.js',
|
||||
'dist/index.html',
|
||||
'dist/.well-known/config',
|
||||
]) {
|
||||
fs.mkdirSync(path.dirname(path.join(staticRoot, filename)), {
|
||||
recursive: true,
|
||||
});
|
||||
fs.writeFileSync(path.join(staticRoot, filename), filename);
|
||||
}
|
||||
writeManifest(source, staticRoot);
|
||||
const manifestFile = path.join(staticRoot, 'build-info.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestFile));
|
||||
manifest.sourceCommit = git('-C', source, 'rev-parse', 'HEAD');
|
||||
fs.writeFileSync(manifestFile, JSON.stringify(manifest));
|
||||
const remotes = ['github', 'gitlab', 'gitee'].map((name) =>
|
||||
path.join(tmp, `${name}.git`),
|
||||
);
|
||||
for (const remote of remotes) git('init', '--bare', remote);
|
||||
const publish = path.resolve('scripts/publish-static.sh'),
|
||||
sync = path.resolve('scripts/sync-static.sh');
|
||||
const options = { cwd: source, stdio: 'pipe', env: process.env };
|
||||
execFileSync('bash', [publish, remotes[0], 'master', 'branch'], options);
|
||||
execFileSync('bash', [publish, remotes[0], 'v2.22.0', 'tag'], options);
|
||||
for (const remote of remotes.slice(1))
|
||||
execFileSync('bash', [sync, remote, 'v2.22.0', 'tag'], {
|
||||
...options,
|
||||
env: { ...process.env, QL_STATIC_SOURCE: remotes[0] },
|
||||
});
|
||||
const snapshot = git('-C', remotes[0], 'rev-parse', 'refs/heads/v2.22.0');
|
||||
for (const remote of remotes) {
|
||||
assert.equal(
|
||||
git('-C', remote, 'rev-parse', 'refs/heads/v2.22.0'),
|
||||
snapshot,
|
||||
);
|
||||
assert.equal(git('-C', remote, 'rev-parse', 'refs/tags/v2.22.0'), snapshot);
|
||||
assert.equal(
|
||||
git('-C', remote, 'show', 'v2.22.0:dist/.well-known/config'),
|
||||
'dist/.well-known/config',
|
||||
);
|
||||
}
|
||||
assert.notEqual(
|
||||
spawnSync('bash', [publish, remotes[0], 'v2.23.0', 'tag'], options).status,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
spawnSync('git', [
|
||||
'-C',
|
||||
remotes[0],
|
||||
'rev-parse',
|
||||
'--verify',
|
||||
'refs/tags/v2.23.0',
|
||||
]).status,
|
||||
128,
|
||||
);
|
||||
});
|
||||
|
||||
test('static backfill uses existing snapshots and skips all source, build and image publication jobs', () => {
|
||||
const workflow = require('js-yaml').load(
|
||||
fs.readFileSync('.github/workflows/build-docker-image.yml', 'utf8'),
|
||||
);
|
||||
assert.equal(
|
||||
workflow.on.workflow_dispatch.inputs.static_sync_only.default,
|
||||
false,
|
||||
);
|
||||
const job = workflow.jobs['sync-existing-static'];
|
||||
assert.match(job.if, /workflow_dispatch.*inputs.static_sync_only/);
|
||||
assert.deepEqual(job.strategy.matrix.mirror, ['gitlab']);
|
||||
const sync = job.steps.find((step) => step.run);
|
||||
assert.equal(sync.env.STATIC_REF, '${{ inputs.static_ref }}');
|
||||
assert.equal(sync.env.STATIC_REF_TYPE, '${{ inputs.static_ref_type }}');
|
||||
assert.match(sync.run, /bash scripts\/sync-static.sh/);
|
||||
assert.doesNotMatch(sync.run, /publish-static|docker|pub.sh/);
|
||||
for (const name of ['validate', 'code_gitlab', 'build-static'])
|
||||
assert.match(workflow.jobs[name].if, /!inputs.static_sync_only/);
|
||||
// Downstream jobs require the skipped build; none bypass dependency failures.
|
||||
for (const name of [
|
||||
'static_gitlab',
|
||||
'verify-gitee-mirrors',
|
||||
'build-alpine',
|
||||
'build-debian',
|
||||
'build-alpine310',
|
||||
'build-debian310',
|
||||
]) {
|
||||
assert.equal(workflow.jobs[name].needs, 'build-static');
|
||||
assert.doesNotMatch(workflow.jobs[name].if || '', /always\(/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
const logger = { info() {}, error() {} };
|
||||
|
||||
function cronFixture(killTask) {
|
||||
const events = [];
|
||||
const { runCron } = load('back/shared/runCron.ts', {
|
||||
'cross-spawn': {
|
||||
spawn: () => {
|
||||
events.push('spawn');
|
||||
return {};
|
||||
},
|
||||
},
|
||||
'./childProcess': {
|
||||
observeChildProcess: () => ({ completed: Promise.resolve({ code: 0 }) }),
|
||||
asError: (e) => e,
|
||||
},
|
||||
'./pLimit': {
|
||||
runWithCronLimit: async (_, fn) => fn(),
|
||||
removeQueuedCron: () => events.push('release'),
|
||||
},
|
||||
'../loaders/logger': logger,
|
||||
'../config/util': { killTask },
|
||||
'../data/cron': {
|
||||
CrontabModel: {
|
||||
findOne: async () => ({
|
||||
pid: 100,
|
||||
status: 0,
|
||||
allow_multiple_instances: 0,
|
||||
}),
|
||||
update: async () => events.push('idle'),
|
||||
},
|
||||
CrontabStatus: { running: 0, idle: 1, queued: 3 },
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: { update: async () => events.push('stopped') },
|
||||
InstanceStatus: { running: 0, stopped: 2 },
|
||||
},
|
||||
});
|
||||
return { run: () => runCron('ignored', { id: '1' }), events };
|
||||
}
|
||||
|
||||
test('single-instance replacement does not spawn or finalize when termination fails', async () => {
|
||||
const f = cronFixture(async () => {
|
||||
throw Error('EPERM');
|
||||
});
|
||||
await f.run();
|
||||
assert.deepEqual(f.events, ['release']);
|
||||
});
|
||||
|
||||
test('single-instance replacement waits for verified exit before spawning', async () => {
|
||||
let finish, entered, verified;
|
||||
const pending = new Promise((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const gate = new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
const f = cronFixture(async (pid, wait) => {
|
||||
assert.equal(pid, 100);
|
||||
verified = wait;
|
||||
entered();
|
||||
await gate;
|
||||
});
|
||||
const running = f.run();
|
||||
await pending;
|
||||
assert.deepEqual(f.events, []);
|
||||
finish();
|
||||
await running;
|
||||
assert.equal(verified, true);
|
||||
assert.deepEqual(f.events, ['stopped', 'idle', 'spawn', 'release']);
|
||||
});
|
||||
|
||||
function subscriptionFixture(killTask) {
|
||||
const updates = [];
|
||||
const Service = load('back/services/subscription.ts', {
|
||||
'../config': {},
|
||||
'../data/cron': {},
|
||||
'../config/const': {},
|
||||
'../data/subscription': {
|
||||
SubscriptionModel: {
|
||||
findAll: async () => [
|
||||
{ id: 1, pid: 101 },
|
||||
{ id: 2, pid: 102 },
|
||||
],
|
||||
update: async (values, query) => updates.push({ values, query }),
|
||||
},
|
||||
SubscriptionStatus: { idle: 1 },
|
||||
},
|
||||
'../config/util': { killTask },
|
||||
'../config/subscription': {},
|
||||
'../shared/i18n': {},
|
||||
'../shared/pLimit': {},
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
return { service: new Service(logger, {}, {}, {}, {}), updates };
|
||||
}
|
||||
|
||||
test('batch subscription stop preserves failed items and reports failure while stopping others', async () => {
|
||||
const calls = [];
|
||||
const denied = Error('EPERM');
|
||||
const f = subscriptionFixture(async (pid, wait) => {
|
||||
calls.push([pid, wait]);
|
||||
if (pid === 101) throw denied;
|
||||
});
|
||||
await assert.rejects(f.service.stop([1, 2]), (e) => e === denied);
|
||||
assert.deepEqual(calls, [
|
||||
[101, true],
|
||||
[102, true],
|
||||
]);
|
||||
assert.equal(f.updates.length, 1);
|
||||
assert.equal(f.updates[0].query.where.id, 2);
|
||||
assert.equal(f.updates[0].values.status, 1);
|
||||
});
|
||||
|
||||
function scriptFixture(killTask, pid = 100) {
|
||||
const Service = load('back/services/script.ts', {
|
||||
'../config': { scriptPath: '/scripts' },
|
||||
'../config/const': { TASK_COMMAND: 'task' },
|
||||
'../config/util': { killTask, getPid: async () => pid },
|
||||
'../shared/pLimit': { removeQueuedCron() {} },
|
||||
'./sock': {},
|
||||
'./cron': {},
|
||||
'./schedule': {},
|
||||
'../shared/fileAccess': {},
|
||||
}).default;
|
||||
return new Service(logger, {}, {}, {});
|
||||
}
|
||||
|
||||
test('script stop propagates failure instead of returning success', async () => {
|
||||
const denied = Error('EPERM');
|
||||
const service = scriptFixture(async () => {
|
||||
throw denied;
|
||||
});
|
||||
await assert.rejects(
|
||||
service.stopScript('/scripts/test.js', 100),
|
||||
(e) => e === denied,
|
||||
);
|
||||
});
|
||||
|
||||
test('script stop verifies exit and treats an absent queued process as already stopped', async () => {
|
||||
const calls = [];
|
||||
const service = scriptFixture(async (...args) => calls.push(args), undefined);
|
||||
assert.equal((await service.stopScript('/scripts/test.js', 100)).code, 200);
|
||||
assert.deepEqual(calls, [[100, true]]);
|
||||
const absent = scriptFixture(async () => assert.fail('no PID to stop'), null);
|
||||
assert.equal(
|
||||
(await absent.stopScript('/scripts/test.js', undefined)).code,
|
||||
200,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
async function fixture(t, onKill = async () => {}) {
|
||||
const logPath = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-stop-log-'));
|
||||
t.after(() => fs.rmSync(logPath, { recursive: true, force: true }));
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => db.close());
|
||||
const crons = db.define('Cron', {
|
||||
command: DataTypes.STRING,
|
||||
status: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
log_path: DataTypes.STRING,
|
||||
queued_token: DataTypes.STRING,
|
||||
last_execution_time: DataTypes.INTEGER,
|
||||
last_running_time: DataTypes.INTEGER,
|
||||
});
|
||||
const instances = db.define('Instance', {
|
||||
cron_id: DataTypes.INTEGER,
|
||||
pid: DataTypes.INTEGER,
|
||||
status: DataTypes.INTEGER,
|
||||
exit_code: DataTypes.INTEGER,
|
||||
finished_at: DataTypes.INTEGER,
|
||||
});
|
||||
await db.sync();
|
||||
await crons.bulkCreate([
|
||||
{ id: 1, command: 'task one.js', status: 0, pid: 101, log_path: 'one.log' },
|
||||
{ id: 2, command: 'task two.js', status: 0, pid: 201, log_path: 'two.log' },
|
||||
]);
|
||||
await instances.bulkCreate([
|
||||
{ id: 1, cron_id: 1, pid: 101, status: 0 },
|
||||
{ id: 2, cron_id: 1, pid: 102, status: 0 },
|
||||
{ id: 3, cron_id: 1, pid: 99, status: 1, exit_code: 0 },
|
||||
{ id: 4, cron_id: 1, pid: 98, status: 3, exit_code: 7 },
|
||||
{ id: 5, cron_id: 2, pid: 201, status: 0 },
|
||||
]);
|
||||
let service;
|
||||
const killed = [];
|
||||
const CronService = load(path.resolve('back/services/cron.ts'), {
|
||||
'../config': { logPath },
|
||||
'../data/cron': {
|
||||
CrontabModel: crons,
|
||||
CrontabStatus: { queued: 3, running: 0, idle: 1 },
|
||||
},
|
||||
'../data/runningInstance': {
|
||||
RunningInstanceModel: instances,
|
||||
InstanceStatus: { running: 0, finished: 1, stopped: 2, error: 3 },
|
||||
},
|
||||
'../config/util': {
|
||||
killTask: async (pid, wait) => {
|
||||
assert.equal(wait, true);
|
||||
killed.push(pid);
|
||||
await onKill({ pid, service, instances, crons });
|
||||
},
|
||||
killAllTasks: async () => {
|
||||
throw Error('command-wide scans must not run');
|
||||
},
|
||||
},
|
||||
'../config/const': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/pLimit': {},
|
||||
'../shared/utils': {},
|
||||
'../shared/i18n': { t: (s) => s },
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {},
|
||||
'../shared/childProcess': require('../../back/shared/childProcess'),
|
||||
}).default;
|
||||
service = new CronService({ info() {}, error() {} });
|
||||
service.getDb = ({ id }) => crons.findByPk(id);
|
||||
return { service, instances, crons, killed };
|
||||
}
|
||||
async function report(service, pid, code) {
|
||||
await service.status({
|
||||
ids: [1],
|
||||
status: 1,
|
||||
pid,
|
||||
log_path: 'one.log',
|
||||
last_running_time: 1,
|
||||
last_execution_time: 100,
|
||||
exit_code: code,
|
||||
});
|
||||
}
|
||||
test('stop wins when shell exit writes error or success during termination, preserving exit codes', async (t) => {
|
||||
const f = await fixture(t, async ({ service }) => {
|
||||
await report(service, 101, 143);
|
||||
await report(service, 102, 0);
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
const rows = await f.instances.findAll({ order: [['id', 'ASC']], raw: true });
|
||||
assert.deepEqual(
|
||||
rows.map((r) => [r.id, r.status, r.exit_code]),
|
||||
[
|
||||
[1, 2, 143],
|
||||
[2, 2, 0],
|
||||
[3, 1, 0],
|
||||
[4, 3, 7],
|
||||
[5, 0, null],
|
||||
],
|
||||
);
|
||||
});
|
||||
test('late shell completion cannot overwrite stopped instances', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.stop([1]);
|
||||
await report(f.service, 101, 143);
|
||||
assert.equal((await f.instances.findByPk(1)).status, 2);
|
||||
assert.equal((await f.instances.findByPk(2)).status, 2);
|
||||
});
|
||||
test('rows created after the stop snapshot are not relabelled', async (t) => {
|
||||
const f = await fixture(t, async ({ instances }) => {
|
||||
if (!(await instances.findByPk(6)))
|
||||
await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 });
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
assert.equal((await f.instances.findByPk(6)).status, 0);
|
||||
assert.equal((await f.instances.findByPk(1)).status, 2);
|
||||
});
|
||||
test('repeated stop does not rewrite historical rows or their finished timestamps', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.stop([1]);
|
||||
const before = await f.instances.findAll({
|
||||
raw: true,
|
||||
order: [['id', 'ASC']],
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
assert.deepEqual(
|
||||
await f.instances.findAll({ raw: true, order: [['id', 'ASC']] }),
|
||||
before,
|
||||
);
|
||||
});
|
||||
test('batch stop captures running instances from every requested cron', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.stop([1, 2]);
|
||||
assert.equal((await f.instances.findByPk(5)).status, 2);
|
||||
assert.equal((await f.instances.findByPk(3)).status, 1);
|
||||
});
|
||||
|
||||
test('stop signals every snapshotted PID exactly once and preserves a later running instance', async (t) => {
|
||||
const f = await fixture(t, async ({ instances, crons }) => {
|
||||
if (!(await instances.findByPk(6))) {
|
||||
await instances.create({ id: 6, cron_id: 1, pid: 103, status: 0 });
|
||||
await crons.update(
|
||||
{ pid: 103, log_path: 'later.log', status: 0 },
|
||||
{ where: { id: 1 } },
|
||||
);
|
||||
}
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
assert.deepEqual(f.killed, [101, 102]);
|
||||
assert.equal((await f.instances.findByPk(6)).status, 0);
|
||||
assert.equal((await f.crons.findByPk(1)).pid, 103);
|
||||
assert.equal((await f.crons.findByPk(1)).status, 0);
|
||||
});
|
||||
|
||||
test('failed termination is not finalized as stopped', async (t) => {
|
||||
const f = await fixture(t, async ({ pid }) => {
|
||||
if (pid === 102) throw Error('still alive');
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
assert.deepEqual(f.killed, [101, 102]);
|
||||
assert.equal((await f.instances.findByPk(1)).status, 2);
|
||||
assert.equal((await f.instances.findByPk(2)).status, 0);
|
||||
assert.equal((await f.crons.findByPk(1)).status, 0);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const dayjs = require('dayjs');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
test('subscription becomes idle and closes its log even when completion logging fails', async () => {
|
||||
const updates = [];
|
||||
let closed = 0;
|
||||
let notified = 0;
|
||||
const Subscription = load(path.resolve('back/services/subscription.ts'), {
|
||||
'../config': {},
|
||||
'../data/subscription': {
|
||||
SubscriptionModel: { update: async (values) => updates.push(values) },
|
||||
SubscriptionStatus: { idle: 1 },
|
||||
},
|
||||
'../data/cron': {},
|
||||
'../config/util': { handleLogPath: async () => '/tmp/unused-log' },
|
||||
'../config/const': { LOG_END_SYMBOL: 'end' },
|
||||
'../config/subscription': {},
|
||||
'../shared/i18n': { t: (s) => s, tf: (s) => s },
|
||||
'../shared/pLimit': {},
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {
|
||||
logStreamManager: {
|
||||
write: async () => {
|
||||
throw new Error('ENOSPC');
|
||||
},
|
||||
closeStream: async () => {
|
||||
closed++;
|
||||
throw new Error('ENOSPC');
|
||||
},
|
||||
},
|
||||
},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
const service = new Subscription(
|
||||
{},
|
||||
{},
|
||||
{ sendMessage: () => notified++ },
|
||||
{},
|
||||
{},
|
||||
);
|
||||
service.getDb = async () => ({ id: 1, log_path: 'log' });
|
||||
const callbacks = service.taskCallbacks({ id: 1 });
|
||||
await assert.rejects(callbacks.onEnd(undefined, dayjs(), 1), /ENOSPC/);
|
||||
assert.equal(closed, 1);
|
||||
assert.equal(updates.at(-1).status, 1);
|
||||
assert.equal(notified, 1);
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
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 { spawn } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-subscription-lock-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const config = { scriptPath: path.join(root, 'scripts/') };
|
||||
const { withSubscriptionMutation } = load(
|
||||
'back/shared/subscriptionMutationLock.ts',
|
||||
{ '../config': config },
|
||||
);
|
||||
return { root, config, withSubscriptionMutation };
|
||||
}
|
||||
|
||||
test('subscription ownership mutations exclude a second process until cleanup completes', async (t) => {
|
||||
const f = fixture(t);
|
||||
let release, entered;
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const held = new Promise((resolve) => (entered = resolve));
|
||||
t.after(() => release());
|
||||
const first = f.withSubscriptionMutation(async () => {
|
||||
entered();
|
||||
await gate;
|
||||
});
|
||||
await held;
|
||||
const marker = path.join(f.root, 'second-writer');
|
||||
const source = `
|
||||
const load = require(${JSON.stringify(
|
||||
path.resolve('test/helpers/load-security-module.cjs'),
|
||||
)});
|
||||
const { withSubscriptionMutation } = load(${JSON.stringify(
|
||||
path.resolve('back/shared/subscriptionMutationLock.ts'),
|
||||
)}, { '../config': ${JSON.stringify(f.config)} });
|
||||
process.send('attempt');
|
||||
withSubscriptionMutation(async () => require('fs').writeFileSync(${JSON.stringify(
|
||||
marker,
|
||||
)}, 'done'))
|
||||
.then(() => process.exit(0), (error) => { console.error(error); process.exit(1); });
|
||||
`;
|
||||
const child = spawn(process.execPath, ['-e', source], {
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
});
|
||||
t.after(() => child.kill());
|
||||
let errors = '';
|
||||
child.stderr.on('data', (chunk) => (errors += chunk));
|
||||
const completed = new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code) =>
|
||||
code === 0
|
||||
? resolve()
|
||||
: reject(new Error(errors || `child exit ${code}`)),
|
||||
);
|
||||
});
|
||||
await new Promise((resolve) => child.once('message', resolve));
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
assert.equal(fs.existsSync(marker), false);
|
||||
release();
|
||||
await Promise.all([first, completed]);
|
||||
assert.equal(fs.readFileSync(marker, 'utf8'), 'done');
|
||||
assert.equal(
|
||||
fs.existsSync(`${path.resolve(f.config.scriptPath)}.subscriptions.lock`),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('failed subscription mutations release the lock before the next operation', async (t) => {
|
||||
const f = fixture(t);
|
||||
await assert.rejects(
|
||||
f.withSubscriptionMutation(async () => {
|
||||
throw new Error('failed cleanup');
|
||||
}),
|
||||
/failed cleanup/,
|
||||
);
|
||||
assert.equal(
|
||||
await f.withSubscriptionMutation(async () => 'next mutation'),
|
||||
'next mutation',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,607 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const ts = require('typescript');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const paths = load(path.resolve('back/shared/subscriptionPath.ts'));
|
||||
const { formatUrl, formatCommand } = load(
|
||||
path.resolve('back/config/subscription.ts'),
|
||||
);
|
||||
|
||||
function temporary(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-sub-review-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
}
|
||||
|
||||
function privateSubscription(alias, id = 1) {
|
||||
return {
|
||||
id,
|
||||
type: 'private-repo',
|
||||
pull_type: 'ssh-key',
|
||||
alias,
|
||||
url: 'git@example.com:owner/repo.git',
|
||||
pull_option: { private_key: 'fixture-key' },
|
||||
};
|
||||
}
|
||||
|
||||
function sshService(root, warnings = []) {
|
||||
const Ssh = load(path.resolve('back/services/sshKey.ts'), {
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
'../config': { sshdPath: root },
|
||||
'../config/util': {
|
||||
fileExist: async (p) => fs.existsSync(p),
|
||||
rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }),
|
||||
},
|
||||
}).default;
|
||||
return Object.assign(Object.create(Ssh.prototype), {
|
||||
sshPath: root,
|
||||
logger: { error() {}, warn: (...args) => warnings.push(args) },
|
||||
});
|
||||
}
|
||||
|
||||
test('aliases generated by the frontend for valid branches remain accepted', () => {
|
||||
const text = fs.readFileSync(
|
||||
path.resolve('src/pages/subscription/modal.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const source = text.slice(
|
||||
text.indexOf('const formatAlias ='),
|
||||
text.indexOf('\n\n const IntervalSelect'),
|
||||
);
|
||||
const js = ts.transpileModule(source, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const formatAlias = new Function(
|
||||
'repoUrlRegx',
|
||||
'fileUrlRegx',
|
||||
'type',
|
||||
js + '\nreturn formatAlias;',
|
||||
)(
|
||||
/([^\/\:]+\/[^\/]+)(?=\.git)/,
|
||||
/([^\/\:]+\/[^\/\.]+)\.[a-z]+/,
|
||||
'public-repo',
|
||||
);
|
||||
for (const branch of [
|
||||
'main',
|
||||
'release+hotfix',
|
||||
'feature/foo',
|
||||
'feature/foo/bar',
|
||||
'release@v2',
|
||||
'release#v2',
|
||||
]) {
|
||||
const alias = formatAlias('https://example.com/owner/repo.git', branch);
|
||||
assert.equal(alias, `owner_repo_${branch}`);
|
||||
assert.doesNotThrow(() => paths.assertSubscriptionAlias(alias));
|
||||
}
|
||||
});
|
||||
|
||||
test('nested aliases stay inside the root and cannot claim internal SSH names', (t) => {
|
||||
const root = temporary(t);
|
||||
const scripts = path.join(root, 'scripts');
|
||||
fs.mkdirSync(scripts);
|
||||
assert.equal(
|
||||
paths.resolveSubscriptionPath(scripts, 'owner_repo_feature/foo'),
|
||||
path.join(scripts, 'owner_repo_feature/foo'),
|
||||
);
|
||||
fs.symlinkSync(root, path.join(scripts, 'escape'));
|
||||
assert.throws(() => paths.resolveSubscriptionPath(scripts, 'escape/outside'));
|
||||
for (const alias of [
|
||||
'.',
|
||||
'..',
|
||||
'../outside',
|
||||
'/outside',
|
||||
'x/../outside',
|
||||
'x/./a',
|
||||
'x//a',
|
||||
'x/',
|
||||
'x\\a',
|
||||
'x\0a',
|
||||
'x\nHost *',
|
||||
'x y',
|
||||
'~global_secret',
|
||||
'~sub_' + 'a'.repeat(64),
|
||||
]) {
|
||||
assert.throws(() => paths.assertSubscriptionAlias(alias), alias);
|
||||
}
|
||||
assert.throws(() => paths.assertSubscriptionAlias('a'.repeat(201)));
|
||||
});
|
||||
|
||||
test('SSH URL, key and configuration use the same stable name for nested aliases', async (t) => {
|
||||
const root = temporary(t);
|
||||
const ssh = sshService(root);
|
||||
const docs = [
|
||||
privateSubscription('owner_repo_release+hotfix'),
|
||||
privateSubscription('owner_repo_feature/foo', 2),
|
||||
privateSubscription('owner_repo_feature_foo', 3),
|
||||
privateSubscription('owner_repo.config', 4),
|
||||
];
|
||||
const snapshot = structuredClone(docs);
|
||||
await ssh.setSshConfig(docs);
|
||||
assert.deepEqual(docs, snapshot);
|
||||
const mapped = paths.getSubscriptionSshAlias(docs[1].alias);
|
||||
assert.match(mapped, /^~sub_[a-f0-9]{64}$/);
|
||||
assert.notEqual(mapped, paths.getSubscriptionSshAlias(docs[2].alias));
|
||||
assert.equal(paths.getSubscriptionSshAlias(docs[0].alias), docs[0].alias);
|
||||
for (const doc of docs) {
|
||||
const name = paths.getSubscriptionSshAlias(doc.alias);
|
||||
const key = path.join(root, name);
|
||||
const config = path.join(root, `${name}.config`);
|
||||
assert.equal(fs.readFileSync(key, 'utf8').trim(), 'fixture-key');
|
||||
assert.equal(fs.statSync(key).mode & 0o777, 0o400);
|
||||
assert.equal(fs.statSync(config).mode & 0o777, 0o600);
|
||||
assert.ok(fs.readFileSync(config, 'utf8').startsWith(`Host ${name}\n`));
|
||||
assert.equal(formatUrl(doc).url, `git@${name}:owner/repo.git`);
|
||||
const parsed = execFileSync('ssh', ['-G', '-F', config, name], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
assert.match(parsed, /^hostname example\.com$/m);
|
||||
assert.ok(parsed.includes(`identityfile ${key}\n`));
|
||||
}
|
||||
await ssh.removeSSHKey(docs[1].alias, 'example.com');
|
||||
assert.equal(fs.existsSync(path.join(root, mapped)), false);
|
||||
assert.equal(fs.existsSync(path.join(root, `${mapped}.config`)), false);
|
||||
await ssh.addSSHKey('replacement', docs[1].alias, 'example.com');
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(root, mapped), 'utf8').trim(),
|
||||
'replacement',
|
||||
);
|
||||
});
|
||||
|
||||
test('startup retains historical aliases and isolates bad rows and asynchronous scheduling failures', async (t) => {
|
||||
const root = temporary(t);
|
||||
const warnings = [];
|
||||
const ssh = sshService(root, warnings);
|
||||
const docs = [
|
||||
privateSubscription('owner_repo_release+hotfix', 1),
|
||||
privateSubscription('owner_repo_feature/foo', 2),
|
||||
privateSubscription('../outside', 3),
|
||||
{
|
||||
id: 4,
|
||||
type: 'public-repo',
|
||||
alias: 'failed-job',
|
||||
url: 'https://example.com/repo.git',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
type: 'public-repo',
|
||||
alias: 'healthy-job',
|
||||
url: 'https://example.com/repo.git',
|
||||
},
|
||||
];
|
||||
const registered = [];
|
||||
const types = Object.fromEntries(
|
||||
['system', 'schedule', 'subscription', 'ssh'].map((key) => [key, class {}]),
|
||||
);
|
||||
const services = new Map([
|
||||
[types.system, { getSystemConfig: async () => ({}) }],
|
||||
[
|
||||
types.schedule,
|
||||
{ cancelIntervalTask: async () => {}, createIntervalTask: () => {} },
|
||||
],
|
||||
[types.ssh, ssh],
|
||||
[
|
||||
types.subscription,
|
||||
{
|
||||
setSshConfig: () => ssh.setSshConfig(docs),
|
||||
list: async () => docs.map((doc) => ({ ...doc, get: () => doc })),
|
||||
handleTask: async (doc) => {
|
||||
if (doc.id === 4) throw new Error('unavailable scheduler');
|
||||
registered.push(doc.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
const initTask = load(path.resolve('back/loaders/initTask.ts'), {
|
||||
'../config': { rootPath: root },
|
||||
'../config/util': { fileExist: async () => false },
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'./logger': { warn: (...args) => warnings.push(args) },
|
||||
'../services/system': types.system,
|
||||
'../services/schedule': types.schedule,
|
||||
'../services/subscription': types.subscription,
|
||||
'../services/sshKey': types.ssh,
|
||||
typedi: { Container: { get: (key) => services.get(key) } },
|
||||
}).default;
|
||||
await initTask();
|
||||
assert.deepEqual(registered, [1, 2, 5]);
|
||||
assert.equal(warnings.length, 3);
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(root, 'owner_repo_release+hotfix.config')),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('subscription create and update preserve branch aliases without rewriting records', async (t) => {
|
||||
const root = temporary(t);
|
||||
const Subscription = load(path.resolve('back/services/subscription.ts'), {
|
||||
'../config': { logPath: root, scriptPath: root },
|
||||
'../data/subscription': {
|
||||
Subscription: class {
|
||||
constructor(value) {
|
||||
Object.assign(this, value);
|
||||
}
|
||||
},
|
||||
SubscriptionModel: {},
|
||||
},
|
||||
'../data/cron': {},
|
||||
'../config/util': {},
|
||||
'../config/const': {},
|
||||
'../shared/i18n': { t: (x) => x, tf: (x) => x },
|
||||
'../shared/pLimit': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
const service = new Subscription({}, {}, {}, {}, {});
|
||||
const tasks = [];
|
||||
service.insert = async (value) => ({ ...value, get: () => value });
|
||||
service.getDb = async () => ({ id: 1, alias: 'owner_repo_release+hotfix' });
|
||||
service.updateDb = async (value) => value;
|
||||
service.handleTask = async (doc) => tasks.push(doc.alias);
|
||||
service.setSshConfig = async () => {};
|
||||
for (const alias of ['owner_repo_release+hotfix', 'owner_repo_feature/foo']) {
|
||||
await service.create({ id: 1, alias });
|
||||
await service.update({ id: 1, alias });
|
||||
}
|
||||
assert.deepEqual(tasks, [
|
||||
'owner_repo_release+hotfix',
|
||||
'owner_repo_release+hotfix',
|
||||
'owner_repo_feature/foo',
|
||||
'owner_repo_feature/foo',
|
||||
]);
|
||||
});
|
||||
|
||||
function shellFunction(name) {
|
||||
const source = fs.readFileSync(path.resolve('shell/update.sh'), 'utf8');
|
||||
const start = source.indexOf(`${name}() {`);
|
||||
assert.ok(start >= 0);
|
||||
return source.slice(start, source.indexOf('\n}', start) + 2);
|
||||
}
|
||||
|
||||
function updateFixture(t) {
|
||||
const root = temporary(t);
|
||||
const template = path.join(root, 'template');
|
||||
for (const dir of ['template', 'repo', 'scripts', 'lists'])
|
||||
fs.mkdirSync(path.join(root, dir));
|
||||
for (const file of ['keep.js', 'keep.py', 'omit.js', 'other.txt', 'dep.js'])
|
||||
fs.writeFileSync(path.join(template, file), file);
|
||||
for (const file of ['notify.js', 'notify.py', 'crontab'])
|
||||
fs.writeFileSync(path.join(root, file), '');
|
||||
const helpers = [
|
||||
'update_repo',
|
||||
'diff_scripts',
|
||||
'gen_list_repo',
|
||||
'get_uniq_path',
|
||||
]
|
||||
.map(shellFunction)
|
||||
.join('\n');
|
||||
return {
|
||||
root,
|
||||
run: (extensions, dependence = 'dep') => {
|
||||
const doc = {
|
||||
id: 1,
|
||||
type: 'public-repo',
|
||||
alias: 'owner_repo',
|
||||
url: 'https://example.invalid/owner/repo.git',
|
||||
whitelist: 'keep',
|
||||
blacklist: 'omit',
|
||||
dependences: dependence,
|
||||
extensions,
|
||||
autoAddCron: false,
|
||||
autoDelCron: false,
|
||||
};
|
||||
const code = `dir_repo="$QA_ROOT/repo"; dir_scripts="$QA_ROOT/scripts"; dir_list_tmp="$QA_ROOT/lists"
|
||||
file_notify_js="$QA_ROOT/notify.js"; file_notify_py="$QA_ROOT/notify.py"; list_crontab_user="$QA_ROOT/crontab"
|
||||
cmd_task=task; file_extensions='js py'
|
||||
${helpers}
|
||||
t() { :; }; make_dir() { mkdir -p "$1"; }; diff_cron() { :; }
|
||||
git_clone_scripts() { mkdir -p "$2"; cp "$QA_ROOT/template/"* "$2/"; exit_status=0; }
|
||||
ql() { get_uniq_path "$2" "$6"; update_repo "${'${@:2}'}"; }
|
||||
${formatCommand(doc)}`;
|
||||
execFileSync('/bin/bash', ['-c', code], {
|
||||
env: { ...process.env, QA_ROOT: root },
|
||||
encoding: 'utf8',
|
||||
timeout: 5000,
|
||||
});
|
||||
return path.join(root, 'scripts', 'owner_repo');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('real repository update supports extension separators, filtering and dependency copies', (t) => {
|
||||
for (const extension of ['js|py', 'js py', 'js\tpy', 'js\npy', '']) {
|
||||
const f = updateFixture(t);
|
||||
const scripts = f.run(extension);
|
||||
assert.deepEqual(fs.readdirSync(scripts).sort(), [
|
||||
'dep.js',
|
||||
'keep.js',
|
||||
'keep.py',
|
||||
'notify.js',
|
||||
'notify.py',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
fs
|
||||
.readFileSync(
|
||||
path.join(f.root, 'lists', 'owner_repo_scripts.list'),
|
||||
'utf8',
|
||||
)
|
||||
.trim()
|
||||
.split('\n')
|
||||
.sort(),
|
||||
['owner_repo/keep.js', 'owner_repo/keep.py'],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('real repository update never evaluates extension input, including dependency discovery', (t) => {
|
||||
const f = updateFixture(t);
|
||||
const marker = path.join(f.root, 'executed');
|
||||
const payloads = [
|
||||
'js|$(touch${IFS}' + marker + ')',
|
||||
'js|`touch${IFS}' + marker + '`',
|
||||
'js|py";touch${IFS}' + marker + ';#',
|
||||
'js|$(touch${IFS}' + marker + ')\npy',
|
||||
];
|
||||
for (const payload of payloads) {
|
||||
const scripts = f.run(payload);
|
||||
assert.equal(fs.existsSync(marker), false, payload);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(scripts, 'keep.js'), 'utf8'),
|
||||
'keep.js',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(scripts, 'dep.js'), 'utf8'),
|
||||
'dep.js',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('force deletion supports historical nested aliases while preserving outside targets', async (t) => {
|
||||
const root = temporary(t);
|
||||
const config = {
|
||||
scriptPath: path.join(root, 'scripts'),
|
||||
repoPath: path.join(root, 'repo'),
|
||||
};
|
||||
const alias = 'owner_repo_feature/foo';
|
||||
for (const dir of [config.scriptPath, config.repoPath]) {
|
||||
fs.mkdirSync(path.join(dir, alias), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, alias, 'file'), 'fixture');
|
||||
}
|
||||
const outside = path.join(root, 'outside');
|
||||
fs.mkdirSync(outside);
|
||||
fs.writeFileSync(path.join(outside, 'file'), 'keep');
|
||||
let destroyed = 0;
|
||||
const doc = { id: 1, alias, get: () => doc };
|
||||
const Subscription = load(path.resolve('back/services/subscription.ts'), {
|
||||
'../config': config,
|
||||
'../data/subscription': {
|
||||
SubscriptionModel: {
|
||||
findAll: async () => [doc],
|
||||
destroy: async () => destroyed++,
|
||||
},
|
||||
},
|
||||
'../data/cron': { CrontabModel: { findAll: async () => [] } },
|
||||
'../config/util': {
|
||||
rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }),
|
||||
},
|
||||
'../config/const': {},
|
||||
'../shared/i18n': { t: (x) => x, tf: (x) => x },
|
||||
'../shared/pLimit': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
const service = new Subscription({}, {}, {}, {}, {});
|
||||
service.handleTask = async () => {};
|
||||
service.setSshConfig = async () => {};
|
||||
await service.remove([1], { force: true });
|
||||
assert.equal(destroyed, 1);
|
||||
for (const dir of [config.scriptPath, config.repoPath])
|
||||
assert.equal(fs.existsSync(path.join(dir, alias)), false);
|
||||
fs.symlinkSync(outside, path.join(config.scriptPath, 'escape'));
|
||||
doc.alias = 'escape/file';
|
||||
await assert.rejects(
|
||||
service.remove([1], { force: true }),
|
||||
/Invalid subscription path/,
|
||||
);
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(fs.readFileSync(path.join(outside, 'file'), 'utf8'), 'keep');
|
||||
assert.equal(paths.getSubscriptionSshAlias('仓库-main'), '仓库-main');
|
||||
});
|
||||
|
||||
function cancellationFixture(t, subscriptions) {
|
||||
const root = temporary(t);
|
||||
const events = [];
|
||||
const docs = subscriptions.map((value) => {
|
||||
const doc = { ...value };
|
||||
doc.get = () => ({ ...doc });
|
||||
return doc;
|
||||
});
|
||||
const Subscription = load(path.resolve('back/services/subscription.ts'), {
|
||||
'../config': { scriptPath: root, repoPath: root },
|
||||
'../data/subscription': {
|
||||
SubscriptionModel: {
|
||||
findAll: async ({ where }) =>
|
||||
docs.filter((doc) => !where.id || where.id.includes(doc.id)),
|
||||
destroy: async ({ where }) => events.push(['destroy', where.id]),
|
||||
update: async (value, { where }) => {
|
||||
for (const doc of docs) {
|
||||
if (where.id.includes(doc.id)) Object.assign(doc, value);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
'../data/cron': {},
|
||||
'../config/util': { rmPath: async () => events.push(['removeFile']) },
|
||||
'../config/const': {},
|
||||
'../shared/i18n': { t: (x) => x, tf: (x) => x },
|
||||
'../shared/pLimit': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
const schedule = {
|
||||
cancelCronTask: (doc) => events.push(['cancelCron', doc.id]),
|
||||
cancelIntervalTask: (doc) => events.push(['cancelInterval', doc.id]),
|
||||
createCronTask: async (doc, callbacks, immediately) =>
|
||||
events.push(['createCron', doc.command, immediately]),
|
||||
createIntervalTask: async (doc, interval, immediately) =>
|
||||
events.push(['createInterval', doc.command, interval, immediately]),
|
||||
};
|
||||
const service = new Subscription(
|
||||
{}, schedule, {}, { removeSSHKey: async () => {} }, {},
|
||||
);
|
||||
service.setSshConfig = async () => {};
|
||||
return { service, docs, events };
|
||||
}
|
||||
|
||||
async function sshLifecycleFixture(t, initial) {
|
||||
const root = temporary(t);
|
||||
const ssh = sshService(root);
|
||||
let docs = [{ ...initial, get() { return { ...this }; } }];
|
||||
const SubscriptionService = load(path.resolve('back/services/subscription.ts'), {
|
||||
'../config': { scriptPath: root, repoPath: root },
|
||||
'../data/subscription': {
|
||||
Subscription: class { constructor(value) { Object.assign(this, value); } },
|
||||
SubscriptionModel: {
|
||||
findAll: async () => docs,
|
||||
destroy: async () => { docs = []; },
|
||||
},
|
||||
},
|
||||
'../data/cron': { CrontabModel: { findAll: async () => [] } },
|
||||
'../config/util': {
|
||||
rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }),
|
||||
},
|
||||
'../config/const': {},
|
||||
'../shared/i18n': { t: (x) => x, tf: (x) => x },
|
||||
'../shared/pLimit': {},
|
||||
'./schedule': {}, './sock': {}, './sshKey': {}, './cron': {},
|
||||
}).default;
|
||||
const service = new SubscriptionService({}, {}, {}, ssh, {});
|
||||
service.handleTask = async () => {};
|
||||
service.getDb = async () => ({ ...docs[0] });
|
||||
service.updateDb = async (value) => {
|
||||
Object.assign(docs[0], value);
|
||||
return { ...docs[0] };
|
||||
};
|
||||
await service.setSshConfig();
|
||||
return { service, root };
|
||||
}
|
||||
|
||||
test('editing an SSH subscription removes the old alias key and configuration', async (t) => {
|
||||
const initial = privateSubscription('owner_repo_main');
|
||||
const f = await sshLifecycleFixture(t, initial);
|
||||
const alias = 'owner_repo_feature/中文';
|
||||
await f.service.update({ ...initial, alias });
|
||||
const mapped = paths.getSubscriptionSshAlias(alias);
|
||||
assert.equal(fs.existsSync(path.join(f.root, initial.alias)), false);
|
||||
assert.equal(fs.existsSync(path.join(f.root, initial.alias + '.config')), false);
|
||||
assert.equal(fs.readFileSync(path.join(f.root, mapped), 'utf8').trim(), 'fixture-key');
|
||||
assert.ok(fs.existsSync(path.join(f.root, mapped + '.config')));
|
||||
});
|
||||
|
||||
test('switching an SSH subscription to a public repository removes obsolete credentials', async (t) => {
|
||||
const initial = privateSubscription('owner_repo.config');
|
||||
const f = await sshLifecycleFixture(t, initial);
|
||||
await f.service.update({ ...initial, type: 'public-repo' });
|
||||
assert.deepEqual(fs.readdirSync(f.root), []);
|
||||
});
|
||||
|
||||
test('ordinary and forced subscription deletion remove the current SSH key and configuration', async (t) => {
|
||||
for (const force of [false, true]) {
|
||||
const initial = privateSubscription('owner_repo_feature/中文');
|
||||
const f = await sshLifecycleFixture(t, initial);
|
||||
await f.service.remove([initial.id], { force });
|
||||
assert.deepEqual(fs.readdirSync(f.root), []);
|
||||
}
|
||||
});
|
||||
|
||||
test('ordinary deletion cancels invalid historical SSH subscriptions by ID', async (t) => {
|
||||
for (const schedule_type of ['crontab', 'interval']) {
|
||||
const f = cancellationFixture(t, [
|
||||
{
|
||||
...privateSubscription('../outside'),
|
||||
schedule_type,
|
||||
interval_schedule: { type: 'hours', value: 1 },
|
||||
},
|
||||
]);
|
||||
await f.service.remove([1], { force: false });
|
||||
assert.deepEqual(f.events, [
|
||||
[schedule_type === 'crontab' ? 'cancelCron' : 'cancelInterval', 1],
|
||||
['destroy', [1]],
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('invalid historical SSH rows do not block mixed batch deletion or disabling', async (t) => {
|
||||
const docs = [
|
||||
{ ...privateSubscription('healthy', 1), schedule_type: 'crontab' },
|
||||
{ ...privateSubscription('../outside', 2), schedule_type: 'crontab' },
|
||||
{
|
||||
...privateSubscription('old alias', 3),
|
||||
schedule_type: 'interval',
|
||||
interval_schedule: { type: 'hours', value: 1 },
|
||||
},
|
||||
];
|
||||
const cancellations = [
|
||||
['cancelCron', 1],
|
||||
['cancelCron', 2],
|
||||
['cancelInterval', 3],
|
||||
];
|
||||
const removed = cancellationFixture(t, docs);
|
||||
await removed.service.remove([1, 2, 3], {});
|
||||
assert.deepEqual(removed.events, [...cancellations, ['destroy', [1, 2, 3]]]);
|
||||
const disabled = cancellationFixture(t, docs);
|
||||
await disabled.service.disabled([1, 2, 3]);
|
||||
assert.deepEqual(disabled.events, cancellations);
|
||||
assert.ok(disabled.docs.every((doc) => doc.is_disabled === 1));
|
||||
});
|
||||
|
||||
test('cancellation bypass leaves alias validation for creation and forced deletion intact', async (t) => {
|
||||
const doc = {
|
||||
...privateSubscription('../outside'),
|
||||
schedule_type: 'crontab',
|
||||
};
|
||||
const f = cancellationFixture(t, [doc]);
|
||||
await assert.rejects(f.service.handleTask(doc), /Invalid subscription alias/);
|
||||
await assert.rejects(f.service.create(doc), /Invalid subscription alias/);
|
||||
await assert.rejects(f.service.enabled([1]), /Invalid subscription alias/);
|
||||
await assert.rejects(
|
||||
f.service.remove([1], { force: true }),
|
||||
/Invalid subscription alias/,
|
||||
);
|
||||
assert.deepEqual(f.events, []);
|
||||
});
|
||||
|
||||
test('valid subscription scheduling still creates quoted commands for both schedule types', async (t) => {
|
||||
for (const schedule_type of ['crontab', 'interval']) {
|
||||
const doc = {
|
||||
...privateSubscription('owner_repo_feature/foo'),
|
||||
schedule_type,
|
||||
interval_schedule: { type: 'hours', value: 1 },
|
||||
};
|
||||
const f = cancellationFixture(t, [doc]);
|
||||
await f.service.handleTask(doc, true, true);
|
||||
const command = formatCommand(doc);
|
||||
assert.deepEqual(
|
||||
f.events,
|
||||
schedule_type === 'crontab'
|
||||
? [
|
||||
['cancelCron', 1],
|
||||
['createCron', command, true],
|
||||
]
|
||||
: [
|
||||
['cancelInterval', 1],
|
||||
['createInterval', command, { hours: 1 }, true],
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
async function fixture(t, onKill = async () => {}) {
|
||||
const db = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
logging: false,
|
||||
});
|
||||
t.after(() => db.close());
|
||||
const rows = db.define('Subscription', {
|
||||
pid: DataTypes.INTEGER,
|
||||
status: DataTypes.INTEGER,
|
||||
});
|
||||
await db.sync();
|
||||
await rows.create({ id: 1, pid: 101, status: 0 });
|
||||
const Service = load('back/services/subscription.ts', {
|
||||
'../config': {},
|
||||
'../data/cron': {},
|
||||
'../config/const': {},
|
||||
'../data/subscription': {
|
||||
SubscriptionModel: rows,
|
||||
SubscriptionStatus: { idle: 1 },
|
||||
},
|
||||
'../config/util': { killTask: async () => onKill(rows) },
|
||||
'../config/subscription': {},
|
||||
'../shared/i18n': {},
|
||||
'../shared/pLimit': {},
|
||||
'../shared/logReader': {},
|
||||
'../shared/logStreamManager': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
return { rows, service: new Service({ error() {} }, {}, {}, {}, {}) };
|
||||
}
|
||||
|
||||
test('successful subscription stop clears the stored PID', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.service.stop([1]);
|
||||
const row = await f.rows.findByPk(1);
|
||||
assert.equal(row.status, 1);
|
||||
assert.equal(row.pid, null);
|
||||
});
|
||||
|
||||
test('subscription stop does not overwrite a replacement process started during termination', async (t) => {
|
||||
const f = await fixture(t, async (rows) => {
|
||||
await rows.update({ pid: 202, status: 0 }, { where: { id: 1 } });
|
||||
});
|
||||
await f.service.stop([1]);
|
||||
const row = await f.rows.findByPk(1);
|
||||
assert.equal(row.status, 0);
|
||||
assert.equal(row.pid, 202);
|
||||
});
|
||||
|
||||
test('subscription stop cancels a queued row without a PID', async (t) => {
|
||||
const f = await fixture(t, () => assert.fail('queued row has no process'));
|
||||
await f.rows.update({ pid: null, status: 3 }, { where: { id: 1 } });
|
||||
await f.service.stop([1]);
|
||||
const row = await f.rows.findByPk(1);
|
||||
assert.equal(row.status, 1);
|
||||
assert.equal(row.pid, null);
|
||||
});
|
||||
@@ -0,0 +1,553 @@
|
||||
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 { execFileSync } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { getSubscriptionStorageNames, getSubscriptionStorageCandidates } = load(
|
||||
'back/shared/subscriptionStorage.ts',
|
||||
);
|
||||
const { formatUrl } = load('back/config/subscription.ts');
|
||||
const cases = [
|
||||
{
|
||||
type: 'public-repo',
|
||||
url: 'git://127.0.0.1:9418/public.git',
|
||||
branch: 'main',
|
||||
},
|
||||
{
|
||||
type: 'public-repo',
|
||||
url: 'https://github.com/owner/repo.git',
|
||||
branch: 'feature/release+hotfix',
|
||||
},
|
||||
{ type: 'public-repo', url: 'https://github.com/owner/repo.git/' },
|
||||
{
|
||||
type: 'private-repo',
|
||||
pull_type: 'ssh-key',
|
||||
url: 'root@127.0.0.1:/qa/fixtures/private.git',
|
||||
branch: 'main',
|
||||
},
|
||||
{
|
||||
type: 'private-repo',
|
||||
pull_type: 'ssh-key',
|
||||
url: 'git@github.com:owner/repo.git',
|
||||
branch: 'dev',
|
||||
},
|
||||
{
|
||||
type: 'private-repo',
|
||||
pull_type: 'user-pwd',
|
||||
pull_option: { username: 'tester', password: 'password' },
|
||||
url: 'http://127.0.0.1:8788/git/http_private.git',
|
||||
branch: 'main',
|
||||
},
|
||||
{ type: 'file', url: 'http://127.0.0.1:8788/raw.js' },
|
||||
{ type: 'file', url: 'https://example.com/owner/job.py', branch: 'ignored' },
|
||||
].map((doc, index) => ({
|
||||
...doc,
|
||||
id: index + 1,
|
||||
alias: `custom/alias+${index}`,
|
||||
}));
|
||||
const source = fs.readFileSync('shell/update.sh', 'utf8');
|
||||
const bashFunction = source.match(/^get_uniq_path\(\) \{[\s\S]*?^\}/m)[0];
|
||||
function shellNames(doc) {
|
||||
const url = formatUrl(doc).url;
|
||||
const stem = execFileSync(
|
||||
'bash',
|
||||
[
|
||||
'-c',
|
||||
`${bashFunction}\nget_uniq_path "$1" "$2"\nprintf '%s' "$uniq_path"`,
|
||||
'--',
|
||||
url,
|
||||
doc.type === 'file' ? '' : doc.branch || '',
|
||||
],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
if (doc.type === 'file') {
|
||||
const raw = `${stem}.${url.slice(url.lastIndexOf('.') + 1)}`;
|
||||
return { script: `raw_${raw}`, raw };
|
||||
}
|
||||
return { script: stem, repo: stem };
|
||||
}
|
||||
|
||||
test('subscription deletion paths match the actual shell URL/branch naming for public, private and raw subscriptions', () => {
|
||||
for (const doc of cases)
|
||||
assert.deepEqual(
|
||||
getSubscriptionStorageNames(doc),
|
||||
shellNames(doc),
|
||||
doc.url,
|
||||
);
|
||||
});
|
||||
|
||||
function fixture(t, values) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-storage-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const config = {
|
||||
dataPath: root,
|
||||
scriptPath: path.join(root, 'scripts'),
|
||||
repoPath: path.join(root, 'repo'),
|
||||
};
|
||||
fs.mkdirSync(config.scriptPath, { recursive: true });
|
||||
fs.mkdirSync(config.repoPath, { recursive: true });
|
||||
const events = [];
|
||||
const docs = values.map((value) => ({
|
||||
...value,
|
||||
get() {
|
||||
return { ...this };
|
||||
},
|
||||
}));
|
||||
const Service = load('back/services/subscription.ts', {
|
||||
'../config': config,
|
||||
'../data/subscription': {
|
||||
Subscription: class {
|
||||
constructor(value) {
|
||||
Object.assign(this, value);
|
||||
}
|
||||
},
|
||||
SubscriptionModel: {
|
||||
findAll: async ({ where }) =>
|
||||
docs.filter((doc) => !where.id || where.id.includes(doc.id)),
|
||||
create: async (value) => {
|
||||
const doc = {
|
||||
...value,
|
||||
get() {
|
||||
return { ...this };
|
||||
},
|
||||
};
|
||||
docs.push(doc);
|
||||
return doc;
|
||||
},
|
||||
findOne: async ({ where }) => docs.find((doc) => doc.id === where.id),
|
||||
update: async (value, { where }) => {
|
||||
Object.assign(
|
||||
docs.find((doc) => doc.id === where.id),
|
||||
value,
|
||||
);
|
||||
},
|
||||
destroy: async ({ where }) => {
|
||||
events.push('destroy');
|
||||
for (let i = docs.length - 1; i >= 0; i--) {
|
||||
if (where.id.includes(docs[i].id)) docs.splice(i, 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
'../data/cron': { CrontabModel: { findAll: async () => [{ id: 99 }] } },
|
||||
'../config/util': {
|
||||
rmPath: (p) => fs.promises.rm(p, { recursive: true, force: true }),
|
||||
},
|
||||
'../config/const': {},
|
||||
'../shared/i18n': { t: (x) => x, tf: (x) => x },
|
||||
'../shared/pLimit': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
'./sshKey': {},
|
||||
'./cron': {},
|
||||
}).default;
|
||||
const service = new Service(
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
{ removeSSHKey: async (alias) => events.push(['key', alias]) },
|
||||
{ remove: async (ids) => events.push(['crons', ids]) },
|
||||
);
|
||||
service.handleTask = async () => events.push('cancel');
|
||||
service.setSshConfig = async () => {};
|
||||
const paths = (doc) =>
|
||||
Object.entries(shellNames(doc)).map(([kind, name]) =>
|
||||
path.join(
|
||||
kind === 'script'
|
||||
? config.scriptPath
|
||||
: kind === 'repo'
|
||||
? config.repoPath
|
||||
: path.join(root, 'raw'),
|
||||
name,
|
||||
),
|
||||
);
|
||||
const write = (target) => {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, 'fixture');
|
||||
};
|
||||
return { root, config, docs, events, service, paths, write };
|
||||
}
|
||||
|
||||
test('force deletion cleans URL-derived clone/script/raw artifacts and associated jobs, preserving unrelated alias folders', async (t) => {
|
||||
const f = fixture(t, cases);
|
||||
const artifacts = [];
|
||||
for (const doc of cases) {
|
||||
for (const dir of f.paths(doc)) {
|
||||
const artifact = doc.type === 'file' ? dir : path.join(dir, 'job.js');
|
||||
artifacts.push(artifact);
|
||||
f.write(artifact);
|
||||
}
|
||||
f.write(path.join(f.config.scriptPath, doc.alias, 'unrelated.js'));
|
||||
}
|
||||
await f.service.remove(
|
||||
cases.map((doc) => doc.id),
|
||||
{ force: true },
|
||||
);
|
||||
for (const artifact of artifacts)
|
||||
assert.equal(fs.existsSync(artifact), false, artifact);
|
||||
for (const doc of cases)
|
||||
assert.equal(
|
||||
fs.readFileSync(
|
||||
path.join(f.config.scriptPath, doc.alias, 'unrelated.js'),
|
||||
'utf8',
|
||||
),
|
||||
'fixture',
|
||||
);
|
||||
assert.ok(f.events.some((e) => Array.isArray(e) && e[0] === 'crons'));
|
||||
assert.equal(
|
||||
f.events.filter((e) => Array.isArray(e) && e[0] === 'key').length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('ordinary deletion preserves subscription artifacts', async (t) => {
|
||||
const f = fixture(t, [cases[0]]);
|
||||
const file = path.join(f.paths(cases[0])[0], 'job.js');
|
||||
f.write(file);
|
||||
await f.service.remove([1], {});
|
||||
assert.equal(fs.readFileSync(file, 'utf8'), 'fixture');
|
||||
});
|
||||
|
||||
test('a URL-derived symlink escape rejects the entire force deletion before cancelling jobs or deleting rows', async (t) => {
|
||||
const f = fixture(t, [cases[0], cases[6]]);
|
||||
const outside = path.join(f.root, 'outside');
|
||||
fs.mkdirSync(outside);
|
||||
fs.writeFileSync(path.join(outside, 'keep'), 'keep');
|
||||
fs.mkdirSync(f.config.repoPath, { recursive: true });
|
||||
fs.symlinkSync(outside, f.paths(cases[0])[1]);
|
||||
await assert.rejects(
|
||||
f.service.remove([1, 7], { force: true }),
|
||||
/Invalid subscription path/,
|
||||
);
|
||||
assert.deepEqual(f.events, []);
|
||||
assert.equal(fs.readFileSync(path.join(outside, 'keep'), 'utf8'), 'keep');
|
||||
});
|
||||
|
||||
const cliCases = [
|
||||
{
|
||||
type: 'public-repo',
|
||||
url: 'https://github.com/owner/repo.git/',
|
||||
branch: 'main',
|
||||
},
|
||||
{
|
||||
type: 'private-repo',
|
||||
url: 'git@github.com:repo.git',
|
||||
branch: 'main',
|
||||
pull_type: 'ssh-key',
|
||||
},
|
||||
{ type: 'file', url: 'https://example.com/owner/job.py?version=2' },
|
||||
{ type: 'file', url: 'https://example.com/owner/job' },
|
||||
].map((doc, index) => ({ ...doc, id: index + 1, alias: `cli-${index}` }));
|
||||
|
||||
test('cleanup candidates match both actual CLI naming and legacy Shell naming', () => {
|
||||
const { repositoryName } = load(
|
||||
'cli/src/internal/subscription/subscriptionRunner.ts',
|
||||
);
|
||||
for (const doc of [...cases, ...cliCases]) {
|
||||
const url = formatUrl(doc).url;
|
||||
const stem = repositoryName(
|
||||
url,
|
||||
doc.type === 'file' ? undefined : doc.branch,
|
||||
);
|
||||
const raw =
|
||||
doc.type === 'file'
|
||||
? stem + path.extname(new URL(url).pathname)
|
||||
: undefined;
|
||||
assert.deepEqual(
|
||||
getSubscriptionStorageNames(doc, 'cli'),
|
||||
raw === undefined
|
||||
? { script: stem, repo: stem }
|
||||
: { script: `raw_${raw}`, raw },
|
||||
);
|
||||
assert.deepEqual(getSubscriptionStorageNames(doc), shellNames(doc));
|
||||
}
|
||||
});
|
||||
|
||||
test('force deletion removes CLI and legacy artifacts for trailing slashes, SCP URLs and raw URL queries', async (t) => {
|
||||
const f = fixture(t, cliCases);
|
||||
const artifacts = [];
|
||||
for (const doc of cliCases)
|
||||
for (const names of getSubscriptionStorageCandidates(doc)) {
|
||||
for (const [kind, name] of Object.entries(names)) {
|
||||
const base =
|
||||
kind === 'script'
|
||||
? f.config.scriptPath
|
||||
: kind === 'repo'
|
||||
? f.config.repoPath
|
||||
: path.join(f.root, 'raw');
|
||||
const target = path.join(
|
||||
base,
|
||||
name,
|
||||
...(doc.type === 'file' ? [] : ['job.js']),
|
||||
);
|
||||
artifacts.push(target);
|
||||
f.write(target);
|
||||
}
|
||||
}
|
||||
await f.service.remove(
|
||||
cliCases.map((doc) => doc.id),
|
||||
{ force: true },
|
||||
);
|
||||
for (const file of artifacts) assert.equal(fs.existsSync(file), false, file);
|
||||
});
|
||||
|
||||
for (const type of ['public-repo', 'file']) {
|
||||
test(`force deletion retains shared ${type} artifacts until the last subscription is removed`, async (t) => {
|
||||
const first = { ...cases[type === 'file' ? 6 : 0], id: 1, alias: 'first' };
|
||||
const second = { ...first, id: 2, alias: 'second' };
|
||||
const f = fixture(t, [first, second]);
|
||||
const artifacts = f
|
||||
.paths(first)
|
||||
.map((p) => (type === 'file' ? p : path.join(p, 'job.js')));
|
||||
artifacts.forEach(f.write);
|
||||
await f.service.remove([1], { force: true });
|
||||
for (const file of artifacts) assert.equal(fs.existsSync(file), true, file);
|
||||
await f.service.remove([2], { force: true });
|
||||
for (const file of artifacts)
|
||||
assert.equal(fs.existsSync(file), false, file);
|
||||
});
|
||||
}
|
||||
|
||||
test('batch deletion of every reference removes shared storage once', async (t) => {
|
||||
const f = fixture(t, [
|
||||
{ ...cases[0], id: 1 },
|
||||
{ ...cases[0], id: 2, alias: 'second' },
|
||||
]);
|
||||
const artifacts = f.paths(cases[0]).map((p) => path.join(p, 'job.js'));
|
||||
artifacts.forEach(f.write);
|
||||
await f.service.remove([1, 2], { force: true });
|
||||
for (const file of artifacts) assert.equal(fs.existsSync(file), false, file);
|
||||
});
|
||||
|
||||
test('recursive cleanup retains overlapping parent and nested branch directories', async (t) => {
|
||||
const parent = { ...cases[0], id: 1, branch: 'main', alias: 'parent' };
|
||||
const child = { ...parent, id: 2, branch: 'main/nested', alias: 'child' };
|
||||
for (const removed of [1, 2]) {
|
||||
const f = fixture(t, [parent, child]);
|
||||
const artifacts = [parent, child].flatMap((doc) =>
|
||||
f.paths(doc).map((p) => path.join(p, 'job.js')),
|
||||
);
|
||||
artifacts.forEach(f.write);
|
||||
await f.service.remove([removed], { force: true });
|
||||
for (const file of artifacts) assert.equal(fs.existsSync(file), true, file);
|
||||
}
|
||||
});
|
||||
|
||||
test('unrelated malformed historical rows do not block force deletion, and malformed shared references still retain storage', async (t) => {
|
||||
const selected = { ...cases[1], id: 1, branch: 'main', alias: 'selected' };
|
||||
for (const shared of [false, true]) {
|
||||
const invalid = {
|
||||
id: 2,
|
||||
alias: '../historical',
|
||||
type: 'private-repo',
|
||||
pull_type: 'ssh-key',
|
||||
url: `git@github.com:${shared ? 'owner/repo' : 'other/unrelated'}.git`,
|
||||
branch: 'main',
|
||||
};
|
||||
const f = fixture(t, [selected, invalid]);
|
||||
const files = f.paths(selected).map((p) => path.join(p, 'job.js'));
|
||||
files.forEach(f.write);
|
||||
await f.service.remove([1], { force: true });
|
||||
assert.deepEqual(
|
||||
f.docs.map((doc) => doc.id),
|
||||
[2],
|
||||
);
|
||||
for (const file of files) assert.equal(fs.existsSync(file), shared);
|
||||
}
|
||||
});
|
||||
|
||||
test('an unused invalid Shell candidate does not block cleanup of the valid CLI raw files', async (t) => {
|
||||
const doc = {
|
||||
id: 1,
|
||||
alias: 'valid-cli-raw',
|
||||
type: 'file',
|
||||
url: 'https://example.com/job.js?source=https://foo/bar',
|
||||
};
|
||||
const f = fixture(t, [doc]);
|
||||
const names = getSubscriptionStorageNames(doc, 'cli');
|
||||
const files = [
|
||||
path.join(f.config.scriptPath, names.script),
|
||||
path.join(f.root, 'raw', names.raw),
|
||||
];
|
||||
files.forEach(f.write);
|
||||
await f.service.remove([1], { force: true });
|
||||
assert.deepEqual(f.docs, []);
|
||||
for (const file of files) assert.equal(fs.existsSync(file), false);
|
||||
});
|
||||
|
||||
for (const mutation of ['create', 'update']) {
|
||||
test(`force deletion prevents a concurrent ${mutation} from changing ownership after its reference snapshot`, async (t) => {
|
||||
const selected = { ...cases[0], id: 1, alias: 'selected' };
|
||||
const other = {
|
||||
...selected,
|
||||
id: 2,
|
||||
alias: 'other',
|
||||
url: 'https://github.com/other/unrelated.git',
|
||||
};
|
||||
const f = fixture(
|
||||
t,
|
||||
mutation === 'update' ? [selected, other] : [selected],
|
||||
);
|
||||
let release, entered;
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const held = new Promise((resolve) => (entered = resolve));
|
||||
t.after(() => release());
|
||||
f.service.handleTask = async (doc, needCreate) => {
|
||||
if (doc.id === 1 && needCreate === false) {
|
||||
entered();
|
||||
await gate;
|
||||
}
|
||||
};
|
||||
const deletion = f.service.remove([1], { force: true });
|
||||
await held;
|
||||
const change = f.service[mutation]({ ...selected, id: 2, alias: 'other' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
if (mutation === 'create')
|
||||
assert.deepEqual(
|
||||
f.docs.map((doc) => doc.id),
|
||||
[1],
|
||||
);
|
||||
else assert.equal(f.docs.find((doc) => doc.id === 2).url, other.url);
|
||||
release();
|
||||
await Promise.all([deletion, change]);
|
||||
assert.equal(f.docs.find((doc) => doc.id === 2).url, selected.url);
|
||||
});
|
||||
|
||||
test(`force deletion observes shared storage ownership established by an in-flight ${mutation}`, async (t) => {
|
||||
const selected = { ...cases[0], id: 1, alias: 'selected' };
|
||||
const other = {
|
||||
...selected,
|
||||
id: 2,
|
||||
alias: 'other',
|
||||
url: 'https://github.com/other/unrelated.git',
|
||||
};
|
||||
const f = fixture(
|
||||
t,
|
||||
mutation === 'update' ? [selected, other] : [selected],
|
||||
);
|
||||
const file = path.join(f.paths(selected)[0], 'job.js');
|
||||
f.write(file);
|
||||
let release, entered;
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const held = new Promise((resolve) => (entered = resolve));
|
||||
t.after(() => release());
|
||||
f.service.handleTask = async (doc, needCreate) => {
|
||||
if (doc.id === 2 && needCreate !== false) {
|
||||
entered();
|
||||
await gate;
|
||||
}
|
||||
};
|
||||
const change = f.service[mutation]({ ...selected, id: 2, alias: 'other' });
|
||||
await held;
|
||||
const deletion = f.service.remove([1], { force: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
assert.ok(
|
||||
f.docs.some((doc) => doc.id === 1),
|
||||
'deletion waits until the ownership mutation is complete',
|
||||
);
|
||||
release();
|
||||
await Promise.all([change, deletion]);
|
||||
assert.deepEqual(
|
||||
f.docs.map((doc) => doc.id),
|
||||
[2],
|
||||
);
|
||||
assert.equal(fs.existsSync(file), true);
|
||||
});
|
||||
}
|
||||
|
||||
test('an unrelated dangling or outside symlink reference does not block safe cleanup', async (t) => {
|
||||
const selected = { ...cases[0], id: 1, alias: 'selected' };
|
||||
const other = {
|
||||
...selected,
|
||||
id: 2,
|
||||
alias: 'other',
|
||||
url: 'https://github.com/other/repo.git',
|
||||
};
|
||||
const f = fixture(t, [selected, other]);
|
||||
const outside = path.join(f.root, 'outside');
|
||||
fs.mkdirSync(outside);
|
||||
fs.writeFileSync(path.join(outside, 'keep'), 'keep');
|
||||
fs.symlinkSync(outside, f.paths(other)[0]);
|
||||
const file = path.join(f.paths(selected)[0], 'job.js');
|
||||
f.write(file);
|
||||
await f.service.remove([1], { force: true });
|
||||
assert.equal(fs.existsSync(file), false);
|
||||
assert.equal(fs.readFileSync(path.join(outside, 'keep'), 'utf8'), 'keep');
|
||||
});
|
||||
|
||||
test('URL-derived storage accepts legal directory names longer than user aliases and containing spaces', async (t) => {
|
||||
const doc = { ...cases[0], id: 1, alias: 'short-alias', url: `/tmp/owner/${'r'.repeat(205)} repo.git` };
|
||||
const f = fixture(t, [doc]);
|
||||
const files = f.paths(doc).map((p) => path.join(p, 'job.js'));
|
||||
files.forEach(f.write);
|
||||
await f.service.remove([1], { force: true });
|
||||
for (const file of files) assert.equal(fs.existsSync(file), false, file);
|
||||
});
|
||||
|
||||
test('an unrepresentable unused Shell filename does not block valid CLI raw cleanup', async (t) => {
|
||||
const doc = {
|
||||
id: 1,
|
||||
alias: 'long-query',
|
||||
type: 'file',
|
||||
url: 'https://example.com/job.js?token=' + 'a'.repeat(300),
|
||||
};
|
||||
const f = fixture(t, [doc]);
|
||||
const names = getSubscriptionStorageNames(doc, 'cli');
|
||||
const files = [
|
||||
path.join(f.config.scriptPath, names.script),
|
||||
path.join(f.root, 'raw', names.raw),
|
||||
];
|
||||
files.forEach(f.write);
|
||||
await f.service.remove([1], { force: true });
|
||||
assert.deepEqual(f.docs, []);
|
||||
for (const file of files) assert.equal(fs.existsSync(file), false);
|
||||
});
|
||||
|
||||
test('cleanup retains each representable path when only its prefixed Shell script name is too long', async (t) => {
|
||||
const doc = { id: 1, alias: 'partial-raw', type: 'file', url: 'https://example.com/job.js?token=' };
|
||||
const prefix = getSubscriptionStorageNames(doc).raw;
|
||||
doc.url += 'a'.repeat(255 - Buffer.byteLength(prefix));
|
||||
const shell = getSubscriptionStorageNames(doc);
|
||||
assert.equal(Buffer.byteLength(shell.raw), 255);
|
||||
assert.ok(Buffer.byteLength(shell.script) > 255);
|
||||
const f = fixture(t, [doc]);
|
||||
const cli = getSubscriptionStorageNames(doc, 'cli');
|
||||
const files = [
|
||||
path.join(f.root, 'raw', shell.raw),
|
||||
path.join(f.root, 'raw', cli.raw),
|
||||
path.join(f.config.scriptPath, cli.script),
|
||||
];
|
||||
files.forEach(f.write);
|
||||
await f.service.remove([1], { force: true });
|
||||
assert.deepEqual(f.docs, []);
|
||||
for (const file of files) assert.equal(fs.existsSync(file), false);
|
||||
});
|
||||
|
||||
test('a legal Unicode alias with an unrepresentable unused directory does not block URL-derived cleanup', async (t) => {
|
||||
const doc = { ...cases[0], id: 1, alias: '中'.repeat(86) };
|
||||
const f = fixture(t, [doc]);
|
||||
const files = f.paths(doc).map((p) => path.join(p, 'job.js'));
|
||||
files.forEach(f.write);
|
||||
await f.service.remove([1], { force: true });
|
||||
assert.deepEqual(f.docs, []);
|
||||
for (const file of files) assert.equal(fs.existsSync(file), false);
|
||||
});
|
||||
|
||||
test('Unicode storage follows filesystem limits rather than imposing a UTF-8 byte cap', async (t) => {
|
||||
const doc = { ...cases[0], id: 1, alias: 'short-alias', url: `/tmp/owner/${'中'.repeat(86)}.git` };
|
||||
const f = fixture(t, [doc]);
|
||||
const files = f.paths(doc).map((p) => path.join(p, 'job.js'));
|
||||
for (const file of files) {
|
||||
try {
|
||||
f.write(file);
|
||||
} catch (error) {
|
||||
// These names are representable on macOS but not Linux.
|
||||
assert.equal(error.code, 'ENAMETOOLONG');
|
||||
}
|
||||
}
|
||||
await f.service.remove([1], { force: true });
|
||||
assert.deepEqual(f.docs, []);
|
||||
for (const file of files) assert.equal(fs.existsSync(file), false);
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
|
||||
test('clearing a dependency cache marks installed entries for reinstall', async (t) => {
|
||||
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-dependence-'));
|
||||
const nodeCache = path.join(directory, 'node');
|
||||
await fs.mkdir(nodeCache);
|
||||
await fs.writeFile(path.join(nodeCache, 'package'), 'cached');
|
||||
t.after(() => fs.rm(directory, { recursive: true, force: true }));
|
||||
|
||||
const moduleStubs = new Map();
|
||||
const stubModule = (modulePath, exports) => {
|
||||
const resolved = require.resolve(modulePath);
|
||||
moduleStubs.set(resolved, require.cache[resolved]);
|
||||
require.cache[resolved] = {
|
||||
id: resolved,
|
||||
filename: resolved,
|
||||
loaded: true,
|
||||
exports,
|
||||
children: [],
|
||||
paths: [],
|
||||
};
|
||||
};
|
||||
const updates = [];
|
||||
const DependenceStatus = { installed: 1, installFailed: 2 };
|
||||
const DependenceTypes = { nodejs: 0, python3: 1 };
|
||||
|
||||
stubModule('../../back/config', {
|
||||
__esModule: true,
|
||||
default: { dependenceCachePath: directory },
|
||||
});
|
||||
stubModule('../../back/config/const', {
|
||||
NotificationModeStringMap: {},
|
||||
TASK_COMMAND: 'task',
|
||||
});
|
||||
stubModule('../../back/config/util', {
|
||||
getPid: async () => 0,
|
||||
killTask: async () => {},
|
||||
parseContentVersion: () => '',
|
||||
parseVersion: () => '',
|
||||
promiseExec: async () => '',
|
||||
readDirs: async () => [],
|
||||
rmPath: async () => {},
|
||||
setSystemTimezone: async () => true,
|
||||
updateLinuxMirrorFile: async () => {},
|
||||
});
|
||||
stubModule('../../back/data/dependence', {
|
||||
DependenceModel: {
|
||||
update: async (values, options) => updates.push({ values, options }),
|
||||
},
|
||||
DependenceStatus,
|
||||
DependenceTypes,
|
||||
});
|
||||
stubModule('../../back/data/notify', {});
|
||||
stubModule('../../back/data/system', {
|
||||
AuthDataType: {},
|
||||
SystemModel: {},
|
||||
});
|
||||
stubModule('../../back/schedule/client', { __esModule: true, default: {} });
|
||||
stubModule('../../back/shared/pLimit', {
|
||||
__esModule: true,
|
||||
default: {},
|
||||
});
|
||||
stubModule('../../back/shared/i18n', {
|
||||
setLang: () => {},
|
||||
t: (message) => message,
|
||||
});
|
||||
stubModule('../../back/services/notify', {
|
||||
__esModule: true,
|
||||
default: class NotificationService {},
|
||||
});
|
||||
stubModule('../../back/services/schedule', {
|
||||
__esModule: true,
|
||||
default: class ScheduleService {},
|
||||
});
|
||||
stubModule('../../back/services/sock', {
|
||||
__esModule: true,
|
||||
default: class SockService {},
|
||||
});
|
||||
|
||||
const servicePath = require.resolve('../../back/services/system');
|
||||
const originalService = require.cache[servicePath];
|
||||
delete require.cache[servicePath];
|
||||
t.after(() => {
|
||||
if (originalService) {
|
||||
require.cache[servicePath] = originalService;
|
||||
} else {
|
||||
delete require.cache[servicePath];
|
||||
}
|
||||
for (const [resolved, original] of moduleStubs) {
|
||||
if (original) {
|
||||
require.cache[resolved] = original;
|
||||
} else {
|
||||
delete require.cache[resolved];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const SystemService = require('../../back/services/system').default;
|
||||
const service = new SystemService({}, {}, {});
|
||||
assert.deepEqual(await service.cleanDependence('node'), { code: 200 });
|
||||
assert.deepEqual(await service.cleanDependence('python3'), { code: 200 });
|
||||
await assert.rejects(fs.stat(nodeCache), { code: 'ENOENT' });
|
||||
assert.deepEqual(updates, [
|
||||
{
|
||||
values: { status: DependenceStatus.installFailed },
|
||||
options: {
|
||||
where: {
|
||||
type: DependenceTypes.nodejs,
|
||||
status: DependenceStatus.installed,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
values: { status: DependenceStatus.installFailed },
|
||||
options: {
|
||||
where: {
|
||||
type: DependenceTypes.python3,
|
||||
status: DependenceStatus.installed,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
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 { execFileSync } = require('node:child_process');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { ScriptHistory } = load('back/shared/scriptHistory.ts', {
|
||||
'../config/util': {},
|
||||
});
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-history-export-'));
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const dataPath = path.join(root, 'data');
|
||||
for (const dir of ['db', 'upload', 'scripts'])
|
||||
await fs.mkdir(path.join(dataPath, dir), { recursive: true });
|
||||
const archive = path.join(root, 'backup.tgz');
|
||||
const Service = load('back/services/system.ts', {
|
||||
'../config': { dataPath, dataTgzFile: archive },
|
||||
'../config/const': {},
|
||||
'../config/util': {},
|
||||
'../data/dependence': {},
|
||||
'../data/notify': {},
|
||||
'../data/system': {},
|
||||
'../shared/pLimit': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'./notify': class {},
|
||||
'./schedule': class {},
|
||||
'./sock': class {},
|
||||
}).default;
|
||||
const service = new Service({}, {}, {});
|
||||
let downloaded, status, error;
|
||||
const res = {
|
||||
download: (file) => {
|
||||
downloaded = file;
|
||||
},
|
||||
status: (code) => {
|
||||
status = code;
|
||||
return res;
|
||||
},
|
||||
send: (value) => {
|
||||
error = value;
|
||||
return res;
|
||||
},
|
||||
};
|
||||
const exportData = async (types) => {
|
||||
downloaded = status = error = undefined;
|
||||
await service.exportData(res, types);
|
||||
return { downloaded, status, error };
|
||||
};
|
||||
return { root, dataPath, archive, exportData };
|
||||
}
|
||||
|
||||
test('a scripts backup preserves history versions and they remain readable and restorable after extraction', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const scripts = path.join(f.dataPath, 'scripts');
|
||||
const historyRoot = path.join(f.dataPath, 'script-history');
|
||||
await fs.writeFile(path.join(scripts, 'history.js'), 'baseline');
|
||||
const history = new ScriptHistory(scripts, historyRoot);
|
||||
await history.save('', 'history.js', 'second');
|
||||
await history.save('', 'history.js', 'third');
|
||||
const before = await history.list('', 'history.js');
|
||||
assert.ok(before.versions.length >= 2);
|
||||
assert.deepEqual(await f.exportData(['scripts']), {
|
||||
downloaded: f.archive,
|
||||
status: undefined,
|
||||
error: undefined,
|
||||
});
|
||||
const restored = path.join(f.root, 'restored');
|
||||
await fs.mkdir(restored);
|
||||
execFileSync('tar', ['-zxf', f.archive, '-C', restored]);
|
||||
const after = new ScriptHistory(
|
||||
path.join(restored, 'data/scripts'),
|
||||
path.join(restored, 'data/script-history'),
|
||||
);
|
||||
assert.deepEqual(await after.list('', 'history.js'), before);
|
||||
const oldest = before.versions.at(-1);
|
||||
const detail = await after.detail('', 'history.js', oldest.id);
|
||||
assert.equal(detail.version.content, 'baseline');
|
||||
await after.restore('', 'history.js', oldest.id, before.currentHash);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(restored, 'data/scripts/history.js'), 'utf8'),
|
||||
'baseline',
|
||||
);
|
||||
});
|
||||
|
||||
test('fresh installations export scripts without requiring a history directory', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.writeFile(path.join(f.dataPath, 'scripts/job.js'), 'fresh');
|
||||
assert.equal((await f.exportData(['scripts'])).downloaded, f.archive);
|
||||
const entries = execFileSync('tar', ['-ztf', f.archive], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.match(entries, /data\/scripts\/job.js/);
|
||||
assert.doesNotMatch(entries, /script-history/);
|
||||
});
|
||||
|
||||
test('history can be exported explicitly, while directory traversal is rejected before tar runs', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await fs.mkdir(path.join(f.dataPath, 'script-history'));
|
||||
await fs.writeFile(
|
||||
path.join(f.dataPath, 'script-history/version.br'),
|
||||
'history',
|
||||
);
|
||||
assert.equal((await f.exportData(['script-history'])).downloaded, f.archive);
|
||||
assert.match(
|
||||
execFileSync('tar', ['-ztf', f.archive], { encoding: 'utf8' }),
|
||||
/script-history\/version.br/,
|
||||
);
|
||||
await fs.rm(f.archive);
|
||||
for (const type of ['../outside', '/etc', 'scripts/../../outside']) {
|
||||
const result = await f.exportData([type]);
|
||||
assert.equal(result.status, 400);
|
||||
assert.equal(result.downloaded, undefined);
|
||||
}
|
||||
await assert.rejects(fs.stat(f.archive), { code: 'ENOENT' });
|
||||
});
|
||||
@@ -0,0 +1,497 @@
|
||||
require('reflect-metadata');
|
||||
require('ts-node/register/transpile-only');
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { execFile } = require('node:child_process');
|
||||
const { promisify } = require('node:util');
|
||||
const environment = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-env-'));
|
||||
fs.writeFileSync(path.join(environment, '.env'), '');
|
||||
fs.mkdirSync(path.join(environment, 'data/db'), { recursive: true });
|
||||
process.env.QL_DIR = environment;
|
||||
process.env.QL_DATA_DIR = path.join(environment, 'data');
|
||||
require('node:test').after(async () => {
|
||||
await require('../../back/shared/store').keyvStore.disconnect();
|
||||
await require('../../back/data').sequelize.close();
|
||||
fs.rmSync(environment, { recursive: true, force: true });
|
||||
});
|
||||
const OpenService = require('../../back/services/open').default;
|
||||
const { AppModel } = require('../../back/data/open');
|
||||
const { shareStore } = require('../../back/shared/store');
|
||||
const config = require('../../back/config').default;
|
||||
|
||||
const DAY = 86400;
|
||||
const START = 1787004000;
|
||||
const execute = promisify(execFile);
|
||||
|
||||
function fixture(t, initialTokens = []) {
|
||||
const previousConfigPath = config.configPath;
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-renewal-'));
|
||||
config.configPath = directory;
|
||||
t.after(() => {
|
||||
config.configPath = previousConfigPath;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
let now = START;
|
||||
let app = {
|
||||
name: 'system', client_id: 'system-client', client_secret: 'system-secret',
|
||||
scopes: ['crons', 'system', 'dashboard'], tokens: initialTokens,
|
||||
};
|
||||
t.mock.method(Date, 'now', () => now * 1000);
|
||||
t.mock.method(shareStore, 'getApps', async () => [app]);
|
||||
t.mock.method(shareStore, 'updateApps', async (apps) => { [app] = apps; });
|
||||
t.mock.method(AppModel, 'findOne', async ({ where }) => {
|
||||
if (where.name) assert.equal(where.name, app.name);
|
||||
else {
|
||||
assert.equal(where.client_id, app.client_id);
|
||||
assert.equal(where.client_secret, app.client_secret);
|
||||
}
|
||||
return { tokens: app.tokens, get: () => app };
|
||||
});
|
||||
t.mock.method(AppModel, 'update', async ({ tokens }) => {
|
||||
app = { ...app, tokens };
|
||||
return [1];
|
||||
});
|
||||
t.mock.method(AppModel, 'findAll', async () => [{ get: () => app }]);
|
||||
return {
|
||||
service: new OpenService({}),
|
||||
atDay(day) { now = START + day * DAY; },
|
||||
tokens() { return app.tokens; },
|
||||
};
|
||||
}
|
||||
|
||||
test('28-day token generation renews ahead of the 30-day expiry', async (t) => {
|
||||
const state = fixture(t);
|
||||
const initial = await state.service.generateSystemToken();
|
||||
assert.equal(initial.expiration, START + 30 * DAY);
|
||||
state.atDay(28);
|
||||
const renewed = await state.service.generateSystemToken(true);
|
||||
assert.equal(renewed.expiration, START + 58 * DAY);
|
||||
assert.notEqual(renewed.value, initial.value);
|
||||
assert.ok(state.tokens().some((token) => token.value === initial.value));
|
||||
state.atDay(56);
|
||||
const next = await state.service.generateSystemToken(true);
|
||||
assert.equal(next.expiration, START + 86 * DAY);
|
||||
assert.ok(state.tokens().some((token) => token.value === renewed.value));
|
||||
assert.ok(!state.tokens().some((token) => token.value === initial.value));
|
||||
});
|
||||
|
||||
test('startup generation renews before a restarted 28-day interval', async (t) => {
|
||||
const state = fixture(t);
|
||||
await state.service.generateSystemToken();
|
||||
state.atDay(20);
|
||||
const restarted = await state.service.generateSystemToken(true);
|
||||
assert.equal(restarted.expiration, START + 50 * DAY);
|
||||
state.atDay(48);
|
||||
const renewed = await state.service.generateSystemToken(true);
|
||||
assert.equal(renewed.expiration, START + 78 * DAY);
|
||||
assert.ok(state.tokens().some((token) => token.value === restarted.value));
|
||||
});
|
||||
|
||||
test('generation at the five-token limit extends the reused token without revoking active tokens', async (t) => {
|
||||
const initial = Array.from({ length: 5 }, (_, index) => ({
|
||||
value: `token-${index}`, expiration: START + 2 * DAY,
|
||||
}));
|
||||
const state = fixture(t, initial);
|
||||
const renewed = await state.service.generateSystemToken(true);
|
||||
assert.equal(renewed.value, 'token-4');
|
||||
assert.equal(renewed.expiration, START + 30 * DAY);
|
||||
assert.equal(state.tokens().length, 5);
|
||||
for (const token of initial) {
|
||||
assert.ok(state.tokens().some((current) => current.value === token.value));
|
||||
}
|
||||
});
|
||||
|
||||
test('ordinary acquisition reuses the newest valid database token and republishes its cache', async (t) => {
|
||||
const initial = [
|
||||
{ value: 'older-token', expiration: START + DAY },
|
||||
{ value: 'newer-token', expiration: START + 20 * DAY },
|
||||
];
|
||||
const state = fixture(t, initial);
|
||||
// A stale process cache must not override the token chosen from the database.
|
||||
shareStore.getApps = async () => [{ name: 'system', tokens: [] }];
|
||||
const token = await state.service.generateSystemToken();
|
||||
assert.equal(token.value, 'newer-token');
|
||||
assert.deepEqual(state.tokens(), initial);
|
||||
assert.deepEqual(
|
||||
JSON.parse(fs.readFileSync(path.join(config.configPath, 'token.json'), 'utf8')),
|
||||
token,
|
||||
);
|
||||
});
|
||||
|
||||
test('a failed generation releases its lock and does not publish a token', async (t) => {
|
||||
const state = fixture(t);
|
||||
const update = AppModel.update;
|
||||
AppModel.update = async () => { throw new Error('generation failed'); };
|
||||
await assert.rejects(state.service.generateSystemToken(), /generation failed/);
|
||||
assert.equal(fs.existsSync(path.join(config.configPath, 'token.json')), false);
|
||||
assert.equal(fs.existsSync(path.join(config.configPath, 'token.json.refresh.lock')), false);
|
||||
AppModel.update = update;
|
||||
const token = await state.service.generateSystemToken();
|
||||
assert.ok(state.tokens().some((current) => current.value === token.value));
|
||||
});
|
||||
|
||||
test('acquisition recovers an authentication cache failure after the database commit', async (t) => {
|
||||
const state = fixture(t);
|
||||
const updateCache = shareStore.updateApps;
|
||||
let attempts = 0;
|
||||
shareStore.updateApps = async (apps) => {
|
||||
if (++attempts === 1) throw new Error('authentication cache failed');
|
||||
return updateCache(apps);
|
||||
};
|
||||
await assert.rejects(state.service.generateSystemToken(), /authentication cache failed/);
|
||||
assert.equal(fs.existsSync(path.join(config.configPath, 'token.json')), false);
|
||||
const committed = state.tokens()[0];
|
||||
const token = await state.service.generateSystemToken();
|
||||
assert.equal(token.value, committed.value);
|
||||
assert.equal(state.tokens().length, 1);
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
|
||||
async function processFixture(t, initialTokens = []) {
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-token-process-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const databaseFile = path.join(directory, 'database.json');
|
||||
fs.writeFileSync(databaseFile, JSON.stringify({
|
||||
name: 'system', client_id: 'system-client', client_secret: 'system-secret',
|
||||
scopes: ['crons', 'system', 'dashboard'], tokens: initialTokens,
|
||||
}));
|
||||
const worker = path.join(directory, 'worker.cjs');
|
||||
fs.writeFileSync(worker, `
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const root = process.cwd();
|
||||
const directory = __dirname;
|
||||
const config = require(path.join(root, 'back/config')).default;
|
||||
const { AppModel } = require(path.join(root, 'back/data/open'));
|
||||
const { shareStore } = require(path.join(root, 'back/shared/store'));
|
||||
config.configPath = directory;
|
||||
const databaseFile = path.join(directory, 'database.json');
|
||||
const read = async () => JSON.parse(await fs.readFile(databaseFile, 'utf8'));
|
||||
AppModel.findOne = async () => {
|
||||
if (process.env.QL_TOKEN_TEST_HOLD === '1') {
|
||||
await fs.writeFile(path.join(directory, 'locked'), 'ready');
|
||||
await new Promise(() => setInterval(() => {}, 1000));
|
||||
}
|
||||
const app = await read();
|
||||
// Widen the read/write overlap so the original race is reproducible.
|
||||
await new Promise(resolve => setTimeout(resolve, 75));
|
||||
return { ...app, get: () => app };
|
||||
};
|
||||
AppModel.update = async ({ tokens }) => {
|
||||
if (process.env.QL_TOKEN_TEST_FAIL === '1') throw new Error('generation failed');
|
||||
const app = await read();
|
||||
await fs.writeFile(databaseFile, JSON.stringify({ ...app, tokens }));
|
||||
return [1];
|
||||
};
|
||||
AppModel.findAll = async () => {
|
||||
const app = await read();
|
||||
return [{ get: () => app }];
|
||||
};
|
||||
shareStore.getApps = async () => { throw new Error('stale cache must not be read'); };
|
||||
shareStore.updateApps = async () => {};
|
||||
require(path.join(root, 'back/token'));
|
||||
`);
|
||||
return {
|
||||
directory,
|
||||
run(renew = false, fail = false, hold = false) {
|
||||
return execute(process.execPath, [
|
||||
'-r', require.resolve('ts-node/register/transpile-only'), worker,
|
||||
...(renew ? ['--renew'] : []),
|
||||
], {
|
||||
cwd: root, timeout: 20_000,
|
||||
env: { ...process.env, QL_TOKEN_TEST_FAIL: fail ? '1' : '0', QL_TOKEN_TEST_HOLD: hold ? '1' : '0' },
|
||||
});
|
||||
},
|
||||
tokens() { return JSON.parse(fs.readFileSync(databaseFile, 'utf8')).tokens; },
|
||||
cached() { return JSON.parse(fs.readFileSync(path.join(directory, 'token.json'), 'utf8')); },
|
||||
};
|
||||
}
|
||||
|
||||
test('four independent token processes converge on one usable token when the cache is missing', async (t) => {
|
||||
const state = await processFixture(t);
|
||||
const results = await Promise.all(Array.from({ length: 4 }, () => state.run()));
|
||||
const issued = results.map(({ stdout, stderr }) => {
|
||||
assert.equal(stderr, '');
|
||||
return stdout.trim();
|
||||
});
|
||||
assert.equal(new Set(issued).size, 1);
|
||||
assert.equal(state.tokens().length, 1);
|
||||
assert.equal(state.tokens()[0].value, issued[0]);
|
||||
assert.equal(state.cached().value, issued[0]);
|
||||
assert.equal(fs.existsSync(path.join(state.directory, 'token.json.refresh.lock')), false);
|
||||
});
|
||||
|
||||
test('concurrent scheduled renewal and ordinary acquisition preserve all returned tokens', async (t) => {
|
||||
const original = { value: 'original-token', expiration: Math.round(Date.now() / 1000) + DAY };
|
||||
const state = await processFixture(t, [original]);
|
||||
const results = await Promise.all([state.run(true), state.run(), state.run(true), state.run()]);
|
||||
const tokens = state.tokens();
|
||||
assert.equal(tokens.length, 3);
|
||||
assert.ok(tokens.some((token) => token.value === original.value));
|
||||
for (const { stdout, stderr } of results) {
|
||||
assert.equal(stderr, '');
|
||||
assert.ok(tokens.some((token) => token.value === stdout.trim()));
|
||||
}
|
||||
assert.ok(tokens.some((token) => token.value === state.cached().value));
|
||||
});
|
||||
|
||||
test('token CLI reports generation failure on stderr with a nonzero exit code', async (t) => {
|
||||
const state = await processFixture(t);
|
||||
await assert.rejects(state.run(false, true), (error) => {
|
||||
assert.equal(error.code, 1);
|
||||
assert.equal(error.stdout, '');
|
||||
assert.match(error.stderr, /generation failed/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(fs.existsSync(path.join(state.directory, 'token.json')), false);
|
||||
const recovered = await state.run();
|
||||
assert.equal(recovered.stdout.trim(), state.cached().value);
|
||||
});
|
||||
|
||||
test('startup configures the 28-day token task with explicit renewal', async (t) => {
|
||||
const { Container } = require('typedi');
|
||||
const initialize = require('../../back/loaders/initTask').default;
|
||||
const SystemService = require('../../back/services/system').default;
|
||||
const ScheduleService = require('../../back/services/schedule').default;
|
||||
const SubscriptionService = require('../../back/services/subscription').default;
|
||||
const SshKeyService = require('../../back/services/sshKey').default;
|
||||
const scheduled = [];
|
||||
const services = new Map([
|
||||
[SystemService, { getSystemConfig: async () => undefined }],
|
||||
[ScheduleService, {
|
||||
cancelIntervalTask: async () => {},
|
||||
createIntervalTask: (...args) => scheduled.push(args),
|
||||
}],
|
||||
[SubscriptionService, { setSshConfig: async () => {}, list: async () => [] }],
|
||||
[SshKeyService, {}],
|
||||
]);
|
||||
t.mock.method(Container, 'get', (service) => {
|
||||
assert.ok(services.has(service));
|
||||
return services.get(service);
|
||||
});
|
||||
await initialize();
|
||||
assert.equal(scheduled.length, 1);
|
||||
const [task, interval, immediate] = scheduled[0];
|
||||
assert.match(task.command, /(?:token\.ts|token\.js) --renew$/);
|
||||
assert.equal(task.runOrigin, 'system');
|
||||
assert.deepEqual(interval, { days: 28 });
|
||||
assert.equal(immediate, true);
|
||||
});
|
||||
|
||||
function appsFixture(t) {
|
||||
const previousConfigPath = config.configPath;
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-apps-concurrency-'));
|
||||
config.configPath = directory;
|
||||
t.after(() => {
|
||||
config.configPath = previousConfigPath;
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
});
|
||||
t.mock.method(Date, 'now', () => START * 1000);
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
let apps = [
|
||||
{ id: 1, name: 'system', client_id: 'system-client', client_secret: 'system-secret',
|
||||
scopes: ['crons', 'system', 'dashboard'], tokens: [{ value: 'system-token', expiration: START + DAY }] },
|
||||
{ id: 2, name: 'external', client_id: 'external-client', client_secret: 'external-secret',
|
||||
scopes: ['crons'], tokens: [] },
|
||||
];
|
||||
let cached = clone(apps);
|
||||
const matches = (app, where) => Object.entries(where).every(([key, value]) =>
|
||||
Array.isArray(value) ? value.includes(app[key]) : app[key] === value);
|
||||
t.mock.method(AppModel, 'findOne', async ({ where }) => {
|
||||
const app = apps.find((app) => matches(app, where));
|
||||
if (!app) return null;
|
||||
const snapshot = clone(app);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
return { ...snapshot, get: () => snapshot };
|
||||
});
|
||||
t.mock.method(AppModel, 'findAll', async () => clone(apps).map((app) => ({ get: () => app })));
|
||||
t.mock.method(AppModel, 'update', async (values, { where }) => {
|
||||
apps = apps.map((app) => matches(app, where) ? { ...app, ...clone(values) } : app);
|
||||
return [1];
|
||||
});
|
||||
t.mock.method(AppModel, 'destroy', async ({ where }) => {
|
||||
apps = apps.filter((app) => !matches(app, where));
|
||||
});
|
||||
t.mock.method(AppModel, 'create', async (values) => {
|
||||
const app = { id: Math.max(0, ...apps.map((app) => app.id)) + 1, tokens: [], ...values };
|
||||
apps.push(app);
|
||||
return { get: () => clone(app) };
|
||||
});
|
||||
t.mock.method(shareStore, 'updateApps', async (values) => { cached = clone(values); });
|
||||
t.mock.method(shareStore, 'getApps', async () => clone(cached));
|
||||
return { service: new OpenService({}), apps: () => clone(apps), cached: () => clone(cached) };
|
||||
}
|
||||
|
||||
for (const operation of ['acquisition', 'startup refresh']) {
|
||||
test(`${operation} cannot overwrite another application's newly issued token`, async (t) => {
|
||||
const state = appsFixture(t);
|
||||
let resume;
|
||||
let entered;
|
||||
const paused = new Promise((resolve) => { entered = resolve; });
|
||||
const barrier = new Promise((resolve) => { resume = resolve; });
|
||||
const findAll = AppModel.findAll;
|
||||
let first = true;
|
||||
AppModel.findAll = async (...args) => {
|
||||
const snapshot = await findAll(...args);
|
||||
if (first) {
|
||||
first = false;
|
||||
entered();
|
||||
await barrier;
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
const publication = operation === 'acquisition'
|
||||
? state.service.generateSystemToken() : state.service.refreshApps();
|
||||
await paused;
|
||||
const issuance = state.service.authToken({ client_id: 'external-client', client_secret: 'external-secret' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
resume();
|
||||
const [, response] = await Promise.all([publication, issuance]);
|
||||
assert.equal(response.code, 200);
|
||||
assert.ok(state.apps()[1].tokens.some((token) => token.value === response.data.token));
|
||||
assert.deepEqual(state.cached(), state.apps());
|
||||
});
|
||||
}
|
||||
|
||||
test('concurrent token API calls and scheduled generation preserve every issued token', async (t) => {
|
||||
const state = appsFixture(t);
|
||||
const credentials = { client_id: 'system-client', client_secret: 'system-secret' };
|
||||
const results = await Promise.all([
|
||||
state.service.authToken(credentials), state.service.generateSystemToken(true),
|
||||
state.service.authToken(credentials), state.service.generateSystemToken(true),
|
||||
]);
|
||||
const tokens = state.apps()[0].tokens;
|
||||
assert.equal(tokens.length, 5);
|
||||
for (const response of results) {
|
||||
assert.ok(tokens.some((token) => token.value === (response.value || response.data.token)));
|
||||
}
|
||||
assert.deepEqual(state.cached(), state.apps());
|
||||
});
|
||||
|
||||
for (const mutation of ['resetSecret', 'remove', 'update', 'create']) {
|
||||
test(`concurrent cache refresh and ${mutation} retain the latest application state`, async (t) => {
|
||||
const state = appsFixture(t);
|
||||
await state.service.authToken({ client_id: 'external-client', client_secret: 'external-secret' });
|
||||
const change = () => mutation === 'resetSecret' ? state.service.resetSecret(2)
|
||||
: mutation === 'remove' ? state.service.remove([2])
|
||||
: mutation === 'update' ? state.service.update({ id: 2, name: 'external', scopes: ['dashboard'] })
|
||||
: state.service.create({ name: 'created', scopes: ['crons'] });
|
||||
await Promise.all([state.service.refreshApps(), change(), state.service.generateSystemToken()]);
|
||||
assert.deepEqual(state.cached(), state.apps());
|
||||
if (mutation === 'resetSecret') {
|
||||
assert.equal(state.cached()[1].tokens.length, 0);
|
||||
assert.notEqual(state.cached()[1].client_secret, 'external-secret');
|
||||
assert.equal((await state.service.authToken({ client_id: 'external-client', client_secret: 'external-secret' })).code, 400);
|
||||
} else if (mutation === 'remove') {
|
||||
assert.equal(state.cached().length, 1);
|
||||
} else if (mutation === 'update') {
|
||||
assert.deepEqual(state.cached()[1].scopes, ['dashboard']);
|
||||
} else {
|
||||
assert.equal(state.cached().length, 3);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('system initialization preserves active tokens while migrating dashboard scope', async (t) => {
|
||||
const state = appsFixture(t);
|
||||
await AppModel.update({ scopes: ['crons', 'system'] }, { where: { id: 1 } });
|
||||
await Promise.all([state.service.initializeSystemApp(), state.service.generateSystemToken(true)]);
|
||||
assert.deepEqual(state.cached(), state.apps());
|
||||
assert.deepEqual(state.apps()[0].scopes, ['crons', 'system', 'dashboard']);
|
||||
assert.equal(state.apps()[0].tokens.length, 2);
|
||||
});
|
||||
|
||||
test('failed atomic token-file publication releases the application lock for recovery', async (t) => {
|
||||
const state = appsFixture(t);
|
||||
const temporary = path.join(config.configPath, `token.json.${process.pid}.tmp`);
|
||||
fs.mkdirSync(temporary);
|
||||
await assert.rejects(state.service.generateSystemToken(), /EISDIR/);
|
||||
fs.rmSync(temporary, { recursive: true });
|
||||
const token = await state.service.generateSystemToken();
|
||||
assert.equal(token.value, 'system-token');
|
||||
assert.equal(fs.existsSync(path.join(config.configPath, '.apps-state.lock')), false);
|
||||
});
|
||||
|
||||
test('real SQLite and authentication cache retain concurrent process updates', async (t) => {
|
||||
fs.mkdirSync(config.configPath, { recursive: true });
|
||||
await AppModel.sync({ force: true });
|
||||
const service = new OpenService({});
|
||||
await service.initializeSystemApp();
|
||||
const external = await service.create({ name: 'sqlite-external', scopes: ['crons'] });
|
||||
const worker = path.join(environment, 'sqlite-worker.cjs');
|
||||
const root = path.resolve(__dirname, '../..');
|
||||
fs.writeFileSync(worker, `
|
||||
require(${JSON.stringify(require.resolve('reflect-metadata'))});
|
||||
const path = require('node:path');
|
||||
const root = process.cwd();
|
||||
const OpenService = require(path.join(root, 'back/services/open')).default;
|
||||
const { shareStore, keyvStore } = require(path.join(root, 'back/shared/store'));
|
||||
const { sequelize } = require(path.join(root, 'back/data'));
|
||||
const service = new OpenService({});
|
||||
(async () => {
|
||||
const action = process.argv[2];
|
||||
const result = action === 'auth'
|
||||
? await service.authToken({ client_id: process.argv[3], client_secret: process.argv[4] })
|
||||
: action === 'refresh' ? await service.refreshApps()
|
||||
: await service.generateSystemToken(action === 'renew');
|
||||
console.log(JSON.stringify(result || {}));
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; })
|
||||
.finally(async () => { await keyvStore.disconnect(); await sequelize.close(); });
|
||||
`);
|
||||
const actions = ['renew', 'acquire', 'auth', 'refresh', 'renew', 'auth', 'auth'];
|
||||
const outcomes = await Promise.allSettled(actions.map((action) => execute(process.execPath, [
|
||||
'-r', require.resolve('ts-node/register/transpile-only'), worker, action,
|
||||
external.client_id, external.client_secret,
|
||||
], { cwd: root, timeout: 20_000, env: process.env })));
|
||||
const results = outcomes.map((outcome) => {
|
||||
assert.equal(outcome.status, 'fulfilled', outcome.reason?.stderr);
|
||||
return outcome.value;
|
||||
});
|
||||
const database = await service.findApps();
|
||||
const cached = await shareStore.getApps();
|
||||
assert.deepEqual(cached, JSON.parse(JSON.stringify(database)));
|
||||
for (let index = 0; index < results.length; index++) {
|
||||
assert.equal(results[index].stderr, '');
|
||||
const result = JSON.parse(results[index].stdout.trim());
|
||||
const value = result.value || result.data?.token;
|
||||
if (value) assert.ok(cached.some((app) => app.tokens?.some((token) => token.value === value)));
|
||||
}
|
||||
assert.equal(database.find((app) => app.name === 'sqlite-external').tokens.length, 3);
|
||||
const file = JSON.parse(fs.readFileSync(path.join(config.configPath, 'token.json'), 'utf8'));
|
||||
assert.ok(cached.find((app) => app.name === 'system').tokens.some((token) => token.value === file.value));
|
||||
});
|
||||
|
||||
|
||||
test('atomic publication preserves restricted token-file permissions', async (t) => {
|
||||
const state = fixture(t, [{ value: 'current', expiration: START + DAY }]);
|
||||
const file = path.join(config.configPath, 'token.json');
|
||||
await state.service.generateSystemToken();
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||
fs.chmodSync(file, 0o640);
|
||||
await state.service.generateSystemToken(true);
|
||||
assert.equal(fs.statSync(file).mode & 0o777, 0o640);
|
||||
});
|
||||
|
||||
|
||||
test('a SIGKILLed generator leaves a lock that the next acquisition recovers', async (t) => {
|
||||
const state = await processFixture(t);
|
||||
const running = state.run(false, false, true);
|
||||
const stopped = running.catch((error) => error);
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (!fs.existsSync(path.join(state.directory, 'locked'))) {
|
||||
assert.ok(Date.now() < deadline, 'generator did not acquire the lock');
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
running.child.kill('SIGKILL');
|
||||
assert.equal((await stopped).signal, 'SIGKILL');
|
||||
assert.equal(fs.existsSync(path.join(state.directory, '.apps-state.lock')), true);
|
||||
const recovered = await state.run();
|
||||
assert.equal(recovered.stderr, '');
|
||||
assert.equal(recovered.stdout.trim(), state.cached().value);
|
||||
assert.equal(fs.existsSync(path.join(state.directory, '.apps-state.lock')), false);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
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 { spawnSync } = require('node:child_process');
|
||||
|
||||
function extract(file, name) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
const start = text.indexOf(name + '() {');
|
||||
assert.ok(start >= 0, name);
|
||||
return text.slice(start, text.indexOf('\n}', start) + 2);
|
||||
}
|
||||
const helpers = [
|
||||
fs.readFileSync('shell/task-timeout.sh', 'utf8'),
|
||||
...['handle_task_start', 'handle_task_end', 'run_task_before', 'run_task_after', 'get_env_array', 'clear_env'].map(n => extract('shell/share.sh', n)),
|
||||
...['run_shell_script', 'define_program', 'format_params'].map(n => extract('shell/task.sh', n)),
|
||||
].join('\n');
|
||||
const taskSource = path.resolve('shell/otask.sh');
|
||||
|
||||
function fixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-shell-lifecycle-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const write = (name, body) => fs.writeFileSync(path.join(root, name), body);
|
||||
write('env.sh', 'export QA_PANEL="alpha&beta&gamma"\n');
|
||||
write('before.sh', 'export QA_BEFORE=ready\nqa_function() { printf "HOOK_FUNCTION\\n"; }\n');
|
||||
write('after.sh', 'printf "AFTER:%s:%s\\n" "$QA_PANEL" "$QA_BEFORE" >> "$QA_ROOT/events"\n');
|
||||
const run = (args, timeout = '') => {
|
||||
const r = spawnSync('/bin/bash', ['-c', helpers + `
|
||||
dir_scripts=$QA_ROOT; dir_shell=$QA_ROOT; dir_dep=$QA_ROOT
|
||||
file_env=$QA_ROOT/env.sh; file_task_before=$QA_ROOT/before.sh; file_task_after=$QA_ROOT/after.sh
|
||||
ID=42; log_path=fixture.log; is_macos=0
|
||||
dir_log=$QA_ROOT; log_dir=.; mtime_format='%Y-%m-%d %H:%M:%S'
|
||||
format_log_time() { printf fixture; }
|
||||
time_format='%Y-%m-%d %H:%M:%S'; begin_time=start; begin_timestamp=$(date +%s)
|
||||
real_time=true; no_delay=true; NODE_OPTIONS=''; PYTHONPATH=''
|
||||
pnpm() { printf '/fixture/global\\n'; }
|
||||
t() { :; }
|
||||
update_cron() { printf 'STATUS:%s:%s\\n' "$2" "$7" >> "$QA_ROOT/events"; }
|
||||
record_cron_stat() { printf 'STAT:%s\\n' "$2" >> "$QA_ROOT/events"; }
|
||||
format_params "$@"; define_program "${'${task_shell_params[@]}'}"
|
||||
. "$QA_TASK_SOURCE"
|
||||
printf 'WRAPPER_FINISHED\\n' >> "$QA_ROOT/events"
|
||||
`, 'fixture', ...args], { cwd: root, env: { ...process.env, command_timeout_time: timeout, QA_ROOT: root, QA_TASK_SOURCE: taskSource }, encoding: 'utf8', timeout: 10000 });
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
return { stdout: r.stdout, events: fs.readFileSync(path.join(root, 'events'), 'utf8').trim().split('\n') };
|
||||
};
|
||||
return { write, run };
|
||||
}
|
||||
|
||||
for (const [label, body, code] of [
|
||||
['normal return', ':', 0],
|
||||
['exit zero', 'exit 0', 0],
|
||||
['exit failure', 'exit 7', 7],
|
||||
['exec replacement', "exec bash -c 'exit 9'", 9],
|
||||
['errexit failure', 'set -e\nfalse\necho SHOULD_NOT_RUN', 1],
|
||||
['script EXIT trap', `trap 'echo SCRIPT_EXIT_TRAP' EXIT\nexit 4`, 4],
|
||||
]) {
|
||||
test(`sourced shell ${label} still runs after hook and reports exactly one completion`, t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.sh', `[[ "$QA_PANEL" == 'alpha&beta&gamma' && "$QA_BEFORE" == ready ]] || exit 99\nqa_function\n${body}\n`);
|
||||
const r = f.run(['probe.sh']);
|
||||
assert.match(r.stdout, /HOOK_FUNCTION/);
|
||||
assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/);
|
||||
if (label === 'script EXIT trap') assert.match(r.stdout, /SCRIPT_EXIT_TRAP/);
|
||||
assert.deepEqual(r.events, ['STATUS:0:', 'AFTER:alpha&beta&gamma:ready', `STATUS:1:${code}`, `STAT:${code}`, 'WRAPPER_FINISHED']);
|
||||
});
|
||||
}
|
||||
|
||||
test('shell arguments and before-hook functions survive isolation while script state stays in the child', t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.sh', '[[ "$1" == "arg with spaces" ]] || exit 99\nqa_function\nexport QA_BEFORE=changed\ncd /\nexit 5\n');
|
||||
const r = f.run(['probe.sh', '--', 'arg with spaces']);
|
||||
assert.match(r.stdout, /HOOK_FUNCTION/);
|
||||
assert.ok(r.events.includes('AFTER:alpha&beta&gamma:ready'));
|
||||
assert.ok(r.events.includes('STATUS:1:5'));
|
||||
});
|
||||
|
||||
for (const [label, args] of [
|
||||
['explicit shell interpreter', ['bash', 'probe.sh', 'argument']],
|
||||
['shell script with positional args', ['probe.sh', 'argument']],
|
||||
]) {
|
||||
test(`${label} preserves generated environment and reports failure`, t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.sh', '[[ "$QA_PANEL" == "alpha&beta&gamma" && "$QA_BEFORE" == ready && "$1" == argument ]] || exit 99\nexit 6\n');
|
||||
const r = f.run(args);
|
||||
assert.ok(r.events.includes('STATUS:1:6'));
|
||||
assert.equal(r.events.filter(x => x.startsWith('STATUS:1:')).length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('explicit Python interpreter receives panel and before-hook environment', t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.py', 'import os,sys\nassert os.environ["QA_PANEL"] == "alpha&beta&gamma"\nassert os.environ["QA_BEFORE"] == "ready"\nassert sys.argv[1] == "argument"\nsys.exit(8)\n');
|
||||
assert.ok(f.run(['python3', 'probe.py', 'argument']).events.includes('STATUS:1:8'));
|
||||
});
|
||||
|
||||
test('explicit Node interpreter retains generated environment', t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.cjs', 'if (process.env.QA_PANEL !== "alpha&beta&gamma" || process.env.QA_BEFORE !== "ready") process.exit(99); process.exit(8);\n');
|
||||
assert.ok(f.run(['node', 'probe.cjs']).events.includes('STATUS:1:8'));
|
||||
});
|
||||
|
||||
test('plain commands retain generated environment', t => {
|
||||
const f = fixture(t);
|
||||
assert.match(f.run(['printenv', 'QA_PANEL']).stdout, /alpha&beta&gamma/);
|
||||
});
|
||||
|
||||
for (const mode of ['desi', 'conc']) {
|
||||
test(`shell ${mode} keeps selected account values and completion reporting`, t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.sh', 'printf "ACCOUNT=%s\\n" "$QA_PANEL"\nexit 0\n');
|
||||
const r = f.run(['probe.sh', mode, 'QA_PANEL', '2-3']);
|
||||
assert.match(r.stdout, mode === 'desi' ? /ACCOUNT=beta&gamma/ : /ACCOUNT=beta\nACCOUNT=gamma/);
|
||||
assert.doesNotMatch(r.stdout, /ACCOUNT=alpha/);
|
||||
assert.ok(r.events.includes(`AFTER:${mode === 'desi' ? 'beta&gamma' : 'gamma'}:ready`));
|
||||
assert.equal(r.events.filter(x => x === 'STATUS:1:0').length, 1);
|
||||
assert.equal(r.events.filter(x => x.startsWith('AFTER:')).length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [label, args] of [
|
||||
['sourced shell', ['probe.sh', '--', 'space value']],
|
||||
['explicit bash', ['bash', 'probe.sh', 'space value']],
|
||||
]) {
|
||||
test(`${label} timeout stops descendants and finalizes once with 124`, t => {
|
||||
const f = fixture(t);
|
||||
f.write('before.sh', 'export QA_BEFORE=ready\nprivate_value=private\nprivate_array=(one "two words")\nqa_function() { printf "HOOK_FUNCTION\\n"; }\n');
|
||||
f.write('probe.sh', `[[ "$QA_PANEL" == 'alpha&beta&gamma' && "$QA_BEFORE" == ready && "$1" == 'space value' ]] || exit 91\n${label === 'sourced shell' ? '[[ "$private_value" = private && "${private_array[1]}" = "two words" ]] || exit 92\nqa_function\n' : ''}trap 'echo EXIT_TRAP' EXIT\nsleep 3\necho SHOULD_NOT_RUN\n`);
|
||||
const before = Date.now();
|
||||
const r = f.run(args, '0.2s');
|
||||
assert.ok(Date.now() - before < 2500, r.stdout);
|
||||
assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/);
|
||||
assert.match(r.stdout, /EXIT_TRAP/);
|
||||
assert.deepEqual(r.events, ['STATUS:0:', 'AFTER:alpha&beta&gamma:ready', 'STATUS:1:124', 'STAT:124', 'WRAPPER_FINISHED']);
|
||||
});
|
||||
}
|
||||
|
||||
test('timeout escalates when a shell and its children ignore TERM', t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.sh', 'trap "" TERM\nsleep 3\necho SHOULD_NOT_RUN\n');
|
||||
const before = Date.now();
|
||||
const r = f.run(['probe.sh'], '0.1');
|
||||
assert.ok(Date.now() - before < 2500);
|
||||
assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/);
|
||||
assert.ok(r.events.includes('STATUS:1:124'));
|
||||
});
|
||||
|
||||
test('completed timed tasks keep their exit code and do not wait for the timer', t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.sh', 'exit 7\n');
|
||||
const before = Date.now();
|
||||
const r = f.run(['probe.sh'], '1h');
|
||||
assert.ok(Date.now() - before < 1500);
|
||||
assert.ok(r.events.includes('STATUS:1:7'));
|
||||
});
|
||||
|
||||
test('zero disables the deadline and invalid durations never start the script', t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.sh', 'echo DID_RUN\n');
|
||||
assert.match(f.run(['probe.sh'], '0s').stdout, /DID_RUN/);
|
||||
f.write('events', '');
|
||||
const r = f.run(['probe.sh'], 'wrong');
|
||||
assert.doesNotMatch(r.stdout, /DID_RUN/);
|
||||
assert.ok(r.events.includes('STATUS:1:125'));
|
||||
});
|
||||
|
||||
for (const [runtime, filename, body] of [
|
||||
['python3', 'timed.py', 'import time\nprint("STARTED", flush=True)\ntime.sleep(3)\nprint("SHOULD_NOT_RUN", flush=True)\n'],
|
||||
['node', 'timed.cjs', 'console.log("STARTED"); setTimeout(() => console.log("SHOULD_NOT_RUN"), 3000);\n'],
|
||||
]) {
|
||||
test(`${runtime} timeout stops execution and reports 124`, t => {
|
||||
const f = fixture(t);
|
||||
f.write(filename, body);
|
||||
const r = f.run([runtime, filename], '0.5s');
|
||||
assert.match(r.stdout, /STARTED/);
|
||||
assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/);
|
||||
assert.ok(r.events.includes('STATUS:1:124'));
|
||||
assert.equal(r.events.filter(x => x.startsWith('AFTER:')).length, 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const mode of ['desi', 'conc']) {
|
||||
test(`timed shell ${mode} preserves selected accounts and terminates every worker`, t => {
|
||||
const f = fixture(t);
|
||||
f.write('probe.sh', 'echo "ACCOUNT=$QA_PANEL"\nsleep 3\necho SHOULD_NOT_RUN\n');
|
||||
const r = f.run(['probe.sh', mode, 'QA_PANEL', '2-3'], '0.2s');
|
||||
assert.match(r.stdout, mode === 'desi' ? /ACCOUNT=beta&gamma/ : /ACCOUNT=beta\nACCOUNT=gamma/);
|
||||
assert.doesNotMatch(r.stdout, /SHOULD_NOT_RUN/);
|
||||
assert.equal(r.events.filter(x => x.startsWith('AFTER:')).length, 1);
|
||||
// Concurrent mode retains its existing aggregate wait status behavior.
|
||||
assert.ok(r.events.includes(`STATUS:1:${mode === 'desi' ? 124 : 0}`));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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 { spawnSync } = require('node:child_process');
|
||||
function extract(file, name) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
const start = text.indexOf(name + '() {');
|
||||
assert.ok(start >= 0);
|
||||
return text.slice(start, text.indexOf('\n}', start) + 2);
|
||||
}
|
||||
const source =
|
||||
['handle_log_path', 'init_begin_time']
|
||||
.map((n) => extract('shell/task.sh', n))
|
||||
.join('\n') +
|
||||
'\n' +
|
||||
['format_time', 'format_log_time', 'format_timestamp', 'handle_task_end']
|
||||
.map((n) => extract('shell/share.sh', n))
|
||||
.join('\n');
|
||||
function run(t, body) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ql-task-time-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const result = spawnSync(
|
||||
'/bin/bash',
|
||||
[
|
||||
'-ec',
|
||||
source +
|
||||
`
|
||||
is_macos=0
|
||||
mtime_format='%Y-%m-%d %H:%M:%S.%3N'
|
||||
time_format='%Y-%m-%d %H:%M:%S'
|
||||
ID=42; dir_log=/tmp; log_name=''; real_log_path=''; no_tee=''; real_time=''
|
||||
make_dir() { :; }
|
||||
t() { printf '%s\\n' "$*" >> "$TEST_DIR/messages"; }
|
||||
update_cron() { printf '%s\\n' "$*" >> "$TEST_DIR/status"; }
|
||||
record_cron_stat() { printf '%s\\n' "$*" >> "$TEST_DIR/stats"; }
|
||||
date() {
|
||||
printf '%s\\n' "$*" >> "$TEST_DIR/dates"
|
||||
case "$1" in
|
||||
'+%Y-%m-%d %H:%M:%S.%3N|%Y-%m-%d-%H-%M-%S-%3N') printf '%s\\n' '2026-09-12 23:59:59.999|2026-09-12-23-59-59-999';;
|
||||
'+%Y-%m-%d %H:%M:%S|%s') printf '%s\\n' '2026-09-13 00:00:00|101';;
|
||||
'-d') [[ "$3" == '+%s' ]] && echo 100;;
|
||||
*) echo raw-time;;
|
||||
esac
|
||||
}
|
||||
` +
|
||||
body,
|
||||
],
|
||||
{ encoding: 'utf8', env: { ...process.env, TEST_DIR: dir } },
|
||||
);
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
return {
|
||||
dir,
|
||||
text: (name) =>
|
||||
fs.existsSync(path.join(dir, name))
|
||||
? fs.readFileSync(path.join(dir, name), 'utf8')
|
||||
: '',
|
||||
};
|
||||
}
|
||||
test('Linux task timestamps share a snapshot and retain millisecond log names across midnight', (t) => {
|
||||
const r = run(
|
||||
t,
|
||||
`handle_log_path 'folder/example.js'
|
||||
init_begin_time
|
||||
[[ "$log_path" == 'folder_example_42/2026-09-12-23-59-59-999.log' ]]
|
||||
[[ "$time" == '2026-09-12 23:59:59.999' && "$begin_time" == '2026-09-12 23:59:59' && "$begin_timestamp" == 100 ]]
|
||||
handle_task_end`,
|
||||
);
|
||||
assert.equal(r.text('dates').trim().split('\n').length, 3);
|
||||
assert.match(r.text('stats'), /^42 0 1\n$/);
|
||||
assert.match(r.text('messages'), /2026-09-13 00:00:00/);
|
||||
});
|
||||
test('explicit log paths and output mode remain intact', (t) => {
|
||||
run(
|
||||
t,
|
||||
`real_log_path='custom/output.log'; real_time=true
|
||||
handle_log_path 'folder/example.js'
|
||||
[[ "$log_path" == 'custom/output.log' && "$cmd" == '' ]]`,
|
||||
);
|
||||
});
|
||||
test('task failure keeps exit code and minimum one-second runtime', (t) => {
|
||||
const r = run(
|
||||
t,
|
||||
`begin_timestamp=101; _task_exit_code=7; log_path=test.log
|
||||
handle_task_end`,
|
||||
);
|
||||
assert.equal(r.text('stats'), '42 7 1\n');
|
||||
assert.match(r.text('messages'), /失败/);
|
||||
assert.match(r.text('status'), /101 1 7/);
|
||||
});
|
||||
test('manual stop retains stopped message and exit status reporting', (t) => {
|
||||
const r = run(
|
||||
t,
|
||||
`begin_timestamp=99; _task_exit_code=1; log_path=test.log; MANUAL=true
|
||||
handle_task_end`,
|
||||
);
|
||||
assert.equal(r.text('stats'), '42 1 2\n');
|
||||
assert.match(r.text('messages'), /已停止/);
|
||||
});
|
||||
for (const mode of ['macos', 'custom'])
|
||||
test(`${mode} keeps legacy time conversion helpers`, (t) => {
|
||||
run(
|
||||
t,
|
||||
`
|
||||
${mode === 'macos' ? 'is_macos=1' : 'mtime_format=custom; time_format=custom'}
|
||||
format_log_time() { echo legacy-log; }
|
||||
format_time() { echo legacy-time; }
|
||||
format_timestamp() { echo 100; }
|
||||
handle_log_path example.js
|
||||
init_begin_time
|
||||
[[ "$log_time" == legacy-log && "$begin_time" == legacy-time && "$begin_timestamp" == 100 ]]
|
||||
handle_task_end
|
||||
`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { Sequelize, DataTypes } = require('sequelize');
|
||||
const i18n = require.resolve('../../back/shared/i18n');
|
||||
require.cache[i18n] = {
|
||||
id: i18n,
|
||||
filename: i18n,
|
||||
loaded: true,
|
||||
exports: { t: (x) => x },
|
||||
};
|
||||
const handler = require('../../back/middlewares/uniqueConstraintError').default;
|
||||
|
||||
test('database uniqueness conflicts return 409 without values and preserve existing rows', async () => {
|
||||
const db = new Sequelize('sqlite::memory:', { logging: false });
|
||||
try {
|
||||
const App = db.define('App', {
|
||||
name: { type: DataTypes.STRING, unique: true },
|
||||
});
|
||||
const Env = db.define('Env', {
|
||||
name: { type: DataTypes.STRING, unique: 'pair' },
|
||||
value: { type: DataTypes.STRING, unique: 'pair' },
|
||||
});
|
||||
await db.sync();
|
||||
const first = await App.create({ name: 'private-app' });
|
||||
const second = await App.create({ name: 'other' });
|
||||
await Env.create({ name: 'TOKEN', value: 'private-value' });
|
||||
const env = await Env.create({ name: 'TOKEN', value: 'other-value' });
|
||||
for (const write of [
|
||||
() => App.create({ name: first.name }),
|
||||
() => second.update({ name: first.name }),
|
||||
() => env.update({ value: 'private-value' }),
|
||||
]) {
|
||||
await assert.rejects(write, (err) => {
|
||||
let status, body;
|
||||
handler(
|
||||
err,
|
||||
{},
|
||||
{
|
||||
status: (x) => {
|
||||
status = x;
|
||||
return {
|
||||
json: (x) => {
|
||||
body = x;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
() => assert.fail('constraint passed through'),
|
||||
);
|
||||
assert.equal(status, 409);
|
||||
assert.equal(body.code, 409);
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(body),
|
||||
/private-|TOKEN|INSERT|UPDATE/,
|
||||
);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
assert.equal((await second.reload()).name, 'other');
|
||||
assert.equal((await env.reload()).value, 'other-value');
|
||||
const writes = await Promise.allSettled([
|
||||
App.create({ name: 'race' }),
|
||||
App.create({ name: 'race' }),
|
||||
]);
|
||||
assert.equal(writes.filter((x) => x.status === 'fulfilled').length, 1);
|
||||
assert.equal(await App.count({ where: { name: 'race' } }), 1);
|
||||
const unrelated = new Error('unrelated');
|
||||
handler(unrelated, {}, {}, (err) => assert.equal(err, unrelated));
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
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 { randomUUID } = require('node:crypto');
|
||||
const { spawn } = require('node:child_process');
|
||||
const { withUpgradeLock, waitForHealth } = require('../../shell/upgrade.cjs');
|
||||
|
||||
async function temporary(t) {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'ql-upgrade-guards-'));
|
||||
t.after(() => fs.rm(tmp, { recursive: true, force: true }));
|
||||
return tmp;
|
||||
}
|
||||
|
||||
for (const legacy of [true, false])
|
||||
test(
|
||||
`concurrent reclamation of a ${
|
||||
legacy ? 'legacy lock' : 'unique owner lock'
|
||||
} cannot remove a new owner`,
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
const lock = path.join(tmp, 'upgrade.lock');
|
||||
const owner = legacy ? 'pid' : `owner-99999999-${randomUUID()}`;
|
||||
await fs.mkdir(lock);
|
||||
await fs.writeFile(path.join(lock, owner), legacy ? '99999999' : '');
|
||||
const original = {
|
||||
readFile: fs.readFile,
|
||||
readdir: fs.readdir,
|
||||
rm: fs.rm,
|
||||
unlink: fs.unlink,
|
||||
};
|
||||
let readCount = 0,
|
||||
removeCount = 0,
|
||||
active = 0,
|
||||
maximum = 0;
|
||||
let readyReaders, firstEntered, release;
|
||||
const readers = new Promise((resolve) => {
|
||||
readyReaders = resolve;
|
||||
});
|
||||
const entered = new Promise((resolve) => {
|
||||
firstEntered = resolve;
|
||||
});
|
||||
const held = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const synchronize = async () => {
|
||||
if (++readCount === 2) readyReaders();
|
||||
await readers;
|
||||
};
|
||||
t.mock.method(fs, 'readFile', async (...args) => {
|
||||
const value = await original.readFile(...args);
|
||||
if (legacy && args[0] === path.join(lock, owner) && readCount < 2)
|
||||
await synchronize();
|
||||
return value;
|
||||
});
|
||||
t.mock.method(fs, 'readdir', async (...args) => {
|
||||
const value = await original.readdir(...args);
|
||||
if (!legacy && args[0] === lock && readCount < 2) await synchronize();
|
||||
return value;
|
||||
});
|
||||
t.mock.method(fs, 'rm', async (...args) => {
|
||||
if (args[0] === lock && ++removeCount === 2) await entered;
|
||||
return original.rm(...args);
|
||||
});
|
||||
t.mock.method(fs, 'unlink', async (...args) => {
|
||||
if (args[0] === path.join(lock, owner) && ++removeCount === 2)
|
||||
await entered;
|
||||
return original.unlink(...args);
|
||||
});
|
||||
const operation = async () => {
|
||||
maximum = Math.max(maximum, ++active);
|
||||
firstEntered();
|
||||
if (active === 1) await held;
|
||||
active--;
|
||||
};
|
||||
const attempts = [
|
||||
withUpgradeLock(tmp, operation),
|
||||
withUpgradeLock(tmp, operation),
|
||||
].map((attempt) =>
|
||||
attempt.then(
|
||||
() => ({ ok: true }),
|
||||
(error) => ({ error }),
|
||||
),
|
||||
);
|
||||
try {
|
||||
await entered;
|
||||
const contender = await Promise.race(attempts);
|
||||
assert.match(contender.error?.message || '', /Another upgrade/);
|
||||
assert.equal(maximum, 1);
|
||||
assert.equal((await original.readdir(lock)).length, 1);
|
||||
} finally {
|
||||
release();
|
||||
await Promise.all(attempts);
|
||||
}
|
||||
assert.deepEqual(await original.readdir(tmp), []);
|
||||
},
|
||||
);
|
||||
|
||||
test('an empty abandoned lock is acquired atomically and a failed operation releases its owner', async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
await fs.mkdir(path.join(tmp, 'upgrade.lock'));
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, async () => {
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, async () => assert.fail('concurrent operation')),
|
||||
/Another upgrade/,
|
||||
);
|
||||
throw new Error('operation failed');
|
||||
}),
|
||||
/operation failed/,
|
||||
);
|
||||
await withUpgradeLock(tmp, async () => {});
|
||||
assert.deepEqual(await fs.readdir(tmp), []);
|
||||
});
|
||||
|
||||
test('release never deletes another owner after the original lock was replaced', async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
const lock = path.join(tmp, 'upgrade.lock');
|
||||
const replacement = `owner-${process.pid}-${randomUUID()}`;
|
||||
await withUpgradeLock(tmp, async () => {
|
||||
await fs.rm(lock, { recursive: true });
|
||||
await fs.mkdir(lock);
|
||||
await fs.writeFile(path.join(lock, replacement), '');
|
||||
});
|
||||
assert.deepEqual(await fs.readdir(lock), [replacement]);
|
||||
});
|
||||
|
||||
test(
|
||||
'a lock left by a killed process can be reclaimed by a new process',
|
||||
{ timeout: 5000 },
|
||||
async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
const worker = path.join(tmp, 'worker.cjs');
|
||||
await fs.writeFile(
|
||||
worker,
|
||||
`
|
||||
const { withUpgradeLock } = require(${JSON.stringify(
|
||||
path.resolve('shell/upgrade.cjs'),
|
||||
)});
|
||||
withUpgradeLock(process.argv[2], async () => {
|
||||
process.stdout.write('owned\\n');
|
||||
await new Promise(() => setInterval(() => {}, 1000));
|
||||
}).catch(error => { process.stderr.write(error.message); process.exitCode = 1; });
|
||||
`,
|
||||
);
|
||||
const child = spawn(process.execPath, [worker, tmp], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
});
|
||||
const closed = new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
child.once('close', resolve);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
child.stdout.once('data', resolve);
|
||||
child.once('error', reject);
|
||||
child.once('exit', () =>
|
||||
reject(new Error('worker exited before acquiring its lock')),
|
||||
);
|
||||
});
|
||||
child.kill('SIGKILL');
|
||||
await closed;
|
||||
await withUpgradeLock(tmp, async () => {
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, async () => {}),
|
||||
/Another upgrade/,
|
||||
);
|
||||
});
|
||||
assert.deepEqual(await fs.readdir(tmp), ['worker.cjs']);
|
||||
},
|
||||
);
|
||||
|
||||
async function healthServer(t, handler) {
|
||||
const server = http.createServer(handler);
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
t.after(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
server.close(resolve);
|
||||
server.closeAllConnections();
|
||||
}),
|
||||
);
|
||||
return String(server.address().port);
|
||||
}
|
||||
|
||||
test('health checks normalize supported URL prefixes exactly like the backend', async (t) => {
|
||||
const requests = [];
|
||||
const port = await healthServer(t, (req, res) => {
|
||||
requests.push(req.url);
|
||||
if (
|
||||
['/api/health', '/panel/api/health', '/panel//api/health'].includes(
|
||||
req.url,
|
||||
)
|
||||
)
|
||||
res.end('{"code":200,"data":{"status":"ok"}}');
|
||||
else {
|
||||
res.statusCode = 404;
|
||||
res.end('{}');
|
||||
}
|
||||
});
|
||||
for (const prefix of [
|
||||
'',
|
||||
'/',
|
||||
'panel',
|
||||
'/panel',
|
||||
'panel/',
|
||||
'/panel/',
|
||||
'panel//',
|
||||
'/panel//',
|
||||
])
|
||||
await waitForHealth({ QlPort: port, QlBaseUrl: prefix }, 100);
|
||||
assert.deepEqual(requests, [
|
||||
'/api/health',
|
||||
'/api/health',
|
||||
...Array(4).fill('/panel/api/health'),
|
||||
...Array(2).fill('/panel//api/health'),
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
'health timeout bounds a continuously streaming response',
|
||||
{ timeout: 3000 },
|
||||
async (t) => {
|
||||
const port = await healthServer(t, (req, res) => {
|
||||
const timer = setInterval(() => res.write(' '), 10);
|
||||
res.once('close', () => clearInterval(timer));
|
||||
});
|
||||
const began = Date.now();
|
||||
await assert.rejects(waitForHealth({ QlPort: port }, 80), /health check/);
|
||||
assert.ok(
|
||||
Date.now() - began < 1000,
|
||||
'deadline must not depend on socket inactivity',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (const streaming of [false, true])
|
||||
test(
|
||||
`cancellation interrupts ${
|
||||
streaming ? 'an active response' : 'the retry delay'
|
||||
} and releases the upgrade lock`,
|
||||
{ timeout: 3000 },
|
||||
async (t) => {
|
||||
const tmp = await temporary(t);
|
||||
const controller = new AbortController();
|
||||
const reason = new Error('cancelled health check');
|
||||
const port = await healthServer(t, (req, res) => {
|
||||
if (streaming) res.write('{');
|
||||
else {
|
||||
res.statusCode = 503;
|
||||
res.end('{}');
|
||||
}
|
||||
setTimeout(() => controller.abort(reason), 30);
|
||||
});
|
||||
const began = Date.now();
|
||||
await assert.rejects(
|
||||
withUpgradeLock(tmp, () =>
|
||||
waitForHealth({ QlPort: port }, 5000, controller.signal),
|
||||
),
|
||||
(error) => error === reason,
|
||||
);
|
||||
assert.ok(
|
||||
Date.now() - began < 400,
|
||||
'cancel must interrupt the current wait',
|
||||
);
|
||||
await withUpgradeLock(tmp, async () => {});
|
||||
assert.deepEqual(await fs.readdir(tmp), []);
|
||||
},
|
||||
);
|
||||
|
||||
test('unhealthy, malformed and oversized health responses fail within the deadline', async (t) => {
|
||||
for (const body of ['{}', '{', ' '.repeat(65537)]) {
|
||||
const port = await healthServer(t, (req, res) => res.end(body));
|
||||
await assert.rejects(waitForHealth({ QlPort: port }, 20), /health check/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
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 { randomUUID } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const {
|
||||
runProcess,
|
||||
withLifecycleHooks,
|
||||
withUpgradeLock,
|
||||
} = require('../../shell/upgrade.cjs');
|
||||
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function assertStopped(pid) {
|
||||
for (let attempt = 0; attempt < 50; attempt++) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
} catch (error) {
|
||||
if (error.code === 'ESRCH') return;
|
||||
throw error;
|
||||
}
|
||||
const state = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
// Linux PID 1 can retain a dead orphan until it is reaped. It can also
|
||||
// disappear between kill(0) and ps; neither state is a running installer.
|
||||
if (
|
||||
(state.status === 1 && !state.stdout.trim()) ||
|
||||
/^Z/.test(state.stdout.trim())
|
||||
)
|
||||
return;
|
||||
assert.equal(state.status, 0, state.stderr);
|
||||
await delay(20);
|
||||
}
|
||||
assert.fail(`test process ${pid} is still running`);
|
||||
}
|
||||
|
||||
async function fixture(t, directory) {
|
||||
const base = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-upgrade-lifecycle-'),
|
||||
);
|
||||
t.after(() => fs.rm(base, { recursive: true, force: true }));
|
||||
const root = directory ? path.join(base, directory) : base;
|
||||
const tmp = path.join(root, '.tmp');
|
||||
await fs.mkdir(tmp, { recursive: true });
|
||||
await fs.mkdir(path.join(root, 'shell/lang'), { recursive: true });
|
||||
await fs.writeFile(path.join(root, 'shell/lang/zh.sh'), '');
|
||||
await fs.writeFile(
|
||||
path.join(root, 'shell/env.sh'),
|
||||
await fs.readFile('shell/env.sh'),
|
||||
);
|
||||
return { root, tmp, env: { ...process.env, QL_DIR: root } };
|
||||
}
|
||||
|
||||
test('installed stop/start hooks do not match their own pkill and survive shell replacement', async (t) => {
|
||||
const f = await fixture(t);
|
||||
// Only the test's random path can match; never signal a running panel.
|
||||
const marker = `review/${randomUUID()}/app.js`;
|
||||
const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll(
|
||||
'static/build/app.js',
|
||||
marker,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`${helpers}
|
||||
pm2() { printf '%s\\n' "$*" >> "$QL_DIR/pm2-calls"; }
|
||||
${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''}
|
||||
`,
|
||||
);
|
||||
await withUpgradeLock(f.tmp, () =>
|
||||
withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => {
|
||||
await fs.writeFile(path.join(f.root, 'shell/share.sh'), 'exit 99');
|
||||
await hook('stop');
|
||||
await hook('start');
|
||||
assert.match(
|
||||
await fs.readFile(path.join(f.root, 'pm2-calls'), 'utf8'),
|
||||
/startOrGracefulReload/,
|
||||
);
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
|
||||
test(
|
||||
'the installed Node fallback starts successfully and its stop hook kills only the test backend',
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const f = await fixture(t);
|
||||
const marker = `review/${randomUUID()}/app.js`;
|
||||
let pid;
|
||||
t.after(() => {
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
const helpers = (await fs.readFile('shell/share.sh', 'utf8')).replaceAll(
|
||||
'static/build/app.js',
|
||||
marker,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`${helpers}
|
||||
pm2() { return 1; }
|
||||
${process.platform === 'darwin' ? 'pkill() { command pkill -a "$@"; }' : ''}
|
||||
`,
|
||||
);
|
||||
await fs.mkdir(path.dirname(path.join(f.root, marker)), {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.mkdir(path.join(f.root, 'data/log'), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(f.root, marker),
|
||||
`require('node:fs').writeFileSync(process.env.QL_DIR + '/backend-pid', String(process.pid)); setInterval(() => {}, 1000);`,
|
||||
);
|
||||
await withUpgradeLock(f.tmp, () =>
|
||||
withLifecycleHooks(f.root, f.tmp, f.env, async (hook) => {
|
||||
await hook('start');
|
||||
for (let attempt = 0; attempt < 150; attempt++) {
|
||||
try {
|
||||
pid = Number(
|
||||
await fs.readFile(path.join(f.root, 'backend-pid'), 'utf8'),
|
||||
);
|
||||
break;
|
||||
} catch {
|
||||
await delay(20);
|
||||
}
|
||||
}
|
||||
assert.ok(pid, 'fallback backend started');
|
||||
process.kill(pid, 0);
|
||||
await hook('stop');
|
||||
await assertStopped(pid);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
for (const { redirect, ignoreShell } of [
|
||||
{ redirect: false, ignoreShell: false },
|
||||
{ redirect: true, ignoreShell: false },
|
||||
{ redirect: false, ignoreShell: true },
|
||||
])
|
||||
test(
|
||||
`cancelled installation stops descendants before releasing its lock (${JSON.stringify(
|
||||
{ redirect, ignoreShell },
|
||||
)})`,
|
||||
{ timeout: 10000 },
|
||||
async (t) => {
|
||||
const f = await fixture(t);
|
||||
const controller = new AbortController();
|
||||
let pid;
|
||||
t.after(() => {
|
||||
controller.abort();
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
const worker = path.join(f.root, 'installer.cjs');
|
||||
await fs.writeFile(
|
||||
worker,
|
||||
`
|
||||
const fs = require('node:fs'), path = require('node:path');
|
||||
process.on('SIGTERM', () => {});
|
||||
fs.writeFileSync(path.join(process.env.QL_DIR, 'installer-pid'), String(process.pid));
|
||||
setInterval(() => fs.appendFileSync(path.join(process.env.QL_DIR, 'heartbeat'), 'x'), 20);
|
||||
`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`import_config() { :; }
|
||||
npm_install_2() {
|
||||
${ignoreShell ? "trap '' TERM" : ':'}
|
||||
"$TEST_NODE" "$1"${redirect ? ' >/dev/null 2>&1' : ''}
|
||||
exit_status=$?
|
||||
}
|
||||
`,
|
||||
);
|
||||
const operation = withUpgradeLock(f.tmp, () =>
|
||||
withLifecycleHooks(
|
||||
f.root,
|
||||
f.tmp,
|
||||
{ ...f.env, TEST_NODE: process.execPath },
|
||||
(hook) => hook('install', [worker], controller.signal),
|
||||
),
|
||||
);
|
||||
// Attach rejection handling before aborting the in-flight operation.
|
||||
const rejection = assert.rejects(operation, /cancelled installation/);
|
||||
for (let attempt = 0; attempt < 150; attempt++) {
|
||||
try {
|
||||
pid = Number(
|
||||
await fs.readFile(path.join(f.root, 'installer-pid'), 'utf8'),
|
||||
);
|
||||
await fs.access(path.join(f.root, 'heartbeat'));
|
||||
break;
|
||||
} catch {
|
||||
await delay(20);
|
||||
}
|
||||
}
|
||||
assert.ok(pid, 'installer started');
|
||||
await fs.access(path.join(f.tmp, 'upgrade.lock'));
|
||||
controller.abort(new Error('cancelled installation'));
|
||||
await rejection;
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
const before = await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8');
|
||||
await delay(100);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'heartbeat'), 'utf8'),
|
||||
before,
|
||||
);
|
||||
await assertStopped(pid);
|
||||
},
|
||||
);
|
||||
|
||||
test('an already cancelled command does not spawn and a spawn failure releases the upgrade lock', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const controller = new AbortController();
|
||||
controller.abort(new Error('cancelled before spawn'));
|
||||
const script = path.join(f.root, 'never-run.sh');
|
||||
await fs.writeFile(script, 'touch "$QL_DIR/spawned"');
|
||||
await assert.rejects(
|
||||
runProcess(f.env, 'bash', ['--', script], false, controller.signal),
|
||||
/cancelled before spawn/,
|
||||
);
|
||||
await assert.rejects(fs.access(path.join(f.root, 'spawned')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
await assert.rejects(
|
||||
withUpgradeLock(f.tmp, () =>
|
||||
runProcess(f.env, path.join(f.root, 'missing-program'), []),
|
||||
),
|
||||
{ code: 'ENOENT' },
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
|
||||
test('fixed lifecycle dispatch treats spaces, quotes and command substitutions in paths as data', async (t) => {
|
||||
const special =
|
||||
'panel spaces \u0027"; $(touch "$TEST_MARKER"); `touch "$TEST_MARKER"`';
|
||||
const f = await fixture(t, special);
|
||||
const marker = path.join(f.root, 'injected');
|
||||
const source = path.join(f.root, 'source ' + special);
|
||||
await fs.writeFile(
|
||||
path.join(f.root, 'shell/share.sh'),
|
||||
`
|
||||
import_config() { :; }
|
||||
delete_pm2() { printf stopped > "$QL_DIR/stopped"; }
|
||||
reload_pm2() { printf started > "$QL_DIR/started"; }
|
||||
npm_install_2() { printf '%s' "$1" > "$QL_DIR/installed"; exit_status=0; }
|
||||
`,
|
||||
);
|
||||
await withUpgradeLock(f.tmp, () =>
|
||||
withLifecycleHooks(
|
||||
f.root,
|
||||
f.tmp,
|
||||
{ ...f.env, TEST_MARKER: marker },
|
||||
async (hook) => {
|
||||
await hook('stop');
|
||||
await hook('start');
|
||||
await hook('install', [source]);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'stopped'), 'utf8'),
|
||||
'stopped',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'started'), 'utf8'),
|
||||
'started',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, 'installed'), 'utf8'),
|
||||
source,
|
||||
);
|
||||
await assert.rejects(fs.access(marker), { code: 'ENOENT' });
|
||||
await assert.rejects(hook('touch "$TEST_MARKER"'), /exit 2/);
|
||||
await assert.rejects(fs.access(marker), { code: 'ENOENT' });
|
||||
},
|
||||
),
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.tmp), []);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
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 { replaceAndReload } = require('../../shell/upgrade.cjs');
|
||||
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'ql-upgrade-transaction-'),
|
||||
);
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }));
|
||||
const source = path.join(root, 'source'),
|
||||
target = path.join(root, 'target');
|
||||
for (const directory of [source, target]) await fs.mkdir(directory);
|
||||
await fs.writeFile(path.join(source, 'version'), 'new');
|
||||
await fs.writeFile(path.join(target, 'version'), 'old');
|
||||
return { root, source, target };
|
||||
}
|
||||
|
||||
for (const failure of ['backup', 'remove', 'replacement', 'start'])
|
||||
test(`Bash transaction restores the full original after an overlay ${failure} failure`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
const rename = fs.rename,
|
||||
cp = fs.cp,
|
||||
rm = fs.rm;
|
||||
t.mock.method(fs, 'rename', async (source, target) => {
|
||||
if (source === f.target || source.includes('.ql-backup-')) {
|
||||
const error = new Error('overlay rename');
|
||||
error.code = 'EXDEV';
|
||||
throw error;
|
||||
}
|
||||
return rename(source, target);
|
||||
});
|
||||
let injected = false;
|
||||
t.mock.method(fs, 'cp', async (source, target, options) => {
|
||||
if (
|
||||
!injected &&
|
||||
((failure === 'backup' && source === f.target) ||
|
||||
(failure === 'replacement' && source === f.source))
|
||||
) {
|
||||
injected = true;
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
await fs.writeFile(path.join(target, 'partial'), 'partial');
|
||||
throw new Error('copy failed');
|
||||
}
|
||||
return cp(source, target, options);
|
||||
});
|
||||
t.mock.method(fs, 'rm', async (target, options) => {
|
||||
if (!injected && failure === 'remove' && target === f.target) {
|
||||
injected = true;
|
||||
await fs.unlink(path.join(target, 'version'));
|
||||
throw new Error('remove failed');
|
||||
}
|
||||
return rm(target, options);
|
||||
});
|
||||
let starts = 0;
|
||||
await assert.rejects(
|
||||
replaceAndReload([f], {
|
||||
stop: async () => {},
|
||||
start: async () => {
|
||||
if (++starts === 1 && failure === 'start')
|
||||
throw new Error('start failed');
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.target, 'version'), 'utf8'),
|
||||
'old',
|
||||
);
|
||||
},
|
||||
}),
|
||||
/failed/,
|
||||
);
|
||||
assert.deepEqual(await fs.readdir(f.target), ['version']);
|
||||
assert.ok(
|
||||
!(await fs.readdir(f.root)).some((name) => name.includes('.ql-backup-')),
|
||||
);
|
||||
});
|
||||
|
||||
test('interrupted upgrade recovers the previous service outside cancellation', async (t) => {
|
||||
const f = await fixture(t),
|
||||
controller = new AbortController();
|
||||
let starts = 0;
|
||||
await assert.rejects(
|
||||
replaceAndReload([f], {
|
||||
signal: controller.signal,
|
||||
stop: async () => {},
|
||||
start: async (recover) => {
|
||||
if (++starts === 1) {
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.target, 'version'), 'utf8'),
|
||||
'new',
|
||||
);
|
||||
controller.abort(new Error('interrupted'));
|
||||
} else {
|
||||
assert.equal(recover, true);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.target, 'version'), 'utf8'),
|
||||
'old',
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
/interrupted/,
|
||||
);
|
||||
assert.equal(starts, 2);
|
||||
});
|
||||
|
||||
test('failed stop during rollback retains backups without overwriting running files', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let stops = 0;
|
||||
await assert.rejects(
|
||||
replaceAndReload([f], {
|
||||
stop: async () => {
|
||||
if (++stops === 2) throw new Error('stop failed');
|
||||
},
|
||||
start: async () => {
|
||||
throw new Error('start failed');
|
||||
},
|
||||
}),
|
||||
/stop failed/,
|
||||
);
|
||||
const backup = (await fs.readdir(f.root)).find((name) =>
|
||||
name.includes('.ql-backup-'),
|
||||
);
|
||||
assert.ok(backup);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.root, backup, 'version'), 'utf8'),
|
||||
'old',
|
||||
);
|
||||
assert.equal(
|
||||
await fs.readFile(path.join(f.target, 'version'), 'utf8'),
|
||||
'new',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const express = require('express');
|
||||
const { Container } = require('typedi');
|
||||
|
||||
test('initialization returns the username and password validation result', async (t) => {
|
||||
const originalGet = Container.get;
|
||||
const userServicePath = require.resolve('../../back/services/user');
|
||||
const originalUserService = require.cache[userServicePath];
|
||||
const i18nPath = require.resolve('../../back/shared/i18n');
|
||||
const originalI18n = require.cache[i18nPath];
|
||||
const utilPath = require.resolve('../../back/config/util');
|
||||
const originalUtil = require.cache[utilPath];
|
||||
const authPath = require.resolve('../../back/shared/auth');
|
||||
const originalAuth = require.cache[authPath];
|
||||
require.cache[userServicePath] = {
|
||||
id: userServicePath,
|
||||
filename: userServicePath,
|
||||
loaded: true,
|
||||
exports: { __esModule: true, default: class UserService {} },
|
||||
children: [],
|
||||
paths: [],
|
||||
};
|
||||
require.cache[utilPath] = {
|
||||
id: utilPath,
|
||||
filename: utilPath,
|
||||
loaded: true,
|
||||
exports: { getToken: () => '', isDemoEnv: () => false },
|
||||
children: [],
|
||||
paths: [],
|
||||
};
|
||||
require.cache[authPath] = {
|
||||
id: authPath,
|
||||
filename: authPath,
|
||||
loaded: true,
|
||||
exports: { isDefaultAuthInfo: () => false },
|
||||
children: [],
|
||||
paths: [],
|
||||
};
|
||||
require.cache[i18nPath] = {
|
||||
id: i18nPath,
|
||||
filename: i18nPath,
|
||||
loaded: true,
|
||||
exports: { t: (message) => message },
|
||||
children: [],
|
||||
paths: [],
|
||||
};
|
||||
Container.get = () => ({
|
||||
initializeUser: async () => ({
|
||||
code: 400,
|
||||
message: 'password rejected',
|
||||
}),
|
||||
});
|
||||
t.after(() => {
|
||||
Container.get = originalGet;
|
||||
if (originalUserService) {
|
||||
require.cache[userServicePath] = originalUserService;
|
||||
} else {
|
||||
delete require.cache[userServicePath];
|
||||
}
|
||||
if (originalI18n) {
|
||||
require.cache[i18nPath] = originalI18n;
|
||||
} else {
|
||||
delete require.cache[i18nPath];
|
||||
}
|
||||
if (originalUtil) {
|
||||
require.cache[utilPath] = originalUtil;
|
||||
} else {
|
||||
delete require.cache[utilPath];
|
||||
}
|
||||
if (originalAuth) {
|
||||
require.cache[authPath] = originalAuth;
|
||||
} else {
|
||||
delete require.cache[authPath];
|
||||
}
|
||||
});
|
||||
|
||||
const app = express.Router();
|
||||
require('../../back/api/user').default(app);
|
||||
const userRouter = app.stack.find((layer) => layer.name === 'router').handle;
|
||||
const initRoute = userRouter.stack.find(
|
||||
(layer) => layer.route?.path === '/init',
|
||||
);
|
||||
const handler = initRoute.route.stack.at(-1).handle;
|
||||
let responseBody;
|
||||
|
||||
await handler(
|
||||
{ body: { username: 'admin', password: 'admin' } },
|
||||
{ send: (body) => (responseBody = body) },
|
||||
(error) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(responseBody, {
|
||||
code: 400,
|
||||
message: 'password rejected',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { authenticator } = require('@otplib/preset-default');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const { verifyPassword, isPasswordHash } = load(
|
||||
path.join(__dirname, '../../back/shared/password.ts'),
|
||||
);
|
||||
const req = {
|
||||
platform: 'desktop',
|
||||
headers: {},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
};
|
||||
|
||||
function fixture(initial) {
|
||||
let auth = structuredClone(initial);
|
||||
let closed = 0;
|
||||
const model = {
|
||||
findOne: async () => ({ id: 1 }),
|
||||
update: async () => [1, [{}]],
|
||||
create: async (x) => x,
|
||||
findAll: async () => [],
|
||||
destroy: async () => {},
|
||||
};
|
||||
const mocks = {
|
||||
typedi: { Service: () => (x) => x, Inject: () => () => {} },
|
||||
'../config': { jwt: { secret: 'test-secret' }, maxTokensPerPlatform: 10 },
|
||||
'../data/system': {
|
||||
AuthDataType: { authConfig: 'authConfig', loginLog: 'loginLog' },
|
||||
LoginStatus: { fail: 0, success: 1 },
|
||||
SystemModel: model,
|
||||
},
|
||||
'../config/util': {
|
||||
createRandomString: () =>
|
||||
require('node:crypto').randomBytes(32).toString('hex'),
|
||||
},
|
||||
'./notify': class {},
|
||||
'./schedule': class {},
|
||||
'./sock': class {},
|
||||
'../shared/store': {
|
||||
shareStore: {
|
||||
getAuthInfo: async () => auth,
|
||||
updateAuthInfo: async (x) => {
|
||||
auth = x;
|
||||
},
|
||||
},
|
||||
},
|
||||
'../shared/i18n': { t: (x) => x, tf: (x, y) => x.replace('%s', y) },
|
||||
'../shared/clientIp': {
|
||||
getClientIp: (request) => request.socket.remoteAddress,
|
||||
normalizeClientIp: (x) => x,
|
||||
},
|
||||
ip2region: class {
|
||||
search() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
const User = load(
|
||||
path.join(__dirname, '../../back/services/user.ts'),
|
||||
mocks,
|
||||
).default;
|
||||
const user = new User(
|
||||
{ warn() {}, info() {} },
|
||||
{},
|
||||
{ getClients: () => [{ close: () => closed++ }] },
|
||||
);
|
||||
user.notificationService = { notify() {} };
|
||||
return {
|
||||
user,
|
||||
get auth() {
|
||||
return auth;
|
||||
},
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
};
|
||||
}
|
||||
const initialized = () => ({
|
||||
username: 'owner',
|
||||
password: 'old-password',
|
||||
token: 'stolen-token',
|
||||
tokens: { desktop: [{ value: 'stolen-token' }] },
|
||||
retries: 0,
|
||||
lastlogon: 0,
|
||||
});
|
||||
|
||||
test('initialization checks state inside a serialized mutation', async () => {
|
||||
const f = fixture({ username: 'admin', password: 'admin' });
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'admin', password: 'admin' }, req)).code,
|
||||
450,
|
||||
);
|
||||
assert.deepEqual(f.auth, { username: 'admin', password: 'admin' });
|
||||
const results = await Promise.all([
|
||||
f.user.initializeUser({ username: 'owner', password: 'first-password' }),
|
||||
f.user.initializeUser({
|
||||
username: 'attacker',
|
||||
password: 'second-password',
|
||||
}),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
results.map((x) => x.code),
|
||||
[200, 450],
|
||||
);
|
||||
assert.equal(f.auth.username, 'owner');
|
||||
assert.equal(await verifyPassword('first-password', f.auth.password), true);
|
||||
assert.equal(
|
||||
(
|
||||
await f.user.initializeUser({
|
||||
username: 'attacker',
|
||||
password: 'third-password',
|
||||
})
|
||||
).code,
|
||||
450,
|
||||
);
|
||||
});
|
||||
|
||||
test('password change revokes sessions and closes connected clients', async () => {
|
||||
const f = fixture(initialized());
|
||||
await f.user.updateUsernameAndPassword({
|
||||
username: 'owner',
|
||||
password: 'new-password',
|
||||
});
|
||||
assert.equal(f.auth.token, '');
|
||||
assert.deepEqual(f.auth.tokens, {});
|
||||
assert.equal(f.closed, 1);
|
||||
assert.equal(await verifyPassword('new-password', f.auth.password), true);
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'owner', password: 'old-password' }, req))
|
||||
.code,
|
||||
400,
|
||||
);
|
||||
const login = await f.user.login(
|
||||
{ username: 'owner', password: 'new-password' },
|
||||
req,
|
||||
);
|
||||
assert.equal(login.code, 200);
|
||||
jwt.verify(login.data.token, 'test-secret', { algorithms: ['HS384'] });
|
||||
});
|
||||
|
||||
test('a concurrent old-password login cannot restore a session after reset', async () => {
|
||||
const f = fixture(initialized());
|
||||
const results = await Promise.all([
|
||||
f.user.login({ username: 'owner', password: 'old-password' }, req),
|
||||
f.user.resetAuthInfo({ password: 'new-password' }),
|
||||
]);
|
||||
assert.equal(results[0].code, 200);
|
||||
assert.equal(f.auth.token, '');
|
||||
assert.deepEqual(f.auth.tokens, {});
|
||||
assert.equal(await verifyPassword('new-password', f.auth.password), true);
|
||||
});
|
||||
|
||||
test('legacy plaintext migrates after a successful login', async () => {
|
||||
const f = fixture(initialized());
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'owner', password: 'old-password' }, req))
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
assert.equal(isPasswordHash(f.auth.password), true);
|
||||
assert.equal(await verifyPassword('old-password', f.auth.password), true);
|
||||
});
|
||||
|
||||
test('TOTP failures are counted serially and further attempts are throttled', async () => {
|
||||
const secret = authenticator.generateSecret();
|
||||
const f = fixture({
|
||||
...initialized(),
|
||||
twoFactorActivated: true,
|
||||
twoFactorSecret: secret,
|
||||
});
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'owner', password: 'old-password' }, req))
|
||||
.code,
|
||||
420,
|
||||
);
|
||||
const badCode =
|
||||
authenticator.generate(secret) === '000000' ? '111111' : '000000';
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 120 }, () =>
|
||||
f.user.twoFactorLogin(
|
||||
{ username: 'owner', password: 'old-password', code: badCode },
|
||||
req,
|
||||
),
|
||||
),
|
||||
);
|
||||
assert.equal(f.auth.retries, 3);
|
||||
assert.equal(results.filter((x) => x.code === 430).length, 3);
|
||||
assert.equal(results.filter((x) => x.code === 410).length, 117);
|
||||
});
|
||||
|
||||
test('TOTP challenge expires and valid codes cannot be reused in the same step', async () => {
|
||||
const secret = authenticator.generateSecret();
|
||||
const f = fixture({
|
||||
...initialized(),
|
||||
twoFactorActivated: true,
|
||||
twoFactorSecret: secret,
|
||||
});
|
||||
await f.user.login({ username: 'owner', password: 'old-password' }, req);
|
||||
f.auth.twoFactorExpiresAt = Date.now() - 1;
|
||||
const payload = {
|
||||
username: 'owner',
|
||||
password: 'old-password',
|
||||
code: authenticator.generate(secret),
|
||||
};
|
||||
assert.equal((await f.user.twoFactorLogin(payload, req)).code, 450);
|
||||
await f.user.login({ username: 'owner', password: 'old-password' }, req);
|
||||
assert.equal((await f.user.twoFactorLogin(payload, req)).code, 200);
|
||||
await f.user.login({ username: 'owner', password: 'old-password' }, req);
|
||||
assert.equal((await f.user.twoFactorLogin(payload, req)).code, 430);
|
||||
});
|
||||
|
||||
test('active two-factor secret cannot be silently replaced and disabling revokes sessions', async () => {
|
||||
const f = fixture({
|
||||
...initialized(),
|
||||
twoFactorActivated: true,
|
||||
twoFactorSecret: authenticator.generateSecret(),
|
||||
});
|
||||
await assert.rejects(f.user.initTwoFactor());
|
||||
await f.user.deactivateTwoFactor();
|
||||
assert.equal(f.auth.token, '');
|
||||
assert.deepEqual(f.auth.tokens, {});
|
||||
assert.equal(f.auth.twoFactorSecret, '');
|
||||
});
|
||||
|
||||
test('default credentials with historical metadata still require initialization', async () => {
|
||||
const f = fixture({
|
||||
username: 'admin',
|
||||
password: 'admin',
|
||||
retries: 1,
|
||||
token: 'old-default-session',
|
||||
});
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'admin', password: 'admin' }, req)).code,
|
||||
450,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await f.user.initializeUser({
|
||||
username: 'owner',
|
||||
password: 'new-password',
|
||||
})
|
||||
).code,
|
||||
200,
|
||||
);
|
||||
assert.equal(f.auth.token, '');
|
||||
});
|
||||
|
||||
test('one source cannot lock out another or revive a persisted global lock', async () => {
|
||||
const f = fixture({ ...initialized(), retries: 40, lastlogon: Date.now() });
|
||||
const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } };
|
||||
const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } };
|
||||
for (let i = 0; i < 3; i++) {
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'owner', password: 'wrong' }, attacker))
|
||||
.code,
|
||||
400,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await f.user.login(
|
||||
{ username: 'owner', password: 'old-password' },
|
||||
attacker,
|
||||
)
|
||||
).code,
|
||||
410,
|
||||
);
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'owner', password: 'old-password' }, owner))
|
||||
.code,
|
||||
200,
|
||||
);
|
||||
// Another client's successful login does not reset the attacker's own budget.
|
||||
assert.equal(
|
||||
(await f.user.login({ username: 'owner', password: 'wrong' }, attacker))
|
||||
.code,
|
||||
410,
|
||||
);
|
||||
await f.user.resetAuthInfo({ retries: 0 });
|
||||
assert.equal(
|
||||
(
|
||||
await f.user.login(
|
||||
{ username: 'owner', password: 'old-password' },
|
||||
attacker,
|
||||
)
|
||||
).code,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test('TOTP guesses from another source do not lock the owner out', async () => {
|
||||
const secret = authenticator.generateSecret();
|
||||
const f = fixture({
|
||||
...initialized(),
|
||||
twoFactorActivated: true,
|
||||
twoFactorSecret: secret,
|
||||
});
|
||||
const attacker = { ...req, socket: { remoteAddress: '192.0.2.1' } };
|
||||
const owner = { ...req, socket: { remoteAddress: '192.0.2.2' } };
|
||||
await f.user.login({ username: 'owner', password: 'old-password' }, owner);
|
||||
const good = authenticator.generate(secret);
|
||||
const bad = good === '000000' ? '111111' : '000000';
|
||||
for (let i = 0; i < 3; i++) {
|
||||
assert.equal(
|
||||
(
|
||||
await f.user.twoFactorLogin(
|
||||
{ username: 'owner', password: 'old-password', code: bad },
|
||||
attacker,
|
||||
)
|
||||
).code,
|
||||
430,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await f.user.twoFactorLogin(
|
||||
{ username: 'owner', password: 'old-password', code: bad },
|
||||
attacker,
|
||||
)
|
||||
).code,
|
||||
410,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await f.user.twoFactorLogin(
|
||||
{ username: 'owner', password: 'old-password', code: good },
|
||||
owner,
|
||||
)
|
||||
).code,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test('source backoff is capped and expires after inactivity', () => {
|
||||
const { LoginThrottle } = load(path.resolve('back/shared/loginThrottle.ts'));
|
||||
const throttle = new LoginThrottle();
|
||||
let now = 1;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
throttle.fail('attacker', now);
|
||||
const wait = throttle.retryAfter('attacker', now);
|
||||
assert.ok(wait <= 300);
|
||||
now += (wait + 1) * 1000;
|
||||
}
|
||||
assert.equal(throttle.retryAfter('owner', now), 0);
|
||||
assert.equal(throttle.retryAfter('attacker', now + 15 * 60 * 1000), 0);
|
||||
assert.equal(throttle.fail('attacker', now + 15 * 60 * 1000), 1);
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const source = fs.readFileSync('back/app.ts', 'utf8');
|
||||
const entry = source.indexOf('\nconst app = new Application();');
|
||||
assert.ok(entry > 0, 'application entry point exists');
|
||||
const compiled = ts.transpileModule(
|
||||
source.slice(0, entry) + '\nmodule.exports = Application;',
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
).outputText;
|
||||
function create(env) {
|
||||
const calls = [];
|
||||
const cluster = {
|
||||
fork(options) {
|
||||
const worker = {
|
||||
id: calls.length + 1,
|
||||
process: { pid: 100 + calls.length },
|
||||
};
|
||||
calls.push({ options, inherited: { ...env, ...options }, worker });
|
||||
return worker;
|
||||
},
|
||||
};
|
||||
const module = { exports: {} };
|
||||
const sandbox = {
|
||||
module,
|
||||
exports: module.exports,
|
||||
process: { env },
|
||||
require(name) {
|
||||
if (name === 'cluster') return cluster;
|
||||
if (name === 'express') return () => ({ use() {} });
|
||||
return {};
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(compiled, sandbox);
|
||||
return { app: new module.exports(), calls };
|
||||
}
|
||||
test('PM2-managed primary disables only inherited worker APM and keeps worker metadata', () => {
|
||||
const env = { pm_id: '0', pmx: 'true', CUSTOM_VALUE: 'kept' };
|
||||
const { app, calls } = create(env);
|
||||
for (const role of ['grpc', 'http']) app.forkWorker(role);
|
||||
assert.deepEqual(
|
||||
calls.map((x) => ({ ...x.options })),
|
||||
[
|
||||
{ SERVICE_TYPE: 'grpc', pmx: 'false' },
|
||||
{ SERVICE_TYPE: 'http', pmx: 'false' },
|
||||
],
|
||||
);
|
||||
assert.equal(env.pmx, 'true');
|
||||
assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept');
|
||||
for (const call of calls) {
|
||||
const entry = app.workerMetadataMap.get(call.worker.id);
|
||||
assert.equal(entry.pid, call.worker.process.pid);
|
||||
assert.equal(entry.serviceType, call.options.SERVICE_TYPE);
|
||||
}
|
||||
});
|
||||
test('worker APM opt-in restores inherited PM2 settings, including explicit disable', () => {
|
||||
for (const pmx of ['true', 'false']) {
|
||||
const { app, calls } = create({ pm_id: '0', pmx, QL_WORKER_APM: 'true' });
|
||||
app.forkWorker('http');
|
||||
assert.equal(Object.hasOwn(calls[0].options, 'pmx'), false);
|
||||
assert.equal(calls[0].inherited.pmx, pmx);
|
||||
}
|
||||
});
|
||||
test('standalone startup does not introduce a PM2-specific override', () => {
|
||||
const { app, calls } = create({ CUSTOM_VALUE: 'kept' });
|
||||
app.forkWorker('grpc');
|
||||
assert.deepEqual({ ...calls[0].options }, { SERVICE_TYPE: 'grpc' });
|
||||
assert.equal(calls[0].inherited.CUSTOM_VALUE, 'kept');
|
||||
});
|
||||
test('replacement workers receive the same monitoring policy', () => {
|
||||
const { app, calls } = create({ pm_id: '0', pmx: 'true' });
|
||||
app.forkWorker('grpc');
|
||||
app.forkWorker('grpc');
|
||||
assert.equal(calls[1].inherited.pmx, 'false');
|
||||
assert.notEqual(calls[0].worker.id, calls[1].worker.id);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
const back = (file) => path.join(__dirname, '../../back', file);
|
||||
const protocol = load(back('protos/api.ts'));
|
||||
const constants = load(back('config/const.ts'), {
|
||||
'./container': { maybeSudo: (x) => x },
|
||||
});
|
||||
|
||||
test('WPUSH survives protobuf and JSON round trips without changing existing modes', () => {
|
||||
assert.equal(protocol.NotificationMode.wpush, 22);
|
||||
for (let value = 0; value <= 22; value++) {
|
||||
const name = protocol.notificationModeToJSON(value);
|
||||
assert.equal(protocol.notificationModeFromJSON(name), value);
|
||||
assert.equal(constants.NotificationModeStringMap[value], name);
|
||||
}
|
||||
const request = protocol.SystemNotifyRequest.create({
|
||||
title: 'title',
|
||||
content: 'content',
|
||||
notificationInfo: {
|
||||
type: 22,
|
||||
wpushApiKey: 'test-key',
|
||||
wpushChannel: 'feishu',
|
||||
wpushTopicCode: 'test-topic',
|
||||
},
|
||||
});
|
||||
const decoded = protocol.SystemNotifyRequest.decode(
|
||||
protocol.SystemNotifyRequest.encode(request).finish(),
|
||||
);
|
||||
assert.equal(decoded.notificationInfo.wpushApiKey, 'test-key');
|
||||
assert.equal(decoded.notificationInfo.wpushChannel, 'feishu');
|
||||
assert.equal(decoded.notificationInfo.wpushTopicCode, 'test-topic');
|
||||
assert.deepEqual(decoded, request);
|
||||
assert.deepEqual(
|
||||
protocol.SystemNotifyRequest.fromJSON(
|
||||
protocol.SystemNotifyRequest.toJSON(request),
|
||||
),
|
||||
request,
|
||||
);
|
||||
});
|
||||
|
||||
test('decoded SystemNotify requests reach WPUSH with explicit or saved configuration', async () => {
|
||||
const calls = [];
|
||||
const decorators = { Service: () => (x) => x, Inject: () => () => {} };
|
||||
const NotificationService = load(back('services/notify.ts'), {
|
||||
typedi: decorators,
|
||||
'./user': {},
|
||||
'../config/util': {},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'../config/http': {
|
||||
httpClient: {
|
||||
post: async (url, options) => {
|
||||
calls.push({ url, body: options.json });
|
||||
return { code: 0 };
|
||||
},
|
||||
},
|
||||
},
|
||||
}).default;
|
||||
const SystemService = load(back('services/system.ts'), {
|
||||
typedi: decorators,
|
||||
'../config': {},
|
||||
'../config/const': constants,
|
||||
'../config/util': {},
|
||||
'../data/dependence': {},
|
||||
'../data/system': {},
|
||||
'../shared/pLimit': {},
|
||||
'../schedule/client': {},
|
||||
'../shared/i18n': { t: (x) => x },
|
||||
'./notify': {},
|
||||
'./schedule': {},
|
||||
'./sock': {},
|
||||
}).default;
|
||||
const notification = new NotificationService();
|
||||
notification.userService = {
|
||||
getNotificationMode: async () => ({
|
||||
type: 'wpush',
|
||||
wpushApiKey: 'saved-key',
|
||||
}),
|
||||
};
|
||||
const system = new SystemService();
|
||||
system.notificationService = notification;
|
||||
|
||||
for (const info of [
|
||||
{
|
||||
type: 22,
|
||||
wpushApiKey: 'explicit-key',
|
||||
wpushChannel: 'feishu',
|
||||
wpushTopicCode: 'topic',
|
||||
},
|
||||
{ type: 22, wpushApiKey: 'explicit-key' },
|
||||
undefined,
|
||||
]) {
|
||||
const decoded = protocol.SystemNotifyRequest.decode(
|
||||
protocol.SystemNotifyRequest.encode(
|
||||
protocol.SystemNotifyRequest.create({
|
||||
title: 'title',
|
||||
content: 'content',
|
||||
notificationInfo: info,
|
||||
}),
|
||||
).finish(),
|
||||
);
|
||||
assert.equal((await system.notify(decoded)).code, 200);
|
||||
assert.deepEqual(calls.at(-1), {
|
||||
url: 'https://api.wpush.cn/api/v1/send',
|
||||
body: {
|
||||
apikey: info ? 'explicit-key' : 'saved-key',
|
||||
title: 'title',
|
||||
content: 'content',
|
||||
channel: info?.wpushChannel || 'wechat',
|
||||
...(info?.wpushTopicCode ? { topic_code: 'topic' } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
Vendored
+1371
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const fsp = require('node:fs/promises');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const { readLogChunk } = require('../../back/shared/logReader');
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '../../src/pages/crontab/logModal.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
'logModal.tsx',
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
const declarations = new Map();
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
['getCronLog', 'cancel'].includes(node.name.getText(ast))
|
||||
)
|
||||
declarations.set(node.name.getText(ast), node.initializer.getText(ast));
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
|
||||
function fixture(get) {
|
||||
const timers = new Map();
|
||||
const storage = new Map([['logCron', '1']]);
|
||||
const values = { content: '', executing: true };
|
||||
const pending = [];
|
||||
let timerId = 0;
|
||||
const context = {
|
||||
logUrl: undefined,
|
||||
config: { apiPrefix: '/api/' },
|
||||
cron: { id: 1 },
|
||||
uniqPath: '1',
|
||||
LOG_CHUNK_BYTES: 256 * 1024,
|
||||
MAX_LOG_VIEW_CHARS: 1024 * 1024,
|
||||
logOffsetRef: { current: undefined },
|
||||
valueRef: { current: '' },
|
||||
pollTimerRef: {},
|
||||
request: {
|
||||
get(url) {
|
||||
const result = get(url);
|
||||
pending.push(result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
localStorage: {
|
||||
getItem: (key) => storage.get(key),
|
||||
removeItem: (key) => storage.delete(key),
|
||||
},
|
||||
intl: { get: (value) => value },
|
||||
setLoading() {},
|
||||
autoScroll() {},
|
||||
handleCancel() {},
|
||||
setValue(value) {
|
||||
values.content = value;
|
||||
},
|
||||
setExecuting(value) {
|
||||
values.executing = value;
|
||||
},
|
||||
setTimeout(fn, delay) {
|
||||
timers.set(++timerId, { fn, delay });
|
||||
return timerId;
|
||||
},
|
||||
clearTimeout(id) {
|
||||
timers.delete(id);
|
||||
},
|
||||
};
|
||||
vm.createContext(context);
|
||||
for (const [name, initializer] of declarations)
|
||||
vm.runInContext(
|
||||
ts.transpile(`var ${name} = ${initializer}`, {
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
}),
|
||||
context,
|
||||
);
|
||||
async function settle() {
|
||||
await Promise.all(pending.splice(0));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
return {
|
||||
values,
|
||||
timers,
|
||||
start() {
|
||||
context.getCronLog(true);
|
||||
},
|
||||
settle,
|
||||
async read() {
|
||||
context.getCronLog(true);
|
||||
await settle();
|
||||
},
|
||||
async tick() {
|
||||
const [id, timer] = timers.entries().next().value;
|
||||
timers.delete(id);
|
||||
timer.fn();
|
||||
await settle();
|
||||
},
|
||||
close() {
|
||||
context.cancel();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('completed tasks drain unread chunks and display the final exit marker', async (t) => {
|
||||
const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'ql-log-pagination-'));
|
||||
t.after(() => fsp.rm(dir, { recursive: true, force: true }));
|
||||
const file = path.join(dir, 'task.log');
|
||||
await fsp.writeFile(file, 'started\n');
|
||||
let running = true;
|
||||
const offsets = [];
|
||||
const view = fixture(async (url) => {
|
||||
const query = new URL(url, 'http://localhost').searchParams;
|
||||
const offset = query.has('offset')
|
||||
? Number(query.get('offset'))
|
||||
: undefined;
|
||||
offsets.push(offset);
|
||||
const chunk = await readLogChunk(file, {
|
||||
offset,
|
||||
tail: query.get('tail') === 'true',
|
||||
limit: Number(query.get('limit')),
|
||||
});
|
||||
return {
|
||||
code: 200,
|
||||
data: chunk.content,
|
||||
logStatus: running ? 'running' : 'completed',
|
||||
...chunk,
|
||||
};
|
||||
});
|
||||
await view.read();
|
||||
assert.equal(view.timers.values().next().value.delay, 2000);
|
||||
await fsp.appendFile(file, 'x'.repeat(600 * 1024) + '\nFINAL_EXIT_CODE=1\n');
|
||||
running = false;
|
||||
await view.tick();
|
||||
assert.equal(view.values.executing, false);
|
||||
assert.equal(view.timers.values().next().value.delay, 0);
|
||||
await view.tick();
|
||||
await view.tick();
|
||||
assert.ok(view.values.content.endsWith('FINAL_EXIT_CODE=1\n'));
|
||||
assert.equal(view.timers.size, 0);
|
||||
assert.deepEqual(offsets, [undefined, 8, 8 + 256 * 1024, 8 + 512 * 1024]);
|
||||
});
|
||||
|
||||
test('completed tail does not refetch discarded historical data', async () => {
|
||||
const view = fixture(async () => ({
|
||||
code: 200,
|
||||
data: 'tail',
|
||||
logStatus: 'completed',
|
||||
nextOffset: 900000,
|
||||
total: 900000,
|
||||
truncated: true,
|
||||
}));
|
||||
await view.read();
|
||||
assert.equal(view.values.content, 'tail');
|
||||
assert.equal(view.timers.size, 0);
|
||||
});
|
||||
|
||||
test('running tasks poll without new bytes and closing cancels polling', async () => {
|
||||
const view = fixture(async () => ({
|
||||
code: 200,
|
||||
data: '',
|
||||
logStatus: 'running',
|
||||
nextOffset: 0,
|
||||
total: 0,
|
||||
}));
|
||||
await view.read();
|
||||
assert.equal(view.values.executing, true);
|
||||
assert.equal(view.timers.values().next().value.delay, 2000);
|
||||
view.close();
|
||||
assert.equal(view.timers.size, 0);
|
||||
});
|
||||
|
||||
test('closing ignores an in-flight response with unread data', async () => {
|
||||
let resolve;
|
||||
const view = fixture(
|
||||
() =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
);
|
||||
view.start();
|
||||
view.close();
|
||||
resolve({
|
||||
code: 200,
|
||||
data: 'late',
|
||||
logStatus: 'completed',
|
||||
nextOffset: 4,
|
||||
total: 100,
|
||||
});
|
||||
await view.settle();
|
||||
assert.equal(view.values.content, '');
|
||||
assert.equal(view.timers.size, 0);
|
||||
});
|
||||
|
||||
test('legacy and non-advancing completed responses do not loop', async () => {
|
||||
for (const pagination of [{}, { nextOffset: 0, total: 100 }]) {
|
||||
const view = fixture(async () => ({
|
||||
code: 200,
|
||||
data: '',
|
||||
logStatus: 'completed',
|
||||
...pagination,
|
||||
}));
|
||||
await view.read();
|
||||
assert.equal(view.timers.size, 0);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '../../src/pages/diff/index.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
'diff.tsx',
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
const callbacks = new Map();
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
['getConfig', 'updateConfig', 'getSample'].includes(node.name.getText(ast))
|
||||
) {
|
||||
callbacks.set(node.name.getText(ast), node.initializer.getText(ast));
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
|
||||
function fixture(current, editorContent) {
|
||||
const calls = [];
|
||||
const values = {};
|
||||
const context = {
|
||||
current,
|
||||
origin: 'sample/notify.js',
|
||||
currentValue: '',
|
||||
editorRef: {
|
||||
current:
|
||||
editorContent === undefined
|
||||
? null
|
||||
: {
|
||||
getModel: () => ({ modified: { getValue: () => editorContent } }),
|
||||
},
|
||||
},
|
||||
config: { apiPrefix: '/panel/api/' },
|
||||
request: {
|
||||
get: async (url) => {
|
||||
calls.push({ method: 'GET', url });
|
||||
return { code: 200, data: 'file content' };
|
||||
},
|
||||
post: async (url, body) => {
|
||||
calls.push({ method: 'POST', url, body });
|
||||
return { code: 200 };
|
||||
},
|
||||
},
|
||||
setCurrentValue: (value) => {
|
||||
values.current = value;
|
||||
},
|
||||
setOriginValue: (value) => {
|
||||
values.origin = value;
|
||||
},
|
||||
message: {
|
||||
success: (value) => {
|
||||
values.message = value;
|
||||
},
|
||||
},
|
||||
intl: { get: (value) => value },
|
||||
};
|
||||
vm.createContext(context);
|
||||
for (const [name, initializer] of callbacks) {
|
||||
vm.runInContext(
|
||||
ts.transpile(`var ${name} = ${initializer}`, {
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
}),
|
||||
context,
|
||||
);
|
||||
}
|
||||
return { context, calls, values };
|
||||
}
|
||||
|
||||
for (const [target, filename] of [
|
||||
['config.sh', null],
|
||||
['data/scripts/sendNotify.js', 'sendNotify.js'],
|
||||
['data/scripts/notify.py', 'notify.py'],
|
||||
['data/scripts/子目录/a b.js', '子目录/a b.js'],
|
||||
]) {
|
||||
test(`diff tool reads and saves ${target} through its resource API`, async () => {
|
||||
const { context, calls, values } = fixture(target, 'updated\r\n');
|
||||
context.getConfig();
|
||||
context.updateConfig();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
calls[0].url,
|
||||
filename
|
||||
? `/panel/api/scripts/detail?file=${encodeURIComponent(filename)}`
|
||||
: '/panel/api/configs/detail?path=config.sh',
|
||||
);
|
||||
assert.equal(
|
||||
calls[1].url,
|
||||
filename ? '/panel/api/scripts' : '/panel/api/configs/save',
|
||||
);
|
||||
assert.equal(
|
||||
calls[1].body[filename ? 'filename' : 'name'],
|
||||
filename || target,
|
||||
);
|
||||
assert.equal(calls[1].body.content, 'updated\n');
|
||||
assert.equal(values.current, 'file content');
|
||||
assert.equal(values.message, '保存成功');
|
||||
});
|
||||
}
|
||||
|
||||
test('mobile diff tool can save an empty notification script and load its sample', async () => {
|
||||
const { context, calls, values } = fixture('data/scripts/sendNotify.js');
|
||||
context.updateConfig();
|
||||
context.getSample();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(calls[0].url, '/panel/api/scripts');
|
||||
assert.equal(calls[0].body.filename, 'sendNotify.js');
|
||||
assert.equal(calls[0].body.content, '');
|
||||
assert.equal(
|
||||
calls[1].url,
|
||||
'/panel/api/configs/detail?path=sample%2Fnotify.js',
|
||||
);
|
||||
assert.equal(values.origin, 'file content');
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const ts = require('typescript');
|
||||
const axios = require('axios');
|
||||
|
||||
function fixture(pathname = '/login', baseUrl = '/') {
|
||||
const storage = new Map([['token', 'expired-token']]);
|
||||
const pushes = [];
|
||||
const history = { location: { pathname }, push: (p) => pushes.push(p) };
|
||||
const config = { authKey: 'token', baseUrl };
|
||||
const mocks = {
|
||||
axios,
|
||||
'react-intl-universal': { get: (s) => s },
|
||||
antd: {
|
||||
message: { config() {}, error() {} },
|
||||
notification: { error() {} },
|
||||
},
|
||||
'./config': config,
|
||||
'@umijs/max': { history },
|
||||
'./httpError': { getErrorDetails: () => [] },
|
||||
};
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '../../src/utils/http.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const { outputText } = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
jsx: ts.JsxEmit.React,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
});
|
||||
const module = { exports: {} };
|
||||
new Function('require', 'module', 'exports', 'localStorage', outputText)(
|
||||
(name) => {
|
||||
assert.ok(Object.hasOwn(mocks, name), name);
|
||||
return mocks[name];
|
||||
},
|
||||
module,
|
||||
module.exports,
|
||||
{
|
||||
getItem: (k) => storage.get(k) ?? null,
|
||||
removeItem: (k) => storage.delete(k),
|
||||
},
|
||||
);
|
||||
return { request: module.exports.request, storage, pushes };
|
||||
}
|
||||
|
||||
for (const pathname of ['/login', '/dashboard']) {
|
||||
test(`401 clears expired credentials on ${pathname}`, async () => {
|
||||
const { request, storage, pushes } = fixture(pathname);
|
||||
await assert.rejects(
|
||||
request.get('/api/user', {
|
||||
adapter: async (config) => {
|
||||
throw new axios.AxiosError(
|
||||
'Unauthorized',
|
||||
'ERR_BAD_REQUEST',
|
||||
config,
|
||||
null,
|
||||
{ status: 401, data: { message: 'expired' }, config },
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert.equal(storage.has('token'), false);
|
||||
assert.deepEqual(pushes, pathname === '/login' ? [] : ['/login']);
|
||||
});
|
||||
}
|
||||
for (const baseUrl of ['/', '/panel/']) {
|
||||
test(`health is anonymous while protected requests keep credentials (${baseUrl})`, async () => {
|
||||
const { request } = fixture('/login', baseUrl);
|
||||
const headers = [];
|
||||
const adapter = async (config) => {
|
||||
headers.push(config.headers.get('Authorization'));
|
||||
return { status: 200, data: { code: 200 }, config };
|
||||
};
|
||||
await request.get(`${baseUrl}api/health`, { adapter });
|
||||
await request.get(`${baseUrl}api/user`, { adapter });
|
||||
assert.deepEqual(headers, [undefined, 'Bearer expired-token']);
|
||||
});
|
||||
}
|
||||
|
||||
function healthFixture(get) {
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, '../../src/layouts/index.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
const ast = ts.createSourceFile(
|
||||
'index.tsx',
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
let initializer;
|
||||
function visit(node) {
|
||||
if (
|
||||
ts.isVariableDeclaration(node) &&
|
||||
node.name.getText(ast) === 'getHealthStatus'
|
||||
)
|
||||
initializer = node.initializer;
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
assert.ok(initializer, 'layout health bootstrap exists');
|
||||
const { outputText } = ts.transpileModule(
|
||||
`const bootstrap = ${initializer.getText(ast)};`,
|
||||
{
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2020 },
|
||||
},
|
||||
);
|
||||
const events = [];
|
||||
const bootstrap = new Function(
|
||||
'request',
|
||||
'config',
|
||||
'history',
|
||||
'window',
|
||||
'getSystemInfo',
|
||||
'setInitLoading',
|
||||
`${outputText}; return bootstrap;`,
|
||||
)(
|
||||
{ get },
|
||||
{ apiPrefix: '/api/' },
|
||||
{ push: (p) => events.push(p) },
|
||||
{ location: { reload: () => events.push('reload') } },
|
||||
() => events.push('system'),
|
||||
(value) => events.push(['loading', value]),
|
||||
);
|
||||
return { bootstrap, events };
|
||||
}
|
||||
for (const status of [401, 503, undefined]) {
|
||||
test(`health failure ${
|
||||
status ?? 'network'
|
||||
} settles without reloading`, async () => {
|
||||
const { bootstrap, events } = healthFixture(async () => {
|
||||
throw status ? { response: { status } } : new Error('Network Error');
|
||||
});
|
||||
bootstrap();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(events, ['/error', ['loading', false]]);
|
||||
});
|
||||
}
|
||||
test('healthy bootstrap continues loading system information', async () => {
|
||||
const { bootstrap, events } = healthFixture(async () => ({
|
||||
data: { status: 'ok' },
|
||||
}));
|
||||
bootstrap();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(events, ['system', ['loading', false]]);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const { getErrorDetails } = require('../../src/utils/httpError');
|
||||
|
||||
test('validation errors include the failing field names', () => {
|
||||
const details = getErrorDetails({
|
||||
message: 'Validation failed',
|
||||
validation: {
|
||||
body: {
|
||||
source: 'body',
|
||||
keys: ['labels', 'allow_multiple_instances'],
|
||||
message: 'request body contains invalid values',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(details, [
|
||||
'labels: request body contains invalid values',
|
||||
'allow_multiple_instances: request body contains invalid values',
|
||||
]);
|
||||
});
|
||||
|
||||
test('existing API error details remain visible', () => {
|
||||
assert.deepEqual(
|
||||
getErrorDetails({ errors: [{ message: 'duplicate value', value: 'foo' }] }),
|
||||
['duplicate value (foo)'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const path = require('node:path');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
|
||||
function fixture(send) {
|
||||
const dialogs = [],
|
||||
notices = [],
|
||||
calls = [];
|
||||
const request = {};
|
||||
for (const method of ['put', 'post'])
|
||||
request[method] = async (url, body, options) => {
|
||||
calls.push({ method, body });
|
||||
const result = await send(body);
|
||||
if (result.status) return options.onError(result);
|
||||
return result;
|
||||
};
|
||||
const { saveWithHistory } = load(
|
||||
path.resolve(__dirname, '../../src/pages/script/saveWithHistory.ts'),
|
||||
{
|
||||
'@/utils/http': { request },
|
||||
antd: {
|
||||
Modal: {
|
||||
confirm: (dialog) => {
|
||||
dialogs.push(dialog);
|
||||
return { update: (options) => Object.assign(dialog, options) };
|
||||
},
|
||||
},
|
||||
message: Object.fromEntries(
|
||||
['error', 'warning', 'success'].map((type) => [
|
||||
type,
|
||||
(text) => notices.push({ type, text }),
|
||||
]),
|
||||
),
|
||||
},
|
||||
'react-intl-universal': {
|
||||
__esModule: true,
|
||||
default: { get: (key) => key },
|
||||
},
|
||||
},
|
||||
);
|
||||
return { saveWithHistory, dialogs, notices, calls };
|
||||
}
|
||||
const unavailable = {
|
||||
status: 413,
|
||||
data: { historyUnavailable: true, currentHash: 'a'.repeat(64) },
|
||||
};
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
test('oversized editor save requires explicit confirmation and cancellation keeps the draft unsaved', async () => {
|
||||
const f = fixture(() => unavailable);
|
||||
const pending = f.saveWithHistory('put', '/api/scripts', {
|
||||
filename: 'large.js',
|
||||
content: 'small',
|
||||
});
|
||||
await tick();
|
||||
assert.equal(f.calls.length, 1);
|
||||
assert.equal(f.dialogs.length, 1);
|
||||
assert.match(f.dialogs[0].content, /不会保留/);
|
||||
f.dialogs[0].onCancel();
|
||||
assert.equal(await pending, undefined);
|
||||
assert.equal(f.calls.length, 1);
|
||||
assert.equal(f.notices.length, 0);
|
||||
});
|
||||
|
||||
test('debugger confirmation retries POST with the exact preview hash and displays a warning', async () => {
|
||||
const f = fixture((body) =>
|
||||
body.skipHistory
|
||||
? { code: 200, data: { historyRecorded: false } }
|
||||
: unavailable,
|
||||
);
|
||||
const pending = f.saveWithHistory('post', '/api/scripts', {
|
||||
content: 'small',
|
||||
});
|
||||
await tick();
|
||||
await f.dialogs[0].onOk();
|
||||
assert.equal((await pending).code, 200);
|
||||
assert.equal(f.dialogs[0].cancelButtonProps.disabled, true);
|
||||
assert.equal(f.dialogs[0].keyboard, false);
|
||||
assert.equal(f.calls[1].method, 'post');
|
||||
assert.equal(f.calls[1].body.expectedHash, unavailable.data.currentHash);
|
||||
assert.equal(f.calls[1].body.skipHistory, true);
|
||||
assert.deepEqual(
|
||||
f.notices.map((v) => v.type),
|
||||
['warning'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a stale confirmation reports the conflict without marking the editor saved', async () => {
|
||||
const f = fixture((body) =>
|
||||
body.skipHistory
|
||||
? { status: 409, data: { message: 'changed' } }
|
||||
: unavailable,
|
||||
);
|
||||
const pending = f.saveWithHistory('put', '/api/scripts', {});
|
||||
await tick();
|
||||
await f.dialogs[0].onOk();
|
||||
assert.equal(await pending, undefined);
|
||||
assert.deepEqual(f.notices, [{ type: 'error', text: 'changed' }]);
|
||||
});
|
||||
|
||||
test('unrelated errors never offer history bypass, and post-commit cleanup failures remain successful saves', async () => {
|
||||
const failed = fixture(() => ({
|
||||
status: 500,
|
||||
data: { message: 'storage failed' },
|
||||
}));
|
||||
assert.equal(
|
||||
await failed.saveWithHistory('put', '/api/scripts', {}),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(failed.dialogs.length, 0);
|
||||
const committed = fixture(() => ({
|
||||
code: 200,
|
||||
data: { cleanupPending: true },
|
||||
}));
|
||||
assert.equal(
|
||||
(await committed.saveWithHistory('put', '/api/scripts', {})).code,
|
||||
200,
|
||||
);
|
||||
assert.equal(committed.notices[0].type, 'warning');
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const ts = require('typescript');
|
||||
|
||||
module.exports = function loadSecurityModule(
|
||||
file,
|
||||
mocks = {},
|
||||
cache = new Map(),
|
||||
) {
|
||||
file = path.resolve(file);
|
||||
if (cache.has(file)) return cache.get(file).exports;
|
||||
const module = { exports: {} };
|
||||
cache.set(file, module);
|
||||
const localRequire = createRequire(file);
|
||||
const requireModule = (name) => {
|
||||
if (Object.hasOwn(mocks, name)) return mocks[name];
|
||||
if (name.startsWith('.')) {
|
||||
const target = path.resolve(path.dirname(file), name);
|
||||
if (fs.existsSync(`${target}.ts`)) {
|
||||
return loadSecurityModule(`${target}.ts`, mocks, cache);
|
||||
}
|
||||
}
|
||||
return localRequire(name);
|
||||
};
|
||||
const { outputText } = ts.transpileModule(fs.readFileSync(file, 'utf8'), {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
experimentalDecorators: true,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
});
|
||||
// Use the host realm: express-unless checks RegExp with instanceof.
|
||||
new Function('require', 'module', 'exports', '__dirname', outputText)(
|
||||
requireModule,
|
||||
module,
|
||||
module.exports,
|
||||
path.dirname(file),
|
||||
);
|
||||
return module.exports;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { createHash } = require('node:crypto');
|
||||
const { collectBuildFiles } = require('../../docker/build-manifest.cjs');
|
||||
const sourceCommit = 'a'.repeat(40);
|
||||
|
||||
function writeManifest(source, staticRoot) {
|
||||
const lockfile = path.join(source, 'pnpm-lock.yaml');
|
||||
if (!fs.existsSync(lockfile)) fs.writeFileSync(lockfile, 'fixture lock\n');
|
||||
fs.mkdirSync(path.join(staticRoot, 'dist'), { recursive: true });
|
||||
if (!fs.existsSync(path.join(staticRoot, 'dist/index.html')))
|
||||
fs.writeFileSync(
|
||||
path.join(staticRoot, 'dist/index.html'),
|
||||
'<div id="root"></div>',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(staticRoot, 'build-info.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
dirty: false,
|
||||
sourceCommit,
|
||||
lockfileSha256: createHash('sha256')
|
||||
.update(fs.readFileSync(lockfile))
|
||||
.digest('hex'),
|
||||
files: collectBuildFiles(staticRoot),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { writeManifest, sourceCommit };
|
||||
Reference in New Issue
Block a user