diff --git a/back/shared/i18n.ts b/back/shared/i18n.ts index 060bbfa1..78e3e8b1 100644 --- a/back/shared/i18n.ts +++ b/back/shared/i18n.ts @@ -212,6 +212,9 @@ const messages: Record> = { 日志设置为忽略: 'Log set to ignore', 定时规则不能为空: 'Schedule rule cannot be empty', 无效的定时规则: 'Invalid schedule rule', + 定时规则必须是字符串: 'Schedule rule must be a string', + 额外定时规则必须是对象: 'Extra schedule must be an object', + 额外定时规则必须是数组: 'Extra schedules must be an array', '日志名称只能包含中文、字母、数字、下划线、连字符、点和路径分隔符': 'Log name can only contain Chinese characters, letters, numbers, underscores, hyphens, dots, and path separators', 日志名称不能超过100个字符: 'Log name cannot exceed 100 characters', diff --git a/back/validation/schedule.ts b/back/validation/schedule.ts index c6422c00..ebb669a3 100644 --- a/back/validation/schedule.ts +++ b/back/validation/schedule.ts @@ -20,8 +20,10 @@ export const scheduleSchema = Joi.string() .required() .custom(validateSchedule) .messages({ + 'any.required': '定时规则不能为空', 'any.invalid': '无效的定时规则', 'string.empty': '定时规则不能为空', + 'string.base': '定时规则必须是字符串', }); export const commonCronSchema = { @@ -30,7 +32,27 @@ export const commonCronSchema = { schedule: scheduleSchema, labels: Joi.array().optional().allow(null), sub_id: Joi.number().optional().allow(null), - extra_schedules: Joi.array().optional().allow(null), + extra_schedules: Joi.array() + .items( + Joi.object({ + schedule: Joi.string() + .required() + .custom((value, helpers) => + isValidCronSchedule(value) ? value : helpers.error('any.invalid'), + ) + .messages({ + 'any.required': '定时规则不能为空', + 'any.invalid': '无效的定时规则', + 'string.empty': '定时规则不能为空', + 'string.base': '定时规则必须是字符串', + }), + }) + .unknown(true) + .messages({ 'object.base': '额外定时规则必须是对象' }), + ) + .optional() + .allow(null) + .messages({ 'array.base': '额外定时规则必须是数组' }), task_before: Joi.string().optional().allow('').allow(null), task_after: Joi.string().optional().allow('').allow(null), log_name: Joi.string() diff --git a/test/back/cron-validation.test.cjs b/test/back/cron-validation.test.cjs index ddf5f9a1..fc1e00a9 100644 --- a/test/back/cron-validation.test.cjs +++ b/test/back/cron-validation.test.cjs @@ -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); +}); diff --git a/test/back/dynamic-locales.test.cjs b/test/back/dynamic-locales.test.cjs index 0769b419..087bf395 100644 --- a/test/back/dynamic-locales.test.cjs +++ b/test/back/dynamic-locales.test.cjs @@ -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 = [ '请先初始化',