@o3co/create-auth-provider 0.9.0 → 0.11.0

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 (37) hide show
  1. package/dist/index.d.mts +20 -0
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +110 -7
  4. package/package.json +4 -4
  5. package/templates/standalone/.env.example +49 -5
  6. package/templates/standalone/Dockerfile +59 -16
  7. package/templates/standalone/README.ja.md +92 -13
  8. package/templates/standalone/README.md +358 -18
  9. package/templates/standalone/config/application.conf +132 -4
  10. package/templates/standalone/config/clients.yaml.example +56 -2
  11. package/templates/standalone/config/production.conf +9 -3
  12. package/templates/standalone/docker-compose.production.yml +132 -0
  13. package/templates/standalone/docker-compose.yml +7 -0
  14. package/templates/standalone/gitignore +21 -0
  15. package/templates/standalone/package.json +15 -20
  16. package/templates/standalone/src/__tests__/audit-sink.test.mts +343 -0
  17. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +423 -0
  18. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +114 -0
  19. package/templates/standalone/src/__tests__/metrics.test.mts +231 -0
  20. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +76 -1
  21. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +117 -0
  22. package/templates/standalone/src/__tests__/shutdown.test.mts +244 -0
  23. package/templates/standalone/src/__tests__/smoke.test.mts +213 -11
  24. package/templates/standalone/src/__tests__/terminal-error.test.mts +96 -0
  25. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +117 -7
  26. package/templates/standalone/src/app.mts +78 -10
  27. package/templates/standalone/src/buildModules.mts +43 -1
  28. package/templates/standalone/src/configPath.mts +2 -2
  29. package/templates/standalone/src/logger.mts +90 -2
  30. package/templates/standalone/src/metrics.mts +190 -0
  31. package/templates/standalone/src/modules.mts +319 -33
  32. package/templates/standalone/src/shutdown.mts +162 -0
  33. package/templates/standalone/src/terminalError.mts +60 -0
  34. package/templates/standalone/tests/index.test.js +69 -5
  35. package/templates/standalone/tsconfig.json +9 -1
  36. package/templates/standalone/vitest.config.mts +8 -0
  37. package/templates/versions.json +12 -11
@@ -0,0 +1,231 @@
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
+ import { request as httpRequest } from "node:http";
18
+ import express from "express";
19
+ import request from "supertest";
20
+ import { describe, expect, it, vi } from "vitest";
21
+ import { createMetrics } from "../metrics.mjs";
22
+
23
+ function buildApp(probes: Parameters<ReturnType<typeof createMetrics>["route"]>[1]["probes"] = []) {
24
+ const metrics = createMetrics();
25
+ const app = express();
26
+ app.use(metrics.middleware);
27
+ app.use(metrics.route(express, { probes, probeTimeoutMs: 100 }));
28
+
29
+ const api = express.Router();
30
+ api.get("/widgets/:id", (_req, res) => {
31
+ res.status(200).json({ ok: true });
32
+ });
33
+ api.get("/boom", (_req, res) => {
34
+ res.status(500).json({ ok: false });
35
+ });
36
+ app.use("/api", api);
37
+ return app;
38
+ }
39
+
40
+ describe("GET /metrics", () => {
41
+ it("serves the Prometheus text exposition format", async () => {
42
+ const res = await request(buildApp()).get("/metrics");
43
+
44
+ expect(res.status).toBe(200);
45
+ expect(res.headers["content-type"]).toContain("text/plain");
46
+ // Node process defaults are namespaced so they cannot collide with
47
+ // anything else scraped from the same job.
48
+ expect(res.text).toContain("auth_provider_process_cpu_user_seconds_total");
49
+ });
50
+
51
+ it("records latency by method, route and status", async () => {
52
+ const app = buildApp();
53
+ await request(app).get("/api/widgets/42");
54
+ await request(app).get("/api/boom");
55
+
56
+ const res = await request(app).get("/metrics");
57
+
58
+ expect(res.text).toContain('route="/api/widgets/:id"');
59
+ expect(res.text).toContain('status="200"');
60
+ expect(res.text).toContain('status="500"');
61
+ });
62
+
63
+ it("labels by the route pattern, not the URL, so the label set stays bounded", async () => {
64
+ // Labelling by path would mint a series per id and eventually take down
65
+ // the system that is supposed to be watching this one.
66
+ const app = buildApp();
67
+ for (const id of ["1", "2", "3"]) {
68
+ await request(app).get(`/api/widgets/${id}`);
69
+ }
70
+
71
+ const res = await request(app).get("/metrics");
72
+ const series = res.text
73
+ .split("\n")
74
+ .filter((l) => l.startsWith("http_request_duration_seconds_count"));
75
+
76
+ expect(series.some((l) => l.includes('route="/api/widgets/:id"'))).toBe(true);
77
+ expect(res.text).not.toContain('route="/api/widgets/1"');
78
+ });
79
+
80
+ it("collapses unmatched requests into a single bucket", async () => {
81
+ // 404 probes from the internet carry attacker-chosen paths.
82
+ const app = buildApp();
83
+ await request(app).get(`/nope/${"x".repeat(50)}`);
84
+
85
+ const res = await request(app).get("/metrics");
86
+
87
+ expect(res.text).toContain('route="unmatched"');
88
+ expect(res.text).not.toContain("xxxxx");
89
+ });
90
+
91
+ it("collapses unknown HTTP methods into one label", async () => {
92
+ // req.method is attacker-controlled — Node's HTTP parser accepts any
93
+ // valid token, so `FOO` and `M000001` arrive as readily as `GET`. An
94
+ // unbounded method label is the same cardinality bomb as an unbounded
95
+ // route label, reachable without any access to /metrics. Sent over a real
96
+ // socket because supertest cannot express a non-standard method.
97
+ const app = buildApp();
98
+ const server = app.listen(0);
99
+ const port = (server.address() as { port: number }).port;
100
+ try {
101
+ for (const method of ["FOO", "M000001", "PURGE"]) {
102
+ await new Promise<void>((resolve) => {
103
+ const req = httpRequest(
104
+ { host: "127.0.0.1", port, path: "/api/widgets/1", method },
105
+ (res) => {
106
+ res.resume();
107
+ res.once("end", resolve);
108
+ },
109
+ );
110
+ req.once("error", () => resolve());
111
+ req.end();
112
+ });
113
+ }
114
+
115
+ const res = await request(app).get("/metrics");
116
+
117
+ expect(res.text).toContain('method="other"');
118
+ expect(res.text).not.toContain('method="M000001"');
119
+ expect(res.text).not.toContain('method="FOO"');
120
+ } finally {
121
+ server.close();
122
+ }
123
+ });
124
+
125
+ it("counts a request whose client disconnects before the response finishes", async () => {
126
+ // `close` fires without `finish` when a client or proxy gives up
127
+ // mid-handler, and those are disproportionately the slow and failing
128
+ // requests RED metrics exist to surface.
129
+ const metrics = createMetrics();
130
+ const app = express();
131
+ app.use(metrics.middleware);
132
+ app.use(metrics.route(express, { probes: [], probeTimeoutMs: 100 }));
133
+ app.get("/slow", (_req, res) => {
134
+ // Never responds; the test aborts the socket instead.
135
+ void res;
136
+ });
137
+
138
+ const server = app.listen(0);
139
+ const port = (server.address() as { port: number }).port;
140
+ try {
141
+ const controller = new AbortController();
142
+ const pending = fetch(`http://127.0.0.1:${port}/slow`, {
143
+ signal: controller.signal,
144
+ }).catch(() => undefined);
145
+ await new Promise((resolve) => setTimeout(resolve, 30));
146
+ controller.abort();
147
+ await pending;
148
+ await new Promise((resolve) => setTimeout(resolve, 30));
149
+
150
+ const res = await request(app).get("/metrics");
151
+ const slow = res.text
152
+ .split("\n")
153
+ .filter((l) => l.startsWith("http_request_duration_seconds_count"))
154
+ .filter((l) => l.includes('route="/slow"'));
155
+ expect(slow.length).toBeGreaterThan(0);
156
+ } finally {
157
+ server.close();
158
+ }
159
+ });
160
+
161
+ it("observes a response exactly once even though both finish and close fire", async () => {
162
+ const app = buildApp();
163
+ await request(app).get("/api/widgets/1");
164
+
165
+ const res = await request(app).get("/metrics");
166
+ const line = res.text
167
+ .split("\n")
168
+ .find(
169
+ (l) =>
170
+ l.startsWith("http_request_duration_seconds_count") &&
171
+ l.includes('route="/api/widgets/:id"'),
172
+ );
173
+ expect(line?.trim().endsWith(" 1")).toBe(true);
174
+ });
175
+
176
+ it("publishes a per-dependency gauge from the readiness probes", async () => {
177
+ const app = buildApp([
178
+ { name: "redis", check: async () => "PONG" },
179
+ {
180
+ name: "session-store",
181
+ check: async () => {
182
+ throw new Error("ECONNREFUSED");
183
+ },
184
+ },
185
+ ]);
186
+
187
+ const res = await request(app).get("/metrics");
188
+
189
+ expect(res.text).toContain('auth_dependency_up{dependency="redis"} 1');
190
+ expect(res.text).toContain('auth_dependency_up{dependency="session-store"} 0');
191
+ });
192
+
193
+ it("re-samples dependencies on every scrape instead of caching a verdict", async () => {
194
+ let healthy = true;
195
+ const app = buildApp([
196
+ {
197
+ name: "redis",
198
+ check: async () => {
199
+ if (!healthy) throw new Error("down");
200
+ return "PONG";
201
+ },
202
+ },
203
+ ]);
204
+
205
+ expect((await request(app).get("/metrics")).text).toContain(
206
+ 'auth_dependency_up{dependency="redis"} 1',
207
+ );
208
+ healthy = false;
209
+ expect((await request(app).get("/metrics")).text).toContain(
210
+ 'auth_dependency_up{dependency="redis"} 0',
211
+ );
212
+ });
213
+
214
+ it("joins an in-flight probe rather than issuing one per scrape", async () => {
215
+ // A partitioned backend never answers, and a scrape loop would otherwise
216
+ // stack one command per scrape against the dependency already in trouble.
217
+ const check = vi.fn(() => new Promise(() => {}));
218
+ const app = buildApp([{ name: "redis", check }]);
219
+
220
+ await request(app).get("/metrics");
221
+ await request(app).get("/metrics");
222
+
223
+ expect(check).toHaveBeenCalledTimes(1);
224
+ });
225
+
226
+ it("is never cached", async () => {
227
+ const res = await request(buildApp()).get("/metrics");
228
+
229
+ expect(res.headers["cache-control"]).toBe("no-store");
230
+ });
231
+ });
@@ -31,6 +31,7 @@ vi.mock("ioredis", () => {
31
31
  }
32
32
  on = vi.fn();
33
33
  quit = vi.fn(async () => "OK" as const);
34
+ ping = vi.fn(async () => "PONG" as const);
34
35
  // Minimal command surface — `makeIoredisClients` only invokes these
35
36
  // during construction in some paths; tests never exercise the wrapped
36
37
  // client itself, so most stubs are unused.
@@ -168,7 +169,50 @@ describe("D-2 / standaloneRedisClientsModule", () => {
168
169
  expect(onSpies[0]).toHaveBeenCalledWith("error", expect.any(Function));
169
170
  });
170
171
 
171
- it("declares 'config' as required and 'lifecycleRegistrar' as optional", async () => {
172
+ it("bounds every command and every connect attempt (#286)", async () => {
173
+ // Without these the driver's defaults apply: no command timeout at all,
174
+ // a 10s connect timeout, and 20 reconnect attempts before a queued
175
+ // command is failed. A partition therefore parks in-flight `/token`
176
+ // requests indefinitely instead of erroring, which is what stops the
177
+ // fail-closed rate-limit policy from ever shedding load.
178
+ const { standaloneRedisClientsModule } = await importModule();
179
+ const provides = (
180
+ standaloneRedisClientsModule as unknown as {
181
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
182
+ }
183
+ ).provides;
184
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
185
+
186
+ const options = redisCtorCalls[0]?.options;
187
+ expect(options?.commandTimeout).toBe(1_000);
188
+ expect(options?.connectTimeout).toBe(5_000);
189
+ expect(options?.maxRetriesPerRequest).toBe(3);
190
+ // `lazyConnect: false` is the driver default, but it is declared in
191
+ // SHARED_REDIS_TIMEOUTS as a load-bearing decision (connect at boot,
192
+ // not at first command) — pin it so a future flip has to come here.
193
+ expect(options?.lazyConnect).toBe(false);
194
+ });
195
+
196
+ it("keeps the offline queue ON, explicitly, because one socket serves every purpose (#286)", async () => {
197
+ // `enableOfflineQueue` is a per-CONNECTION option and every adapter in
198
+ // this template draws from the single socket built below, so the
199
+ // "off for the rate limiter, on elsewhere" split is not expressible
200
+ // here. Asserting the explicit `true` rather than accepting the
201
+ // driver default is the point: the value is a decision (see the
202
+ // comment in `getOrCreateClients`), and a future change that wants
203
+ // `false` has to come through this test.
204
+ const { standaloneRedisClientsModule } = await importModule();
205
+ const provides = (
206
+ standaloneRedisClientsModule as unknown as {
207
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
208
+ }
209
+ ).provides;
210
+ await provides.refreshTokenFamilyClient({ config: { ...baseConfig } });
211
+
212
+ expect(redisCtorCalls[0]?.options?.enableOfflineQueue).toBe(true);
213
+ });
214
+
215
+ it("declares 'config' as required and both registrars as optional", async () => {
172
216
  const { standaloneRedisClientsModule } = await importModule();
173
217
  const m = standaloneRedisClientsModule as {
174
218
  requires?: readonly string[];
@@ -176,5 +220,36 @@ describe("D-2 / standaloneRedisClientsModule", () => {
176
220
  };
177
221
  expect(m.requires).toContain("config");
178
222
  expect(m.optional).toContain("lifecycleRegistrar");
223
+ expect(m.optional).toContain("readinessRegistrar");
224
+ });
225
+
226
+ it("registers one readiness probe for the shared connection, not one per slot", async () => {
227
+ // Every Redis-backed adapter draws from a single socket, so a probe per
228
+ // consumed slot would report the same connection six times.
229
+ const probes: Array<{ name: string; check: () => Promise<unknown> }> = [];
230
+ const readinessRegistrar = { register: (p: (typeof probes)[number]) => probes.push(p) };
231
+
232
+ const { standaloneRedisClientsModule } = await importModule();
233
+ const provides = (
234
+ standaloneRedisClientsModule as unknown as {
235
+ provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
236
+ }
237
+ ).provides;
238
+ const lifecycleRegistrar: LifecycleRegistrar = { register: () => {} };
239
+
240
+ await provides.refreshTokenFamilyClient({
241
+ config: { ...baseConfig },
242
+ lifecycleRegistrar,
243
+ readinessRegistrar,
244
+ });
245
+ await provides.rateLimiterClient({
246
+ config: { ...baseConfig },
247
+ lifecycleRegistrar,
248
+ readinessRegistrar,
249
+ });
250
+
251
+ expect(redisCtorCalls).toHaveLength(1);
252
+ expect(probes.map((p) => p.name)).toEqual(["redis"]);
253
+ await expect(probes[0]?.check()).resolves.toBe("PONG");
179
254
  });
180
255
  });
@@ -0,0 +1,117 @@
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
+ * Issue #407 — scaffold hardening from the v0.10.0 release-cut audit.
19
+ *
20
+ * These pin the parts of the scaffold that are checkable from inside the
21
+ * repository. The scaffold is the artifact an operator actually deploys, and
22
+ * every one of these was invisible until someone read the file: the container
23
+ * install losing an allowlist, the dev compose dialling a Redis that is not
24
+ * there, and one `git add .` committing the signing key.
25
+ */
26
+
27
+ import { existsSync, readFileSync } from "node:fs";
28
+ import { fileURLToPath } from "node:url";
29
+ import { describe, expect, it } from "vitest";
30
+
31
+ const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
32
+ const read = (rel: string): string => readFileSync(`${standaloneDir}${rel}`, "utf8");
33
+
34
+ describe("#407 — the Dockerfile installs with everything pnpm needs", () => {
35
+ it("copies pnpm-workspace.yaml into the deps stage", () => {
36
+ // `create-auth-provider` generates it to carry the bcrypt
37
+ // `onlyBuiltDependencies` allowlist, because pnpm >= 10.29 reads that
38
+ // setting ONLY from this file — in single-package projects too (#360).
39
+ // A deps stage that copies `package.json` and the lockfile but not this
40
+ // runs allowlist-less, which re-creates #360 the day an alpine prebuild
41
+ // is missing and bcrypt has to compile.
42
+ const dockerfile = read("/Dockerfile");
43
+ const depsStage = dockerfile.slice(
44
+ dockerfile.indexOf("FROM node-base AS deps"),
45
+ dockerfile.indexOf("FROM", dockerfile.indexOf("FROM node-base AS deps") + 1),
46
+ );
47
+ // Matched as a COPY instruction, not as a substring of the stage: the
48
+ // comment above that COPY names the file too, so a substring check
49
+ // would keep passing if the instruction regressed and the comment
50
+ // stayed — which is the failure this test exists to catch.
51
+ const copyLines = depsStage
52
+ .split("\n")
53
+ .filter((line) => /^COPY\b/.test(line.trim()) && !line.trim().startsWith("#"));
54
+ expect(copyLines.some((line) => /\bpnpm-workspace\.yaml\b/.test(line))).toBe(true);
55
+ });
56
+ });
57
+
58
+ describe("#407 — the dev compose can reach every Redis it configures", () => {
59
+ it("sets every *_REDIS_URL the template config reads", () => {
60
+ // The template's application.conf substitutes several Redis URLs, each
61
+ // defaulting to `redis://localhost:6379` — which inside the container is
62
+ // the container itself. `.env.example` is what the compose file loads,
63
+ // so a URL missing from it is a boot that dials nothing.
64
+ const conf = read("/config/application.conf");
65
+ const declared = [...conf.matchAll(/\$\{\?([A-Z0-9_]*REDIS_URL)\}/g)].map((m) => m[1]);
66
+ expect(declared.length).toBeGreaterThan(0);
67
+
68
+ const env = read("/.env.example");
69
+ const missing = [...new Set(declared)].filter(
70
+ (name) => !new RegExp(`^${name}=`, "m").test(env),
71
+ );
72
+ expect(missing).toEqual([]);
73
+ });
74
+ });
75
+
76
+ describe("#407 — the scaffold does not invite committing its own secrets", () => {
77
+ it("ships a .gitignore", () => {
78
+ // Without one, the first `git add .` in a scaffolded project commits
79
+ // `.env` and `jwt-private.pem` — both of which the README tells the
80
+ // operator to create right there.
81
+ expect(existsSync(`${standaloneDir}/.gitignore`)).toBe(true);
82
+ });
83
+
84
+ it("ignores the files the scaffold's own setup steps create", () => {
85
+ const ignored = read("/.gitignore");
86
+ for (const pattern of [".env", "*.pem", "node_modules", "dist"]) {
87
+ expect(ignored).toContain(pattern);
88
+ }
89
+ });
90
+
91
+ it("keeps .env.example tracked — it is the documentation", () => {
92
+ // A bare `.env*` would take the example with it, which is the mistake
93
+ // this pins against.
94
+ expect(read("/.gitignore")).toMatch(/^!\.env\.example$/m);
95
+ });
96
+ });
97
+
98
+ describe("#407 — the production compose matches the topology it documents", () => {
99
+ it("does not publish the app port on every interface", () => {
100
+ // The README says to keep `/metrics` off the public listener and the
101
+ // file assumes TLS is terminated in front, so a bare "3000:3000"
102
+ // contradicts both — it binds 0.0.0.0 on the host.
103
+ const compose = read("/docker-compose.production.yml");
104
+ expect(compose).not.toMatch(/^\s*-\s*"3000:3000"\s*$/m);
105
+ expect(compose).toMatch(/127\.0\.0\.1:3000:3000/);
106
+ });
107
+ });
108
+
109
+ describe("#407 — the two READMEs agree on security advice", () => {
110
+ it("does not recommend HTTP_TRUST_PROXY=true in the Japanese README", () => {
111
+ // #292 made `trust proxy` a CIDR/hop policy precisely because `true`
112
+ // means "believe the leftmost forwarded entry from whoever opened the
113
+ // connection". The English README warns against it; the Japanese one
114
+ // still recommended it, so the two disagreed on which is safe.
115
+ expect(read("/README.ja.md")).not.toMatch(/HTTP_TRUST_PROXY=true/);
116
+ });
117
+ });
@@ -0,0 +1,244 @@
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
+ * 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.
31
+ */
32
+
33
+ import type { Server } from "node:http";
34
+ import type { Logger } from "@o3co/auth-provider-core";
35
+ import { describe, expect, it, vi } from "vitest";
36
+ import { installGracefulShutdown } from "../shutdown.mjs";
37
+
38
+ /** A `Server` double whose `close` callback fires only when we say so. */
39
+ function makeServer() {
40
+ let closeCallback: ((err?: Error) => void) | undefined;
41
+ const server = {
42
+ close: vi.fn((cb?: (err?: Error) => void) => {
43
+ closeCallback = cb;
44
+ return server;
45
+ }),
46
+ closeIdleConnections: vi.fn(),
47
+ closeAllConnections: vi.fn(),
48
+ };
49
+ return {
50
+ server: server as unknown as Server,
51
+ spies: server,
52
+ /** Simulate the last in-flight request finishing. */
53
+ finishDraining: () => closeCallback?.(),
54
+ /** Simulate `close` reporting a failure through its callback. */
55
+ failClose: (err: Error) => closeCallback?.(err),
56
+ get drained() {
57
+ return closeCallback !== undefined;
58
+ },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * A `Logger`-shaped spy. Typed rather than cast: `as never` would hide a real
64
+ * mismatch the day the port gains a method, which is the whole reason the
65
+ * shutdown path logs through the app logger instead of `console`.
66
+ */
67
+ const makeLogger = () => {
68
+ const spy = {
69
+ trace: vi.fn(),
70
+ debug: vi.fn(),
71
+ info: vi.fn(),
72
+ warn: vi.fn(),
73
+ error: vi.fn(),
74
+ fatal: vi.fn(),
75
+ // `child` returns a Logger; returning this one keeps a child's calls
76
+ // visible on the same spies, which is what a test wants.
77
+ child: vi.fn(() => spy as unknown as Logger),
78
+ };
79
+ return spy satisfies Logger;
80
+ };
81
+
82
+ /** Drive one shutdown without touching the real `process` or exiting. */
83
+ function install(opts: { cleanup?: () => void | Promise<void>; drainTimeoutMs?: number } = {}) {
84
+ const { server, spies, finishDraining, failClose } = makeServer();
85
+ const logger = makeLogger();
86
+ const exit = vi.fn();
87
+ const signals = new Map<string, () => void>();
88
+
89
+ installGracefulShutdown(server, {
90
+ logger,
91
+ cleanup: opts.cleanup ?? (() => {}),
92
+ ...(opts.drainTimeoutMs === undefined ? {} : { drainTimeoutMs: opts.drainTimeoutMs }),
93
+ exit,
94
+ onSignal: (name, handler) => signals.set(name, handler),
95
+ offSignal: (name) => signals.delete(name),
96
+ });
97
+
98
+ return { spies, logger, exit, signals, finishDraining, failClose };
99
+ }
100
+
101
+ describe("installGracefulShutdown (#290)", () => {
102
+ it("listens for both SIGTERM and SIGINT", async () => {
103
+ const { signals } = install();
104
+ expect([...signals.keys()].sort()).toEqual(["SIGINT", "SIGTERM"]);
105
+ });
106
+
107
+ it("stops accepting connections and releases idle keep-alive sockets", async () => {
108
+ const { spies, signals } = install();
109
+ signals.get("SIGTERM")?.();
110
+ expect(spies.close).toHaveBeenCalled();
111
+ // Idle keep-alive sockets hold the server open with no request behind
112
+ // them; releasing them is what lets a quiet server exit promptly.
113
+ expect(spies.closeIdleConnections).toHaveBeenCalled();
114
+ });
115
+
116
+ it("runs cleanup once draining completes, then exits zero", async () => {
117
+ const cleanup = vi.fn();
118
+ const { exit, signals, finishDraining } = install({ cleanup });
119
+ signals.get("SIGTERM")?.();
120
+ expect(cleanup).not.toHaveBeenCalled();
121
+ finishDraining();
122
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
123
+ expect(cleanup).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it("ignores a second signal instead of running cleanup twice", async () => {
127
+ // An operator pressing ^C twice, or a SIGINT arriving after SIGTERM,
128
+ // must not start a second dispose over the first one's stores.
129
+ const cleanup = vi.fn();
130
+ const { signals, finishDraining, exit } = install({ cleanup });
131
+ signals.get("SIGTERM")?.();
132
+ signals.get("SIGINT")?.();
133
+ finishDraining();
134
+ await vi.waitFor(() => expect(exit).toHaveBeenCalled());
135
+ expect(cleanup).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ // The gap the audit was pointing at.
139
+ it("forces the remaining connections closed when draining outruns the deadline", async () => {
140
+ vi.useFakeTimers();
141
+ try {
142
+ const { spies, signals, exit } = install({ drainTimeoutMs: 5_000 });
143
+ signals.get("SIGTERM")?.();
144
+ expect(spies.closeAllConnections).not.toHaveBeenCalled();
145
+ await vi.advanceTimersByTimeAsync(5_000);
146
+ expect(spies.closeAllConnections).toHaveBeenCalled();
147
+ expect(exit).toHaveBeenCalled();
148
+ } finally {
149
+ vi.useRealTimers();
150
+ }
151
+ });
152
+
153
+ it("exits non-zero on a forced close, so the drain outcome is visible", async () => {
154
+ // An orchestrator that only ever sees exit 0 cannot tell a clean drain
155
+ // from one that ran out of time and cut requests off.
156
+ vi.useFakeTimers();
157
+ try {
158
+ const { signals, exit, logger } = install({ drainTimeoutMs: 5_000 });
159
+ signals.get("SIGTERM")?.();
160
+ await vi.advanceTimersByTimeAsync(5_000);
161
+ expect(exit).toHaveBeenCalledWith(1);
162
+ expect(logger.error).toHaveBeenCalled();
163
+ } finally {
164
+ vi.useRealTimers();
165
+ }
166
+ });
167
+
168
+ it("does not force-close a drain that finished in time", async () => {
169
+ vi.useFakeTimers();
170
+ try {
171
+ const { spies, signals, finishDraining, exit } = install({ drainTimeoutMs: 5_000 });
172
+ signals.get("SIGTERM")?.();
173
+ finishDraining();
174
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0));
175
+ await vi.advanceTimersByTimeAsync(10_000);
176
+ expect(spies.closeAllConnections).not.toHaveBeenCalled();
177
+ expect(exit).toHaveBeenCalledTimes(1);
178
+ } finally {
179
+ vi.useRealTimers();
180
+ }
181
+ });
182
+
183
+ it("reports a cleanup failure through the app logger, not console", async () => {
184
+ // Every other line this service emits is NDJSON through pino; a bare
185
+ // console.error on the shutdown path is the one a log pipeline drops.
186
+ const { logger, exit, signals, finishDraining } = install({
187
+ cleanup: () => {
188
+ throw new Error("redis quit failed");
189
+ },
190
+ });
191
+ signals.get("SIGTERM")?.();
192
+ finishDraining();
193
+ await vi.waitFor(() => expect(exit).toHaveBeenCalled());
194
+ expect(logger.error).toHaveBeenCalledWith(
195
+ expect.objectContaining({ err: expect.any(Error) }),
196
+ expect.stringContaining("cleanup"),
197
+ );
198
+ });
199
+
200
+ it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
201
+ const { exit, signals, finishDraining } = install({
202
+ cleanup: async () => {
203
+ throw new Error("dispose failed");
204
+ },
205
+ });
206
+ signals.get("SIGTERM")?.();
207
+ finishDraining();
208
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
209
+ });
210
+
211
+ it("does not report a failed close as a clean drain", async () => {
212
+ // `server.close` reports through its callback -- "Server is not running"
213
+ // is the common one, but any listener teardown failure lands there.
214
+ // Exiting 0 on it would tell an orchestrator the listener came down
215
+ // when it did not.
216
+ const { exit, logger, signals, failClose } = install();
217
+ signals.get("SIGTERM")?.();
218
+ failClose(new Error("Server is not running"));
219
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
220
+ expect(logger.error).toHaveBeenCalledWith(
221
+ expect.objectContaining({ err: expect.any(Error) }),
222
+ expect.stringContaining("close"),
223
+ );
224
+ });
225
+
226
+ it("still runs cleanup when close reports a failure", async () => {
227
+ // The listener failing to come down is no reason to leak the Redis
228
+ // connections behind it.
229
+ const cleanup = vi.fn();
230
+ const { exit, signals, failClose } = install({ cleanup });
231
+ signals.get("SIGTERM")?.();
232
+ failClose(new Error("teardown failed"));
233
+ await vi.waitFor(() => expect(exit).toHaveBeenCalled());
234
+ expect(cleanup).toHaveBeenCalledTimes(1);
235
+ });
236
+
237
+ it("removes its own signal listeners once shutting down", async () => {
238
+ // Otherwise a repeated signal keeps re-entering a handler that has
239
+ // already handed the process over to `close`.
240
+ const { signals } = install();
241
+ signals.get("SIGTERM")?.();
242
+ expect(signals.size).toBe(0);
243
+ });
244
+ });