@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.
- package/README.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -913
- package/templates/standalone/README.md +0 -1537
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -118
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -424
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -55
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1192
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -346
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1290
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -784
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -461
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -308
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -216
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1512
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -654
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -504
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -215
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -296
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -634
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -148
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -312
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -382
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -136
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -377
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- 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
|
-
});
|