mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-16 22:35:32 +08:00
fix: 加固安全输入边界并修复订阅兼容性 (#3084)
* fix: harden scoped API inputs and isolate login throttling * fix: close script path gaps and harden security boundaries * fix: preserve subscription aliases and avoid extension eval * fix: allow cancelling invalid historical subscriptions * fix: make script parent validation explicit for CodeQL * fix: limit command execution and clarify trusted path branches * fix: separate diff tool script access from config APIs * fix: clean up obsolete subscription SSH credentials
This commit is contained in:
@@ -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 },
|
||||
'../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.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],
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user