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:
Anso
2026-06-24 23:00:56 -04:00
committed by GitHub
parent bc8c051962
commit db8bb70b7d
10 changed files with 715 additions and 472 deletions
@@ -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,