@o3co/create-auth-provider 0.16.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 (90) 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 -26
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +293 -162
  14. package/templates/standalone/README.md +533 -199
  15. package/templates/standalone/config/application.conf +196 -341
  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 +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
  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 +656 -269
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  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 +4 -9
  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 +7 -11
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  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 +153 -142
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  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 +163 -96
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
  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 +64 -79
  73. package/templates/standalone/src/buildModules.mts +212 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +12 -18
  76. package/templates/standalone/src/logger.mts +31 -60
  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 +458 -476
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +8 -14
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +45 -123
  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
@@ -0,0 +1,299 @@
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 template's posture on session admission. What it expects is its
19
+ * configuration's `core.sessionRequirements.expected`
20
+ * — `[]` in the shipped `config/application.conf` — with `mfa` added when the
21
+ * template's MFA switch, `mfaMode` (`MFA_MODE`), is not `off`
22
+ * (`expectedSessionRequirements`); under that switch the template installs
23
+ * the MFA module, whose requirement registers `mfa`. And what it hands boot
24
+ * of the MFA module's section, `mfa`: nothing unless a loaded module owns it,
25
+ * its mode written from the switch when the template installs MFA, and a
26
+ * mode the configuration writes that the switch does not say refused.
27
+ */
28
+
29
+ import { mkdtempSync, writeFileSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { join } from "node:path";
32
+ import { BootError, type Module } from "@o3co/auth-provider-core";
33
+ import { afterEach, describe, expect, it } from "vitest";
34
+ import { buildModules } from "../buildModules.mjs";
35
+ import {
36
+ expectedSessionRequirements,
37
+ readOwnLayers,
38
+ readSwitches,
39
+ resolveForBoot,
40
+ resolveLayers,
41
+ type Switches,
42
+ } from "../configPath.mjs";
43
+ import {
44
+ type Composition,
45
+ compose,
46
+ ownFiles,
47
+ SINGLE_ENV,
48
+ } from "./all-modules-composition.fixture.mjs";
49
+
50
+ let current: Composition | undefined;
51
+
52
+ afterEach(async () => {
53
+ await current?.handle.dispose();
54
+ current = undefined;
55
+ });
56
+
57
+ /** The boot's refusal, or `undefined` when it booted (kept for disposal). */
58
+ const refusal = (composing: Promise<Composition>): Promise<unknown> =>
59
+ composing.then(
60
+ (composition) => {
61
+ current = composition;
62
+ return undefined;
63
+ },
64
+ (caught: unknown) => caught,
65
+ );
66
+
67
+ /** What MFA on needs in production beside the switch: a key, and a mail relay. */
68
+ const MFA_ENV: Readonly<Record<string, string>> = {
69
+ MFA_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString("base64"),
70
+ STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.auth.test",
71
+ STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
72
+ };
73
+
74
+ describe("what the template expects of session admission", () => {
75
+ it("is [] in the shipped configuration under MFA_MODE=off, and the composition boots with it", async () => {
76
+ const own = readOwnLayers(ownFiles(), { env: SINGLE_ENV });
77
+ expect(resolveLayers(own, []).core).toMatchObject({ sessionRequirements: { expected: [] } });
78
+ expect(readSwitches(own).mfaMode).toBe("off");
79
+ current = await compose();
80
+ expect(current.config).not.toHaveProperty("mfa");
81
+ expect(current.config.core?.sessionRequirements).toEqual({ expected: [] });
82
+ expect([...(current.handle.components.sessionRequirementResolver?.entries() ?? [])]).toEqual(
83
+ [],
84
+ );
85
+ });
86
+
87
+ it.each(["optional", "required"] as const)(
88
+ "adds mfa under MFA_MODE=%s, which the MFA module the template installs registers",
89
+ async (mode) => {
90
+ current = await compose({
91
+ env: { ...SINGLE_ENV, ...MFA_ENV, MFA_MODE: mode },
92
+ environment: "test",
93
+ });
94
+ expect(current.config.core?.sessionRequirements).toEqual({
95
+ expected: ["mfa"],
96
+ secondFactorAuthority: "mfa",
97
+ });
98
+ expect(
99
+ [...(current.handle.components.sessionRequirementResolver?.entries() ?? [])].map(
100
+ ([name]) => name,
101
+ ),
102
+ ).toEqual(["mfa"]);
103
+ },
104
+ );
105
+
106
+ it("keeps an operator's own list and adds mfa beside it, overwriting nothing", async () => {
107
+ const err = await refusal(
108
+ compose({
109
+ env: { ...SINGLE_ENV, ...MFA_ENV, MFA_MODE: "required" },
110
+ environment: "test",
111
+ operatorHocon: 'core.sessionRequirements.expected = ["risk"]\n',
112
+ }),
113
+ );
114
+ expect(err).toBeInstanceOf(BootError);
115
+ expect((err as BootError).reason).toBe("session-requirement-missing");
116
+ expect((err as BootError).details).toMatchObject({
117
+ missing: ["risk"],
118
+ declared: ["risk", "mfa"],
119
+ });
120
+ });
121
+
122
+ it("refuses the boot when the configuration expects mfa under the switch off: nothing registers it", async () => {
123
+ const err = await refusal(
124
+ compose({ operatorHocon: 'core.sessionRequirements.expected = ["mfa"]\n' }),
125
+ );
126
+ expect(err).toBeInstanceOf(BootError);
127
+ expect((err as BootError).reason).toBe("session-requirement-missing");
128
+ expect((err as BootError).details).toMatchObject({
129
+ configKey: "core.sessionRequirements.expected",
130
+ missing: ["mfa"],
131
+ });
132
+ });
133
+ });
134
+
135
+ describe("what the template hands boot of the mfa section", () => {
136
+ const ownUnder = (env: Readonly<Record<string, string>>, hocon?: string) =>
137
+ readOwnLayers(hocon === undefined ? ownFiles() : [operatorLayer(hocon), ...ownFiles()], {
138
+ env,
139
+ });
140
+ /** A module whose section is `mfa`: resolveForBoot reads its name and section alone. */
141
+ const reader = { name: "mfa", section: {} } as unknown as Module;
142
+ const mfaOf = (resolved: unknown) => (resolved as { mfa?: unknown }).mfa;
143
+
144
+ it("hands none under the switch off unless a loaded module owns the section, whatever the configuration writes there", () => {
145
+ const own = ownUnder(SINGLE_ENV, 'mfa.page.url = "/my-mfa"\nmfa.mdoe = "required"\n');
146
+ const switches = readSwitches(own);
147
+ expect(resolveForBoot(own, [], switches)).not.toHaveProperty("mfa");
148
+ // A module the deployment adds that owns it: handed as written.
149
+ expect(mfaOf(resolveForBoot(own, [reader], switches))).toEqual({
150
+ page: { url: "/my-mfa" },
151
+ mdoe: "required",
152
+ });
153
+ });
154
+
155
+ it("hands it, owned by the MFA module the switch installs, with the mode written from the switch over the MFA package's default", () => {
156
+ for (const mode of ["optional", "required"] as const) {
157
+ // The switch written in a layer, MFA_MODE unset: the package's reference says off.
158
+ const { MFA_MODE: _unset, ...env } = SINGLE_ENV;
159
+ const own = ownUnder(env, `mfaMode = "${mode}"\n`);
160
+ const switches = readSwitches(own);
161
+ const modules = buildModules(switches, { environment: "test" });
162
+ expect(mfaOf(resolveForBoot(own, modules, switches)), mode).toMatchObject({ mode });
163
+ }
164
+ });
165
+
166
+ it.each([
167
+ ["the switch off", {}, 'mfa.mode = "required"\n'],
168
+ ["the switch off, the mode optional", {}, 'mfa.mode = "optional"\n'],
169
+ ["MFA_MODE=optional", { MFA_MODE: "optional" }, 'mfa.mode = "required"\n'],
170
+ ["MFA_MODE=required", { MFA_MODE: "required" }, 'mfa.mode = "optional"\n'],
171
+ ])(
172
+ "refuses, under %s, an mfa.mode the configuration writes that the switch does not say, naming both keys and quoting nothing",
173
+ (_label, env, hocon) => {
174
+ const own = ownUnder({ ...SINGLE_ENV, ...env }, hocon);
175
+ const switches = readSwitches(own);
176
+ let err: unknown;
177
+ try {
178
+ resolveForBoot(own, [reader], switches);
179
+ } catch (caught) {
180
+ err = caught;
181
+ }
182
+ expect(err).toBeInstanceOf(RangeError);
183
+ const message = (err as RangeError).message;
184
+ expect(message).toContain("mfa.mode");
185
+ expect(message).toContain("mfaMode");
186
+ expect(message).not.toMatch(/"(?:off|optional|required)"/);
187
+ },
188
+ );
189
+
190
+ it.each([
191
+ ["the switch off", {}],
192
+ ["MFA_MODE=required", { MFA_MODE: "required" }],
193
+ ])(
194
+ "refuses, under %s, an mfa written as a value, naming mfa itself and quoting nothing",
195
+ (_label, env) => {
196
+ const own = ownUnder({ ...SINGLE_ENV, ...env }, 'mfa = "required"\n');
197
+ let err: unknown;
198
+ try {
199
+ resolveForBoot(own, [reader], readSwitches(own));
200
+ } catch (caught) {
201
+ err = caught;
202
+ }
203
+ expect(err).toBeInstanceOf(RangeError);
204
+ const message = (err as RangeError).message;
205
+ expect(message).toMatch(/^mfa is written as a value/);
206
+ expect(message).not.toContain("remove mfa.mode");
207
+ expect(message).toContain("mfaMode");
208
+ expect(message).not.toMatch(/"(?:off|optional|required)"/);
209
+ },
210
+ );
211
+
212
+ it("accepts an mfa.mode the configuration writes that the switch says", () => {
213
+ for (const [env, hocon] of [
214
+ [{}, 'mfa.mode = "off"\n'],
215
+ [{ MFA_MODE: "required" }, 'mfa.mode = "required"\n'],
216
+ ] as const) {
217
+ const own = ownUnder({ ...SINGLE_ENV, ...env }, hocon);
218
+ expect(() => resolveForBoot(own, [reader], readSwitches(own)), hocon).not.toThrow();
219
+ }
220
+ });
221
+
222
+ it("is refused through the composition as through phase two: before boot, under the switch off", async () => {
223
+ const err = await refusal(compose({ operatorHocon: 'mfa.mode = "required"\n' }));
224
+ expect(err).toBeInstanceOf(RangeError);
225
+ expect(err).not.toBeInstanceOf(BootError);
226
+ expect((err as RangeError).message).toContain("mfaMode");
227
+ });
228
+ });
229
+
230
+ describe("expectedSessionRequirements", () => {
231
+ const of = (mfa: string, expected?: readonly string[], authority?: string) =>
232
+ expectedSessionRequirements({
233
+ mfaMode: mfa,
234
+ ...(expected === undefined && authority === undefined
235
+ ? {}
236
+ : {
237
+ core: {
238
+ sessionRequirements: {
239
+ ...(expected === undefined ? {} : { expected }),
240
+ ...(authority === undefined ? {} : { secondFactorAuthority: authority }),
241
+ },
242
+ },
243
+ }),
244
+ } as unknown as Switches);
245
+ const AUTHORITY = { secondFactorAuthority: "mfa" } as const;
246
+
247
+ it("is the configuration's list, with mfa added once and named the second-factor authority when the switch is not off", () => {
248
+ expect(of("off", ["risk"])).toEqual({ expected: ["risk"] });
249
+ expect(of("optional", ["risk"])).toEqual({ expected: ["risk", "mfa"], ...AUTHORITY });
250
+ expect(of("required", ["mfa"])).toEqual({ expected: ["mfa"], ...AUTHORITY });
251
+ expect(of("required")).toEqual({ expected: ["mfa"], ...AUTHORITY });
252
+ });
253
+
254
+ it("keeps the configuration's list as written, repeats included, and adds mfa only when it is absent", () => {
255
+ expect(of("required", ["risk", "risk"])).toEqual({
256
+ expected: ["risk", "risk", "mfa"],
257
+ ...AUTHORITY,
258
+ });
259
+ expect(of("required", ["mfa", "risk", "mfa"])).toEqual({
260
+ expected: ["mfa", "risk", "mfa"],
261
+ ...AUTHORITY,
262
+ });
263
+ expect(of("off", ["risk", "risk"])).toEqual({ expected: ["risk", "risk"] });
264
+ });
265
+
266
+ it("accepts a written second-factor authority the switch says, and keeps one written under the switch off", () => {
267
+ expect(of("required", ["mfa"], "mfa")).toEqual({ expected: ["mfa"], ...AUTHORITY });
268
+ expect(of("off", ["risk"], "risk")).toEqual({
269
+ expected: ["risk"],
270
+ secondFactorAuthority: "risk",
271
+ });
272
+ });
273
+
274
+ it("refuses, with the switch on, a written second-factor authority other than mfa, naming the key and the switch and quoting nothing", () => {
275
+ let err: unknown;
276
+ try {
277
+ of("required", ["mfa", "sentinel-requirement"], "sentinel-requirement");
278
+ } catch (caught) {
279
+ err = caught;
280
+ }
281
+ expect(err).toBeInstanceOf(RangeError);
282
+ const message = (err as RangeError).message;
283
+ expect(message).toContain("core.sessionRequirements.secondFactorAuthority");
284
+ expect(message).toContain("mfaMode");
285
+ expect(message).toContain("MFA_MODE");
286
+ expect(message).not.toContain("sentinel-requirement");
287
+ });
288
+
289
+ it("declares nothing when the configuration writes no list and the switch is off, so boot's own rule for an unwritten key applies", () => {
290
+ expect(of("off")).toBeUndefined();
291
+ });
292
+ });
293
+
294
+ /** `text` in a file of its own, a layer above the composition's files. */
295
+ function operatorLayer(text: string): string {
296
+ const file = join(mkdtempSync(join(tmpdir(), "session-requirements-")), "operator.conf");
297
+ writeFileSync(file, text);
298
+ return file;
299
+ }
@@ -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
  }