@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.
- 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 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- 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 -427
- 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 -56
- 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 -1188
- 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 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- 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 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- 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__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- 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 -217
- 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 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- 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__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- 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 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- 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 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- 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 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- 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 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- 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,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
|
-
});
|