@o3co/create-auth-provider 0.17.0-rc.4 → 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 (110) 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 -913
  19. package/templates/standalone/README.md +0 -1537
  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 -118
  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 -424
  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 -55
  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 -1192
  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 -346
  39. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  40. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  41. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  42. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  43. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  44. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  45. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1290
  46. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -784
  47. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -461
  48. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  49. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  50. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -308
  51. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  52. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  53. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  54. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -216
  55. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  56. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  57. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  58. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  59. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  60. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  61. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  62. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  63. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  64. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1512
  65. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  66. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -214
  67. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  68. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  69. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  70. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -654
  71. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  72. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  73. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  74. package/templates/standalone/src/__tests__/sections.test.mts +0 -504
  75. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -215
  76. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -296
  77. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  78. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  79. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  80. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  81. package/templates/standalone/src/__tests__/smoke.test.mts +0 -634
  82. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -148
  83. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -312
  84. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  85. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  86. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  87. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -382
  88. package/templates/standalone/src/adapters.mts +0 -162
  89. package/templates/standalone/src/app.mts +0 -136
  90. package/templates/standalone/src/bootRefusal.mts +0 -117
  91. package/templates/standalone/src/buildModules.mts +0 -358
  92. package/templates/standalone/src/configPath.mts +0 -487
  93. package/templates/standalone/src/listen.mts +0 -60
  94. package/templates/standalone/src/logger.mts +0 -80
  95. package/templates/standalone/src/metrics.mts +0 -167
  96. package/templates/standalone/src/mfaSwitch.mts +0 -284
  97. package/templates/standalone/src/modules.mts +0 -705
  98. package/templates/standalone/src/rootRenames.mts +0 -95
  99. package/templates/standalone/src/routes.mts +0 -89
  100. package/templates/standalone/src/sections.mts +0 -377
  101. package/templates/standalone/src/shutdown.mts +0 -213
  102. package/templates/standalone/tests/Makefile +0 -5
  103. package/templates/standalone/tests/index.test.js +0 -283
  104. package/templates/standalone/tests/package-lock.json +0 -1617
  105. package/templates/standalone/tests/package.json +0 -16
  106. package/templates/standalone/tests/vitest.config.mjs +0 -26
  107. package/templates/standalone/tsconfig.json +0 -31
  108. package/templates/standalone/vitest.config.mts +0 -31
  109. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  110. 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
- });