fix(notification): bound audit logs without breaking delivery

This commit is contained in:
whyour
2026-10-12 00:23:20 +08:00
492 changed files with 56188 additions and 5037 deletions
+416
View File
@@ -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');
});
+74
View File
@@ -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);
});
+150
View File
@@ -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);
});
+189
View File
@@ -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);
});
+110
View File
@@ -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);
}
});
+51
View File
@@ -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);
});
+59
View File
@@ -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`);
}
}
});
+45
View File
@@ -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'],
);
});
+173
View File
@@ -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);
});
+193
View File
@@ -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);
}
});
+125
View File
@@ -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);
});
+82
View File
@@ -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} 接口`,
});
});
}
+235
View File
@@ -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);
});
+28
View File
@@ -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);
});
+91
View File
@@ -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',
);
});
+96
View File
@@ -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)),
);
});
+236
View File
@@ -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$/);
},
);
+180
View File
@@ -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');
});
+47
View File
@@ -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,
);
}
});
+57
View File
@@ -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);
});
+60
View File
@@ -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);
});
+136
View File
@@ -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);
});
+31
View File
@@ -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();
}
});
+313
View File
@@ -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',
);
}
});
+76
View File
@@ -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'));
}
}
});
+176
View File
@@ -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/);
},
);
}
}
+68
View File
@@ -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`],
);
});
}
+158
View File
@@ -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']);
});
+72
View File
@@ -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,
});
});
+85
View File
@@ -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);
},
);
+220
View File
@@ -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, []);
});
+34
View File
@@ -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 },
);
});
+257
View File
@@ -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');
}
});
+73
View File
@@ -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);
});
+65
View File
@@ -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);
});
+202
View File
@@ -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');
}
});
+182
View File
@@ -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');
});
+91
View File
@@ -0,0 +1,91 @@
const test = require('node:test'),
assert = require('node:assert/strict'),
{ Sequelize, DataTypes } = require('sequelize');
const load = require('../helpers/load-security-module.cjs');
const decorators = { Service: () => (x) => x, Inject: () => () => {} };
async function fixture(t) {
const db = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
t.after(() => db.close());
const rows = db.define('System', {
type: DataTypes.STRING,
info: DataTypes.JSON,
});
await db.sync();
const logger = { error() {} };
const S = load('back/services/system.ts', {
typedi: decorators,
'../config': {},
'../config/const': { NotificationModeStringMap: { 0: 'gotify' } },
'../config/util': {},
'../data/dependence': {},
'../data/system': {
SystemModel: rows,
AuthDataType: { notifyLog: 'notifyLog', notification: 'notification' },
NotifyStatus: { success: 0, fail: 1 },
},
'./schedule': class {},
'./sock': class {},
'./notify': class {},
'../shared/pLimit': {},
'../schedule/client': {},
'../config/index': {},
'../shared/store': {},
'../shared/i18n': { t: (s) => s },
}).default;
const service = Object.assign(Object.create(S.prototype), {
logger,
notificationService: { notify: async () => true },
});
service.getDb = async () => ({ info: { type: 'email' } });
return { rows, service };
}
test('writes cap logs without reading the page and preserve other system data', async (t) => {
const { rows, service } = await fixture(t);
await rows.create({ type: 'authConfig', info: { keep: true } });
for (let i = 0; i < 205; i++)
await service.notify({ title: String(i), content: 'body' });
assert.equal(await rows.count({ where: { type: 'notifyLog' } }), 200);
const logs = await service.getNotifyLog();
assert.equal(logs[0].title, '204');
assert.equal(logs.at(-1).title, '5');
assert.equal(logs[0].notifyType, 'email');
assert.equal(await rows.count({ where: { type: 'authConfig' } }), 1);
});
test('audit DB failure does not turn a successful notification into failure', async (t) => {
const { rows, service } = await fixture(t);
const create = rows.create;
rows.create = async () => {
throw Error('disk full');
};
try {
assert.equal(
(await service.notify({ title: 'ok', content: 'ok' })).code,
200,
);
} finally {
rows.create = create;
}
});
test('failed sends are recorded and message size is bounded', async (t) => {
const { service } = await fixture(t);
service.notificationService.notify = async () => false;
assert.equal(
(
await service.notify({
title: 'x'.repeat(1000),
content: 'y'.repeat(20000),
notificationInfo: { type: 0 },
})
).code,
400,
);
const [row] = await service.getNotifyLog();
assert.equal(row.status, 1);
assert.equal(row.title.length, 512);
assert.equal(row.content.length, 8192);
assert.equal(row.notifyType, 'gotify');
});
+70
View File
@@ -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);
});
}
+168
View File
@@ -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',)
`);
});
+24
View File
@@ -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);
});
+81
View File
@@ -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);
});
+77
View File
@@ -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'],
]);
});
+58
View File
@@ -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,
});
}
});
+128
View File
@@ -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,
);
});
+131
View File
@@ -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' });
});
+118
View File
@@ -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']);
});
+86
View File
@@ -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);
});
}
+411
View File
@@ -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);
});
}
+51
View File
@@ -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');
});
}
+312
View File
@@ -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');
});
+85
View File
@@ -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});
});
+206
View File
@@ -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);
}
});
+49
View File
@@ -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');
});
}
+127
View File
@@ -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/);
});
+210
View File
@@ -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,
);
});
+551
View File
@@ -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 = '';
}
},
);
}
});
+383
View File
@@ -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');
});
+184
View File
@@ -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');
});
+312
View File
@@ -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/,
);
});
+151
View File
@@ -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);
});
}
}
+297
View File
@@ -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();
});
+72
View File
@@ -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);
});
+113
View File
@@ -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,
);
});
+53
View File
@@ -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.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);
});
}
}
+441
View File
@@ -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/);
});
+179
View File
@@ -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\(/);
}
});
+158
View File
@@ -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,
);
});
+171
View File
@@ -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);
});
+53
View File
@@ -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);
});
+127
View File
@@ -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,
},
},
},
]);
});
+119
View File
@@ -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' });
});
+497
View File
@@ -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);
});
+194
View File
@@ -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}`));
});
}
+116
View File
@@ -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();
}
});
+285
View File
@@ -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/);
}
});
+288
View File
@@ -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), []);
});
+133
View File
@@ -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',
);
});
+99
View File
@@ -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',
});
});
+349
View File
@@ -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);
});
+84
View File
@@ -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);
});
+116
View File
@@ -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' } : {}),
},
});
}
});
+1371
View File
File diff suppressed because it is too large Load Diff
+214
View File
@@ -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);
}
});
+126
View File
@@ -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');
});
+152
View File
@@ -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]]);
});
+29
View File
@@ -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)'],
);
});
+121
View File
@@ -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');
});
+42
View File
@@ -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;
};
+30
View File
@@ -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 };