mirror of
https://github.com/whyour/qinglong.git
synced 2026-10-18 18:33:06 +08:00
fix: validate extra schedules and localize validation errors
Reject invalid extra_schedules with HTTP 400 before create or update reaches the task service. Preserve omitted, null and empty extras, supported cron syntax, and additional metadata. Localize missing rules, invalid string and array types, and non-object extra entries through the existing per-request translation middleware. Cover POST/PUT rejection without mutations, dynamic English/Chinese switching, and stable error paths. Validation: 9 related regression tests passed on current develop; backend build passed. The API validation fix also passed full regression and isolated develop/debian-dev acceptance. Refs #3099
This commit is contained in:
@@ -43,3 +43,127 @@ test('cron validation identifies invalid fields', () => {
|
||||
['allow_multiple_instances'],
|
||||
);
|
||||
});
|
||||
|
||||
test('extra schedules preserve optional values, compatible syntax and metadata', () => {
|
||||
for (const extra_schedules of [
|
||||
undefined,
|
||||
null,
|
||||
[],
|
||||
[{ schedule: '0 6/6 * * *', remarks: 'legacy metadata' }],
|
||||
[{ schedule: '0 0 * JAN MON#2' }, { schedule: '@hourly' }],
|
||||
]) {
|
||||
const result = schema.validate({
|
||||
command: 'task valid.js',
|
||||
schedule: '01 7 * * *',
|
||||
extra_schedules,
|
||||
});
|
||||
assert.equal(result.error, undefined, JSON.stringify(extra_schedules));
|
||||
assert.deepEqual(result.value.extra_schedules, extra_schedules);
|
||||
}
|
||||
for (const schedule of ['@boot', '@once 1792245000']) {
|
||||
assert.equal(
|
||||
schema.validate({ command: 'task valid.js', schedule }).error,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('create and update reject invalid extra schedules before calling the service', async (t) => {
|
||||
const express = require('express');
|
||||
const { errors } = require('celebrate');
|
||||
const load = require('../helpers/load-security-module.cjs');
|
||||
const mutations = [];
|
||||
const service = {
|
||||
async create(value) {
|
||||
mutations.push(value);
|
||||
return { id: 1, ...value };
|
||||
},
|
||||
async update(value) {
|
||||
mutations.push(value);
|
||||
return value;
|
||||
},
|
||||
};
|
||||
const route = load('back/api/cron.ts', {
|
||||
typedi: {
|
||||
Container: {
|
||||
get: (key) => (key === 'logger' ? { error() {} } : service),
|
||||
},
|
||||
},
|
||||
'../services/cron': class {},
|
||||
'../services/cronView': class {},
|
||||
'../validation/schedule': { commonCronSchema },
|
||||
'../data/runningInstance': {},
|
||||
'../shared/i18n': { t: (value) => value },
|
||||
'../schedule/client': { readiness: { async ensureReady() {} } },
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
route.default(app);
|
||||
app.use(errors());
|
||||
app.use((error, _req, res, _next) =>
|
||||
res.status(error.status || 500).json({ message: error.message }),
|
||||
);
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('listening', resolve);
|
||||
server.once('error', reject);
|
||||
});
|
||||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||||
|
||||
for (const method of ['POST', 'PUT']) {
|
||||
const payload = {
|
||||
command: 'task valid.js',
|
||||
schedule: '01 7 * * *',
|
||||
...(method === 'PUT' ? { id: 1 } : {}),
|
||||
};
|
||||
const request = (body) =>
|
||||
fetch(`http://127.0.0.1:${server.address().port}/crons`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
for (const extra_schedules of [
|
||||
[{ schedule: '60 * * * *' }],
|
||||
[{ schedule: '0 0 31 2,4 *' }],
|
||||
[{ schedule: '' }],
|
||||
[{ schedule: 12 }],
|
||||
[{ schedule: '@boot' }],
|
||||
[{ schedule: '@once 1792245000' }],
|
||||
[{}],
|
||||
[null],
|
||||
['0 * * * *'],
|
||||
[{ schedule: '0 * * * *' }, { schedule: 'invalid' }],
|
||||
]) {
|
||||
const before = mutations.length;
|
||||
const response = await request({ ...payload, extra_schedules });
|
||||
assert.equal(
|
||||
response.status,
|
||||
400,
|
||||
`${method}: ${JSON.stringify(extra_schedules)}`,
|
||||
);
|
||||
const body = await response.json();
|
||||
assert.ok(
|
||||
body.validation.body.keys.some((key) =>
|
||||
key.startsWith('extra_schedules.'),
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
mutations.length,
|
||||
before,
|
||||
'invalid input must not mutate tasks',
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
(await request({ ...payload, schedule: '60 * * * *' })).status,
|
||||
400,
|
||||
);
|
||||
const extra_schedules = [{ schedule: '0 6/6 * * *', remarks: 'keep' }];
|
||||
const response = await request({ ...payload, extra_schedules });
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(
|
||||
(await response.json()).data.extra_schedules,
|
||||
extra_schedules,
|
||||
);
|
||||
}
|
||||
assert.equal(mutations.length, 2);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ const {
|
||||
} = require('../../back/validation/schedule');
|
||||
const upgrade = require('../../cli/src/internal/maintenance/upgradeArtifacts.cjs');
|
||||
|
||||
function responseFor(schema, value) {
|
||||
function responseFor(schema, value, fullResponse = false) {
|
||||
const validation = schema.validate(value).error;
|
||||
assert.ok(validation);
|
||||
const error = new CelebrateError('Validation failed', { celebrated: true });
|
||||
@@ -41,7 +41,7 @@ function responseFor(schema, value) {
|
||||
validationErrors(error, {}, res, (err) => {
|
||||
throw err;
|
||||
});
|
||||
return response.validation.body.message;
|
||||
return fullResponse ? response : response.validation.body.message;
|
||||
}
|
||||
|
||||
test('compiled validation schemas follow live language changes without altering field paths', () => {
|
||||
@@ -75,6 +75,100 @@ test('compiled validation schemas follow live language changes without altering
|
||||
setLang('zh');
|
||||
});
|
||||
|
||||
test('cron structure errors switch languages while retaining status and field paths', (t) => {
|
||||
const schema = Joi.object(commonCronSchema);
|
||||
const empty = ['定时规则不能为空', 'Schedule rule cannot be empty'];
|
||||
const string = ['定时规则必须是字符串', 'Schedule rule must be a string'];
|
||||
const object = ['额外定时规则必须是对象', 'Extra schedule must be an object'];
|
||||
const array = ['额外定时规则必须是数组', 'Extra schedules must be an array'];
|
||||
const invalid = ['无效的定时规则', 'Invalid schedule rule'];
|
||||
const cases = [
|
||||
[{ schedule: undefined }, 'schedule', empty],
|
||||
[{ schedule: '' }, 'schedule', empty],
|
||||
[{ schedule: 12 }, 'schedule', string],
|
||||
[{ schedule: null }, 'schedule', string],
|
||||
[{ extra_schedules: [{}] }, 'extra_schedules.0.schedule', empty],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: '' }] },
|
||||
'extra_schedules.0.schedule',
|
||||
empty,
|
||||
],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: 12 }] },
|
||||
'extra_schedules.0.schedule',
|
||||
string,
|
||||
],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: null }] },
|
||||
'extra_schedules.0.schedule',
|
||||
string,
|
||||
],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: 'invalid' }] },
|
||||
'extra_schedules.0.schedule',
|
||||
invalid,
|
||||
],
|
||||
[
|
||||
{ extra_schedules: [{ schedule: '0 * * * *' }, {}] },
|
||||
'extra_schedules.1.schedule',
|
||||
empty,
|
||||
],
|
||||
...[null, '0 * * * *', 12, []].map((item) => [
|
||||
{ extra_schedules: [item] },
|
||||
'extra_schedules.0',
|
||||
object,
|
||||
]),
|
||||
...['0 * * * *', {}, 12].map((value) => [
|
||||
{ extra_schedules: value },
|
||||
'extra_schedules',
|
||||
array,
|
||||
]),
|
||||
];
|
||||
t.after(() => setLang('zh', false));
|
||||
for (const lang of ['en', 'zh', 'en']) {
|
||||
setLang(lang, false);
|
||||
for (const [value, field, messages] of cases) {
|
||||
const response = responseFor(
|
||||
schema,
|
||||
{
|
||||
command: 'task valid.js',
|
||||
schedule: '0 * * * *',
|
||||
...value,
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert.equal(response.statusCode, 400);
|
||||
assert.equal(
|
||||
response.validation.body.message,
|
||||
messages[lang === 'zh' ? 0 : 1],
|
||||
`${lang}: ${field}`,
|
||||
);
|
||||
assert.deepEqual(response.validation.body.keys, [field]);
|
||||
}
|
||||
const multiple = responseFor(
|
||||
schema.prefs({ abortEarly: false }),
|
||||
{
|
||||
command: 'task valid.js',
|
||||
schedule: '0 * * * *',
|
||||
extra_schedules: [{}, { schedule: 12 }, null],
|
||||
},
|
||||
true,
|
||||
);
|
||||
assert.equal(multiple.statusCode, 400);
|
||||
assert.equal(
|
||||
multiple.validation.body.message,
|
||||
[empty, string, object]
|
||||
.map((messages) => messages[lang === 'zh' ? 0 : 1])
|
||||
.join('. '),
|
||||
);
|
||||
assert.deepEqual(multiple.validation.body.keys, [
|
||||
'extra_schedules.0.schedule',
|
||||
'extra_schedules.1.schedule',
|
||||
'extra_schedules.2',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test('history, upload, initialization and proxy messages resolve dynamically and preserve user text', () => {
|
||||
const keys = [
|
||||
'请先初始化',
|
||||
|
||||
Reference in New Issue
Block a user