@terreno/jobs 57.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +56 -0
  3. package/biome.jsonc +20 -0
  4. package/bunfig.toml +5 -0
  5. package/dist/activeJobExecution.d.ts +3 -0
  6. package/dist/activeJobExecution.js +29 -0
  7. package/dist/claimLock.d.ts +16 -0
  8. package/dist/claimLock.js +34 -0
  9. package/dist/dispatchCompensation.d.ts +3 -0
  10. package/dist/dispatchCompensation.js +89 -0
  11. package/dist/dispatchError.d.ts +10 -0
  12. package/dist/dispatchError.js +33 -0
  13. package/dist/index.d.ts +9 -0
  14. package/dist/index.js +15 -0
  15. package/dist/jobExecutor.d.ts +34 -0
  16. package/dist/jobExecutor.js +328 -0
  17. package/dist/jobsApp.d.ts +61 -0
  18. package/dist/jobsApp.js +308 -0
  19. package/dist/jobsService.d.ts +38 -0
  20. package/dist/jobsService.js +505 -0
  21. package/dist/modelTypes.d.ts +52 -0
  22. package/dist/modelTypes.js +4 -0
  23. package/dist/models/job.d.ts +2 -0
  24. package/dist/models/job.js +124 -0
  25. package/dist/models/jobSchedule.d.ts +2 -0
  26. package/dist/models/jobSchedule.js +48 -0
  27. package/dist/retryBackoff.d.ts +17 -0
  28. package/dist/retryBackoff.js +22 -0
  29. package/dist/routes/jobsAdmin.d.ts +15 -0
  30. package/dist/routes/jobsAdmin.js +596 -0
  31. package/dist/routes/jobsExecute.d.ts +12 -0
  32. package/dist/routes/jobsExecute.js +119 -0
  33. package/dist/runners/gcpCloudTasks.d.ts +78 -0
  34. package/dist/runners/gcpCloudTasks.js +256 -0
  35. package/dist/runners/mongoRunner.d.ts +14 -0
  36. package/dist/runners/mongoRunner.js +204 -0
  37. package/dist/runners/vercelQueues.d.ts +111 -0
  38. package/dist/runners/vercelQueues.js +326 -0
  39. package/dist/scheduleCron.d.ts +15 -0
  40. package/dist/scheduleCron.js +61 -0
  41. package/dist/scheduler.d.ts +8 -0
  42. package/dist/scheduler.js +178 -0
  43. package/dist/types.d.ts +64 -0
  44. package/dist/types.js +2 -0
  45. package/package.json +82 -0
  46. package/src/activeJobExecution.ts +28 -0
  47. package/src/claimLock.ts +37 -0
  48. package/src/dispatchCompensation.ts +39 -0
  49. package/src/dispatchError.ts +14 -0
  50. package/src/index.ts +24 -0
  51. package/src/jobExecutor.ts +333 -0
  52. package/src/jobsApp.ts +255 -0
  53. package/src/jobsService.ts +395 -0
  54. package/src/modelTypes.ts +78 -0
  55. package/src/models/job.ts +129 -0
  56. package/src/models/jobSchedule.ts +57 -0
  57. package/src/retryBackoff.ts +34 -0
  58. package/src/routes/jobsAdmin.ts +562 -0
  59. package/src/routes/jobsExecute.ts +95 -0
  60. package/src/runners/gcpCloudTasks.ts +290 -0
  61. package/src/runners/mongoRunner.ts +135 -0
  62. package/src/runners/vercelQueues.ts +394 -0
  63. package/src/scheduleCron.ts +65 -0
  64. package/src/scheduler.ts +112 -0
  65. package/src/tests/bunSetup.ts +9 -0
  66. package/src/tests/gcpCloudTasksRunner.test.ts +349 -0
  67. package/src/tests/jobModel.test.ts +62 -0
  68. package/src/tests/jobScheduleModel.test.ts +44 -0
  69. package/src/tests/jobsAdmin.test.ts +728 -0
  70. package/src/tests/jobsApp.test.ts +113 -0
  71. package/src/tests/jobsCustomRunner.test.ts +367 -0
  72. package/src/tests/jobsDelayIdempotency.test.ts +255 -0
  73. package/src/tests/jobsExecute.test.ts +640 -0
  74. package/src/tests/jobsRetry.test.ts +420 -0
  75. package/src/tests/jobsSchedule.test.ts +505 -0
  76. package/src/tests/jobsTracer.test.ts +165 -0
  77. package/src/tests/jobsWorkerLifecycle.test.ts +509 -0
  78. package/src/tests/packageExports.test.ts +24 -0
  79. package/src/tests/retryBackoff.test.ts +76 -0
  80. package/src/tests/scheduleCron.test.ts +87 -0
  81. package/src/tests/vercelQueuesRunner.test.ts +524 -0
  82. package/src/types.ts +74 -0
  83. package/tsconfig.json +33 -0
@@ -0,0 +1,728 @@
1
+ import {beforeEach, describe, it} from "bun:test";
2
+ import {
3
+ type APIError,
4
+ addAuthRoutes,
5
+ apiErrorMiddleware,
6
+ apiUnauthorizedMiddleware,
7
+ createAccess,
8
+ isAPIError,
9
+ setupAuth,
10
+ TerrenoApp,
11
+ terrenoStatements,
12
+ type UserModel as UserModelType,
13
+ } from "@terreno/api";
14
+ import {authAsUser, getBaseServer, setupDb, UserModel} from "@terreno/api/testing";
15
+ import {assert} from "chai";
16
+ import type {Application} from "express";
17
+ import {DateTime} from "luxon";
18
+ import mongoose from "mongoose";
19
+ import supertest from "supertest";
20
+
21
+ import {JobsApp} from "../jobsApp";
22
+ import {getJobsService} from "../jobsService";
23
+ import {Job} from "../models/job";
24
+ import {JobSchedule} from "../models/jobSchedule";
25
+
26
+ const typedUserModel = UserModel as unknown as UserModelType;
27
+ const ADMIN_EMAIL = "admin@example.com";
28
+
29
+ const waitUntil = async (
30
+ predicate: () => Promise<boolean>,
31
+ options?: {intervalMs?: number; timeoutMs?: number}
32
+ ): Promise<void> => {
33
+ const intervalMs = options?.intervalMs ?? 25;
34
+ const timeoutMs = options?.timeoutMs ?? 5_000;
35
+ const deadline = DateTime.utc().plus({milliseconds: timeoutMs});
36
+
37
+ while (DateTime.utc() < deadline) {
38
+ if (await predicate()) {
39
+ return;
40
+ }
41
+ await Bun.sleep(intervalMs);
42
+ }
43
+
44
+ throw new Error("waitUntil timed out");
45
+ };
46
+
47
+ const buildTerrenoJobsApp = (jobsApp: JobsApp): Application =>
48
+ new TerrenoApp({
49
+ logRequests: false,
50
+ skipListen: true,
51
+ userModel: typedUserModel,
52
+ })
53
+ .register(jobsApp)
54
+ .build();
55
+
56
+ const buildLegacyAuthApp = (jobsApp: JobsApp): Application => {
57
+ const app = getBaseServer();
58
+ setupAuth(app, typedUserModel);
59
+ addAuthRoutes(app, typedUserModel);
60
+ jobsApp.register(app);
61
+ app.use(apiUnauthorizedMiddleware);
62
+ app.use(apiErrorMiddleware);
63
+ return app;
64
+ };
65
+
66
+ const buildRbacApp = (
67
+ grantsByEmail: Record<string, Record<string, string[]>>
68
+ ): {app: Application; jobsApp: JobsApp} => {
69
+ const accessControl = createAccess({
70
+ connection: mongoose.connection,
71
+ resolvePermissions: async ({user}) => {
72
+ const email = "email" in user && typeof user.email === "string" ? user.email : "";
73
+ return grantsByEmail[email] ?? {};
74
+ },
75
+ statements: terrenoStatements,
76
+ });
77
+ const jobsApp = new JobsApp({accessControl});
78
+ return {app: buildLegacyAuthApp(jobsApp), jobsApp};
79
+ };
80
+
81
+ const seedJob = async (overrides: Record<string, unknown> = {}): Promise<mongoose.Document> =>
82
+ Job.create({
83
+ attemptCount: 0,
84
+ backoffMs: 1_000,
85
+ maxAttempts: 5,
86
+ maxBackoffMs: 15 * 60 * 1_000,
87
+ name: "demo-job",
88
+ payload: {value: 1},
89
+ payloadRedacted: false,
90
+ runAt: DateTime.utc().toJSDate(),
91
+ status: "pending",
92
+ ...overrides,
93
+ });
94
+
95
+ describe("jobs admin routes", () => {
96
+ beforeEach(async (): Promise<void> => {
97
+ await setupDb();
98
+ await Job.deleteMany({});
99
+ await JobSchedule.deleteMany({});
100
+ });
101
+
102
+ it("returns 401 for unauthenticated list requests", async (): Promise<void> => {
103
+ const jobsApp = new JobsApp();
104
+ jobsApp.define("demo-job", {handler: async () => {}});
105
+ const app = buildTerrenoJobsApp(jobsApp);
106
+
107
+ await supertest(app).get("/jobs").expect(401);
108
+ await supertest(app).get("/jobs/schedules").expect(401);
109
+ await supertest(app).get("/jobs/stats").expect(401);
110
+ });
111
+
112
+ it("returns 403 for authenticated non-admins on legacy admin", async (): Promise<void> => {
113
+ const jobsApp = new JobsApp();
114
+ jobsApp.define("demo-job", {handler: async () => {}});
115
+ const app = buildLegacyAuthApp(jobsApp);
116
+ const user = await authAsUser(app, "notAdmin");
117
+
118
+ await user.get("/jobs").expect(403);
119
+ await user.get("/jobs/schedules").expect(403);
120
+ await user.get("/jobs/stats").expect(403);
121
+ await user.post(`/jobs/${new mongoose.Types.ObjectId().toString()}/retry`).expect(403);
122
+ });
123
+
124
+ it("returns status counts from a single stats aggregation", async (): Promise<void> => {
125
+ const jobsApp = new JobsApp();
126
+ jobsApp.define("stats-job", {handler: async () => {}});
127
+ const app = buildLegacyAuthApp(jobsApp);
128
+ const admin = await authAsUser(app, "admin");
129
+
130
+ await seedJob({status: "dead"});
131
+ await seedJob({status: "dead"});
132
+ await seedJob({status: "running"});
133
+ await seedJob({status: "pending"});
134
+ await seedJob({status: "completed"});
135
+
136
+ const stats = await admin.get("/jobs/stats").expect(200);
137
+ assert.equal(stats.body.data.total, 5);
138
+ assert.equal(stats.body.data.byStatus.dead, 2);
139
+ assert.equal(stats.body.data.byStatus.running, 1);
140
+ assert.equal(stats.body.data.byStatus.pending, 1);
141
+ assert.equal(stats.body.data.byStatus.completed, 1);
142
+ assert.equal(stats.body.data.byStatus.failed, 0);
143
+ assert.equal(stats.body.data.byStatus.cancelled, 0);
144
+ assert.equal(stats.body.data.byStatus.scheduled, 0);
145
+ });
146
+
147
+ it("allows legacy admins to list, filter, and paginate jobs", async (): Promise<void> => {
148
+ const jobsApp = new JobsApp();
149
+ jobsApp.define("alpha", {handler: async () => {}});
150
+ jobsApp.define("beta", {handler: async () => {}});
151
+ const app = buildLegacyAuthApp(jobsApp);
152
+ const admin = await authAsUser(app, "admin");
153
+
154
+ await seedJob({lastError: "boom", name: "alpha", status: "dead"});
155
+ await seedJob({name: "beta", status: "running"});
156
+ await seedJob({name: "gamma", status: "pending"});
157
+
158
+ const filtered = await admin.get("/jobs").query({name: "alpha", status: "dead"}).expect(200);
159
+ assert.equal(filtered.body.total, 1);
160
+ assert.equal(filtered.body.data[0].name, "alpha");
161
+
162
+ const searched = await admin.get("/jobs").query({q: "boom"}).expect(200);
163
+ assert.equal(searched.body.total, 1);
164
+
165
+ const paged = await admin.get("/jobs").query({limit: 1, page: 2}).expect(200);
166
+ assert.equal(paged.body.limit, 1);
167
+ assert.equal(paged.body.page, 2);
168
+ assert.isTrue(paged.body.more);
169
+ });
170
+
171
+ it("returns job detail and 404 for invalid ids", async (): Promise<void> => {
172
+ const jobsApp = new JobsApp();
173
+ jobsApp.define("detail-job", {handler: async () => {}});
174
+ const app = buildLegacyAuthApp(jobsApp);
175
+ const admin = await authAsUser(app, "admin");
176
+ const job = await seedJob({
177
+ attempts: [{at: DateTime.utc().toJSDate(), error: "failed once", errorClass: "Error"}],
178
+ name: "detail-job",
179
+ status: "dead",
180
+ });
181
+
182
+ const response = await admin.get(`/jobs/${String(job._id)}`).expect(200);
183
+ assert.equal(response.body.data.name, "detail-job");
184
+ assert.lengthOf(response.body.data.attempts, 1);
185
+
186
+ await admin.get("/jobs/not-an-id").expect(404);
187
+ await admin.get("/jobs/507f1f77bcf86cd799439011").expect(404);
188
+ });
189
+
190
+ it("does not expose POST /jobs for arbitrary browser create", async (): Promise<void> => {
191
+ const jobsApp = new JobsApp();
192
+ jobsApp.define("demo-job", {handler: async () => {}});
193
+ const app = buildLegacyAuthApp(jobsApp);
194
+ const admin = await authAsUser(app, "admin");
195
+
196
+ await admin.post("/jobs").send({name: "demo-job", payload: {}}).expect(404);
197
+ });
198
+
199
+ it("retry creates a linked row and sets retriedById on the original", async (): Promise<void> => {
200
+ const jobsApp = new JobsApp();
201
+ jobsApp.define("retry-me", {handler: async () => {}});
202
+ const app = buildLegacyAuthApp(jobsApp);
203
+ const admin = await authAsUser(app, "admin");
204
+ const original = await seedJob({name: "retry-me", status: "dead"});
205
+
206
+ const response = await admin.post(`/jobs/${String(original._id)}/retry`).expect(200);
207
+ assert.notEqual(response.body.data._id, String(original._id));
208
+ assert.equal(response.body.data.retriedFromId, String(original._id));
209
+
210
+ const refreshed = await Job.findExactlyOne({_id: original._id});
211
+ assert.equal(String(refreshed.retriedById), response.body.data._id);
212
+ });
213
+
214
+ it("requeue resets dead rows to pending now and clears locks", async (): Promise<void> => {
215
+ const jobsApp = new JobsApp();
216
+ jobsApp.define("requeue-me", {handler: async () => {}});
217
+ const app = buildLegacyAuthApp(jobsApp);
218
+ const admin = await authAsUser(app, "admin");
219
+ const dead = await seedJob({
220
+ lockedAt: DateTime.utc().toJSDate(),
221
+ lockedBy: "worker:1",
222
+ name: "requeue-me",
223
+ status: "dead",
224
+ });
225
+
226
+ const response = await admin.post(`/jobs/${String(dead._id)}/requeue`).expect(200);
227
+ assert.equal(response.body.data.status, "pending");
228
+ assert.isUndefined(response.body.data.lockedAt);
229
+ assert.isUndefined(response.body.data.lockedBy);
230
+ assert.equal(String(response.body.data._id), String(dead._id));
231
+ });
232
+
233
+ it("returns 400 when cancelling completed or dead jobs", async (): Promise<void> => {
234
+ const jobsApp = new JobsApp();
235
+ jobsApp.define("cancel-gate", {handler: async () => {}});
236
+ const app = buildLegacyAuthApp(jobsApp);
237
+ const admin = await authAsUser(app, "admin");
238
+
239
+ for (const status of ["completed", "dead"] as const) {
240
+ const job = await seedJob({name: "cancel-gate", status});
241
+ await admin.post(`/jobs/${String(job._id)}/cancel`).expect(400);
242
+ }
243
+ });
244
+
245
+ it("cancel moves pending jobs to cancelled", async (): Promise<void> => {
246
+ const jobsApp = new JobsApp();
247
+ jobsApp.define("cancel-me", {handler: async () => {}});
248
+ const app = buildLegacyAuthApp(jobsApp);
249
+ const admin = await authAsUser(app, "admin");
250
+ const pending = await seedJob({name: "cancel-me", status: "pending"});
251
+
252
+ const response = await admin.post(`/jobs/${String(pending._id)}/cancel`).expect(200);
253
+ assert.equal(response.body.data.status, "cancelled");
254
+ });
255
+
256
+ it("cancel aborts a running local handler when feasible", async (): Promise<void> => {
257
+ let sawAbort = false;
258
+ let executionStarted = false;
259
+ const jobsApp = new JobsApp({pollIntervalMs: 25});
260
+ jobsApp.define("cancel-running", {
261
+ handler: async (_payload, ctx) => {
262
+ executionStarted = true;
263
+ await waitUntil(() => Promise.resolve(ctx.signal.aborted));
264
+ sawAbort = ctx.signal.aborted;
265
+ },
266
+ });
267
+
268
+ const app = buildLegacyAuthApp(jobsApp);
269
+ const admin = await authAsUser(app, "admin");
270
+ const enqueued = await getJobsService().enqueue({
271
+ name: "cancel-running",
272
+ payload: {},
273
+ });
274
+
275
+ await jobsApp.startWorker();
276
+ await waitUntil(() => Promise.resolve(executionStarted));
277
+
278
+ const response = await admin.post(`/jobs/${String(enqueued._id)}/cancel`).expect(200);
279
+ assert.equal(response.body.data.status, "cancelled");
280
+
281
+ await waitUntil(() => Promise.resolve(sawAbort));
282
+ await jobsApp.stopWorker();
283
+
284
+ const refreshed = await Job.findExactlyOne({_id: enqueued._id});
285
+ assert.equal(refreshed.status, "cancelled");
286
+ });
287
+
288
+ it("lists schedules and supports pause/resume without losing cron metadata", async (): Promise<void> => {
289
+ const jobsApp = new JobsApp();
290
+ jobsApp.define("nightly", {
291
+ handler: async () => {},
292
+ schedule: {cron: "0 0 * * *", timezone: "UTC"},
293
+ });
294
+ const app = buildLegacyAuthApp(jobsApp);
295
+ const admin = await authAsUser(app, "admin");
296
+
297
+ await getJobsService().reconcileSchedules();
298
+
299
+ const listed = await admin.get("/jobs/schedules").expect(200);
300
+ assert.lengthOf(listed.body.data, 1);
301
+ assert.equal(listed.body.data[0].name, "nightly");
302
+ assert.isTrue(listed.body.data[0].enabled);
303
+
304
+ const paused = await admin.post("/jobs/schedules/nightly/pause").expect(200);
305
+ assert.isFalse(paused.body.data.enabled);
306
+ assert.equal(paused.body.data.cron, "0 0 * * *");
307
+
308
+ const resumed = await admin.post("/jobs/schedules/nightly/resume").expect(200);
309
+ assert.isTrue(resumed.body.data.enabled);
310
+ });
311
+
312
+ it("routes /jobs/stats before /jobs/:id", async (): Promise<void> => {
313
+ const jobsApp = new JobsApp();
314
+ jobsApp.define("stats-job", {handler: async () => {}});
315
+ const app = buildLegacyAuthApp(jobsApp);
316
+ const admin = await authAsUser(app, "admin");
317
+
318
+ await seedJob({status: "dead"});
319
+ await seedJob({status: "running"});
320
+
321
+ const stats = await admin.get("/jobs/stats").expect(200);
322
+ assert.equal(stats.body.data.total, 2);
323
+ assert.equal(stats.body.data.byStatus.dead, 1);
324
+ assert.equal(stats.body.data.byStatus.running, 1);
325
+
326
+ await admin.get("/jobs/stats").query({page: 1}).expect(200);
327
+ await admin.get("/jobs/not-an-id").expect(404);
328
+ });
329
+
330
+ it("routes /jobs/schedules before /jobs/:id", async (): Promise<void> => {
331
+ const jobsApp = new JobsApp();
332
+ jobsApp.define("schedules", {handler: async () => {}});
333
+ const app = buildLegacyAuthApp(jobsApp);
334
+ const admin = await authAsUser(app, "admin");
335
+
336
+ const response = await admin.get("/jobs/schedules").expect(200);
337
+ assert.isArray(response.body.data);
338
+ });
339
+
340
+ it("documents admin routes in OpenAPI", async (): Promise<void> => {
341
+ const jobsApp = new JobsApp();
342
+ jobsApp.define("openapi-job", {handler: async () => {}});
343
+ const app = buildTerrenoJobsApp(jobsApp);
344
+
345
+ const response = await supertest(app).get("/openapi.json").expect(200);
346
+
347
+ assert.property(response.body.paths, "/jobs");
348
+ assert.property(response.body.paths["/jobs"], "get");
349
+ assert.notProperty(response.body.paths["/jobs"], "post");
350
+ assert.property(response.body.paths, "/jobs/{id}");
351
+ assert.property(response.body.paths, "/jobs/{id}/retry");
352
+ assert.property(response.body.paths, "/jobs/{id}/requeue");
353
+ assert.property(response.body.paths, "/jobs/{id}/cancel");
354
+ assert.property(response.body.paths, "/jobs/stats");
355
+ assert.property(response.body.paths, "/jobs/schedules");
356
+ assert.property(response.body.paths, "/jobs/schedules/{name}/pause");
357
+ assert.property(response.body.paths, "/jobs/schedules/{name}/resume");
358
+ });
359
+
360
+ it("documents list and schedule array item schemas in OpenAPI", async (): Promise<void> => {
361
+ const jobsApp = new JobsApp();
362
+ jobsApp.define("openapi-job", {handler: async () => {}});
363
+ const app = buildTerrenoJobsApp(jobsApp);
364
+
365
+ const response = await supertest(app).get("/openapi.json").expect(200);
366
+
367
+ const listSchema =
368
+ response.body.paths["/jobs"].get.responses["200"].content["application/json"].schema;
369
+ assert.equal(listSchema.properties.data.type, "array");
370
+ assert.equal(listSchema.properties.data.items.type, "object");
371
+ assert.equal(listSchema.properties.data.items.properties.name.type, "string");
372
+ assert.equal(listSchema.properties.data.items.properties.status.type, "string");
373
+ assert.equal(listSchema.properties.limit.type, "number");
374
+ assert.equal(listSchema.properties.more.type, "boolean");
375
+
376
+ const schedulesSchema =
377
+ response.body.paths["/jobs/schedules"].get.responses["200"].content["application/json"]
378
+ .schema;
379
+ assert.equal(schedulesSchema.properties.data.type, "array");
380
+ assert.equal(schedulesSchema.properties.data.items.type, "object");
381
+ assert.equal(schedulesSchema.properties.data.items.properties.name.type, "string");
382
+ assert.equal(schedulesSchema.properties.data.items.properties.cron.type, "string");
383
+ assert.equal(schedulesSchema.properties.data.items.properties.enabled.type, "boolean");
384
+
385
+ const statsSchema =
386
+ response.body.paths["/jobs/stats"].get.responses["200"].content["application/json"].schema;
387
+ assert.equal(statsSchema.properties.data.type, "object");
388
+ assert.equal(statsSchema.properties.data.properties.byStatus.type, "object");
389
+ assert.equal(statsSchema.properties.data.properties.total.type, "number");
390
+ });
391
+
392
+ it("omits payload from list and detail by default", async (): Promise<void> => {
393
+ const jobsApp = new JobsApp();
394
+ jobsApp.define("secret-job", {handler: async () => {}});
395
+ const app = buildLegacyAuthApp(jobsApp);
396
+ const admin = await authAsUser(app, "admin");
397
+ const job = await seedJob({
398
+ name: "secret-job",
399
+ payload: {secret: "do-not-leak"},
400
+ status: "dead",
401
+ });
402
+
403
+ const listed = await admin.get("/jobs").expect(200);
404
+ assert.notProperty(listed.body.data[0], "payload");
405
+
406
+ const detail = await admin.get(`/jobs/${String(job._id)}`).expect(200);
407
+ assert.notProperty(detail.body.data, "payload");
408
+ });
409
+
410
+ it("uses redactPayload hook for trusted admin projections", async (): Promise<void> => {
411
+ const jobsApp = new JobsApp({
412
+ redactPayload: ({payload}) => payload,
413
+ });
414
+ jobsApp.define("trusted-job", {handler: async () => {}});
415
+ const app = buildLegacyAuthApp(jobsApp);
416
+ const admin = await authAsUser(app, "admin");
417
+ const job = await seedJob({
418
+ name: "trusted-job",
419
+ payload: {visible: true},
420
+ status: "failed",
421
+ });
422
+
423
+ const detail = await admin.get(`/jobs/${String(job._id)}`).expect(200);
424
+ assert.deepEqual(detail.body.data.payload, {visible: true});
425
+ });
426
+
427
+ it("always omits payload when payloadRedacted is true", async (): Promise<void> => {
428
+ const jobsApp = new JobsApp({
429
+ redactPayload: ({payload}) => payload,
430
+ });
431
+ jobsApp.define("redacted-job", {handler: async () => {}});
432
+ const app = buildLegacyAuthApp(jobsApp);
433
+ const admin = await authAsUser(app, "admin");
434
+ const job = await seedJob({
435
+ name: "redacted-job",
436
+ payload: {secret: true},
437
+ payloadRedacted: true,
438
+ status: "dead",
439
+ });
440
+
441
+ const detail = await admin.get(`/jobs/${String(job._id)}`).expect(200);
442
+ assert.notProperty(detail.body.data, "payload");
443
+ });
444
+
445
+ it("supports custom redactPayload projections", async (): Promise<void> => {
446
+ const jobsApp = new JobsApp({
447
+ redactPayload: () => ({kind: "redacted"}),
448
+ });
449
+ jobsApp.define("projected-job", {handler: async () => {}});
450
+ const app = buildLegacyAuthApp(jobsApp);
451
+ const admin = await authAsUser(app, "admin");
452
+ const job = await seedJob({
453
+ name: "projected-job",
454
+ payload: {secret: true},
455
+ status: "failed",
456
+ });
457
+
458
+ const detail = await admin.get(`/jobs/${String(job._id)}`).expect(200);
459
+ assert.deepEqual(detail.body.data.payload, {kind: "redacted"});
460
+ });
461
+
462
+ it("retry stores raw payload internally while admin responses omit it", async (): Promise<void> => {
463
+ const jobsApp = new JobsApp();
464
+ jobsApp.define("retry-secret", {handler: async () => {}});
465
+ const app = buildLegacyAuthApp(jobsApp);
466
+ const admin = await authAsUser(app, "admin");
467
+ const original = await seedJob({
468
+ name: "retry-secret",
469
+ payload: {token: "raw-value"},
470
+ status: "dead",
471
+ });
472
+
473
+ const response = await admin.post(`/jobs/${String(original._id)}/retry`).expect(200);
474
+ assert.notProperty(response.body.data, "payload");
475
+
476
+ const retryRow = await Job.findExactlyOne({_id: response.body.data._id});
477
+ assert.deepEqual(retryRow.payload, {token: "raw-value"});
478
+ });
479
+
480
+ it("treats q as a literal regex and rejects oversized queries", async (): Promise<void> => {
481
+ const jobsApp = new JobsApp();
482
+ jobsApp.define("regex-job", {handler: async () => {}});
483
+ const app = buildLegacyAuthApp(jobsApp);
484
+ const admin = await authAsUser(app, "admin");
485
+
486
+ await seedJob({lastError: "matched literal .* token", name: "regex-job", status: "dead"});
487
+ await seedJob({lastError: "unrelated failure", name: "other-job", status: "dead"});
488
+
489
+ const literal = await admin.get("/jobs").query({q: ".*"}).expect(200);
490
+ assert.equal(literal.body.total, 1);
491
+ assert.equal(literal.body.data[0].name, "regex-job");
492
+
493
+ const oversized = "x".repeat(201);
494
+ await admin.get("/jobs").query({q: oversized}).expect(400);
495
+ });
496
+
497
+ it("returns 400 for invalid list filters", async (): Promise<void> => {
498
+ const jobsApp = new JobsApp();
499
+ jobsApp.define("filter-job", {handler: async () => {}});
500
+ const app = buildLegacyAuthApp(jobsApp);
501
+ const admin = await authAsUser(app, "admin");
502
+
503
+ await admin.get("/jobs").query({scheduleId: "not-an-object-id"}).expect(400);
504
+ await admin.get("/jobs").query({start: "not-a-date"}).expect(400);
505
+ await admin.get("/jobs").query({status: "bogus"}).expect(400);
506
+ });
507
+
508
+ it("returns 400 when retrying non-terminal jobs", async (): Promise<void> => {
509
+ const jobsApp = new JobsApp();
510
+ jobsApp.define("retry-gate", {handler: async () => {}});
511
+ const app = buildLegacyAuthApp(jobsApp);
512
+ const admin = await authAsUser(app, "admin");
513
+
514
+ for (const status of ["completed", "pending", "running", "cancelled"] as const) {
515
+ const job = await seedJob({name: "retry-gate", status});
516
+ await admin.post(`/jobs/${String(job._id)}/retry`).expect(400);
517
+ }
518
+ });
519
+
520
+ it("returns 409 when retrying the same job twice", async (): Promise<void> => {
521
+ const jobsApp = new JobsApp();
522
+ jobsApp.define("double-retry", {handler: async () => {}});
523
+ const app = buildLegacyAuthApp(jobsApp);
524
+ const admin = await authAsUser(app, "admin");
525
+ const original = await seedJob({name: "double-retry", status: "failed"});
526
+
527
+ await admin.post(`/jobs/${String(original._id)}/retry`).expect(200);
528
+ await admin.post(`/jobs/${String(original._id)}/retry`).expect(409);
529
+ });
530
+
531
+ it("requeues failed and cancelled jobs to pending", async (): Promise<void> => {
532
+ const jobsApp = new JobsApp();
533
+ jobsApp.define("requeue-status", {handler: async () => {}});
534
+ const app = buildLegacyAuthApp(jobsApp);
535
+ const admin = await authAsUser(app, "admin");
536
+
537
+ const failed = await seedJob({name: "requeue-status", status: "failed"});
538
+ const failedResponse = await admin.post(`/jobs/${String(failed._id)}/requeue`).expect(200);
539
+ assert.equal(failedResponse.body.data.status, "pending");
540
+
541
+ const cancelled = await seedJob({name: "requeue-status", status: "cancelled"});
542
+ const cancelledResponse = await admin
543
+ .post(`/jobs/${String(cancelled._id)}/requeue`)
544
+ .expect(200);
545
+ assert.equal(cancelledResponse.body.data.status, "pending");
546
+ });
547
+
548
+ it("returns 409 when requeueing an original that already has retriedById", async (): Promise<void> => {
549
+ const jobsApp = new JobsApp();
550
+ jobsApp.define("linked-requeue", {handler: async () => {}});
551
+ const app = buildLegacyAuthApp(jobsApp);
552
+ const admin = await authAsUser(app, "admin");
553
+ const original = await seedJob({name: "linked-requeue", status: "dead"});
554
+
555
+ const retried = await admin.post(`/jobs/${String(original._id)}/retry`).expect(200);
556
+ await admin.post(`/jobs/${String(original._id)}/requeue`).expect(409);
557
+
558
+ const refreshed = await Job.findExactlyOne({_id: original._id});
559
+ assert.equal(String(refreshed.retriedById), retried.body.data._id);
560
+ assert.equal(refreshed.status, "dead");
561
+
562
+ const retryRow = await Job.findExactlyOne({_id: retried.body.data._id});
563
+ assert.equal(retryRow.status, "pending");
564
+ });
565
+
566
+ it("allows only one of concurrent retry and requeue on the same row", async (): Promise<void> => {
567
+ const jobsApp = new JobsApp();
568
+ jobsApp.define("race-job", {handler: async () => {}});
569
+ buildLegacyAuthApp(jobsApp);
570
+ const original = await seedJob({name: "race-job", status: "failed"});
571
+ const jobId = String(original._id);
572
+ const service = getJobsService();
573
+
574
+ const outcomes = await Promise.allSettled([
575
+ service.adminRetryJob(jobId),
576
+ service.adminRequeueJob(jobId),
577
+ ]);
578
+
579
+ const fulfilled = outcomes.filter((outcome) => outcome.status === "fulfilled");
580
+ const rejected = outcomes.filter((outcome) => outcome.status === "rejected");
581
+ assert.lengthOf(fulfilled, 1);
582
+ assert.lengthOf(rejected, 1);
583
+
584
+ const failure = rejected[0] as PromiseRejectedResult;
585
+ assert.isTrue(isAPIError(failure.reason));
586
+ const loserStatus = (failure.reason as APIError).status;
587
+ assert.oneOf(loserStatus, [400, 409]);
588
+
589
+ const refreshed = await Job.findExactlyOne({_id: original._id});
590
+ const linkedRetries = await Job.find({retriedFromId: original._id});
591
+ const orphanRetries = await Job.find({
592
+ _id: {$nin: [original._id]},
593
+ name: "race-job",
594
+ retriedFromId: {$exists: false},
595
+ status: "pending",
596
+ });
597
+
598
+ if (refreshed.retriedById) {
599
+ assert.equal(loserStatus, 409);
600
+ assert.oneOf(refreshed.status, ["dead", "failed"]);
601
+ assert.lengthOf(linkedRetries, 1);
602
+ assert.equal(String(linkedRetries[0]?._id), String(refreshed.retriedById));
603
+ assert.equal(linkedRetries[0]?.status, "pending");
604
+ assert.lengthOf(orphanRetries, 0);
605
+ return;
606
+ }
607
+
608
+ assert.equal(refreshed.status, "pending");
609
+ assert.isUndefined(refreshed.retriedById);
610
+ assert.lengthOf(linkedRetries, 0);
611
+ assert.lengthOf(orphanRetries, 0);
612
+ assert.oneOf(loserStatus, [400, 409]);
613
+ });
614
+
615
+ it("returns 401 for unauthenticated mutations", async (): Promise<void> => {
616
+ const jobsApp = new JobsApp();
617
+ jobsApp.define("mutation-job", {
618
+ handler: async () => {},
619
+ schedule: {cron: "0 0 * * *", timezone: "UTC"},
620
+ });
621
+ const app = buildLegacyAuthApp(jobsApp);
622
+ await getJobsService().reconcileSchedules();
623
+
624
+ const jobId = String((await seedJob({name: "mutation-job", status: "dead"}))._id);
625
+
626
+ await supertest(app).post(`/jobs/${jobId}/retry`).expect(401);
627
+ await supertest(app).post(`/jobs/${jobId}/requeue`).expect(401);
628
+ await supertest(app).post(`/jobs/${jobId}/cancel`).expect(401);
629
+ await supertest(app).post("/jobs/schedules/mutation-job/pause").expect(401);
630
+ });
631
+
632
+ it("keeps cancelled status when a cloud handler returns success after admin cancel", async (): Promise<void> => {
633
+ let resolveHandler: () => void = () => {};
634
+ const handlerDone = new Promise<void>((resolve) => {
635
+ resolveHandler = resolve;
636
+ });
637
+ let executionStarted = false;
638
+
639
+ const jobsApp = new JobsApp({pollIntervalMs: 25});
640
+ jobsApp.define("cloud-cancel", {
641
+ handler: async () => {
642
+ executionStarted = true;
643
+ await handlerDone;
644
+ },
645
+ });
646
+
647
+ const app = buildLegacyAuthApp(jobsApp);
648
+ const admin = await authAsUser(app, "admin");
649
+ const enqueued = await getJobsService().enqueue({
650
+ name: "cloud-cancel",
651
+ payload: {},
652
+ });
653
+
654
+ await jobsApp.startWorker();
655
+ await waitUntil(() => Promise.resolve(executionStarted));
656
+
657
+ await admin.post(`/jobs/${String(enqueued._id)}/cancel`).expect(200);
658
+ resolveHandler();
659
+
660
+ await waitUntil(async () => {
661
+ const job = await Job.findOneOrNone({_id: enqueued._id});
662
+ return job?.status !== "running";
663
+ });
664
+ await jobsApp.stopWorker();
665
+
666
+ const refreshed = await Job.findExactlyOne({_id: enqueued._id});
667
+ assert.equal(refreshed.status, "cancelled");
668
+ });
669
+ });
670
+
671
+ describe("jobs admin RBAC", () => {
672
+ beforeEach(async (): Promise<void> => {
673
+ await setupDb();
674
+ await Job.deleteMany({});
675
+ });
676
+
677
+ it("requires admin:access and admin:jobs when accessControl is configured", async (): Promise<void> => {
678
+ const {app} = buildRbacApp({
679
+ [ADMIN_EMAIL]: {admin: ["access", "jobs"]},
680
+ });
681
+ const allowed = await authAsUser(app, "admin");
682
+ await allowed.get("/jobs").expect(200);
683
+ });
684
+
685
+ it("returns 403 when the caller lacks admin:jobs", async (): Promise<void> => {
686
+ const {app} = buildRbacApp({
687
+ [ADMIN_EMAIL]: {admin: ["access"]},
688
+ });
689
+ const agent = await authAsUser(app, "admin");
690
+ await agent.get("/jobs").expect(403);
691
+ });
692
+
693
+ it("returns 403 when the caller lacks admin:access even with admin:jobs", async (): Promise<void> => {
694
+ const {app} = buildRbacApp({
695
+ [ADMIN_EMAIL]: {admin: ["jobs"]},
696
+ });
697
+ const agent = await authAsUser(app, "admin");
698
+ await agent.get("/jobs").expect(403);
699
+ });
700
+
701
+ it("returns 403 for mutations when the caller lacks admin:jobs", async (): Promise<void> => {
702
+ const {app, jobsApp} = buildRbacApp({
703
+ [ADMIN_EMAIL]: {admin: ["access"]},
704
+ });
705
+ jobsApp.define("rbac-job", {
706
+ handler: async () => {},
707
+ schedule: {cron: "0 0 * * *", timezone: "UTC"},
708
+ });
709
+ await getJobsService().reconcileSchedules();
710
+
711
+ const agent = await authAsUser(app, "admin");
712
+ const dead = await seedJob({name: "rbac-job", status: "dead"});
713
+
714
+ await agent.post(`/jobs/${String(dead._id)}/retry`).expect(403);
715
+ await agent.post("/jobs/schedules/rbac-job/pause").expect(403);
716
+ });
717
+ });
718
+
719
+ describe("JobsApp.adminContribution", () => {
720
+ it("registers the jobs admin screen and home widget metadata", (): void => {
721
+ const contribution = new JobsApp().adminContribution?.();
722
+ assert.exists(contribution);
723
+ assert.deepEqual(contribution?.customScreens, [
724
+ {displayName: "Jobs", icon: "clock", name: "jobs"},
725
+ ]);
726
+ assert.deepEqual(contribution?.homeWidgets, [{displayName: "Jobs", icon: "clock", id: "jobs"}]);
727
+ });
728
+ });