diff --git a/workers/updater/Scheduler.spec.mjs b/workers/updater/Scheduler.spec.mjs index cf47289..c5ff7a7 100644 --- a/workers/updater/Scheduler.spec.mjs +++ b/workers/updater/Scheduler.spec.mjs @@ -392,3 +392,42 @@ describe('Worker routing', () => { expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(200); }); }); + +it('keeps manual runs available with scheduling off and does not replay missed hours', async () => { + network(); + + let scheduler = stub(); + await scheduler.ensureArmed(); + await scheduler.setAutomaticScheduling(false); + + expect(await scheduler.ensureArmed()).toEqual({ armed: false, automaticSchedulingEnabled: false }); + expect(await scheduler.status()).toMatchObject({ + automaticSchedulingEnabled: false, + alarmAt: null, + hourlyAt: null, + retryAt: null, + }); + + let manual = await scheduler.startManual('data'); + expect(manual.ok).toBe(true); + expect(await scheduler.setAutomaticScheduling(true)).toMatchObject({ busy: true }); + + let status = await runAlarmUntil(scheduler, s => s.lastManualRun !== null); + expect(status.lastManualRun.ok).toBe(true); + expect(status.lastRun).toBeNull(); + expect(status.alarmAt).toBeNull(); + expect(status.automaticSchedulingEnabled).toBe(false); + + // A stale platform alarm must not run automatic work after disabling it. + await runInDurableObject(scheduler, instance => instance.alarm()); + expect((await scheduler.status()).lastRun).toBeNull(); + + expect((await scheduler.run({ only: ['Schedules'] })).ok).toBe(true); + expect((await scheduler.status()).alarmAt).toBeNull(); + + await scheduler.setAutomaticScheduling(true); + status = await scheduler.status(); + expect(status.hourlyAt).toBe(nextRunAt()); + expect(status.alarmAt).toBe(status.hourlyAt); + expect(status.lastRun).toBeNull(); +}); diff --git a/workers/updater/admin.spec.mjs b/workers/updater/admin.spec.mjs index 6104ff9..2e2e334 100644 --- a/workers/updater/admin.spec.mjs +++ b/workers/updater/admin.spec.mjs @@ -63,3 +63,27 @@ it.each(['/', '/admin/'])( expect(await response.text()).not.toContain('__NONCE__'); }, ); + +it('protects scheduling changes with Access, same-origin checks and boolean validation', async () => { + const request = (enabled, origin = url) => new Request(url + '/admin/api/scheduling', { + method: 'POST', + headers: { Origin: origin, 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }); + const setAutomaticScheduling = vi.fn(async enabled => ({ ok: true, automaticSchedulingEnabled: enabled })); + const scheduler = () => ({ setAutomaticScheduling }); + + verifyAccess.mockResolvedValue(null); + expect((await adminRequest(request(false), {}, scheduler)).status).toBe(401); + + verifyAccess.mockResolvedValue({ email: 'admin@example.test' }); + expect((await adminRequest(request(false, 'https://other.test'), {}, scheduler)).status).toBe(403); + expect((await adminRequest(request('false'), {}, scheduler)).status).toBe(400); + expect(setAutomaticScheduling).not.toHaveBeenCalled(); + + expect((await adminRequest(request(false), {}, scheduler)).status).toBe(200); + expect(setAutomaticScheduling).toHaveBeenCalledWith(false); + + setAutomaticScheduling.mockResolvedValue({ ok: false, busy: true }); + expect((await adminRequest(request(true), {}, scheduler)).status).toBe(409); +}); diff --git a/workers/updater/preview/server.mjs b/workers/updater/preview/server.mjs index 004d764..b49667f 100644 --- a/workers/updater/preview/server.mjs +++ b/workers/updater/preview/server.mjs @@ -9,6 +9,7 @@ const state = { preview: true, user: { email: 'Local preview' }, paused: false, + automaticSchedulingEnabled: true, busy: false, hourlyAt: hour + 3600000 + 10000, retryAt: null, @@ -68,7 +69,7 @@ createServer(async (request, response) => { if (request.method === 'GET' && url.pathname === '/admin/api/status') return json(state); - if (request.method === 'POST' && url.pathname === '/admin/api/run') { + if (request.method === 'POST' && ['/admin/api/run', '/admin/api/scheduling'].includes(url.pathname)) { if (request.headers.origin !== `http://${request.headers.host}`) return json({ error: 'Invalid origin.' }, 403); @@ -84,7 +85,19 @@ createServer(async (request, response) => { return json({ error: 'Request too large.' }, 413); } - const { mode } = JSON.parse(body); + const input = JSON.parse(body); + + if (url.pathname === '/admin/api/scheduling') { + if (typeof input?.enabled !== 'boolean') + return json({ error: 'Enabled must be a boolean.' }, 400); + + state.automaticSchedulingEnabled = input.enabled; + state.hourlyAt = input.enabled ? Math.floor(Date.now() / 3600000) * 3600000 + 3610000 : null; + + return json({ ok: true }); + } + + const { mode } = input; if (!['data', 'social', 'both'].includes(mode)) return json({ error: 'Unknown mode.' }, 400); diff --git a/workers/updater/src/Scheduler.mjs b/workers/updater/src/Scheduler.mjs index 1c5b6cf..8bd5423 100644 --- a/workers/updater/src/Scheduler.mjs +++ b/workers/updater/src/Scheduler.mjs @@ -26,6 +26,8 @@ export class Scheduler extends DurableObject { // becomes one immediate full run, then the generic queue is retired. return { paused: saved.paused ?? false, + automaticSchedulingEnabled: saved.automaticSchedulingEnabled + ?? (this.env.AUTOMATIC_SCHEDULING_ENABLED !== 'false'), hourlyAt: saved.hourlyAt ?? null, retryAt: saved.retryAt ?? (saved.pending?.length ? Date.now() : null), retries: saved.retries ?? 0, @@ -48,16 +50,70 @@ export class Scheduler extends DurableObject { let armed = (await this.ctx.storage.getAlarm()) === null; - state.hourlyAt ??= nextRunAt(); + let manual = await this.#pendingManual(); + + if (!state.automaticSchedulingEnabled && !manual) { + await this.ctx.storage.deleteAlarm(); + + return { armed: false, automaticSchedulingEnabled: false }; + } + + if (state.automaticSchedulingEnabled) + state.hourlyAt ??= nextRunAt(); await this.ctx.storage.put('state', state); - let alarmAt = (await this.#pendingManual()) ? Date.now() : (state.retryAt ?? state.hourlyAt); + let alarmAt = manual ? Date.now() : (state.retryAt ?? state.hourlyAt); await this.ctx.storage.setAlarm(alarmAt); return { armed, hourlyAt: state.hourlyAt, alarmAt }; } + async setAutomaticScheduling(enabled) { + if (typeof enabled !== 'boolean') + return { ok: false, error: 'Enabled must be a boolean.' }; + + if (this.#running) + return { ok: false, busy: true, error: 'Wait for the current run to finish.' }; + + this.#running = true; + + try { + if (await this.#pendingManual()) + return { ok: false, busy: true, error: 'Wait for the queued run to finish.' }; + + let state = await this.#state(); + + if (state.paused) + return { ok: false, paused: true, error: 'The Worker is paused for maintenance.' }; + + if (state.automaticSchedulingEnabled !== enabled) { + state.hourlyAt = enabled ? nextRunAt() : null; + state.retryAt = null; + state.retries = 0; + } + + state.automaticSchedulingEnabled = enabled; + + if (enabled) + state.hourlyAt ??= nextRunAt(); + + await this.ctx.storage.transaction(async txn => { + await txn.put('state', state); + + if (enabled) { + await txn.setAlarm(state.retryAt ?? state.hourlyAt); + } else { + await txn.deleteAlarm(); + } + }); + + return { ok: true, automaticSchedulingEnabled: enabled }; + } finally { + this.#running = false; + } + } + async pause() { if (this.#running || (await this.#pendingManual())) return { ok: false, busy: true, error: 'Wait for the current run to finish before pausing.' }; @@ -254,6 +310,12 @@ export class Scheduler extends DurableObject { }); } + if (!state.automaticSchedulingEnabled) { + await this.ctx.storage.deleteAlarm(); + + return; + } + state.hourlyAt ??= nextRunAt(); let scheduledFor = state.retryAt ?? state.hourlyAt; diff --git a/workers/updater/src/admin/page.html b/workers/updater/src/admin/page.html index db82e61..37ae787 100644 --- a/workers/updater/src/admin/page.html +++ b/workers/updater/src/admin/page.html @@ -445,7 +445,10 @@