mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 03:06:54 +00:00
fix(scheduler): reject 6-field cron in Scheduled Operations (#1435)
* fix(scheduler): reject 6-field cron in Scheduled Operations Create and edit validation parsed cron with cron-parser, which accepts both 5- and 6-field expressions, while the form, presets, and docs all describe a 5-field cron. Because the scheduler ticks once per minute, a leading seconds field can never improve precision, so a 6-field expression was silently accepted but never honored on its stated schedule. Add a field-count guard on both sides: the API rejects 6-field input at create and edit with a clear message, and the form surfaces the same error inline and disables save. Cron nicknames such as @daily still pass. Document the five-field requirement in the cron reference. * chore: merge main into scheduled cron validation * fix: avoid logging policy bypass actor in debug output
This commit is contained in:
@@ -192,6 +192,38 @@ describe('POST /api/scheduled-tasks', () => {
|
||||
expect(res.body.error).toMatch(/Invalid cron expression/);
|
||||
});
|
||||
|
||||
it('rejects a 6-field cron expression with the seconds field', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
...basePayload, cron_expression: '30 0 3 * * *',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/5 fields/);
|
||||
});
|
||||
|
||||
it('rejects a missing cron expression with a clear message', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
...basePayload, cron_expression: undefined,
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/required/);
|
||||
});
|
||||
|
||||
it('rejects an empty cron expression with a clear message', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
...basePayload, cron_expression: '',
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/required/);
|
||||
});
|
||||
|
||||
it('accepts a cron nickname such as @daily', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
...basePayload, cron_expression: '@daily',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.cron_expression).toBe('@daily');
|
||||
});
|
||||
|
||||
it('rejects unsupported actions', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
...basePayload, action: 'nuke',
|
||||
@@ -697,6 +729,60 @@ describe('PUT /api/scheduled-tasks/:id - stack target validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/scheduled-tasks/:id - cron validation', () => {
|
||||
let taskId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
const now = Date.now();
|
||||
taskId = DatabaseService.getInstance().createScheduledTask({
|
||||
name: 't', target_type: 'stack', target_id: 's', node_id: 1, action: 'update',
|
||||
cron_expression: '0 3 * * *', enabled: 1, created_by: 'admin', created_at: now, updated_at: now,
|
||||
last_run_at: null, next_run_at: null, last_status: null, last_error: null,
|
||||
prune_targets: null, target_services: null, prune_label_filter: null, delete_after_run: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a 6-field cron expression', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/scheduled-tasks/${taskId}`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ cron_expression: '30 0 3 * * *' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/5 fields/);
|
||||
});
|
||||
|
||||
it('accepts a valid 5-field cron expression', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/scheduled-tasks/${taskId}`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ cron_expression: '0 4 * * *' });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.cron_expression).toBe('0 4 * * *');
|
||||
});
|
||||
|
||||
it('rejects a whitespace-only cron expression', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/scheduled-tasks/${taskId}`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ cron_expression: ' ' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/required/);
|
||||
});
|
||||
|
||||
it('rejects an empty cron expression', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/scheduled-tasks/${taskId}`)
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ cron_expression: '' });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/required/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scheduled-tasks state-invalidate broadcast', () => {
|
||||
// Two frontend hooks (useConfigurationStatus, useNextAutoUpdateRun) refetch
|
||||
// when this scope fires; locking it down prevents a silent UX regression
|
||||
|
||||
@@ -595,6 +595,36 @@ describe('SchedulerService - executePrune', () => {
|
||||
expect(mockPruneSystem).toHaveBeenCalledWith('containers', 'env=staging');
|
||||
});
|
||||
|
||||
it('marks scheduled prune runs as failed when a target prune fails', async () => {
|
||||
mockGetScheduledTask.mockReturnValue({
|
||||
id: 74,
|
||||
name: 'prune-fails',
|
||||
action: 'prune',
|
||||
cron_expression: '0 3 * * *',
|
||||
enabled: true,
|
||||
node_id: 1,
|
||||
prune_targets: JSON.stringify(['images']),
|
||||
created_by: 'admin',
|
||||
last_status: null,
|
||||
});
|
||||
mockPruneSystem.mockRejectedValueOnce(new Error('docker prune failed'));
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await svc.triggerTask(74);
|
||||
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
expect.objectContaining({
|
||||
status: 'failure',
|
||||
error: expect.stringContaining('images: failed (docker prune failed)'),
|
||||
}),
|
||||
);
|
||||
expect(mockUpdateScheduledTask).toHaveBeenCalledWith(
|
||||
74,
|
||||
expect.objectContaining({ last_status: 'failure' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails scheduled prune tasks that target remote nodes before pruning', async () => {
|
||||
mockGetScheduledTask.mockReturnValue({
|
||||
id: 73,
|
||||
|
||||
Reference in New Issue
Block a user