@o3co/create-auth-provider 0.17.0-rc.2 → 0.17.0-rc.4

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 (43) hide show
  1. package/README.ja.md +3 -1
  2. package/README.md +5 -2
  3. package/package.json +3 -3
  4. package/templates/standalone/.env.example +7 -2
  5. package/templates/standalone/README.ja.md +17 -11
  6. package/templates/standalone/README.md +47 -21
  7. package/templates/standalone/config/application.conf +1 -0
  8. package/templates/standalone/config/clients.yaml.example +8 -0
  9. package/templates/standalone/config/development.conf +2 -2
  10. package/templates/standalone/config/mailpit.conf +10 -5
  11. package/templates/standalone/config/reference.conf +9 -3
  12. package/templates/standalone/docker-compose.mailpit.yml +3 -0
  13. package/templates/standalone/package.json +6 -6
  14. package/templates/standalone/src/__tests__/adapters.test.mts +12 -6
  15. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +13 -5
  16. package/templates/standalone/src/__tests__/config-notices.test.mts +4 -1
  17. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
  18. package/templates/standalone/src/__tests__/core-federations.test.mts +9 -6
  19. package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
  20. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +91 -87
  21. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +4 -0
  22. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +113 -0
  23. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -14
  24. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
  25. package/templates/standalone/src/__tests__/mfa-switch.test.mts +79 -21
  26. package/templates/standalone/src/__tests__/own-modules.test.mts +38 -12
  27. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
  28. package/templates/standalone/src/__tests__/replica-safety.test.mts +4 -0
  29. package/templates/standalone/src/__tests__/repositories-logger.test.mts +119 -0
  30. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +10 -3
  31. package/templates/standalone/src/__tests__/sections.test.mts +24 -0
  32. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +4 -0
  33. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
  34. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +8 -4
  35. package/templates/standalone/src/__tests__/two-phase-config.test.mts +2 -1
  36. package/templates/standalone/src/adapters.mts +2 -2
  37. package/templates/standalone/src/mfaSwitch.mts +6 -7
  38. package/templates/standalone/src/modules.mts +21 -9
  39. package/templates/standalone/src/rootRenames.mts +12 -13
  40. package/templates/standalone/src/sections.mts +12 -2
  41. package/templates/standalone/tests/package-lock.json +95 -95
  42. package/templates/standalone/tests/package.json +1 -1
  43. package/templates/versions.json +18 -18
@@ -15,11 +15,12 @@
15
15
  */
16
16
 
17
17
  /**
18
- * The development compose run, `docker-compose.yml`, with MFA on as the
19
- * template ships it, and the Mailpit overlay, `docker-compose.mailpit.yml`: a
20
- * development run whose mail goes through the SMTP sender to Mailpit. It is an
21
- * overlay on `docker-compose.yml`, so a plain `docker compose up` starts no
22
- * Mailpit, and the production file names none. The compose files are read as
18
+ * The development compose run, `docker-compose.yml`, with MFA as the
19
+ * configuration's switch says (on as the template ships it), and the Mailpit
20
+ * overlay, `docker-compose.mailpit.yml`: a development run whose mail goes
21
+ * through the SMTP sender to Mailpit. It is an overlay on
22
+ * `docker-compose.yml`, so a plain `docker compose up` starts no Mailpit, and
23
+ * the production file names none. The compose files are read as
23
24
  * the fixed-shape files they are, and the process each run describes is booted
24
25
  * from the shipped files, as `app.mts` boots them, under the environment the
25
26
  * run sets: `.env.example`'s lines, then each compose file's `environment`, the
@@ -44,6 +45,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
44
45
  import { buildModules } from "#/buildModules.mjs";
45
46
  import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
46
47
  import { createRecordingLogger } from "./all-modules-composition.fixture.mjs";
48
+ import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
47
49
 
48
50
  vi.mock("redis", () => ({
49
51
  createClient: vi.fn(() => ({
@@ -74,6 +76,10 @@ vi.mock("ioredis", () => {
74
76
  quit: async () => "OK",
75
77
  disconnect: () => undefined,
76
78
  ping: async () => "PONG",
79
+ // The Redis stores' eviction gate reads the policy from INFO memory: a
80
+ // default server's.
81
+ info: async (section: string) =>
82
+ section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
77
83
  };
78
84
  const makeMockRedis = (): object =>
79
85
  new Proxy(
@@ -245,14 +251,15 @@ describe("the process each development run describes", () => {
245
251
  };
246
252
  }
247
253
 
248
- it("boots docker-compose.yml's run with MFA required: development, the development mail sender, the MFA stores on its Redis", async () => {
254
+ it("boots docker-compose.yml's run with MFA as the configuration's switch says: development, the development mail sender, the MFA stores on its Redis", async () => {
249
255
  const env = containerEnv(false);
250
256
  // `app.mts`: CONFIG_ENV, then NODE_ENV, then development; the run sets neither.
251
257
  expect(env.CONFIG_ENV ?? env.NODE_ENV).toBeUndefined();
252
258
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
253
259
  const own = readOwnLayers([envConfPath, applicationConfPath], { env });
254
260
  const switches = readSwitches(own);
255
- expect(switches.mfaMode).toBe("required");
261
+ const mode = configuredMfaMode("development");
262
+ expect(switches.mfaMode).toBe(mode);
256
263
  // A hot reload restarts the process on every save, and Redis keeps the
257
264
  // factors, and the sessions beside them, across it.
258
265
  expect(switches.adapters).toMatchObject({
@@ -277,7 +284,7 @@ describe("the process each development run describes", () => {
277
284
  "standard-development",
278
285
  );
279
286
  expect((handle.components.config as { mfa?: { mode?: unknown } } | undefined)?.mfa?.mode).toBe(
280
- "required",
287
+ mode === "off" ? undefined : mode,
281
288
  );
282
289
  });
283
290
 
@@ -289,18 +296,27 @@ describe("the process each development run describes", () => {
289
296
  expect(existsSync(resolveConfigPaths(configDir, configEnv as string).envConfPath)).toBe(true);
290
297
  });
291
298
 
292
- it("boots with MFA on and the SMTP sender in the mailSender slot, relaying to Mailpit", async () => {
293
- const env = containerEnv();
299
+ /** The key the operator writes into `.env` for the Mailpit run (`openssl rand -base64 32`). */
300
+ const OWN_MFA_KEY = Buffer.alloc(32, 7).toString("base64");
301
+
302
+ /** The Mailpit run's modules and the configuration boot is handed, under `env`. */
303
+ function mailpitRun(env: Record<string, string>) {
294
304
  const configEnv = env.CONFIG_ENV as string;
295
305
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
296
306
  const own = readOwnLayers([envConfPath, applicationConfPath], { env });
297
307
  const switches = readSwitches(own);
298
- expect(switches.mfaMode).toBe("required");
299
-
300
308
  const modules = buildModules(switches, {
301
309
  environment: configEnv,
302
310
  refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
303
311
  });
312
+ return { switches, modules, config: () => resolveForBoot(own, modules, switches) };
313
+ }
314
+
315
+ it("boots with the SMTP sender relaying to Mailpit: in the mailSender slot, with the operator's MFA key, while MFA is on (the configuration's switch); unread, with no mfa section, while it is off", async () => {
316
+ const env: Record<string, string> = { ...containerEnv(), MFA_ENCRYPTION_KEY: OWN_MFA_KEY };
317
+ const { switches, modules, config } = mailpitRun(env);
318
+ const mode = configuredMfaMode(env.CONFIG_ENV as string);
319
+ expect(switches.mfaMode).toBe(mode);
304
320
  const names = modules.map((module) => module.name);
305
321
  expect(names).toContain(standardSmtpMailSenderModule.name);
306
322
  expect(names).not.toContain(
@@ -310,17 +326,68 @@ describe("the process each development run describes", () => {
310
326
  const handle = await createApp({
311
327
  modules,
312
328
  bootstrapComponents: {
313
- config: resolveForBoot(own, modules, switches),
329
+ config: config(),
314
330
  pathResolver: (s: string) => s,
315
331
  logger: createRecordingLogger(),
316
332
  },
317
333
  });
318
334
  handles.push(handle);
319
- expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe("standard-smtp");
335
+ expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe(
336
+ mode === "off" ? undefined : "standard-smtp",
337
+ );
338
+ const mfa = (
339
+ handle.components.config as {
340
+ mfa?: { mode?: unknown; encryptionKeys?: { key?: unknown }[] };
341
+ }
342
+ ).mfa;
343
+ if (mode === "off") {
344
+ expect(mfa).toBeUndefined();
345
+ } else {
346
+ expect(mfa?.mode).toBe(mode);
347
+ expect(mfa?.encryptionKeys?.map((entry) => entry.key)).toEqual([OWN_MFA_KEY]);
348
+ }
320
349
  expect(
321
350
  (handle.components.config as Record<string, unknown> | undefined)?.[
322
351
  "standard-smtp-mail-sender"
323
352
  ],
324
353
  ).toMatchObject({ host: "localhost", port: 1025, secure: "none" });
325
354
  });
355
+
356
+ it("does not carry the development sample key: with MFA on, without MFA_ENCRYPTION_KEY the run is refused; with it off, nothing reads a key", async () => {
357
+ const env = containerEnv();
358
+ expect(env.MFA_ENCRYPTION_KEY).toBeUndefined();
359
+ const { modules, config } = mailpitRun(env);
360
+ if (configuredMfaMode(env.CONFIG_ENV as string) === "off") {
361
+ const handle = await createApp({
362
+ modules,
363
+ bootstrapComponents: {
364
+ config: config(),
365
+ pathResolver: (s: string) => s,
366
+ logger: createRecordingLogger(),
367
+ },
368
+ });
369
+ handles.push(handle);
370
+ expect(handle.components.config).not.toHaveProperty("mfa");
371
+ return;
372
+ }
373
+ const refused = await (async () => {
374
+ try {
375
+ handles.push(
376
+ await createApp({
377
+ modules,
378
+ bootstrapComponents: {
379
+ config: config(),
380
+ pathResolver: (s: string) => s,
381
+ logger: createRecordingLogger(),
382
+ },
383
+ }),
384
+ );
385
+ } catch (error) {
386
+ return error;
387
+ }
388
+ return undefined;
389
+ })();
390
+ expect(refused).toBeInstanceOf(Error);
391
+ expect(String((refused as Error).message)).toMatch(/mfa\.encryptionKeys/);
392
+ });
326
393
  });
@@ -17,10 +17,10 @@
17
17
  /**
18
18
  * The template's MFA switch under the name production, where the MFA stores
19
19
  * must be shared: both on Redis, as a production deployment selects them.
20
- * ioredis is a stand-in, as in `all-modules-composition.multi.test.mts`:
21
- * nothing here issues a command, and the stores' durability check names what
22
- * it could not read and goes on. `packages/redis` runs the stores against a
23
- * real server.
20
+ * ioredis is a stand-in, as in `all-modules-composition.multi.test.mts`: it
21
+ * answers `INFO memory` as a default server does, so the stores' eviction gate
22
+ * passes, and their persistence check names what it could not read and goes
23
+ * on. `packages/redis` runs the stores against a real server.
24
24
  */
25
25
 
26
26
  import { BootError } from "@o3co/auth-provider-core";
@@ -36,6 +36,10 @@ vi.mock("ioredis", () => {
36
36
  quit: async () => "OK",
37
37
  disconnect: () => undefined,
38
38
  ping: async () => "PONG",
39
+ // The Redis stores' eviction gate reads the policy from INFO memory: a
40
+ // default server's.
41
+ info: async (section: string) =>
42
+ section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
39
43
  };
40
44
  const makeMockRedis = (): object =>
41
45
  new Proxy(
@@ -15,14 +15,15 @@
15
15
  */
16
16
 
17
17
  /**
18
- * The template's MFA switch, `mfaMode` (`MFA_MODE`): `off`, the
19
- * default, installs nothing of MFA; `optional` and `required` install the MFA
20
- * package's modules — the MFA module, the TOTP, recovery-code and email
21
- * factors, the operator reset — with the session package's login completion
22
- * and the two MFA stores `adapters` selects, and declare `mfa` in
23
- * `core.sessionRequirements.expected`. Booted through the all-modules
24
- * fixture, and, where the shipped user repository or the development files
25
- * are the question, the way `app.mts` boots.
18
+ * The template's MFA switch, `mfaMode` (`MFA_MODE`): `off` installs nothing of
19
+ * MFA; `optional` and `required` install the MFA package's modules — the MFA
20
+ * module, the TOTP, recovery-code and email factors, the operator reset —
21
+ * with the session package's login completion and the two MFA stores
22
+ * `adapters` selects, and declare `mfa` in `core.sessionRequirements.expected`.
23
+ * With `MFA_MODE` unset, the switch is what the project's files write
24
+ * (`configured-mfa-mode.fixture.mts`). Booted through the all-modules fixture,
25
+ * and, where the shipped user repository or the development files are the
26
+ * question, the way `app.mts` boots.
26
27
  */
27
28
 
28
29
  import { generateKeyPairSync } from "node:crypto";
@@ -43,6 +44,7 @@ import {
43
44
  } from "@o3co/auth-provider-core";
44
45
  import { MFA_DEVELOPMENT_SAMPLE_KEY, mfaModules, mfaResetModule } from "@o3co/auth-provider-mfa";
45
46
  import { loginCompletionModule } from "@o3co/auth-provider-session";
47
+ import { parseFile } from "@o3co/ts.hocon";
46
48
  import request from "supertest";
47
49
  import { afterEach, describe, expect, it, vi } from "vitest";
48
50
  import { buildModules } from "#/buildModules.mjs";
@@ -53,20 +55,25 @@ import {
53
55
  resolveConfigPaths,
54
56
  resolveForBoot,
55
57
  } from "#/configPath.mjs";
58
+ import { mfaModulesFor } from "#/mfaSwitch.mjs";
59
+ import { templateReference } from "#/modules.mjs";
56
60
  import {
57
61
  ALICE,
58
62
  authorize,
59
63
  type Composition,
64
+ codeFrom,
60
65
  compose,
61
66
  contributionNames,
62
67
  cookiesOf,
63
68
  createRecordingLogger,
64
69
  ISSUER,
70
+ login,
65
71
  ownFiles,
66
72
  type RecordingLogger,
67
73
  SINGLE_ENV,
68
74
  WEB,
69
75
  } from "./all-modules-composition.fixture.mjs";
76
+ import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
70
77
 
71
78
  /** 32 bytes, base64: a key of the deployment's own for the MFA key ring. */
72
79
  const MFA_KEY = Buffer.alloc(32, 9).toString("base64");
@@ -133,17 +140,22 @@ const textOf = (err: unknown): string => {
133
140
  const warned = (logger: RecordingLogger, event: string) =>
134
141
  logger.lines.filter((line) => line.level === "warn" && line.args[1] === event);
135
142
 
136
- /** `env` with MFA_MODE unset: the switch as the template ships it. */
143
+ /** `env` with MFA_MODE unset: the switch as the configuration's files write it (`configuredMfaMode`). */
137
144
  const unset = (env: Readonly<Record<string, string>>): Record<string, string> => {
138
145
  const { MFA_MODE: _unset, ...rest } = env;
139
146
  return rest;
140
147
  };
141
148
 
142
149
  describe("the switch is the template's own key, mfaMode, bound to MFA_MODE", () => {
143
- it("is required unless MFA_MODE says otherwise", () => {
150
+ it("ships required in the template's reference.conf, which --no-mfa leaves as it is", () => {
151
+ const reference = parseFile(fileURLToPath(templateReference()), { env: {} }).toObject();
152
+ expect((reference as Record<string, unknown>).mfaMode).toBe("required");
153
+ });
154
+
155
+ it("is what the configuration's files write unless MFA_MODE says otherwise", () => {
144
156
  const switches = (env: Readonly<Record<string, string>>) =>
145
157
  readSwitches(readOwnLayers(ownFiles(), { env })).mfaMode;
146
- expect(switches(unset(SINGLE_ENV))).toBe("required");
158
+ expect(switches(unset(SINGLE_ENV))).toBe(configuredMfaMode("production"));
147
159
  for (const mode of ["off", "optional", "required"] as const) {
148
160
  expect(switches({ ...SINGLE_ENV, MFA_MODE: mode })).toBe(mode);
149
161
  }
@@ -200,22 +212,37 @@ describe("the switch is the template's own key, mfaMode, bound to MFA_MODE", ()
200
212
  });
201
213
  });
202
214
 
203
- describe("MFA_MODE unset: the template requires a second factor", () => {
204
- it("installs every MFA module and declares mfa, as MFA_MODE=required does", async () => {
215
+ describe("MFA_MODE unset: the switch the configuration's files write decides (required as the template ships it)", () => {
216
+ const mode = configuredMfaMode("production");
217
+
218
+ it("installs every MFA module and declares mfa while the switch is on, and nothing of MFA while it is off", async () => {
205
219
  current = await boot({ env: unset(on("required")) });
206
220
  const names = current.modules.map((m) => m.name);
207
- expect(names.filter((name) => MFA_MODULE_NAMES.includes(name)).sort()).toEqual(
208
- MFA_MODULE_NAMES,
209
- );
221
+ const installed = names.filter((name) => MFA_MODULE_NAMES.includes(name)).sort();
222
+ if (mode === "off") {
223
+ expect(installed).toEqual([]);
224
+ expect(current.config.core?.sessionRequirements?.expected ?? []).not.toContain("mfa");
225
+ expect(current.config).not.toHaveProperty("mfa");
226
+ return;
227
+ }
228
+ expect(installed).toEqual(MFA_MODULE_NAMES);
210
229
  expect(current.config.core?.sessionRequirements).toEqual({
211
230
  expected: ["mfa"],
212
231
  secondFactorAuthority: "mfa",
213
232
  });
214
- expect((current.config as unknown as { mfa: { mode: unknown } }).mfa.mode).toBe("required");
233
+ expect((current.config as unknown as { mfa: { mode: unknown } }).mfa.mode).toBe(mode);
215
234
  });
216
235
 
217
- it("interrupts a password login, opening no session /authorize accepts", async () => {
236
+ it("interrupts a password login while the switch is on, opening no session /authorize accepts; lets it through to a code while off", async () => {
218
237
  current = await boot({ env: unset(on("required")) });
238
+ if (mode === "off") {
239
+ const { res, cookies } = await login(current.app);
240
+ expect(res.status).toBe(200);
241
+ const answer = await authorize(current.app, cookies);
242
+ expect(String(answer.headers.location ?? "").startsWith(WEB.redirectUri)).toBe(true);
243
+ expect(codeFrom(answer)).not.toBe("");
244
+ return;
245
+ }
219
246
  const csrf = await request(current.app).get("/session/csrf");
220
247
  const res = await request(current.app)
221
248
  .post("/session/login")
@@ -402,6 +429,24 @@ describe("the MFA stores in memory: development and test alone", () => {
402
429
  expect((err as BootError).reason).toBe("config-validation-failed");
403
430
  });
404
431
 
432
+ it("names each reason it read: no name at all, a name neither development nor test, production", () => {
433
+ const memory = (environment: string | undefined) => () =>
434
+ mfaModulesFor({
435
+ mode: "required",
436
+ adapters: { mfaFactorStore: "memory", mfaTransactionStore: "memory" },
437
+ storeTransport: undefined,
438
+ environment,
439
+ });
440
+ vi.stubEnv("CONFIG_ENV", "");
441
+ vi.stubEnv("NODE_ENV", "");
442
+ expect(memory(undefined)).toThrow(/refused because no environment is named/);
443
+ expect(memory(" Prod ")).toThrow(/the environment "prod" is not development or test/);
444
+ vi.stubEnv("NODE_ENV", "Production");
445
+ expect(memory("test")).toThrow(/NODE_ENV is "production"/);
446
+ vi.stubEnv("NODE_ENV", " Test\n");
447
+ expect(memory("development")().map((m) => m.name)).toContain(memoryMfaFactorStoreModule.name);
448
+ });
449
+
405
450
  it.each(["development", "test"])("lets them in under %s", async (environment) => {
406
451
  current = await compose({ env: on("required"), environment });
407
452
  expect(current.modules.map((m) => m.name)).toContain(memoryMfaFactorStoreModule.name);
@@ -739,14 +784,27 @@ describe("a deployment that takes MFA's modules out of buildModules() under MFA_
739
784
  });
740
785
 
741
786
  describe("the shipped configuration with nothing set about MFA", () => {
742
- it("boots under CONFIG_ENV=development with MFA required: the sample key, the development mail sender, the MFA stores in memory", async () => {
787
+ it("boots under CONFIG_ENV=development with the switch its files write: with MFA on, on the sample key, the development mail sender and the MFA stores in memory", async () => {
788
+ const mode = configuredMfaMode("development");
743
789
  const { logger, config } = await bootShipped(unset(SHIPPED_ENV), "development");
744
- expect((config as unknown as { mfa: { mode: unknown } }).mfa.mode).toBe("required");
790
+ if (mode === "off") {
791
+ expect(config).not.toHaveProperty("mfa");
792
+ expect(config.core?.sessionRequirements?.expected ?? []).not.toContain("mfa");
793
+ expect(warned(logger, "mfa_development_sample_key_in_use")).toEqual([]);
794
+ return;
795
+ }
796
+ expect((config as unknown as { mfa: { mode: unknown } }).mfa.mode).toBe(mode);
745
797
  expect(config.core?.sessionRequirements?.expected).toContain("mfa");
746
798
  expect(warned(logger, "mfa_development_sample_key_in_use")).toHaveLength(1);
747
799
  });
748
800
 
749
- it("is refused in production before boot, naming the MFA stores, MFA_ENCRYPTION_KEY, the SMTP relay and MFA_MODE=off", async () => {
801
+ it("in production, with MFA on, is refused before boot, naming the MFA stores, MFA_ENCRYPTION_KEY, the SMTP relay and MFA_MODE=off; with it off, boots", async () => {
802
+ if (configuredMfaMode("production") === "off") {
803
+ const { config } = await bootShipped(unset(SHIPPED_ENV), "production");
804
+ expect(config).not.toHaveProperty("mfa");
805
+ expect(config.core?.sessionRequirements?.expected ?? []).not.toContain("mfa");
806
+ return;
807
+ }
750
808
  let err: unknown;
751
809
  await bootShipped(unset(SHIPPED_ENV), "production").catch((caught: unknown) => {
752
810
  err = caught;
@@ -22,8 +22,8 @@
22
22
  * environment and HOCON overrides, through the template's real path:
23
23
  * `readOwnLayers`, then `resolveForBoot` over every loaded module's
24
24
  * reference, then `createApp`. A path a section moved from refuses boot
25
- * naming its new path, and a variable renamed with it refuses boot unless its
26
- * new name carries the same value.
25
+ * naming its new path, and a variable renamed with it refuses boot while its
26
+ * old name is set, whatever its new name holds.
27
27
  */
28
28
 
29
29
  import { generateKeyPairSync } from "node:crypto";
@@ -332,8 +332,19 @@ describe("logging", () => {
332
332
  });
333
333
  });
334
334
 
335
- it("boots with LOG_LEVEL beside LOGGING_LEVEL at the same value", async () => {
336
- const handle = await bootTemplate({ env: { LOG_LEVEL: "debug", LOGGING_LEVEL: "debug" } });
335
+ it("refuses LOG_LEVEL beside LOGGING_LEVEL at the same value", async () => {
336
+ await expect(
337
+ bootTemplate({ env: { LOG_LEVEL: "debug", LOGGING_LEVEL: "debug" } }),
338
+ ).rejects.toMatchObject({
339
+ details: {
340
+ reason: "environment-variable-renamed",
341
+ renamed: [{ module: "logging", from: "LOG_LEVEL", state: "different" }],
342
+ },
343
+ });
344
+ });
345
+
346
+ it("boots with LOGGING_LEVEL alone", async () => {
347
+ const handle = await bootTemplate({ env: { LOGGING_LEVEL: "debug" } });
337
348
  await handle.dispose();
338
349
  });
339
350
  });
@@ -947,17 +958,32 @@ describe("key-store", () => {
947
958
  });
948
959
  });
949
960
 
950
- it("boots with each old name beside its new one at the same value", async () => {
951
- const handle = await bootTemplate({
952
- env: {
953
- OAUTH_JWT_PRIVATE_KEY: signingKey.privateKey,
954
- OAUTH_JWT_PUBLIC_KEY: signingKey.publicKey,
955
- OAUTH_JWT_KID: "k-both",
956
- KEY_STORE_LOCAL_KID: "k-both",
961
+ it("refuses each old name beside its new one at the same value", async () => {
962
+ await expect(
963
+ bootTemplate({
964
+ env: {
965
+ OAUTH_JWT_PRIVATE_KEY: signingKey.privateKey,
966
+ OAUTH_JWT_PUBLIC_KEY: signingKey.publicKey,
967
+ OAUTH_JWT_KID: "k-both",
968
+ KEY_STORE_LOCAL_KID: "k-both",
969
+ },
970
+ }),
971
+ ).rejects.toMatchObject({
972
+ details: {
973
+ reason: "environment-variable-renamed",
974
+ renamed: expect.arrayContaining([
975
+ expect.objectContaining({ from: "OAUTH_JWT_PRIVATE_KEY", state: "different" }),
976
+ expect.objectContaining({ from: "OAUTH_JWT_PUBLIC_KEY", state: "different" }),
977
+ expect.objectContaining({ from: "OAUTH_JWT_KID", state: "different" }),
978
+ ]),
957
979
  },
958
980
  });
981
+ });
982
+
983
+ it("boots with each new name alone", async () => {
984
+ const handle = await bootTemplate({ env: { KEY_STORE_LOCAL_KID: "k-new" } });
959
985
  try {
960
- expect(handle.components.keyStore?.getSigningKidFallback()).toBe("k-both");
986
+ expect(handle.components.keyStore?.getSigningKidFallback()).toBe("k-new");
961
987
  } finally {
962
988
  await handle.dispose();
963
989
  }
@@ -0,0 +1,214 @@
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
+ * `redis-clients.assumeNoEviction` (`REDIS_CLIENTS_ASSUME_NO_EVICTION`), on a
19
+ * Redis that will not report its eviction policy: every store on Redis, as a
20
+ * multi-replica deployment selects them. ioredis is a stand-in that refuses
21
+ * `INFO` and `CONFIG` as an ACL-restricted user's connection does; every
22
+ * other command resolves to nothing. node-redis and connect-redis, behind
23
+ * express-session's store, are stand-ins too, as in
24
+ * `all-modules-composition.multi.test.mts`.
25
+ */
26
+
27
+ import { BootError } from "@o3co/auth-provider-core";
28
+ import { afterEach, describe, expect, it, vi } from "vitest";
29
+ import {
30
+ type Composition,
31
+ compose,
32
+ MULTI_ENV,
33
+ SINGLE_ENV,
34
+ } from "./all-modules-composition.fixture.mjs";
35
+
36
+ vi.mock("redis", () => ({
37
+ createClient: vi.fn(() => ({
38
+ connect: vi.fn().mockResolvedValue(undefined),
39
+ quit: vi.fn().mockResolvedValue(undefined),
40
+ ping: vi.fn().mockResolvedValue("PONG"),
41
+ on: vi.fn(),
42
+ })),
43
+ }));
44
+
45
+ vi.mock("connect-redis", async () => {
46
+ const { EventEmitter } = await import("node:events");
47
+ // express-session subscribes to store events, so the stand-in is an emitter.
48
+ return {
49
+ RedisStore: class MockRedisStore extends EventEmitter {
50
+ get(): unknown {
51
+ return undefined;
52
+ }
53
+ set(): void {}
54
+ destroy(): void {}
55
+ },
56
+ };
57
+ });
58
+
59
+ vi.mock("ioredis", () => {
60
+ const refused = async (): Promise<never> => {
61
+ throw Object.assign(new Error("NOPERM this user has no permissions to run this command"), {
62
+ name: "ReplyError",
63
+ });
64
+ };
65
+ const explicit: Record<string, unknown> = {
66
+ on: () => undefined,
67
+ quit: async () => "OK",
68
+ disconnect: () => undefined,
69
+ ping: async () => "PONG",
70
+ info: refused,
71
+ config: refused,
72
+ };
73
+ const makeMockRedis = (): object =>
74
+ new Proxy(
75
+ {},
76
+ {
77
+ get(_target, prop) {
78
+ // A function-valued `then` would make the instance a thenable.
79
+ if (typeof prop !== "string" || prop === "then") return undefined;
80
+ if (prop === "duplicate") return makeMockRedis;
81
+ if (prop in explicit) return explicit[prop];
82
+ return async () => null;
83
+ },
84
+ },
85
+ );
86
+ function MockRedis(): object {
87
+ return makeMockRedis();
88
+ }
89
+ return { Redis: MockRedis, default: MockRedis };
90
+ });
91
+
92
+ let current: Composition | undefined;
93
+
94
+ afterEach(async () => {
95
+ await current?.handle.dispose();
96
+ current = undefined;
97
+ });
98
+
99
+ /** MFA required, both MFA stores on Redis, a key of the deployment's own and an SMTP relay. */
100
+ const MFA_ON_REDIS: Readonly<Record<string, string>> = {
101
+ MFA_MODE: "required",
102
+ ADAPTERS_MFA_FACTOR_STORE: "redis",
103
+ ADAPTERS_MFA_TRANSACTION_STORE: "redis",
104
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
105
+ MFA_ENCRYPTION_KEY: Buffer.alloc(32, 9).toString("base64"),
106
+ STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.auth.test",
107
+ STANDARD_SMTP_MAIL_SENDER_FROM: "auth@auth.test",
108
+ };
109
+
110
+ const composing = (env: Readonly<Record<string, string>>): Promise<unknown> =>
111
+ compose({ env, environment: "production", shippedRefreshTokenFamilyStore: true }).then(
112
+ (composition) => {
113
+ current = composition;
114
+ return undefined;
115
+ },
116
+ (caught: unknown) => caught,
117
+ );
118
+
119
+ describe("a Redis that will not report its eviction policy", () => {
120
+ it.each([
121
+ ["unset", MULTI_ENV],
122
+ ["false", { ...MULTI_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
123
+ ])(
124
+ "refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming the store",
125
+ async (_label, env) => {
126
+ const err = await composing(env);
127
+ expect(err).toBeInstanceOf(BootError);
128
+ expect(err).toMatchObject({
129
+ reason: "provides-factory-failed",
130
+ cause: { name: "RedisStoreEvictableError", maxmemoryPolicy: undefined },
131
+ });
132
+ },
133
+ );
134
+
135
+ it("boots every store with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
136
+ expect(
137
+ await composing({ ...MULTI_ENV, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
138
+ ).toBeUndefined();
139
+ expect(current?.modules.map((module) => module.name)).toEqual(
140
+ expect.arrayContaining([
141
+ "redis-attempt-counter",
142
+ "redis-session-stores",
143
+ "redis-federation-token-store",
144
+ ]),
145
+ );
146
+ });
147
+
148
+ it("boots with redis-clients.assumeNoEviction = true written in HOCON", async () => {
149
+ const composition = await compose({
150
+ env: MULTI_ENV,
151
+ environment: "production",
152
+ shippedRefreshTokenFamilyStore: true,
153
+ operatorHocon: "redis-clients.assumeNoEviction = true\n",
154
+ });
155
+ current = composition;
156
+ expect(composition.handle).toBeDefined();
157
+ });
158
+ });
159
+
160
+ describe("the MFA stores on a Redis that will not report its eviction policy", () => {
161
+ /** Only the MFA stores on Redis: a refusal can come from no other store. */
162
+ const MFA_ONLY = { ...SINGLE_ENV, ...MFA_ON_REDIS };
163
+
164
+ it.each([
165
+ ["unset", MFA_ONLY],
166
+ ["false", { ...MFA_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
167
+ ])(
168
+ "refuses the boot with REDIS_CLIENTS_ASSUME_NO_EVICTION %s, naming an MFA store",
169
+ async (_label, env) => {
170
+ const err = await composing(env);
171
+ expect(err).toBeInstanceOf(BootError);
172
+ expect(err).toMatchObject({
173
+ reason: "provides-factory-failed",
174
+ cause: {
175
+ name: "RedisStoreEvictableError",
176
+ reason: expect.stringMatching(/^mfa-(factor|transaction)-store-evictable$/),
177
+ maxmemoryPolicy: undefined,
178
+ },
179
+ });
180
+ },
181
+ );
182
+
183
+ it("boots both MFA stores with REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
184
+ expect(
185
+ await composing({ ...MFA_ONLY, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
186
+ ).toBeUndefined();
187
+ expect(current?.modules.map((module) => module.name)).toEqual(
188
+ expect.arrayContaining(["redis-mfa-factor-store", "redis-mfa-transaction-store"]),
189
+ );
190
+ expect(current?.handle.components.mfaFactorStore?.kind).toBe("redis");
191
+ expect(current?.handle.components.mfaTransactionStore?.kind).toBe("redis");
192
+ });
193
+
194
+ it.each([
195
+ ["unset", { ...MULTI_ENV, ...MFA_ON_REDIS }],
196
+ ["false", { ...MULTI_ENV, ...MFA_ON_REDIS, REDIS_CLIENTS_ASSUME_NO_EVICTION: "false" }],
197
+ ])(
198
+ "refuses every store on Redis with MFA on, REDIS_CLIENTS_ASSUME_NO_EVICTION %s",
199
+ async (_label, env) => {
200
+ expect(await composing(env)).toMatchObject({
201
+ reason: "provides-factory-failed",
202
+ cause: { name: "RedisStoreEvictableError", maxmemoryPolicy: undefined },
203
+ });
204
+ },
205
+ );
206
+
207
+ it("boots every store on Redis with MFA on and REDIS_CLIENTS_ASSUME_NO_EVICTION=true", async () => {
208
+ expect(
209
+ await composing({ ...MULTI_ENV, ...MFA_ON_REDIS, REDIS_CLIENTS_ASSUME_NO_EVICTION: "true" }),
210
+ ).toBeUndefined();
211
+ expect(current?.handle.components.mfaFactorStore?.kind).toBe("redis");
212
+ expect(current?.handle.components.mfaTransactionStore?.kind).toBe("redis");
213
+ });
214
+ });