@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.
- package/README.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -26
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +293 -162
- package/templates/standalone/README.md +533 -199
- package/templates/standalone/config/application.conf +196 -341
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
- package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
- package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +64 -79
- package/templates/standalone/src/buildModules.mts +212 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +458 -476
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- 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
|
-
*
|
|
19
|
-
* guarantees
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
389
|
-
//
|
|
390
|
-
//
|
|
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
|
-
// `
|
|
441
|
-
//
|
|
442
|
-
//
|
|
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("
|
|
477
|
-
it("
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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("
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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("
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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("
|
|
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,
|
|
543
|
-
cleanup: () => new Promise<void>((
|
|
531
|
+
const { signals, finishDraining, logger } = install({
|
|
532
|
+
cleanup: () => new Promise<void>(() => {}),
|
|
544
533
|
drainTimeoutMs: 5_000,
|
|
545
|
-
|
|
534
|
+
cleanupAllowanceMs: () => undefined,
|
|
546
535
|
});
|
|
547
536
|
signals.get("SIGTERM")?.();
|
|
548
537
|
finishDraining();
|
|
549
|
-
await vi.advanceTimersByTimeAsync(
|
|
550
|
-
expect(
|
|
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
|
}
|