@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2

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 (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  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 +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  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 +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -15,19 +15,11 @@
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
25
  import { createServer, type Server } from "node:http";
@@ -40,13 +32,7 @@ import {
40
32
  } from "@o3co/auth-provider-core";
41
33
  import { makeValidCoreConfig } from "@o3co/auth-provider-core/testing";
42
34
  import { describe, expect, it, vi } from "vitest";
43
- import {
44
- cleanupAllowanceFor,
45
- deferExit,
46
- FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
47
- FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
48
- installGracefulShutdown,
49
- } from "../shutdown.mjs";
35
+ import { deferExit, installGracefulShutdown } from "../shutdown.mjs";
50
36
 
51
37
  /** A `Server` double whose `close` callback fires only when we say so. */
52
38
  function makeServer() {
@@ -74,8 +60,7 @@ function makeServer() {
74
60
 
75
61
  /**
76
62
  * A `Logger`-shaped spy. Typed rather than cast: `as never` would hide a real
77
- * mismatch the day the port gains a method, which is the whole reason the
78
- * shutdown path logs through the app logger instead of `console`.
63
+ * mismatch the day the port gains a method.
79
64
  */
80
65
  const makeLogger = () => {
81
66
  const spy = {
@@ -134,6 +119,7 @@ function install(
134
119
  cleanup?: () => void | Promise<void>;
135
120
  drainTimeoutMs?: number;
136
121
  cleanupTimeoutMs?: number;
122
+ cleanupAllowanceMs?: () => number | undefined;
137
123
  logger?: ReturnType<typeof makeLogger>;
138
124
  } = {},
139
125
  ) {
@@ -147,6 +133,9 @@ function install(
147
133
  cleanup: opts.cleanup ?? (() => {}),
148
134
  ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
149
135
  ...(opts.cleanupTimeoutMs === undefined ? {} : { cleanupTimeoutMs: opts.cleanupTimeoutMs }),
136
+ ...(opts.cleanupAllowanceMs === undefined
137
+ ? {}
138
+ : { cleanupAllowanceMs: opts.cleanupAllowanceMs }),
150
139
  exit,
151
140
  onSignal: (name, handler) => signals.set(name, handler),
152
141
  offSignal: (name) => signals.delete(name),
@@ -155,7 +144,7 @@ function install(
155
144
  return { spies, logger, exit, signals, finishDraining, failClose };
156
145
  }
157
146
 
158
- describe("installGracefulShutdown (#290)", () => {
147
+ describe("installGracefulShutdown", () => {
159
148
  it("listens for both SIGTERM and SIGINT", async () => {
160
149
  const { signals } = install();
161
150
  expect([...signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]);
@@ -192,7 +181,6 @@ describe("installGracefulShutdown (#290)", () => {
192
181
  expect(cleanup).toHaveBeenCalledTimes(1);
193
182
  });
194
183
 
195
- // The gap the audit was pointing at.
196
184
  it("forces the remaining connections closed when draining outruns the deadline", async () => {
197
185
  vi.useFakeTimers();
198
186
  try {
@@ -217,7 +205,10 @@ describe("installGracefulShutdown (#290)", () => {
217
205
  await vi.advanceTimersByTimeAsync(5_000);
218
206
  expect(exit).toHaveBeenCalledWith(1);
219
207
  // Each stage is an event: object-first, a snake_case name.
220
- expect(logger.info).toHaveBeenCalledWith({ drainTimeoutMs: 5_000 }, "shutdown_draining");
208
+ expect(logger.info).toHaveBeenCalledWith(
209
+ { drainTimeoutMs: 5_000, cleanupTimeoutMs: 5_000 },
210
+ "shutdown_draining",
211
+ );
221
212
  expect(logger.error).toHaveBeenCalledWith(
222
213
  { drainTimeoutMs: 5_000 },
223
214
  "shutdown_drain_deadline_exceeded",
@@ -385,11 +376,9 @@ describe("installGracefulShutdown (#290)", () => {
385
376
  });
386
377
 
387
378
  it("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
388
- // The guarantees above say cleanup "never wedges the process", but `finish`
389
- // awaited it with no deadline — and the drain deadline is already cleared
390
- // by then, so a dispose that never settles meant `exit` was never reached.
391
- // The same defect was found in auth.proxy#81 and auth.policy-verifier#210,
392
- // 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.
393
382
  vi.useFakeTimers();
394
383
  try {
395
384
  const { signals, finishDraining, exit, logger } = install({
@@ -437,9 +426,9 @@ describe("installGracefulShutdown (#290)", () => {
437
426
  });
438
427
 
439
428
  it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
440
- // `exitCode` became 1 while `reason` still said "drained", so the one line
441
- // an operator alerts on contradicted itself. The drain outcome is still
442
- // 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.
443
432
  const { signals, finishDraining, logger, exit } = install({
444
433
  cleanup: () => Promise.reject(new Error("teardown failed")),
445
434
  });
@@ -473,81 +462,159 @@ describe("installGracefulShutdown (#290)", () => {
473
462
  });
474
463
  });
475
464
 
476
- describe("#593 slice 7: the cleanup allowance federation grants need", () => {
477
- it("is at least the 45 seconds the package asks for, and only when the feature is on", () => {
478
- // The package's drain waits for a rotated credential's write; the
479
- // default cleanup budget is the ten-second drain, which is shorter than
480
- // the upstream hard timeout and persist budget the feature ships with —
481
- // a shutdown under it abandons exactly the write the drain exists for.
482
- expect(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS).toBeGreaterThanOrEqual(45_000);
483
- expect(cleanupAllowanceFor({ federationGrants: { enabled: true } })).toEqual({
484
- cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
485
- });
486
- // Off, nothing: the cleanup budget stays the drain's, as it was.
487
- expect(cleanupAllowanceFor({ federationGrants: { enabled: false } })).toEqual({});
488
- 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
+ }
489
481
  });
490
482
 
491
- it("grows with the configured refresh tail, so a raised budget is not cut off by a fixed timer (Copilot, #614)", () => {
492
- // The longest tail one refresh has: the upstream hard timeout, the
493
- // persist budget and the wait for the lock, back to back, plus an exit
494
- // margin. The shipped budgets (25 s + 3 s + 5 s) land exactly on the
495
- // floor; a deployment that doubles its upstream timeout gets more.
496
- const shipped = {
497
- upstreamHardTimeoutMs: 25_000,
498
- persistRetryBudgetMs: 3_000,
499
- lockWaitMs: 5_000,
500
- };
501
- expect(
502
- 25_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS,
503
- "the margin is what makes the shipped budgets the floor",
504
- ).toBe(FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS);
505
- expect(cleanupAllowanceFor({ federationGrants: { enabled: true, ...shipped } })).toEqual({
506
- cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS,
507
- });
508
- expect(
509
- cleanupAllowanceFor({
510
- federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 60_000 },
511
- }),
512
- ).toEqual({ cleanupTimeoutMs: 60_000 + 3_000 + 5_000 + FEDERATION_GRANTS_CLEANUP_MARGIN_MS });
513
- // Lowered budgets never go below the documented minimum, and a config
514
- // without budgets (built by hand) gets the floor rather than a guess.
515
- expect(
516
- cleanupAllowanceFor({
517
- federationGrants: { enabled: true, ...shipped, upstreamHardTimeoutMs: 1_000 },
518
- }),
519
- ).toEqual({ cleanupTimeoutMs: FEDERATION_GRANTS_CLEANUP_ALLOWANCE_MS });
520
- expect(
521
- cleanupAllowanceFor({ federationGrants: { enabled: true, upstreamHardTimeoutMs: 90_000 } }),
522
- ).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
+ }
523
504
  });
524
505
 
525
- it("never asks a timer for more than Node can count: an oversized sum is capped, not overflowed (Copilot, #614)", () => {
526
- // setTimeout takes a 32-bit signed delay; past it the timer fires after
527
- // about a millisecond, which would turn a generous allowance into none.
528
- const oversized = cleanupAllowanceFor({
529
- federationGrants: {
530
- enabled: true,
531
- upstreamHardTimeoutMs: 2_000_000_000,
532
- persistRetryBudgetMs: 2_000_000_000,
533
- lockWaitMs: 5_000,
534
- },
535
- });
536
- 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
+ }
537
526
  });
538
527
 
539
- 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 () => {
540
529
  vi.useFakeTimers();
541
530
  try {
542
- const { signals, finishDraining, exit } = install({
543
- cleanup: () => new Promise<void>((resolve) => setTimeout(resolve, 30_000)),
531
+ const { signals, finishDraining, logger } = install({
532
+ cleanup: () => new Promise<void>(() => {}),
544
533
  drainTimeoutMs: 5_000,
545
- ...cleanupAllowanceFor({ federationGrants: { enabled: true } }),
534
+ cleanupAllowanceMs: () => undefined,
546
535
  });
547
536
  signals.get("SIGTERM")?.();
548
537
  finishDraining();
549
- await vi.advanceTimersByTimeAsync(31_000);
550
- 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);
551
618
  } finally {
552
619
  vi.useRealTimers();
553
620
  }