@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -26
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +293 -162
- package/templates/standalone/README.md +533 -199
- package/templates/standalone/config/application.conf +196 -341
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
- package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
- package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +64 -79
- package/templates/standalone/src/buildModules.mts +212 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +458 -476
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
|
@@ -22,16 +22,11 @@
|
|
|
22
22
|
*
|
|
23
23
|
* Google publishes no end-session endpoint, and without one configured its
|
|
24
24
|
* adapter sends the browser straight to the `postLogoutRedirectUri` it is
|
|
25
|
-
* handed
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* instead.
|
|
31
|
-
*
|
|
32
|
-
* The redirect asserted here is the one a browser would follow: the real
|
|
33
|
-
* Google adapter, composed as a deployment composes it
|
|
34
|
-
* (`google-session.fixture.mts`).
|
|
25
|
+
* handed: unchecked, a caller's own fresh id_token would make this provider's
|
|
26
|
+
* origin answer `303` to any site. With an end-session endpoint configured,
|
|
27
|
+
* an unchecked value would be forwarded to the upstream instead. The real
|
|
28
|
+
* Google adapter is composed (`google-session.fixture.mts`), so the redirect
|
|
29
|
+
* asserted is the one a browser would follow.
|
|
35
30
|
*/
|
|
36
31
|
|
|
37
32
|
import type express from "express";
|
|
@@ -73,6 +68,7 @@ describe("a logout's post_logout_redirect_uri reaches the upstream only once it
|
|
|
73
68
|
tokenType: "Bearer",
|
|
74
69
|
scope: undefined,
|
|
75
70
|
grantedScope: undefined,
|
|
71
|
+
obtainedAt: undefined,
|
|
76
72
|
},
|
|
77
73
|
});
|
|
78
74
|
return session;
|
|
@@ -107,7 +103,7 @@ describe("a logout's post_logout_redirect_uri reaches the upstream only once it
|
|
|
107
103
|
expect(res.status).toBe(303);
|
|
108
104
|
expect(new URL(res.headers.location as string).origin).not.toBe("https://evil.example");
|
|
109
105
|
// Google's own logout page: the adapter's answer when it is handed no
|
|
110
|
-
// redirect target, which is what an unregistered one
|
|
106
|
+
// redirect target, which is what an unregistered one becomes.
|
|
111
107
|
expect(res.headers.location).toBe(`${GOOGLE_LOGOUT}?state=s-1`);
|
|
112
108
|
// The logout itself still happened.
|
|
113
109
|
expect(await userSessionStore.get(sid)).toBeNull();
|
|
@@ -108,11 +108,7 @@ describe("standaloneRedisClientsModule: the shared connection refused by the ser
|
|
|
108
108
|
).provides;
|
|
109
109
|
try {
|
|
110
110
|
await provides.refreshTokenFamilyClient({
|
|
111
|
-
|
|
112
|
-
refreshTokenFamilyStore: {
|
|
113
|
-
redis: { url: `redis://127.0.0.1:${redis.port}`, password: STALE_PASSWORD },
|
|
114
|
-
},
|
|
115
|
-
},
|
|
111
|
+
section: { url: `redis://127.0.0.1:${redis.port}`, password: STALE_PASSWORD },
|
|
116
112
|
lifecycleRegistrar,
|
|
117
113
|
logger,
|
|
118
114
|
});
|
|
@@ -15,83 +15,82 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* module
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `docker-compose.production.yml` ships (`RATE_LIMITER_ADAPTER: redis`) then
|
|
27
|
-
* ran on 60 requests / 60 s no matter what the operator wrote.
|
|
28
|
-
*
|
|
29
|
-
* This starts from the shipped configuration, adds the two overrides an
|
|
30
|
-
* operator writes in their own `application.conf` layer, and follows them all
|
|
31
|
-
* the way to the limiter the module builds.
|
|
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.
|
|
32
26
|
*/
|
|
33
27
|
|
|
28
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
34
31
|
import { fileURLToPath } from "node:url";
|
|
35
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
type AppConfig,
|
|
34
|
+
createApp,
|
|
35
|
+
defineModule,
|
|
36
|
+
type RateLimiter,
|
|
37
|
+
} from "@o3co/auth-provider-core";
|
|
36
38
|
import { redisRateLimiterModule } from "@o3co/auth-provider-redis";
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
40
|
-
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
|
|
39
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
40
|
+
import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "../configPath.mjs";
|
|
41
|
+
import { loggingModule } from "../modules.mjs";
|
|
41
42
|
|
|
42
43
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* The secrets `application.conf` substitutes, plus the one
|
|
46
47
|
* `docker-compose.production.yml` sets to put the OAuth endpoints behind a
|
|
47
|
-
* shared limiter. Test-only values;
|
|
48
|
+
* shared limiter. Test-only values; the entropy floor applies to the two
|
|
48
49
|
* secrets, and the `.` characters keep them out of the base64 alphabet so the
|
|
49
50
|
* UTF-8 length is what counts.
|
|
50
51
|
*/
|
|
51
52
|
const ENV = {
|
|
52
|
-
|
|
53
|
+
KEY_STORE_LOCAL_SECRET: "test-secret-rate-limiter-e2e.at-least-32-bytes.ok",
|
|
53
54
|
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
54
|
-
|
|
55
|
-
|
|
55
|
+
SESSION_STORE_SECRET: "test-session-secret-rate-limiter-e2e.at-least-32-bytes.ok",
|
|
56
|
+
ADAPTERS_RATE_LIMITER: "redis",
|
|
57
|
+
MFA_MODE: "off",
|
|
56
58
|
};
|
|
57
59
|
|
|
58
60
|
/**
|
|
59
61
|
* What an operator adds to their own `application.conf` layer: per-endpoint
|
|
60
62
|
* budgets for the Redis limiter, and the mTLS posture from
|
|
61
63
|
* `@o3co/auth-provider-mtls`. Neither section appears in anything this
|
|
62
|
-
* template ships
|
|
64
|
+
* template ships.
|
|
63
65
|
*/
|
|
64
66
|
const OPERATOR_OVERRIDES = `
|
|
65
|
-
|
|
67
|
+
redis-rate-limiter {
|
|
66
68
|
limits {
|
|
67
69
|
token { limit = 120, windowSeconds = 60 }
|
|
68
70
|
authorize { limit = 30, windowSeconds = 60 }
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
mtls {
|
|
73
75
|
enabled = true
|
|
74
76
|
mode = "full-pki"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
trustedCas = ["-----BEGIN CERTIFICATE-----"]
|
|
78
|
+
fullPki {
|
|
77
79
|
revocation {
|
|
78
80
|
mode = "crl"
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
onUnavailable = "reject"
|
|
82
|
+
allowedHosts = ["crl.example.com"]
|
|
81
83
|
}
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
`;
|
|
85
87
|
|
|
86
|
-
|
|
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);
|
|
87
92
|
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
88
|
-
return
|
|
89
|
-
parseString(OPERATOR_OVERRIDES, { env: ENV })
|
|
90
|
-
.withFallback(parseFile(envConfPath, { env: ENV }))
|
|
91
|
-
.withFallback(parseFile(applicationConfPath, { env: ENV }))
|
|
92
|
-
.withFallback(parseFile(resolveLibraryReferenceConfPath(), { env: ENV })),
|
|
93
|
-
AppConfigSchema,
|
|
94
|
-
);
|
|
93
|
+
return [operator, envConfPath, applicationConfPath];
|
|
95
94
|
}
|
|
96
95
|
|
|
97
96
|
/** Counts one request per key, the way an untouched Redis counter would. */
|
|
@@ -99,79 +98,111 @@ function makeCountingClient(): { incrementWithTtl: (key: string, ttl: number) =>
|
|
|
99
98
|
return { incrementWithTtl: async () => 1 };
|
|
100
99
|
}
|
|
101
100
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
+
};
|
|
111
133
|
}
|
|
112
134
|
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
});
|
|
115
149
|
|
|
116
|
-
|
|
117
|
-
|
|
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");
|
|
118
157
|
});
|
|
119
158
|
|
|
120
|
-
it("carries the operator's per-endpoint budgets through
|
|
121
|
-
expect(
|
|
159
|
+
it("carries the operator's per-endpoint budgets through boot's parse", () => {
|
|
160
|
+
expect(
|
|
161
|
+
(config as unknown as Record<string, { limits?: unknown }>)["redis-rate-limiter"]?.limits,
|
|
162
|
+
).toEqual({
|
|
122
163
|
token: { limit: 120, windowSeconds: 60 },
|
|
123
164
|
authorize: { limit: 30, windowSeconds: 60 },
|
|
124
165
|
});
|
|
125
166
|
});
|
|
126
167
|
|
|
127
168
|
it("applies the declared budget at /token", async () => {
|
|
128
|
-
const decision = await
|
|
129
|
-
ip: "203.0.113.5",
|
|
130
|
-
});
|
|
169
|
+
const decision = await limiter.check("token:ip:203.0.113.5", { ip: "203.0.113.5" });
|
|
131
170
|
expect(decision.limit).toBe(120);
|
|
132
171
|
});
|
|
133
172
|
|
|
134
173
|
it("applies the declared budget at /authorize", async () => {
|
|
135
|
-
const decision = await
|
|
136
|
-
ip: "203.0.113.5",
|
|
137
|
-
});
|
|
174
|
+
const decision = await limiter.check("authorize:ip:203.0.113.5", { ip: "203.0.113.5" });
|
|
138
175
|
expect(decision.limit).toBe(30);
|
|
139
176
|
});
|
|
140
177
|
|
|
141
178
|
it("still falls back to the module's default where nothing is declared", async () => {
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
const decision = await
|
|
146
|
-
ip: "203.0.113.5",
|
|
147
|
-
});
|
|
179
|
+
// 60 is what every endpoint would get were the section stripped, so the
|
|
180
|
+
// budgets above differ from it to tell a working configuration from a
|
|
181
|
+
// lost one.
|
|
182
|
+
const decision = await limiter.check("introspect:ip:203.0.113.5", { ip: "203.0.113.5" });
|
|
148
183
|
expect(decision.limit).toBe(60);
|
|
149
184
|
});
|
|
150
185
|
|
|
151
|
-
it("
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
const decision = await
|
|
156
|
-
|
|
157
|
-
});
|
|
158
|
-
expect(decision.limit).toBe(config.rateLimit?.login.limit);
|
|
186
|
+
it("applies no login budget without the session module: session.rateLimit.login is that module's to contribute", async () => {
|
|
187
|
+
// This boot loads the limiter alone. With the session module, the
|
|
188
|
+
// limiter applies the budget it contributes from session.rateLimit.login.
|
|
189
|
+
expect(config.session).toMatchObject({ rateLimit: { login: { limit: 20 } } });
|
|
190
|
+
const decision = await limiter.check("login:ip:203.0.113.5", { ip: "203.0.113.5" });
|
|
191
|
+
expect(decision.limit).toBe(60);
|
|
159
192
|
});
|
|
160
193
|
});
|
|
161
194
|
|
|
162
|
-
describe("the shipped configuration carries an mTLS posture through
|
|
163
|
-
const config = resolveShippedConfig();
|
|
164
|
-
|
|
195
|
+
describe("the shipped configuration carries an mTLS posture through", () => {
|
|
165
196
|
it("keeps the operator's mTLS block instead of reporting mTLS off", () => {
|
|
166
|
-
expect(config.
|
|
197
|
+
expect((config as unknown as { mtls?: unknown }).mtls).toEqual({
|
|
167
198
|
enabled: true,
|
|
168
199
|
mode: "full-pki",
|
|
169
|
-
|
|
170
|
-
|
|
200
|
+
trustedCas: ["-----BEGIN CERTIFICATE-----"],
|
|
201
|
+
fullPki: {
|
|
171
202
|
revocation: {
|
|
172
203
|
mode: "crl",
|
|
173
|
-
|
|
174
|
-
|
|
204
|
+
onUnavailable: "reject",
|
|
205
|
+
allowedHosts: ["crl.example.com"],
|
|
175
206
|
},
|
|
176
207
|
},
|
|
177
208
|
});
|
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
import type { LifecycleRegistrar } from "@o3co/auth-provider-core";
|
|
17
17
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
18
|
|
|
19
|
-
//
|
|
19
|
+
// ioredis Redis constructor capture. The module under test imports
|
|
20
20
|
// `Redis` from "ioredis"; vi.mock replaces that import so the test never
|
|
21
21
|
// opens a real socket. Captured arguments verify that the validated config
|
|
22
|
-
// reaches the constructor
|
|
22
|
+
// reaches the constructor.
|
|
23
23
|
const redisCtorCalls: Array<{ url: string; options: Record<string, unknown> | undefined }> = [];
|
|
24
24
|
const quitSpies: Array<ReturnType<typeof vi.fn>> = [];
|
|
25
25
|
const onSpies: Array<ReturnType<typeof vi.fn>> = [];
|
|
@@ -60,17 +60,10 @@ const importModule = async () =>
|
|
|
60
60
|
standaloneRedisClientsModule: import("@o3co/auth-provider-core").Module;
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
// exist — TS error → RED.
|
|
63
|
+
/** The module's own section, `redis-clients`, as boot hands it over. */
|
|
64
|
+
const baseSection = { url: "redis://example.com:6379", password: "test-pw" };
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
refreshTokenFamilyStore: {
|
|
69
|
-
redis: { url: "redis://example.com:6379", password: "test-pw" },
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
describe("D-2 / standaloneRedisClientsModule", () => {
|
|
66
|
+
describe("standaloneRedisClientsModule", () => {
|
|
74
67
|
beforeEach(() => {
|
|
75
68
|
redisCtorCalls.length = 0;
|
|
76
69
|
quitSpies.length = 0;
|
|
@@ -81,14 +74,14 @@ describe("D-2 / standaloneRedisClientsModule", () => {
|
|
|
81
74
|
vi.restoreAllMocks();
|
|
82
75
|
});
|
|
83
76
|
|
|
84
|
-
it("passes operator
|
|
77
|
+
it("passes the operator's Redis URL and password from its section to the ioredis constructor", async () => {
|
|
85
78
|
const { standaloneRedisClientsModule } = await importModule();
|
|
86
79
|
const provides = (
|
|
87
80
|
standaloneRedisClientsModule as unknown as {
|
|
88
81
|
provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
|
|
89
82
|
}
|
|
90
83
|
).provides;
|
|
91
|
-
await provides.refreshTokenFamilyClient({
|
|
84
|
+
await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
|
|
92
85
|
|
|
93
86
|
expect(redisCtorCalls).toHaveLength(1);
|
|
94
87
|
expect(redisCtorCalls[0]?.url).toBe("redis://example.com:6379");
|
|
@@ -109,7 +102,7 @@ describe("D-2 / standaloneRedisClientsModule", () => {
|
|
|
109
102
|
provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
|
|
110
103
|
}
|
|
111
104
|
).provides;
|
|
112
|
-
await provides.refreshTokenFamilyClient({
|
|
105
|
+
await provides.refreshTokenFamilyClient({ section: { ...baseSection }, lifecycleRegistrar });
|
|
113
106
|
|
|
114
107
|
expect(registered).toHaveLength(1);
|
|
115
108
|
// Pre-drain: quit not yet called.
|
|
@@ -133,12 +126,12 @@ describe("D-2 / standaloneRedisClientsModule", () => {
|
|
|
133
126
|
// after the factory resolves; the registrar branch is the only place
|
|
134
127
|
// `quit()` would be wired up in the no-real-shutdown unit-test path).
|
|
135
128
|
await expect(
|
|
136
|
-
provides.refreshTokenFamilyClient({
|
|
129
|
+
provides.refreshTokenFamilyClient({ section: { ...baseSection } }),
|
|
137
130
|
).resolves.toBeDefined();
|
|
138
131
|
expect(quitSpies[0]).not.toHaveBeenCalled();
|
|
139
132
|
});
|
|
140
133
|
|
|
141
|
-
it("fails fast
|
|
134
|
+
it("fails fast on an empty redis-clients.url (no silent localhost fallback)", async () => {
|
|
142
135
|
const { standaloneRedisClientsModule } = await importModule();
|
|
143
136
|
const provides = (
|
|
144
137
|
standaloneRedisClientsModule as unknown as {
|
|
@@ -146,11 +139,12 @@ describe("D-2 / standaloneRedisClientsModule", () => {
|
|
|
146
139
|
}
|
|
147
140
|
).provides;
|
|
148
141
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
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/,
|
|
154
148
|
);
|
|
155
149
|
// And no ioredis instance was constructed because we threw before
|
|
156
150
|
// `new Redis(...)`.
|
|
@@ -164,12 +158,12 @@ describe("D-2 / standaloneRedisClientsModule", () => {
|
|
|
164
158
|
provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
|
|
165
159
|
}
|
|
166
160
|
).provides;
|
|
167
|
-
await provides.refreshTokenFamilyClient({
|
|
161
|
+
await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
|
|
168
162
|
|
|
169
163
|
expect(onSpies[0]).toHaveBeenCalledWith("error", expect.any(Function));
|
|
170
164
|
});
|
|
171
165
|
|
|
172
|
-
it("bounds every command and every connect attempt
|
|
166
|
+
it("bounds every command and every connect attempt", async () => {
|
|
173
167
|
// Without these the driver's defaults apply: no command timeout at all,
|
|
174
168
|
// a 10s connect timeout, and 20 reconnect attempts before a queued
|
|
175
169
|
// command is failed. A partition therefore parks in-flight `/token`
|
|
@@ -181,7 +175,7 @@ describe("D-2 / standaloneRedisClientsModule", () => {
|
|
|
181
175
|
provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
|
|
182
176
|
}
|
|
183
177
|
).provides;
|
|
184
|
-
await provides.refreshTokenFamilyClient({
|
|
178
|
+
await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
|
|
185
179
|
|
|
186
180
|
const options = redisCtorCalls[0]?.options;
|
|
187
181
|
expect(options?.commandTimeout).toBe(1_000);
|
|
@@ -193,7 +187,7 @@ describe("D-2 / standaloneRedisClientsModule", () => {
|
|
|
193
187
|
expect(options?.lazyConnect).toBe(false);
|
|
194
188
|
});
|
|
195
189
|
|
|
196
|
-
it("keeps the offline queue ON, explicitly, because one socket serves every purpose
|
|
190
|
+
it("keeps the offline queue ON, explicitly, because one socket serves every purpose", async () => {
|
|
197
191
|
// `enableOfflineQueue` is a per-CONNECTION option and every adapter in
|
|
198
192
|
// this template draws from the single socket built below, so the
|
|
199
193
|
// "off for the rate limiter, on elsewhere" split is not expressible
|
|
@@ -207,18 +201,18 @@ describe("D-2 / standaloneRedisClientsModule", () => {
|
|
|
207
201
|
provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
|
|
208
202
|
}
|
|
209
203
|
).provides;
|
|
210
|
-
await provides.refreshTokenFamilyClient({
|
|
204
|
+
await provides.refreshTokenFamilyClient({ section: { ...baseSection } });
|
|
211
205
|
|
|
212
206
|
expect(redisCtorCalls[0]?.options?.enableOfflineQueue).toBe(true);
|
|
213
207
|
});
|
|
214
208
|
|
|
215
|
-
it("
|
|
209
|
+
it("requires no configuration, and takes both registrars as optional", async () => {
|
|
216
210
|
const { standaloneRedisClientsModule } = await importModule();
|
|
217
211
|
const m = standaloneRedisClientsModule as {
|
|
218
212
|
requires?: readonly string[];
|
|
219
213
|
optional?: readonly string[];
|
|
220
214
|
};
|
|
221
|
-
expect(m.requires).toContain("config");
|
|
215
|
+
expect(m.requires ?? []).not.toContain("config");
|
|
222
216
|
expect(m.optional).toContain("lifecycleRegistrar");
|
|
223
217
|
expect(m.optional).toContain("readinessRegistrar");
|
|
224
218
|
});
|
|
@@ -238,12 +232,12 @@ describe("D-2 / standaloneRedisClientsModule", () => {
|
|
|
238
232
|
const lifecycleRegistrar: LifecycleRegistrar = { register: () => {} };
|
|
239
233
|
|
|
240
234
|
await provides.refreshTokenFamilyClient({
|
|
241
|
-
|
|
235
|
+
section: { ...baseSection },
|
|
242
236
|
lifecycleRegistrar,
|
|
243
237
|
readinessRegistrar,
|
|
244
238
|
});
|
|
245
239
|
await provides.rateLimiterClient({
|
|
246
|
-
|
|
240
|
+
section: { ...baseSection },
|
|
247
241
|
lifecycleRegistrar,
|
|
248
242
|
readinessRegistrar,
|
|
249
243
|
});
|