@o3co/create-auth-provider 0.17.0 → 0.18.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,214 +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
- * Sections declared by a module rather than by core (`redis-rate-limiter`,
19
- * `mtls`) survive this template's real resolution chain, end to end.
20
- * Starting from the shipped configuration plus an operator's own layer, the
21
- * overrides are followed through both configuration phases and `createApp`
22
- * to the limiter the module builds. Were `redis-rate-limiter` lost, the
23
- * module's default of 60 requests / 60 s would apply to the deployment
24
- * `docker-compose.production.yml` ships (`ADAPTERS_RATE_LIMITER: redis`)
25
- * whatever the operator wrote.
26
- */
27
-
28
- import { mkdtempSync, writeFileSync } from "node:fs";
29
- import { tmpdir } from "node:os";
30
- import { join } from "node:path";
31
- import { fileURLToPath } from "node:url";
32
- import {
33
- type AppConfig,
34
- createApp,
35
- defineModule,
36
- type RateLimiter,
37
- } from "@o3co/auth-provider-core";
38
- import { redisRateLimiterModule } from "@o3co/auth-provider-redis";
39
- import { afterAll, beforeAll, describe, expect, it } from "vitest";
40
- import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "../configPath.mjs";
41
- import { loggingModule } from "../modules.mjs";
42
-
43
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
44
-
45
- /**
46
- * The secrets `application.conf` substitutes, plus the one
47
- * `docker-compose.production.yml` sets to put the OAuth endpoints behind a
48
- * shared limiter. Test-only values; the entropy floor applies to the two
49
- * secrets, and the `.` characters keep them out of the base64 alphabet so the
50
- * UTF-8 length is what counts.
51
- */
52
- const ENV = {
53
- KEY_STORE_LOCAL_SECRET: "test-secret-rate-limiter-e2e.at-least-32-bytes.ok",
54
- OAUTH_JWT_ISSUER: "https://auth.test",
55
- SESSION_STORE_SECRET: "test-session-secret-rate-limiter-e2e.at-least-32-bytes.ok",
56
- ADAPTERS_RATE_LIMITER: "redis",
57
- MFA_MODE: "off",
58
- };
59
-
60
- /**
61
- * What an operator adds to their own `application.conf` layer: per-endpoint
62
- * budgets for the Redis limiter, and the mTLS posture from
63
- * `@o3co/auth-provider-mtls`. Neither section appears in anything this
64
- * template ships.
65
- */
66
- const OPERATOR_OVERRIDES = `
67
- redis-rate-limiter {
68
- limits {
69
- token { limit = 120, windowSeconds = 60 }
70
- authorize { limit = 30, windowSeconds = 60 }
71
- }
72
- }
73
-
74
- mtls {
75
- enabled = true
76
- mode = "full-pki"
77
- trustedCas = ["-----BEGIN CERTIFICATE-----"]
78
- fullPki {
79
- revocation {
80
- mode = "crl"
81
- onUnavailable = "reject"
82
- allowedHosts = ["crl.example.com"]
83
- }
84
- }
85
- }
86
- `;
87
-
88
- /** The composition's own files: the operator's layer over the shipped production ones. */
89
- function ownFiles(): string[] {
90
- const operator = join(mkdtempSync(join(tmpdir(), "redis-rate-limiter-")), "operator.conf");
91
- writeFileSync(operator, OPERATOR_OVERRIDES);
92
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
93
- return [operator, envConfPath, applicationConfPath];
94
- }
95
-
96
- /** Counts one request per key, the way an untouched Redis counter would. */
97
- function makeCountingClient(): { incrementWithTtl: (key: string, ttl: number) => Promise<number> } {
98
- return { incrementWithTtl: async () => 1 };
99
- }
100
-
101
- /** The limiter a consumer of the slot is handed, as a route module would be. */
102
- let consumed: RateLimiter | undefined;
103
- const consumerModule = defineModule({
104
- name: "test:rate-limiter-consumer",
105
- requires: ["rateLimiter"] as const,
106
- contributes: {
107
- grantMiddleware: [
108
- ({ rateLimiter }) => {
109
- consumed = rateLimiter;
110
- return null;
111
- },
112
- ],
113
- },
114
- });
115
-
116
- /** The shipped configuration through both phases, booted with the Redis limiter's module. */
117
- async function bootShipped() {
118
- const own = readOwnLayers(ownFiles(), { env: ENV });
119
- const switches = readSwitches(own);
120
- const modules = [redisRateLimiterModule, consumerModule];
121
- return {
122
- switches,
123
- handle: await createApp({
124
- modules,
125
- bootstrapComponents: {
126
- // The template's own reference too, which its application.conf is layered over.
127
- config: resolveForBoot(own, [...modules, loggingModule], switches),
128
- pathResolver: (s: string) => s,
129
- rateLimiterClient: makeCountingClient() as never,
130
- },
131
- }),
132
- };
133
- }
134
-
135
- let booted: Awaited<ReturnType<typeof bootShipped>>;
136
- let config: AppConfig;
137
- let limiter: RateLimiter;
138
-
139
- beforeAll(async () => {
140
- booted = await bootShipped();
141
- const parsed = booted.handle.components.config;
142
- const built = consumed;
143
- if (parsed === undefined || built === undefined) {
144
- throw new Error("the boot filled no config, or handed no consumer the limiter");
145
- }
146
- config = parsed;
147
- limiter = built;
148
- });
149
-
150
- afterAll(async () => {
151
- await booted?.handle.dispose();
152
- });
153
-
154
- describe("the shipped configuration reaches the Redis rate limiter", () => {
155
- it("selects the Redis adapter the production compose file pins, in phase one", () => {
156
- expect(booted.switches.adapters.rateLimiter).toBe("redis");
157
- });
158
-
159
- it("carries the operator's per-endpoint budgets through boot's parse, merged with the template's", () => {
160
- expect(
161
- (config as unknown as Record<string, { limits?: unknown }>)["redis-rate-limiter"]?.limits,
162
- ).toEqual({
163
- token: { limit: 120, windowSeconds: 60 },
164
- authorize: { limit: 30, windowSeconds: 60 },
165
- mfa: { limit: 60, windowSeconds: 300 },
166
- });
167
- });
168
-
169
- it("keeps the MFA routes' budget the template ships beside the operator's", async () => {
170
- const decision = await limiter.check("mfa:ip:203.0.113.5", { ip: "203.0.113.5" });
171
- expect(decision.limit).toBe(60);
172
- });
173
-
174
- it("applies the declared budget at /token", async () => {
175
- const decision = await limiter.check("token:ip:203.0.113.5", { ip: "203.0.113.5" });
176
- expect(decision.limit).toBe(120);
177
- });
178
-
179
- it("applies the declared budget at /authorize", async () => {
180
- const decision = await limiter.check("authorize:ip:203.0.113.5", { ip: "203.0.113.5" });
181
- expect(decision.limit).toBe(30);
182
- });
183
-
184
- it("still falls back to the module's default where nothing is declared", async () => {
185
- // 60 is what every endpoint would get were the section stripped, so the
186
- // budgets above differ from it to tell a working configuration from a
187
- // lost one.
188
- const decision = await limiter.check("introspect:ip:203.0.113.5", { ip: "203.0.113.5" });
189
- expect(decision.limit).toBe(60);
190
- });
191
-
192
- it("applies no login budget: session.rateLimit.login is the session module's own attempt limit, which no limiter applies", async () => {
193
- expect(config.session).toMatchObject({ rateLimit: { login: { limit: 20 } } });
194
- const decision = await limiter.check("login:ip:203.0.113.5", { ip: "203.0.113.5" });
195
- expect(decision.limit).toBe(60);
196
- });
197
- });
198
-
199
- describe("the shipped configuration carries an mTLS posture through", () => {
200
- it("keeps the operator's mTLS block instead of reporting mTLS off", () => {
201
- expect((config as unknown as { mtls?: unknown }).mtls).toEqual({
202
- enabled: true,
203
- mode: "full-pki",
204
- trustedCas: ["-----BEGIN CERTIFICATE-----"],
205
- fullPki: {
206
- revocation: {
207
- mode: "crl",
208
- onUnavailable: "reject",
209
- allowedHosts: ["crl.example.com"],
210
- },
211
- },
212
- });
213
- });
214
- });
@@ -1,98 +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
- * A TCP stand-in for a Redis server, for the tests that boot the template
19
- * against a socket and need no real server: it records what each connection
20
- * sends and answers only what boot needs — the handshake (`HELLO` refused, so
21
- * the client speaks RESP2), `INFO` reporting `maxmemory-policy noeviction`,
22
- * and `+OK` to anything else. It stores nothing and is not a Redis for data:
23
- * a test that reads or writes a store needs a real server.
24
- */
25
-
26
- import { once } from "node:events";
27
- import { type AddressInfo, createServer, type Socket } from "node:net";
28
-
29
- /** The first command `buffer` holds in full, as RESP sends it, and its length; `undefined` until it does. */
30
- function nextCommand(
31
- buffer: string,
32
- ): { readonly name: string; readonly length: number } | undefined {
33
- const header = /^\*(\d+)\r\n/.exec(buffer);
34
- if (header === null) return undefined;
35
- let at = header[0].length;
36
- const args: string[] = [];
37
- for (let i = 0; i < Number(header[1]); i++) {
38
- const bulk = /^\$(\d+)\r\n/.exec(buffer.slice(at));
39
- if (bulk === null) return undefined;
40
- const start = at + bulk[0].length;
41
- const end = start + Number(bulk[1]);
42
- if (buffer.length < end + 2) return undefined;
43
- args.push(buffer.slice(start, end));
44
- at = end + 2;
45
- }
46
- return { name: (args[0] ?? "").toLowerCase(), length: at };
47
- }
48
-
49
- /** What a server that runs `noeviction` and speaks RESP2 answers `name`. */
50
- function replyTo(name: string): string {
51
- if (name === "hello") return "-NOPROTO unsupported protocol version\r\n";
52
- if (name === "info") {
53
- const info =
54
- "# Memory\r\nmaxmemory_policy:noeviction\r\n# Persistence\r\naof_enabled:1\r\nloading:0\r\n";
55
- return `$${Buffer.byteLength(info)}\r\n${info}\r\n`;
56
- }
57
- return "+OK\r\n";
58
- }
59
-
60
- /**
61
- * A TCP server standing in for Redis: it records what each connection sends,
62
- * and answers as a server running `noeviction` does — `HELLO` refused, so the
63
- * client speaks RESP2, `INFO` with the policy, anything else `OK` — so a
64
- * client dialled at it writes its handshake and the stores whose factories
65
- * read the policy build.
66
- */
67
- export async function listeningRedis(): Promise<{
68
- readonly port: number;
69
- readonly received: () => string;
70
- readonly close: () => Promise<void>;
71
- }> {
72
- const sockets = new Set<Socket>();
73
- let received = "";
74
- const server = createServer((socket) => {
75
- sockets.add(socket);
76
- let pending = "";
77
- socket.on("close", () => sockets.delete(socket));
78
- socket.on("data", (chunk) => {
79
- received += chunk.toString("utf8");
80
- pending += chunk.toString("utf8");
81
- for (let command = nextCommand(pending); command; command = nextCommand(pending)) {
82
- pending = pending.slice(command.length);
83
- socket.write(replyTo(command.name));
84
- }
85
- });
86
- });
87
- server.listen(0, "127.0.0.1");
88
- await once(server, "listening");
89
- const { port } = server.address() as AddressInfo;
90
- return {
91
- port,
92
- received: () => received,
93
- close: async () => {
94
- for (const socket of sockets) socket.destroy();
95
- await new Promise<void>((resolve) => server.close(() => resolve()));
96
- },
97
- };
98
- }
@@ -1,249 +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
- import type { LifecycleRegistrar } from "@o3co/auth-provider-core";
17
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
-
19
- // ioredis Redis constructor capture. The module under test imports
20
- // `Redis` from "ioredis"; vi.mock replaces that import so the test never
21
- // opens a real socket. Captured arguments verify that the validated config
22
- // reaches the constructor.
23
- const redisCtorCalls: Array<{ url: string; options: Record<string, unknown> | undefined }> = [];
24
- const quitSpies: Array<ReturnType<typeof vi.fn>> = [];
25
- const onSpies: Array<ReturnType<typeof vi.fn>> = [];
26
-
27
- vi.mock("ioredis", () => {
28
- class MockRedis {
29
- duplicate(): MockRedis {
30
- return new MockRedis("redis://duplicate.local");
31
- }
32
- on = vi.fn();
33
- quit = vi.fn(async () => "OK" as const);
34
- ping = vi.fn(async () => "PONG" as const);
35
- // Minimal command surface — `makeIoredisClients` only invokes these
36
- // during construction in some paths; tests never exercise the wrapped
37
- // client itself, so most stubs are unused.
38
- set = vi.fn();
39
- get = vi.fn();
40
- pttl = vi.fn();
41
- watch = vi.fn();
42
- unwatch = vi.fn();
43
- multi = vi.fn(() => ({
44
- set: vi.fn().mockReturnThis(),
45
- exec: vi.fn(async () => []),
46
- }));
47
-
48
- constructor(url: string, options?: Record<string, unknown>) {
49
- redisCtorCalls.push({ url, options });
50
- onSpies.push(this.on);
51
- quitSpies.push(this.quit);
52
- }
53
- }
54
- return { Redis: MockRedis, default: MockRedis };
55
- });
56
-
57
- // Imported AFTER vi.mock so the mocked ioredis is in scope.
58
- const importModule = async () =>
59
- (await import("../modules.mjs")) as typeof import("../modules.mjs") & {
60
- standaloneRedisClientsModule: import("@o3co/auth-provider-core").Module;
61
- };
62
-
63
- /** The module's own section, `redis-clients`, as boot hands it over. */
64
- const baseSection = { url: "redis://example.com:6379", password: "test-pw" };
65
-
66
- describe("standaloneRedisClientsModule", () => {
67
- beforeEach(() => {
68
- redisCtorCalls.length = 0;
69
- quitSpies.length = 0;
70
- onSpies.length = 0;
71
- });
72
-
73
- afterEach(() => {
74
- vi.restoreAllMocks();
75
- });
76
-
77
- it("passes the operator's Redis URL and password from its section to the ioredis constructor", async () => {
78
- const { standaloneRedisClientsModule } = await importModule();
79
- const provides = (
80
- standaloneRedisClientsModule as unknown as {
81
- provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
82
- }
83
- ).provides;
84
- await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
85
-
86
- expect(redisCtorCalls).toHaveLength(1);
87
- expect(redisCtorCalls[0]?.url).toBe("redis://example.com:6379");
88
- expect(redisCtorCalls[0]?.options?.password).toBe("test-pw");
89
- });
90
-
91
- it("registers io.quit() with lifecycleRegistrar when provided; drain calls quit exactly once", async () => {
92
- const registered: Array<() => Promise<void>> = [];
93
- const lifecycleRegistrar: LifecycleRegistrar = {
94
- register: (cleanup) => {
95
- registered.push(cleanup);
96
- },
97
- };
98
-
99
- const { standaloneRedisClientsModule } = await importModule();
100
- const provides = (
101
- standaloneRedisClientsModule as unknown as {
102
- provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
103
- }
104
- ).provides;
105
- await provides.refreshTokenFamilyClient({ section: { ...baseSection }, lifecycleRegistrar });
106
-
107
- expect(registered).toHaveLength(1);
108
- // Pre-drain: quit not yet called.
109
- expect(quitSpies[0]).not.toHaveBeenCalled();
110
-
111
- // Drain (simulate handle.dispose()).
112
- await registered[0]?.();
113
- expect(quitSpies[0]).toHaveBeenCalledTimes(1);
114
- });
115
-
116
- it("does not crash when lifecycleRegistrar is absent (graceful no-op, no quit registered)", async () => {
117
- const { standaloneRedisClientsModule } = await importModule();
118
- const provides = (
119
- standaloneRedisClientsModule as unknown as {
120
- provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
121
- }
122
- ).provides;
123
-
124
- // No lifecycleRegistrar key in deps — must not throw and must NOT
125
- // register a cleanup (verified by the absence of any quit invocation
126
- // after the factory resolves; the registrar branch is the only place
127
- // `quit()` would be wired up in the no-real-shutdown unit-test path).
128
- await expect(
129
- provides.refreshTokenFamilyClient({ section: { ...baseSection } }),
130
- ).resolves.toBeDefined();
131
- expect(quitSpies[0]).not.toHaveBeenCalled();
132
- });
133
-
134
- it("fails fast on an empty redis-clients.url (no silent localhost fallback)", async () => {
135
- const { standaloneRedisClientsModule } = await importModule();
136
- const provides = (
137
- standaloneRedisClientsModule as unknown as {
138
- provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
139
- }
140
- ).provides;
141
-
142
- // An operator who blanked the URL — must throw instead of silently
143
- // falling back to redis://localhost:6379, which in production would
144
- // leave each replica with refresh-token families the others cannot
145
- // see. A section with no URL at all is refused by boot's parse of it.
146
- await expect(provides.refreshTokenFamilyClient({ section: { url: "" } })).rejects.toThrow(
147
- /redis-clients\.url/,
148
- );
149
- // And no ioredis instance was constructed because we threw before
150
- // `new Redis(...)`.
151
- expect(redisCtorCalls).toHaveLength(0);
152
- });
153
-
154
- it("attaches an error event handler to the ioredis client (prevents unhandled-error crash)", async () => {
155
- const { standaloneRedisClientsModule } = await importModule();
156
- const provides = (
157
- standaloneRedisClientsModule as unknown as {
158
- provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
159
- }
160
- ).provides;
161
- await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
162
-
163
- expect(onSpies[0]).toHaveBeenCalledWith("error", expect.any(Function));
164
- });
165
-
166
- it("bounds every command and every connect attempt", async () => {
167
- // Without these the driver's defaults apply: no command timeout at all,
168
- // a 10s connect timeout, and 20 reconnect attempts before a queued
169
- // command is failed. A partition therefore parks in-flight `/token`
170
- // requests indefinitely instead of erroring, which is what stops the
171
- // fail-closed rate-limit policy from ever shedding load.
172
- const { standaloneRedisClientsModule } = await importModule();
173
- const provides = (
174
- standaloneRedisClientsModule as unknown as {
175
- provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
176
- }
177
- ).provides;
178
- await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
179
-
180
- const options = redisCtorCalls[0]?.options;
181
- expect(options?.commandTimeout).toBe(1_000);
182
- expect(options?.connectTimeout).toBe(5_000);
183
- expect(options?.maxRetriesPerRequest).toBe(3);
184
- // `lazyConnect: false` is the driver default, but it is declared in
185
- // SHARED_REDIS_TIMEOUTS as a load-bearing decision (connect at boot,
186
- // not at first command) — pin it so a future flip has to come here.
187
- expect(options?.lazyConnect).toBe(false);
188
- });
189
-
190
- it("keeps the offline queue ON, explicitly, because one socket serves every purpose", async () => {
191
- // `enableOfflineQueue` is a per-CONNECTION option and every adapter in
192
- // this template draws from the single socket built below, so the
193
- // "off for the rate limiter, on elsewhere" split is not expressible
194
- // here. Asserting the explicit `true` rather than accepting the
195
- // driver default is the point: the value is a decision (see the
196
- // comment in `getOrCreateClients`), and a future change that wants
197
- // `false` has to come through this test.
198
- const { standaloneRedisClientsModule } = await importModule();
199
- const provides = (
200
- standaloneRedisClientsModule as unknown as {
201
- provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
202
- }
203
- ).provides;
204
- await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
205
-
206
- expect(redisCtorCalls[0]?.options?.enableOfflineQueue).toBe(true);
207
- });
208
-
209
- it("requires no configuration, and takes both registrars as optional", async () => {
210
- const { standaloneRedisClientsModule } = await importModule();
211
- const m = standaloneRedisClientsModule as {
212
- requires?: readonly string[];
213
- optional?: readonly string[];
214
- };
215
- expect(m.requires ?? []).not.toContain("config");
216
- expect(m.optional).toContain("lifecycleRegistrar");
217
- expect(m.optional).toContain("readinessRegistrar");
218
- });
219
-
220
- it("registers one readiness probe for the shared connection, not one per slot", async () => {
221
- // Every Redis-backed adapter draws from a single socket, so a probe per
222
- // consumed slot would report the same connection six times.
223
- const probes: Array<{ name: string; check: () => Promise<unknown> }> = [];
224
- const readinessRegistrar = { register: (p: (typeof probes)[number]) => probes.push(p) };
225
-
226
- const { standaloneRedisClientsModule } = await importModule();
227
- const provides = (
228
- standaloneRedisClientsModule as unknown as {
229
- provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
230
- }
231
- ).provides;
232
- const lifecycleRegistrar: LifecycleRegistrar = { register: () => {} };
233
-
234
- await provides.refreshTokenFamilyClient({
235
- section: { ...baseSection },
236
- lifecycleRegistrar,
237
- readinessRegistrar,
238
- });
239
- await provides.rateLimiterClient({
240
- section: { ...baseSection },
241
- lifecycleRegistrar,
242
- readinessRegistrar,
243
- });
244
-
245
- expect(redisCtorCalls).toHaveLength(1);
246
- expect(probes.map((p) => p.name)).toEqual(["redis"]);
247
- await expect(probes[0]?.check()).resolves.toBe("PONG");
248
- });
249
- });