@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1

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 (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -15,31 +15,24 @@
15
15
  */
16
16
 
17
17
  /**
18
- * Issue #290 — shutdown was delegated to `@o3co/auth.utils@0.0.4`, whose
19
- * guarantees were not pinned by any contract this repository could check.
20
- *
21
- * Reading the 22 lines answered the question the issue asked, and the answer
22
- * was worth knowing: **there was no force-close deadline**. `server.close()`
23
- * waits for in-flight requests indefinitely, so one stuck request meant the
24
- * process never exited on its own and the orchestrator's SIGKILL took it down
25
- * mid-flight — the opposite of a graceful shutdown, and invisible until it
26
- * happened. The cleanup-failure path also wrote to `console.error`, a bare
27
- * line in a service whose every other line is NDJSON.
28
- *
29
- * So the behaviour lives here now, with a deadline and the app's own logger,
30
- * and these tests are the contract the issue said was missing.
18
+ * The contract of the template's graceful shutdown (README, "Shutdown
19
+ * guarantees"): a force-close deadline on the drain, a bounded cleanup, and
20
+ * every stage logged through the app's own logger. `server.close()` alone
21
+ * waits for in-flight requests indefinitely, so one stuck request would keep
22
+ * the process up until the orchestrator's SIGKILL took it down mid-flight.
31
23
  */
32
24
 
33
- import type { Server } from "node:http";
34
- import type { Logger } from "@o3co/auth-provider-core";
35
- import { describe, expect, it, vi } from "vitest";
25
+ import { createServer, type Server } from "node:http";
36
26
  import {
37
- cleanupAllowanceFor,
38
- deferExit,
39
- FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
40
- FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
41
- installGracefulShutdown,
42
- } from "../shutdown.mjs";
27
+ type BootstrapMap,
28
+ createApp,
29
+ createMemoryReplaySeenSet,
30
+ defineModule,
31
+ type Logger,
32
+ } from "@o3co/auth-provider-core";
33
+ import { makeValidCoreConfig } from "@o3co/auth-provider-core/testing";
34
+ import { describe, expect, it, vi } from "vitest";
35
+ import { deferExit, installGracefulShutdown } from "../shutdown.mjs";
43
36
 
44
37
  /** A `Server` double whose `close` callback fires only when we say so. */
45
38
  function makeServer() {
@@ -67,8 +60,7 @@ function makeServer() {
67
60
 
68
61
  /**
69
62
  * A `Logger`-shaped spy. Typed rather than cast: `as never` would hide a real
70
- * mismatch the day the port gains a method, which is the whole reason the
71
- * shutdown path logs through the app logger instead of `console`.
63
+ * mismatch the day the port gains a method.
72
64
  */
73
65
  const makeLogger = () => {
74
66
  const spy = {
@@ -85,16 +77,54 @@ const makeLogger = () => {
85
77
  return spy satisfies Logger;
86
78
  };
87
79
 
80
+ /**
81
+ * A logger that serialises every own property of what it is handed, `cause`
82
+ * and non-enumerable fields included — a deployment is free to install one.
83
+ * `lines` is what it wrote; its levels are spies.
84
+ */
85
+ const serialiseEverythingLogger = () => {
86
+ const lines: string[] = [];
87
+ const walk = (value: unknown, seen = new WeakSet<object>()): unknown => {
88
+ if (typeof value !== "object" || value === null) return value;
89
+ if (seen.has(value)) return "[circular]";
90
+ seen.add(value);
91
+ const out: Record<string, unknown> = {};
92
+ for (const key of Object.getOwnPropertyNames(value)) {
93
+ out[key] = walk((value as Record<string, unknown>)[key], seen);
94
+ }
95
+ return out;
96
+ };
97
+ const record = (level: string) =>
98
+ vi.fn((...args: unknown[]): void => {
99
+ lines.push(JSON.stringify({ level, args: walk(args) }));
100
+ });
101
+ const logger = {
102
+ trace: record("trace"),
103
+ debug: record("debug"),
104
+ info: record("info"),
105
+ warn: record("warn"),
106
+ error: record("error"),
107
+ fatal: record("fatal"),
108
+ child: vi.fn(() => logger as unknown as Logger),
109
+ };
110
+ return { logger: logger satisfies Logger, lines };
111
+ };
112
+
113
+ /** A logged projection's `stack`: frames only, from the first. */
114
+ const FRAMES = expect.stringMatching(/^ {4}at /);
115
+
88
116
  /** Drive one shutdown without touching the real `process` or exiting. */
89
117
  function install(
90
118
  opts: {
91
119
  cleanup?: () => void | Promise<void>;
92
120
  drainTimeoutMs?: number;
93
121
  cleanupTimeoutMs?: number;
122
+ cleanupAllowanceMs?: () => number | undefined;
123
+ logger?: ReturnType<typeof makeLogger>;
94
124
  } = {},
95
125
  ) {
96
126
  const { server, spies, finishDraining, failClose } = makeServer();
97
- const logger = makeLogger();
127
+ const logger = opts.logger ?? makeLogger();
98
128
  const exit = vi.fn();
99
129
  const signals = new Map<string, () => void>();
100
130
 
@@ -103,6 +133,9 @@ function install(
103
133
  cleanup: opts.cleanup ?? (() => {}),
104
134
  ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
105
135
  ...(opts.cleanupTimeoutMs === undefined ? {} : { cleanupTimeoutMs: opts.cleanupTimeoutMs }),
136
+ ...(opts.cleanupAllowanceMs === undefined
137
+ ? {}
138
+ : { cleanupAllowanceMs: opts.cleanupAllowanceMs }),
106
139
  exit,
107
140
  onSignal: (name, handler) => signals.set(name, handler),
108
141
  offSignal: (name) => signals.delete(name),
@@ -111,7 +144,7 @@ function install(
111
144
  return { spies, logger, exit, signals, finishDraining, failClose };
112
145
  }
113
146
 
114
- describe("installGracefulShutdown (#290)", () => {
147
+ describe("installGracefulShutdown", () => {
115
148
  it("listens for both SIGTERM and SIGINT", async () => {
116
149
  const { signals } = install();
117
150
  expect([...signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]);
@@ -148,7 +181,6 @@ describe("installGracefulShutdown (#290)", () => {
148
181
  expect(cleanup).toHaveBeenCalledTimes(1);
149
182
  });
150
183
 
151
- // The gap the audit was pointing at.
152
184
  it("forces the remaining connections closed when draining outruns the deadline", async () => {
153
185
  vi.useFakeTimers();
154
186
  try {
@@ -172,7 +204,15 @@ describe("installGracefulShutdown (#290)", () => {
172
204
  signals.get("SIGTERM")?.();
173
205
  await vi.advanceTimersByTimeAsync(5_000);
174
206
  expect(exit).toHaveBeenCalledWith(1);
175
- expect(logger.error).toHaveBeenCalled();
207
+ // Each stage is an event: object-first, a snake_case name.
208
+ expect(logger.info).toHaveBeenCalledWith(
209
+ { drainTimeoutMs: 5_000, cleanupTimeoutMs: 5_000 },
210
+ "shutdown_draining",
211
+ );
212
+ expect(logger.error).toHaveBeenCalledWith(
213
+ { drainTimeoutMs: 5_000 },
214
+ "shutdown_drain_deadline_exceeded",
215
+ );
176
216
  } finally {
177
217
  vi.useRealTimers();
178
218
  }
@@ -193,21 +233,91 @@ describe("installGracefulShutdown (#290)", () => {
193
233
  }
194
234
  });
195
235
 
196
- it("reports a cleanup failure through the app logger, not console", async () => {
236
+ it("reports a cleanup failure through the app logger, not console, as loggableError's projection", async () => {
197
237
  // Every other line this service emits is NDJSON through pino; a bare
198
238
  // console.error on the shutdown path is the one a log pipeline drops.
199
- const { logger, exit, signals, finishDraining } = install({
200
- cleanup: () => {
201
- throw new Error("redis quit failed");
239
+ // What a real `handle.dispose()` rejects with is an AggregateError of
240
+ // every cleanup's own error. The line names each member and its code,
241
+ // and carries nothing of what they hold: an OAuth library's refusal
242
+ // keeps the refresh answer it refused on a non-Error cause, an ioredis
243
+ // reply the write it refused.
244
+ const refusedRefresh = Object.assign(
245
+ new Error("invalid response encountered", {
246
+ cause: { body: { refresh_token: "1//0g-UPSTREAM-S3CRET" } },
247
+ }),
248
+ { name: "ClientError", code: "OAUTH_INVALID_RESPONSE" },
249
+ );
250
+ const refusedWrite = Object.assign(
251
+ new Error("READONLY You can't write against a read only replica."),
252
+ {
253
+ name: "ReplyError",
254
+ command: { name: "set", args: ["fg:credential:grant-1", "1//0g-ARGS-S3CRET"] },
202
255
  },
256
+ );
257
+ const failingCleanups = defineModule({
258
+ name: "test:failing-cleanups",
259
+ provides: {
260
+ replaySeenSet: () => createMemoryReplaySeenSet(),
261
+ challengeStore: () => ({}) as never,
262
+ },
263
+ lifecycle: {
264
+ replaySeenSet: {
265
+ eager: true,
266
+ cleanup: () => {
267
+ throw refusedRefresh;
268
+ },
269
+ },
270
+ challengeStore: {
271
+ eager: true,
272
+ cleanup: () => {
273
+ throw refusedWrite;
274
+ },
275
+ },
276
+ },
277
+ });
278
+ const handle = await createApp({
279
+ modules: [failingCleanups],
280
+ bootstrapComponents: {
281
+ config: makeValidCoreConfig(),
282
+ pathResolver: (path: string) => path,
283
+ } as unknown as BootstrapMap,
284
+ });
285
+ const { logger, lines } = serialiseEverythingLogger();
286
+ const { exit, signals, finishDraining } = install({
287
+ logger,
288
+ cleanup: () => handle.dispose(),
203
289
  });
204
290
  signals.get("SIGTERM")?.();
205
291
  finishDraining();
206
292
  await vi.waitFor(() => expect(exit).toHaveBeenCalled());
207
293
  expect(logger.error).toHaveBeenCalledWith(
208
- expect.objectContaining({ err: expect.any(Error) }),
209
- expect.stringContaining("cleanup"),
294
+ {
295
+ err: {
296
+ name: "AggregateError",
297
+ detail: expect.stringMatching(/^AppHandle\.dispose: 2 cleanup errors /),
298
+ stack: FRAMES,
299
+ aggregateErrors: expect.arrayContaining([
300
+ {
301
+ name: "ClientError",
302
+ detail: "invalid response encountered",
303
+ code: "OAUTH_INVALID_RESPONSE",
304
+ stack: FRAMES,
305
+ },
306
+ {
307
+ name: "ReplyError",
308
+ detail: "READONLY You can't write against a read only replica.",
309
+ command: { name: "set" },
310
+ stack: FRAMES,
311
+ },
312
+ ]),
313
+ },
314
+ },
315
+ "shutdown_cleanup_failed",
210
316
  );
317
+ for (const line of lines) {
318
+ expect(line).not.toContain("S3CRET");
319
+ expect(line).not.toContain("refresh_token");
320
+ }
211
321
  });
212
322
 
213
323
  it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
@@ -221,19 +331,37 @@ describe("installGracefulShutdown (#290)", () => {
221
331
  await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
222
332
  });
223
333
 
224
- it("does not report a failed close as a clean drain", async () => {
334
+ it("does not report a failed close as a clean drain, and logs the failure as loggableError's projection", async () => {
225
335
  // `server.close` reports through its callback -- "Server is not running"
226
336
  // is the common one, but any listener teardown failure lands there.
227
337
  // Exiting 0 on it would tell an orchestrator the listener came down
228
- // when it did not.
229
- const { exit, logger, signals, failClose } = install();
338
+ // when it did not. A real server that is not listening gives Node's
339
+ // own error; the line keeps its name, message, code and frames.
340
+ const server = createServer();
341
+ const { logger, lines } = serialiseEverythingLogger();
342
+ const exit = vi.fn();
343
+ const signals = new Map<string, () => void>();
344
+ installGracefulShutdown(server, {
345
+ logger,
346
+ exit,
347
+ onSignal: (name, handler) => signals.set(name, handler),
348
+ offSignal: (name) => signals.delete(name),
349
+ });
230
350
  signals.get("SIGTERM")?.();
231
- failClose(new Error("Server is not running"));
232
351
  await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
233
352
  expect(logger.error).toHaveBeenCalledWith(
234
- expect.objectContaining({ err: expect.any(Error) }),
235
- expect.stringContaining("close"),
353
+ {
354
+ err: {
355
+ name: "Error",
356
+ detail: "Server is not running.",
357
+ code: "ERR_SERVER_NOT_RUNNING",
358
+ stack: FRAMES,
359
+ },
360
+ },
361
+ "shutdown_server_close_failed",
236
362
  );
363
+ // The frames, never the header line that repeats the message.
364
+ for (const line of lines) expect(line).not.toContain("Error [ERR_SERVER_NOT_RUNNING]");
237
365
  });
238
366
 
239
367
  it("still runs cleanup when close reports a failure", async () => {
@@ -248,11 +376,9 @@ describe("installGracefulShutdown (#290)", () => {
248
376
  });
249
377
 
250
378
  it("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
251
- // The guarantees above say cleanup "never wedges the process", but `finish`
252
- // awaited it with no deadline — and the drain deadline is already cleared
253
- // by then, so a dispose that never settles meant `exit` was never reached.
254
- // The same defect was found in auth.proxy#81 and auth.policy-verifier#210,
255
- // both of which took this file as their starting point.
379
+ // The drain deadline is already cleared when cleanup runs, so without a
380
+ // deadline of its own a dispose that never settles would keep `exit`
381
+ // from ever being reached.
256
382
  vi.useFakeTimers();
257
383
  try {
258
384
  const { signals, finishDraining, exit, logger } = install({
@@ -263,8 +389,8 @@ describe("installGracefulShutdown (#290)", () => {
263
389
  finishDraining();
264
390
  await vi.advanceTimersByTimeAsync(5_000);
265
391
  expect(logger.error).toHaveBeenCalledWith(
266
- expect.objectContaining({ cleanupTimeoutMs: 5_000 }),
267
- expect.stringContaining("cleanup timed out"),
392
+ { cleanupTimeoutMs: 5_000 },
393
+ "shutdown_cleanup_timed_out",
268
394
  );
269
395
  expect(exit).toHaveBeenCalledWith(1);
270
396
  } finally {
@@ -300,9 +426,9 @@ describe("installGracefulShutdown (#290)", () => {
300
426
  });
301
427
 
302
428
  it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
303
- // `exitCode` became 1 while `reason` still said "drained", so the one line
304
- // an operator alerts on contradicted itself. The drain outcome is still
305
- // carried, under its own key, so neither fact is lost.
429
+ // `reason` must agree with `exitCode` on the one line an operator alerts
430
+ // on. The drain outcome is carried under its own key, so neither fact is
431
+ // lost.
306
432
  const { signals, finishDraining, logger, exit } = install({
307
433
  cleanup: () => Promise.reject(new Error("teardown failed")),
308
434
  });
@@ -311,7 +437,7 @@ describe("installGracefulShutdown (#290)", () => {
311
437
  await new Promise((resolve) => setImmediate(resolve));
312
438
  expect(logger.info).toHaveBeenCalledWith(
313
439
  { reason: "cleanup-failed", drain: "drained", exitCode: 1 },
314
- "graceful shutdown: complete",
440
+ "shutdown_complete",
315
441
  );
316
442
  expect(exit).toHaveBeenCalledWith(1);
317
443
  });
@@ -323,7 +449,7 @@ describe("installGracefulShutdown (#290)", () => {
323
449
  await new Promise((resolve) => setImmediate(resolve));
324
450
  expect(logger.info).toHaveBeenCalledWith(
325
451
  { reason: "drained", drain: "drained", exitCode: 0 },
326
- "graceful shutdown: complete",
452
+ "shutdown_complete",
327
453
  );
328
454
  });
329
455
 
@@ -336,81 +462,159 @@ describe("installGracefulShutdown (#290)", () => {
336
462
  });
337
463
  });
338
464
 
339
- describe("#593 slice 7: the cleanup allowance federation grants need", () => {
340
- it("is at least the 45 seconds the package asks for, and only when the feature is on", () => {
341
- // The package's drain waits for a rotated credential's write; the
342
- // default cleanup budget is the ten-second drain, which is shorter than
343
- // the upstream hard timeout and persist budget the feature ships with —
344
- // a shutdown under it abandons exactly the write the drain exists for.
345
- expect(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS).toBeGreaterThanOrEqual(45_000);
346
- expect(cleanupAllowanceFor({ federationGrants: { enabled: true } })).toEqual({
347
- cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
348
- });
349
- // Off, nothing: the cleanup budget stays the drain's, as it was.
350
- expect(cleanupAllowanceFor({ federationGrants: { enabled: false } })).toEqual({});
351
- expect(cleanupAllowanceFor({})).toEqual({});
465
+ describe("the cleanup allowance the handle reports", () => {
466
+ it("gives cleanup the allowance when it is longer than the drain", async () => {
467
+ vi.useFakeTimers();
468
+ try {
469
+ const { signals, finishDraining, exit } = install({
470
+ cleanup: () => new Promise<void>((resolve) => setTimeout(resolve, 30_000)),
471
+ drainTimeoutMs: 5_000,
472
+ cleanupAllowanceMs: () => 45_000,
473
+ });
474
+ signals.get("SIGTERM")?.();
475
+ finishDraining();
476
+ await vi.advanceTimersByTimeAsync(31_000);
477
+ expect(exit).toHaveBeenCalledExactlyOnceWith(0);
478
+ } finally {
479
+ vi.useRealTimers();
480
+ }
352
481
  });
353
482
 
354
- it("grows with the configured refresh tail, so a raised budget is not cut off by a fixed timer (Copilot, #614)", () => {
355
- // The longest tail one refresh has: the upstream hard timeout, the
356
- // persist budget and the wait for the lock, back to back, plus an exit
357
- // margin. The shipped budgets (25 s + 3 s + 5 s) land exactly on the
358
- // floor; a deployment that doubles its upstream timeout gets more.
359
- const shipped = {
360
- upstreamHardTimeoutMs: 25_000,
361
- persistRetryBudgetMs: 3_000,
362
- lockWaitMs: 5_000,
363
- };
364
- expect(
365
- 25_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
366
- "the margin is what makes the shipped budgets the floor",
367
- ).toBe(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS);
368
- expect(cleanupAllowanceFor({ federationGrants: { enabled: true, ...shipped } })).toEqual({
369
- cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
370
- });
371
- expect(
372
- cleanupAllowanceFor({
373
- federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 60_000 },
374
- }),
375
- ).toEqual({ cleanupTimeoutMs: 60_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS });
376
- // Lowered budgets never go below the documented minimum, and a config
377
- // without budgets (built by hand) gets the floor rather than a guess.
378
- expect(
379
- cleanupAllowanceFor({
380
- federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 1_000 },
381
- }),
382
- ).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
383
- expect(
384
- cleanupAllowanceFor({ federationGrants: { enabled: true, upstreamHardTimeoutMs: 90_000 } }),
385
- ).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
483
+ it("gives up on cleanup at the allowance, and logs it as the budget", async () => {
484
+ vi.useFakeTimers();
485
+ try {
486
+ const { signals, finishDraining, exit, logger } = install({
487
+ cleanup: () => new Promise<void>(() => {}),
488
+ drainTimeoutMs: 5_000,
489
+ cleanupAllowanceMs: () => 45_000,
490
+ });
491
+ signals.get("SIGTERM")?.();
492
+ finishDraining();
493
+ await vi.advanceTimersByTimeAsync(44_999);
494
+ expect(exit).not.toHaveBeenCalled();
495
+ await vi.advanceTimersByTimeAsync(1);
496
+ expect(logger.error).toHaveBeenCalledWith(
497
+ { cleanupTimeoutMs: 45_000 },
498
+ "shutdown_cleanup_timed_out",
499
+ );
500
+ expect(exit).toHaveBeenCalledExactlyOnceWith(1);
501
+ } finally {
502
+ vi.useRealTimers();
503
+ }
386
504
  });
387
505
 
388
- it("never asks a timer for more than Node can count: an oversized sum is capped, not overflowed (Copilot, #614)", () => {
389
- // setTimeout takes a 32-bit signed delay; past it the timer fires after
390
- // about a millisecond, which would turn a generous allowance into none.
391
- const oversized = cleanupAllowanceFor({
392
- federationGrants: {
393
- enabled: true,
394
- upstreamHardTimeoutMs: 2_000_000_000,
395
- persistRetryBudgetMs: 2_000_000_000,
396
- lockWaitMs: 5_000,
397
- },
398
- });
399
- expect(oversized).toEqual({ cleanupTimeoutMs: 2_147_483_647 });
506
+ it("keeps the longer budget when the allowance is shorter", async () => {
507
+ vi.useFakeTimers();
508
+ try {
509
+ const { signals, finishDraining, exit, logger } = install({
510
+ cleanup: () => new Promise<void>(() => {}),
511
+ drainTimeoutMs: 5_000,
512
+ cleanupAllowanceMs: () => 2_000,
513
+ });
514
+ signals.get("SIGTERM")?.();
515
+ finishDraining();
516
+ await vi.advanceTimersByTimeAsync(4_999);
517
+ expect(exit).not.toHaveBeenCalled();
518
+ await vi.advanceTimersByTimeAsync(1);
519
+ expect(logger.error).toHaveBeenCalledWith(
520
+ { cleanupTimeoutMs: 5_000 },
521
+ "shutdown_cleanup_timed_out",
522
+ );
523
+ } finally {
524
+ vi.useRealTimers();
525
+ }
400
526
  });
401
527
 
402
- it("is honoured by the shutdown: a cleanup that needs longer than the drain is given it", async () => {
528
+ it("leaves cleanup its own budget when the handle reports no allowance", async () => {
403
529
  vi.useFakeTimers();
404
530
  try {
405
- const { signals, finishDraining, exit } = install({
406
- cleanup: () => new Promise<void>((resolve) => setTimeout(resolve, 30_000)),
531
+ const { signals, finishDraining, logger } = install({
532
+ cleanup: () => new Promise<void>(() => {}),
407
533
  drainTimeoutMs: 5_000,
408
- ...cleanupAllowanceFor({ federationGrants: { enabled: true } }),
534
+ cleanupAllowanceMs: () => undefined,
409
535
  });
410
536
  signals.get("SIGTERM")?.();
411
537
  finishDraining();
412
- await vi.advanceTimersByTimeAsync(31_000);
413
- expect(exit).toHaveBeenCalledExactlyOnceWith(0);
538
+ await vi.advanceTimersByTimeAsync(5_000);
539
+ expect(logger.error).toHaveBeenCalledWith(
540
+ { cleanupTimeoutMs: 5_000 },
541
+ "shutdown_cleanup_timed_out",
542
+ );
543
+ } finally {
544
+ vi.useRealTimers();
545
+ }
546
+ });
547
+
548
+ it("reads the allowance when the signal arrives, so a tail registered after install counts", async () => {
549
+ vi.useFakeTimers();
550
+ try {
551
+ let allowance: number | undefined;
552
+ const { signals, finishDraining, exit, logger } = install({
553
+ cleanup: () => new Promise<void>(() => {}),
554
+ drainTimeoutMs: 5_000,
555
+ cleanupAllowanceMs: () => allowance,
556
+ });
557
+ allowance = 60_000;
558
+ signals.get("SIGTERM")?.();
559
+ expect(logger.info).toHaveBeenCalledWith(
560
+ { drainTimeoutMs: 5_000, cleanupTimeoutMs: 60_000 },
561
+ "shutdown_draining",
562
+ );
563
+ finishDraining();
564
+ await vi.advanceTimersByTimeAsync(59_999);
565
+ expect(exit).not.toHaveBeenCalled();
566
+ await vi.advanceTimersByTimeAsync(1);
567
+ expect(exit).toHaveBeenCalledExactlyOnceWith(1);
568
+ } finally {
569
+ vi.useRealTimers();
570
+ }
571
+ });
572
+
573
+ it.each([Number.NaN, Number.POSITIVE_INFINITY, -1, 0, 1.5, 2_147_483_648])(
574
+ "treats an allowance of %s as absent: cleanup keeps its own budget",
575
+ async (allowance) => {
576
+ // Handed to a timer, NaN or a delay past 2147483647 fires after about a
577
+ // millisecond, which would turn the floor into no budget at all.
578
+ vi.useFakeTimers();
579
+ try {
580
+ const { signals, finishDraining, exit, logger } = install({
581
+ cleanup: () => new Promise<void>(() => {}),
582
+ drainTimeoutMs: 5_000,
583
+ cleanupAllowanceMs: () => allowance,
584
+ });
585
+ signals.get("SIGTERM")?.();
586
+ finishDraining();
587
+ await vi.advanceTimersByTimeAsync(4_999);
588
+ expect(exit).not.toHaveBeenCalled();
589
+ await vi.advanceTimersByTimeAsync(1);
590
+ expect(logger.error).toHaveBeenCalledWith(
591
+ { cleanupTimeoutMs: 5_000 },
592
+ "shutdown_cleanup_timed_out",
593
+ );
594
+ } finally {
595
+ vi.useRealTimers();
596
+ }
597
+ },
598
+ );
599
+
600
+ it("treats an allowance that cannot be read as absent, and still shuts down", async () => {
601
+ vi.useFakeTimers();
602
+ try {
603
+ const { signals, finishDraining, exit, logger } = install({
604
+ cleanup: () => new Promise<void>(() => {}),
605
+ drainTimeoutMs: 5_000,
606
+ cleanupAllowanceMs: () => {
607
+ throw new Error("allowance unavailable");
608
+ },
609
+ });
610
+ signals.get("SIGTERM")?.();
611
+ finishDraining();
612
+ await vi.advanceTimersByTimeAsync(5_000);
613
+ expect(logger.error).toHaveBeenCalledWith(
614
+ { cleanupTimeoutMs: 5_000 },
615
+ "shutdown_cleanup_timed_out",
616
+ );
617
+ expect(exit).toHaveBeenCalledExactlyOnceWith(1);
414
618
  } finally {
415
619
  vi.useRealTimers();
416
620
  }