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

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 (39) hide show
  1. package/README.ja.md +3 -1
  2. package/README.md +5 -2
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +7 -2
  5. package/templates/standalone/README.ja.md +14 -10
  6. package/templates/standalone/README.md +26 -20
  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 +1 -1
  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 +89 -86
  21. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +4 -0
  22. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -14
  23. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
  24. package/templates/standalone/src/__tests__/mfa-switch.test.mts +79 -21
  25. package/templates/standalone/src/__tests__/own-modules.test.mts +38 -12
  26. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
  27. package/templates/standalone/src/__tests__/replica-safety.test.mts +4 -0
  28. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +10 -3
  29. package/templates/standalone/src/__tests__/sections.test.mts +24 -0
  30. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +4 -0
  31. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
  32. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +8 -4
  33. package/templates/standalone/src/__tests__/two-phase-config.test.mts +2 -1
  34. package/templates/standalone/src/adapters.mts +2 -2
  35. package/templates/standalone/src/mfaSwitch.mts +6 -7
  36. package/templates/standalone/src/modules.mts +12 -6
  37. package/templates/standalone/src/rootRenames.mts +12 -13
  38. package/templates/standalone/src/sections.mts +12 -2
  39. package/templates/versions.json +18 -18
@@ -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
+ });
@@ -102,6 +102,10 @@ vi.mock("ioredis", () => {
102
102
  quit: async () => "OK",
103
103
  disconnect: () => undefined,
104
104
  ping: async () => "PONG",
105
+ // The Redis stores' eviction gate reads the policy from INFO memory: a
106
+ // default server's.
107
+ info: async (section: string) =>
108
+ section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
105
109
  };
106
110
  const makeMockRedis = (): object =>
107
111
  new Proxy(
@@ -32,6 +32,7 @@ import { buildModules } from "../buildModules.mjs";
32
32
  import { readOwnLayers, readSwitches, resolveConfigPaths } from "../configPath.mjs";
33
33
  import { templateReference } from "../modules.mjs";
34
34
  import { type Adapters, repositoriesSectionSchema } from "../sections.mjs";
35
+ import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
35
36
 
36
37
  const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
37
38
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
@@ -355,8 +356,8 @@ function dotenvExample(): Record<string, string> {
355
356
  return env;
356
357
  }
357
358
 
358
- describe("the production compose leaves MFA on, for the deployment to decide", () => {
359
- it("reads the switch as required, so a deployment that sets nothing about MFA is refused naming MFA_MODE=off", () => {
359
+ describe("the production compose leaves MFA to the configuration's switch, for the deployment to decide", () => {
360
+ it("reads the switch as the configuration's files write it: on, a deployment that sets nothing about MFA is refused naming MFA_MODE=off; off, it installs nothing of MFA", () => {
360
361
  // `environment:` wins over `env_file`, so the compose file's block is
361
362
  // laid over the `.env` copied from `.env.example`.
362
363
  const env = { ...dotenvExample(), ...bootableEnv("/docker-compose.production.yml") };
@@ -365,8 +366,14 @@ describe("the production compose leaves MFA on, for the deployment to decide", (
365
366
  expect(configEnv).toBe("production");
366
367
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
367
368
  const switches = readSwitches(readOwnLayers([envConfPath, applicationConfPath], { env }));
368
- expect(switches.mfaMode).toBe("required");
369
+ const mode = configuredMfaMode(configEnv);
370
+ expect(switches.mfaMode).toBe(mode);
369
371
 
372
+ if (mode === "off") {
373
+ const names = buildModules(switches, { environment: configEnv }).map((m) => m.name);
374
+ expect(names.filter((name) => /mfa/i.test(name))).toEqual([]);
375
+ return;
376
+ }
370
377
  let err: unknown;
371
378
  try {
372
379
  buildModules(switches, { environment: configEnv });
@@ -443,12 +443,36 @@ describe("redis-clients", () => {
443
443
  expect(redisClientsSectionSchema.parse({ url: "redis://r:6379", password: "p" })).toEqual({
444
444
  url: "redis://r:6379",
445
445
  password: "p",
446
+ assumeNoEviction: false,
446
447
  });
447
448
  expect(redisClientsSectionSchema.parse({ url: "redis://r:6379" })).toEqual({
448
449
  url: "redis://r:6379",
450
+ assumeNoEviction: false,
449
451
  });
450
452
  });
451
453
 
454
+ it.each([
455
+ [true, true],
456
+ ["true", true],
457
+ ["1", true],
458
+ ["false", false],
459
+ ["", false],
460
+ ])("reads assumeNoEviction %j as %s", (given, read) => {
461
+ expect(
462
+ redisClientsSectionSchema.parse({ url: "redis://r:6379", assumeNoEviction: given })
463
+ .assumeNoEviction,
464
+ ).toBe(read);
465
+ });
466
+
467
+ it("refuses an assumeNoEviction it cannot read as a boolean, naming the key", () => {
468
+ const parsed = redisClientsSectionSchema.safeParse({
469
+ url: "redis://r:6379",
470
+ assumeNoEviction: "yes",
471
+ });
472
+ expect(parsed.success).toBe(false);
473
+ expect(parsed.error?.issues[0]?.path).toEqual(["assumeNoEviction"]);
474
+ });
475
+
452
476
  it.each([
453
477
  ["no URL", {}],
454
478
  ["a null URL", { url: null }],
@@ -74,6 +74,10 @@ vi.mock("ioredis", () => {
74
74
  quit: async () => "OK",
75
75
  disconnect: () => undefined,
76
76
  ping: async () => "PONG",
77
+ // The Redis stores' eviction gate reads the policy from INFO memory: a
78
+ // default server's.
79
+ info: async (section: string) =>
80
+ section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
77
81
  };
78
82
  const makeMockRedis = (): object =>
79
83
  new Proxy(