@o3co/create-auth-provider 0.17.0 → 0.18.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 (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,622 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
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.
23
- */
24
-
25
- import { createServer, type Server } from "node:http";
26
- import {
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";
36
-
37
- /** A `Server` double whose `close` callback fires only when we say so. */
38
- function makeServer() {
39
- let closeCallback: ((err?: Error) => void) | undefined;
40
- const server = {
41
- close: vi.fn((cb?: (err?: Error) => void) => {
42
- closeCallback = cb;
43
- return server;
44
- }),
45
- closeIdleConnections: vi.fn(),
46
- closeAllConnections: vi.fn(),
47
- };
48
- return {
49
- server: server as unknown as Server,
50
- spies: server,
51
- /** Simulate the last in-flight request finishing. */
52
- finishDraining: () => closeCallback?.(),
53
- /** Simulate `close` reporting a failure through its callback. */
54
- failClose: (err: Error) => closeCallback?.(err),
55
- get drained() {
56
- return closeCallback !== undefined;
57
- },
58
- };
59
- }
60
-
61
- /**
62
- * A `Logger`-shaped spy. Typed rather than cast: `as never` would hide a real
63
- * mismatch the day the port gains a method.
64
- */
65
- const makeLogger = () => {
66
- const spy = {
67
- trace: vi.fn(),
68
- debug: vi.fn(),
69
- info: vi.fn(),
70
- warn: vi.fn(),
71
- error: vi.fn(),
72
- fatal: vi.fn(),
73
- // `child` returns a Logger; returning this one keeps a child's calls
74
- // visible on the same spies, which is what a test wants.
75
- child: vi.fn(() => spy as unknown as Logger),
76
- };
77
- return spy satisfies Logger;
78
- };
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
-
116
- /** Drive one shutdown without touching the real `process` or exiting. */
117
- function install(
118
- opts: {
119
- cleanup?: () => void | Promise<void>;
120
- drainTimeoutMs?: number;
121
- cleanupTimeoutMs?: number;
122
- cleanupAllowanceMs?: () => number | undefined;
123
- logger?: ReturnType<typeof makeLogger>;
124
- } = {},
125
- ) {
126
- const { server, spies, finishDraining, failClose } = makeServer();
127
- const logger = opts.logger ?? makeLogger();
128
- const exit = vi.fn();
129
- const signals = new Map<string, () => void>();
130
-
131
- installGracefulShutdown(server, {
132
- logger,
133
- cleanup: opts.cleanup ?? (() => {}),
134
- ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
135
- ...(opts.cleanupTimeoutMs === undefined ? {} : { cleanupTimeoutMs: opts.cleanupTimeoutMs }),
136
- ...(opts.cleanupAllowanceMs === undefined
137
- ? {}
138
- : { cleanupAllowanceMs: opts.cleanupAllowanceMs }),
139
- exit,
140
- onSignal: (name, handler) => signals.set(name, handler),
141
- offSignal: (name) => signals.delete(name),
142
- });
143
-
144
- return { spies, logger, exit, signals, finishDraining, failClose };
145
- }
146
-
147
- describe("installGracefulShutdown", () => {
148
- it("listens for both SIGTERM and SIGINT", async () => {
149
- const { signals } = install();
150
- expect([...signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]);
151
- });
152
-
153
- it("stops accepting connections and releases idle keep-alive sockets", async () => {
154
- const { spies, signals } = install();
155
- signals.get("SIGTERM")?.();
156
- expect(spies.close).toHaveBeenCalled();
157
- // Idle keep-alive sockets hold the server open with no request behind
158
- // them; releasing them is what lets a quiet server exit promptly.
159
- expect(spies.closeIdleConnections).toHaveBeenCalled();
160
- });
161
-
162
- it("runs cleanup once draining completes, then exits zero", async () => {
163
- const cleanup = vi.fn();
164
- const { exit, signals, finishDraining } = install({ cleanup });
165
- signals.get("SIGTERM")?.();
166
- expect(cleanup).not.toHaveBeenCalled();
167
- finishDraining();
168
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
169
- expect(cleanup).toHaveBeenCalledTimes(1);
170
- });
171
-
172
- it("ignores a second signal instead of running cleanup twice", async () => {
173
- // An operator pressing ^C twice, or a SIGINT arriving after SIGTERM,
174
- // must not start a second dispose over the first one's stores.
175
- const cleanup = vi.fn();
176
- const { signals, finishDraining, exit } = install({ cleanup });
177
- signals.get("SIGTERM")?.();
178
- signals.get("SIGINT")?.();
179
- finishDraining();
180
- await vi.waitFor(() => expect(exit).toHaveBeenCalled());
181
- expect(cleanup).toHaveBeenCalledTimes(1);
182
- });
183
-
184
- it("forces the remaining connections closed when draining outruns the deadline", async () => {
185
- vi.useFakeTimers();
186
- try {
187
- const { spies, signals, exit } = install({ drainTimeoutMs: 5_000 });
188
- signals.get("SIGTERM")?.();
189
- expect(spies.closeAllConnections).not.toHaveBeenCalled();
190
- await vi.advanceTimersByTimeAsync(5_000);
191
- expect(spies.closeAllConnections).toHaveBeenCalled();
192
- expect(exit).toHaveBeenCalled();
193
- } finally {
194
- vi.useRealTimers();
195
- }
196
- });
197
-
198
- it("exits non-zero on a forced close, so the drain outcome is visible", async () => {
199
- // An orchestrator that only ever sees exit 0 cannot tell a clean drain
200
- // from one that ran out of time and cut requests off.
201
- vi.useFakeTimers();
202
- try {
203
- const { signals, exit, logger } = install({ drainTimeoutMs: 5_000 });
204
- signals.get("SIGTERM")?.();
205
- await vi.advanceTimersByTimeAsync(5_000);
206
- expect(exit).toHaveBeenCalledWith(1);
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
- );
216
- } finally {
217
- vi.useRealTimers();
218
- }
219
- });
220
-
221
- it("does not force-close a drain that finished in time", async () => {
222
- vi.useFakeTimers();
223
- try {
224
- const { spies, signals, finishDraining, exit } = install({ drainTimeoutMs: 5_000 });
225
- signals.get("SIGTERM")?.();
226
- finishDraining();
227
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
228
- await vi.advanceTimersByTimeAsync(10_000);
229
- expect(spies.closeAllConnections).not.toHaveBeenCalled();
230
- expect(exit).toHaveBeenCalledTimes(1);
231
- } finally {
232
- vi.useRealTimers();
233
- }
234
- });
235
-
236
- it("reports a cleanup failure through the app logger, not console, as loggableError's projection", async () => {
237
- // Every other line this service emits is NDJSON through pino; a bare
238
- // console.error on the shutdown path is the one a log pipeline drops.
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"] },
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(),
289
- });
290
- signals.get("SIGTERM")?.();
291
- finishDraining();
292
- await vi.waitFor(() => expect(exit).toHaveBeenCalled());
293
- expect(logger.error).toHaveBeenCalledWith(
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",
316
- );
317
- for (const line of lines) {
318
- expect(line).not.toContain("S3CRET");
319
- expect(line).not.toContain("refresh_token");
320
- }
321
- });
322
-
323
- it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
324
- const { exit, signals, finishDraining } = install({
325
- cleanup: async () => {
326
- throw new Error("dispose failed");
327
- },
328
- });
329
- signals.get("SIGTERM")?.();
330
- finishDraining();
331
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
332
- });
333
-
334
- it("does not report a failed close as a clean drain, and logs the failure as loggableError's projection", async () => {
335
- // `server.close` reports through its callback -- "Server is not running"
336
- // is the common one, but any listener teardown failure lands there.
337
- // Exiting 0 on it would tell an orchestrator the listener came down
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
- });
350
- signals.get("SIGTERM")?.();
351
- await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
352
- expect(logger.error).toHaveBeenCalledWith(
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",
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]");
365
- });
366
-
367
- it("still runs cleanup when close reports a failure", async () => {
368
- // The listener failing to come down is no reason to leak the Redis
369
- // connections behind it.
370
- const cleanup = vi.fn();
371
- const { exit, signals, failClose } = install({ cleanup });
372
- signals.get("SIGTERM")?.();
373
- failClose(new Error("teardown failed"));
374
- await vi.waitFor(() => expect(exit).toHaveBeenCalled());
375
- expect(cleanup).toHaveBeenCalledTimes(1);
376
- });
377
-
378
- it("bounds cleanup so a hanging dispose cannot wedge the process", async () => {
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.
382
- vi.useFakeTimers();
383
- try {
384
- const { signals, finishDraining, exit, logger } = install({
385
- cleanup: () => new Promise<void>(() => {}),
386
- drainTimeoutMs: 5_000,
387
- });
388
- signals.get("SIGTERM")?.();
389
- finishDraining();
390
- await vi.advanceTimersByTimeAsync(5_000);
391
- expect(logger.error).toHaveBeenCalledWith(
392
- { cleanupTimeoutMs: 5_000 },
393
- "shutdown_cleanup_timed_out",
394
- );
395
- expect(exit).toHaveBeenCalledWith(1);
396
- } finally {
397
- vi.useRealTimers();
398
- }
399
- });
400
-
401
- it("does not penalise a cleanup that finishes inside its budget", async () => {
402
- vi.useFakeTimers();
403
- try {
404
- const { signals, finishDraining, exit } = install({
405
- cleanup: () => Promise.resolve(),
406
- drainTimeoutMs: 5_000,
407
- });
408
- signals.get("SIGTERM")?.();
409
- finishDraining();
410
- await vi.advanceTimersByTimeAsync(10_000);
411
- expect(exit).toHaveBeenCalledExactlyOnceWith(0);
412
- } finally {
413
- vi.useRealTimers();
414
- }
415
- });
416
-
417
- it("defers the real exit a turn so a buffered log destination can flush", async () => {
418
- // The scaffolded app logs NDJSON through pino, whose default destination is
419
- // not synchronous: exiting in the same tick as the last `logger.error` can
420
- // drop exactly the `cleanup failed` line an operator would look for.
421
- const exitProcess = vi.fn();
422
- deferExit(3, exitProcess);
423
- expect(exitProcess).not.toHaveBeenCalled();
424
- await new Promise((resolve) => setImmediate(resolve));
425
- expect(exitProcess).toHaveBeenCalledWith(3);
426
- });
427
-
428
- it("reports the cleanup outcome as the reason, not the drain that preceded it", async () => {
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.
432
- const { signals, finishDraining, logger, exit } = install({
433
- cleanup: () => Promise.reject(new Error("teardown failed")),
434
- });
435
- signals.get("SIGTERM")?.();
436
- finishDraining();
437
- await new Promise((resolve) => setImmediate(resolve));
438
- expect(logger.info).toHaveBeenCalledWith(
439
- { reason: "cleanup-failed", drain: "drained", exitCode: 1 },
440
- "shutdown_complete",
441
- );
442
- expect(exit).toHaveBeenCalledWith(1);
443
- });
444
-
445
- it("keeps reason and drain identical when cleanup succeeds", async () => {
446
- const { signals, finishDraining, logger } = install({ cleanup: () => Promise.resolve() });
447
- signals.get("SIGTERM")?.();
448
- finishDraining();
449
- await new Promise((resolve) => setImmediate(resolve));
450
- expect(logger.info).toHaveBeenCalledWith(
451
- { reason: "drained", drain: "drained", exitCode: 0 },
452
- "shutdown_complete",
453
- );
454
- });
455
-
456
- it("removes its own signal listeners once shutting down", async () => {
457
- // Otherwise a repeated signal keeps re-entering a handler that has
458
- // already handed the process over to `close`.
459
- const { signals } = install();
460
- signals.get("SIGTERM")?.();
461
- expect(signals.size).toBe(0);
462
- });
463
- });
464
-
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
- }
481
- });
482
-
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
- }
504
- });
505
-
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
- }
526
- });
527
-
528
- it("leaves cleanup its own budget when the handle reports no allowance", async () => {
529
- vi.useFakeTimers();
530
- try {
531
- const { signals, finishDraining, logger } = install({
532
- cleanup: () => new Promise<void>(() => {}),
533
- drainTimeoutMs: 5_000,
534
- cleanupAllowanceMs: () => undefined,
535
- });
536
- signals.get("SIGTERM")?.();
537
- finishDraining();
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);
618
- } finally {
619
- vi.useRealTimers();
620
- }
621
- });
622
- });