From 29a55948d656368cf85d9226f0b596f3fe678133 Mon Sep 17 00:00:00 2001 From: Sid Date: Sun, 1 Mar 2026 21:01:36 +0800 Subject: [PATCH] fix(cron): guard list sorting against malformed legacy jobs (#28896) * fix(cron): guard list sorting against malformed legacy jobs Prevent list operations from crashing when old or corrupted cron entries are missing name/id fields by hardening sort comparators. Closes #28862 * cron: format list sort guard test imports --------- Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- .../service.list-page-sort-guards.test.ts | 53 +++++++++++++++++++ src/cron/service/ops.ts | 8 ++- 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 src/cron/service.list-page-sort-guards.test.ts diff --git a/src/cron/service.list-page-sort-guards.test.ts b/src/cron/service.list-page-sort-guards.test.ts new file mode 100644 index 00000000000..69349147adf --- /dev/null +++ b/src/cron/service.list-page-sort-guards.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { createMockCronStateForJobs } from "./service.test-harness.js"; +import { listPage } from "./service/ops.js"; +import type { CronJob } from "./types.js"; + +function createBaseJob(overrides?: Partial): CronJob { + return { + id: "job-1", + name: "job", + enabled: true, + schedule: { kind: "cron", expr: "*/5 * * * *", tz: "UTC" }, + sessionTarget: "main", + wakeMode: "now", + payload: { kind: "systemEvent", text: "tick" }, + state: { nextRunAtMs: Date.parse("2026-02-27T15:30:00.000Z") }, + createdAtMs: Date.parse("2026-02-27T15:00:00.000Z"), + updatedAtMs: Date.parse("2026-02-27T15:05:00.000Z"), + ...overrides, + }; +} + +describe("cron listPage sort guards", () => { + it("does not throw when sorting by name with malformed name fields", async () => { + const jobs = [ + createBaseJob({ id: "job-a", name: undefined as unknown as string }), + createBaseJob({ id: "job-b", name: "beta" }), + ]; + const state = createMockCronStateForJobs({ jobs }); + + const page = await listPage(state, { sortBy: "name", sortDir: "asc" }); + expect(page.jobs).toHaveLength(2); + }); + + it("does not throw when tie-break sorting encounters missing ids", async () => { + const nextRunAtMs = Date.parse("2026-02-27T15:30:00.000Z"); + const jobs = [ + createBaseJob({ + id: undefined as unknown as string, + name: "alpha", + state: { nextRunAtMs }, + }), + createBaseJob({ + id: undefined as unknown as string, + name: "alpha", + state: { nextRunAtMs }, + }), + ]; + const state = createMockCronStateForJobs({ jobs }); + + const page = await listPage(state, { sortBy: "nextRunAtMs", sortDir: "asc" }); + expect(page.jobs).toHaveLength(2); + }); +}); diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index bc2ec0934a4..af552acaabb 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -164,7 +164,9 @@ function sortJobs(jobs: CronJob[], sortBy: CronJobsSortBy, sortDir: CronSortDir) return jobs.toSorted((a, b) => { let cmp = 0; if (sortBy === "name") { - cmp = a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + const aName = typeof a.name === "string" ? a.name : ""; + const bName = typeof b.name === "string" ? b.name : ""; + cmp = aName.localeCompare(bName, undefined, { sensitivity: "base" }); } else if (sortBy === "updatedAtMs") { cmp = a.updatedAtMs - b.updatedAtMs; } else { @@ -183,7 +185,9 @@ function sortJobs(jobs: CronJob[], sortBy: CronJobsSortBy, sortDir: CronSortDir) if (cmp !== 0) { return cmp * dir; } - return a.id.localeCompare(b.id); + const aId = typeof a.id === "string" ? a.id : ""; + const bId = typeof b.id === "string" ? b.id : ""; + return aId.localeCompare(bId); }); }