From 9d15db381324d76f29bcc3503f678b0994fdaba6 Mon Sep 17 00:00:00 2001 From: x15967311210-crypto Date: Sun, 11 Oct 2026 12:08:07 +0800 Subject: [PATCH] fix(schedule): reject a blank --cron on update, matching create `schedule create` rejected an empty --cron up front, but `schedule update --cron ""` slipped past that guard and PATCHed an empty cadence to the server. Apply the same trim-and-reject-blank contract to update, so the two verbs agree on what a valid --cron is. Refs #363 --- src/commands/schedule.create.test.ts | 11 +++++++++++ src/commands/schedule.ts | 14 +++++++++++--- src/commands/schedule.write.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/commands/schedule.create.test.ts b/src/commands/schedule.create.test.ts index 751b4c7..5f3e24e 100644 --- a/src/commands/schedule.create.test.ts +++ b/src/commands/schedule.create.test.ts @@ -109,6 +109,17 @@ describe('runCreate — validation', () => { ).rejects.toMatchObject({ code: 'VALIDATION_ERROR' }); }); + it('rejects a whitespace-only cron', async () => { + const { credentialsPath } = makeCreds(); + await expect( + runCreate({ ...VALID, cron: ' ' }, { credentialsPath, fetchImpl: noNetwork(), ...sink }), + ).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + exitCode: 5, + nextAction: expect.stringContaining('--cron'), + }); + }); + it('rejects a missing or unknown target type', async () => { const { credentialsPath } = makeCreds(); const deps = { credentialsPath, fetchImpl: noNetwork(), ...sink }; diff --git a/src/commands/schedule.ts b/src/commands/schedule.ts index 65b1509..5cdf81c 100644 --- a/src/commands/schedule.ts +++ b/src/commands/schedule.ts @@ -395,10 +395,18 @@ export async function runUpdate( } const environment = normalizeScheduleEnvironmentName(opts.env); + // Mirror `schedule create`: a supplied --cron is trimmed and must not be + // blank. Without this guard, `schedule update --cron ""` slipped past the + // check create enforces and PATCHed an empty cadence to the server. + const cron = opts.cron === undefined ? undefined : opts.cron.trim(); + if (cron !== undefined && cron.length === 0) { + throw localValidationError('--cron must not be empty or whitespace-only'); + } + const body: UpdateScheduleRequest = { ...(opts.name !== undefined ? { name: opts.name } : {}), ...(environment !== undefined ? { environment } : {}), - ...(opts.cron !== undefined ? { cron: opts.cron } : {}), + ...(cron !== undefined ? { cron } : {}), ...(opts.timezone !== undefined ? { timezone: opts.timezone } : {}), ...(opts.start !== undefined ? { startAt: opts.start } : {}), ...(opts.end !== undefined ? { endAt: opts.end } : {}), @@ -420,8 +428,8 @@ export async function runUpdate( // to `* * * * *` is the same order-of-magnitude mistake as creating one that // way, on an existing schedule and for the same bill. Printed before the // request so it is visible even if the update then fails. - if (opts.cron !== undefined) { - stderr(formatScheduleFrequencyAdvisory(opts.cron)); + if (cron !== undefined) { + stderr(formatScheduleFrequencyAdvisory(cron)); } const client = makeClient(opts, deps); diff --git a/src/commands/schedule.write.test.ts b/src/commands/schedule.write.test.ts index d9fda4f..db1ad88 100644 --- a/src/commands/schedule.write.test.ts +++ b/src/commands/schedule.write.test.ts @@ -213,6 +213,34 @@ describe('runUpdate', () => { }); }); + it('blank --cron rejects locally on update, matching create', async () => { + const { credentialsPath } = makeCreds(); + for (const cron of ['', ' ']) { + await expect( + runUpdate( + { ...BASE, output: 'json', scheduleId: 'sch_1', cron }, + { credentialsPath, fetchImpl: noNetwork(), ...sink }, + ), + ).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + exitCode: 5, + nextAction: '--cron must not be empty or whitespace-only', + }); + } + }); + + it('trims --cron before patching, matching create', async () => { + const { credentialsPath } = makeCreds(); + const { calls, fetchImpl } = capturing(); + + await runUpdate( + { ...BASE, output: 'json', scheduleId: 'sch_1', cron: ' 0 9 * * * ' }, + { credentialsPath, fetchImpl, ...sink }, + ); + + expect(JSON.parse(String(calls[0]!.init.body))).toEqual({ cron: '0 9 * * *' }); + }); + it('refuses a known old server before changing a pinned schedule', async () => { const { credentialsPath } = makeCreds(); const calls: Array<{ url: string; init: RequestInit }> = [];