@o3co/create-auth-provider 0.17.0-rc.1 → 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 (70) 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 -5
  5. package/templates/standalone/README.ja.md +53 -25
  6. package/templates/standalone/README.md +140 -58
  7. package/templates/standalone/config/application.conf +41 -36
  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 +30 -5
  12. package/templates/standalone/docker-compose.mailpit.yml +3 -0
  13. package/templates/standalone/docker-compose.production.yml +5 -2
  14. package/templates/standalone/package.json +1 -1
  15. package/templates/standalone/src/__tests__/adapters.test.mts +92 -25
  16. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  17. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  18. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +40 -11
  19. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  20. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  21. package/templates/standalone/src/__tests__/config-notices.test.mts +157 -0
  22. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
  23. package/templates/standalone/src/__tests__/core-federations.test.mts +57 -31
  24. package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
  25. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  26. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +434 -136
  27. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +19 -21
  28. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  29. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  30. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  31. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  32. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  33. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  34. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  35. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -16
  36. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  37. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
  38. package/templates/standalone/src/__tests__/mfa-switch.test.mts +149 -46
  39. package/templates/standalone/src/__tests__/own-modules.test.mts +78 -23
  40. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  41. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
  42. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  43. package/templates/standalone/src/__tests__/replica-safety.test.mts +131 -19
  44. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +25 -23
  45. package/templates/standalone/src/__tests__/sections.test.mts +24 -0
  46. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  47. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  48. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  49. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  50. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +274 -0
  51. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  52. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
  53. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  54. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +58 -38
  55. package/templates/standalone/src/__tests__/two-phase-config.test.mts +243 -76
  56. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  57. package/templates/standalone/src/adapters.mts +31 -18
  58. package/templates/standalone/src/app.mts +6 -5
  59. package/templates/standalone/src/bootRefusal.mts +117 -0
  60. package/templates/standalone/src/buildModules.mts +46 -96
  61. package/templates/standalone/src/configPath.mts +180 -153
  62. package/templates/standalone/src/mfaSwitch.mts +49 -27
  63. package/templates/standalone/src/modules.mts +37 -219
  64. package/templates/standalone/src/rootRenames.mts +38 -22
  65. package/templates/standalone/src/sections.mts +13 -2
  66. package/templates/standalone/tests/index.test.js +4 -3
  67. package/templates/versions.json +18 -18
  68. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  69. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  70. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
@@ -31,9 +31,9 @@
31
31
  * - the refresh-token family store under `core.deployment.mode = "single"`: memory,
32
32
  * not the shipped Redis, so the single-replica boot opens no sockets;
33
33
  * - upstream identity providers: core's fake OpenID Provider, through the
34
- * `fetch` option the Google and OIDC adapters take; the config bridges'
35
- * values are read as shipped (`googleFederationConfigModule`, the OIDC
36
- * package's reader), with only `fetch` added;
34
+ * `fetch` option the Google and OIDC federation type modules take: each is
35
+ * the module `buildModules` lists, built with that option, and the entries
36
+ * it handles are read as shipped;
37
37
  * - configuration with no environment form (grant connections, a key ring, a
38
38
  * landing URL): one more HOCON layer above the shipped ones.
39
39
  *
@@ -62,12 +62,15 @@ import {
62
62
  terminalErrorHandler,
63
63
  } from "@o3co/auth-provider-core";
64
64
  import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
65
+ import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
66
+ import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
65
67
  import express from "express";
66
68
  import helmet from "helmet";
67
69
  import request from "supertest";
68
70
  import { beforeAll, describe, expect, it } from "vitest";
69
71
  import { buildModules } from "#/buildModules.mjs";
70
72
  import {
73
+ configDefaultsFor,
71
74
  type OwnLayers,
72
75
  readOwnLayers,
73
76
  readSwitches,
@@ -75,7 +78,6 @@ import {
75
78
  resolveForBoot,
76
79
  type Switches,
77
80
  } from "#/configPath.mjs";
78
- import { googleFederationConfigModule, oidcFederationConfigModule } from "#/modules.mjs";
79
81
 
80
82
  export const ISSUER = "https://auth.test";
81
83
  const OIDC_ISSUER = "https://idp.test";
@@ -109,6 +111,7 @@ export const SINGLE_ENV: Readonly<Record<string, string>> = {
109
111
  SESSION_STORE_STORAGE_TYPE: "memory",
110
112
  ADAPTERS_USER_SESSION_STORES: "memory",
111
113
  ADAPTERS_RATE_LIMITER: "memory",
114
+ ADAPTERS_ATTEMPT_COUNTER: "memory",
112
115
  ADAPTERS_CODE_REPOSITORY: "memory",
113
116
  ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
114
117
  ADAPTERS_REPLAY_SEEN_SET: "memory",
@@ -150,6 +153,8 @@ export const MULTI_ENV: Readonly<Record<string, string>> = {
150
153
  REDIS_CLIENTS_URL: "redis://redis.test:6379",
151
154
  ADAPTERS_USER_SESSION_STORES: "redis",
152
155
  ADAPTERS_RATE_LIMITER: "redis",
156
+ // The login's attempt counter, shared: per process, `multi` refuses the login.
157
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
153
158
  ADAPTERS_CODE_REPOSITORY: "redis",
154
159
  ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
155
160
  ADAPTERS_REPLAY_SEEN_SET: "redis",
@@ -165,19 +170,19 @@ export const CONNECTION = "calendar";
165
170
 
166
171
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
167
172
 
173
+ const quoted = (value: string) => JSON.stringify(value);
174
+
168
175
  /**
169
- * What has no environment form — the federations' landing page, the grant
170
- * key ring and one grant connection — as an operator writes it: a HOCON
171
- * file of their own, the highest layer. Written once per process.
176
+ * What has no environment form — the federations' landing page and one grant
177
+ * connection — as an operator writes it: a HOCON file of their own, the
178
+ * highest layer. Written once per process.
172
179
  */
173
180
  const OPERATOR_LAYER: string = (() => {
174
181
  const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "operator.conf");
175
- const quoted = (value: string) => JSON.stringify(value);
176
182
  writeFileSync(
177
183
  file,
178
184
  `core.federations.google.clientUrl = ${quoted(FEDERATION_LANDING)}
179
185
  core.federations.oidc.clientUrl = ${quoted(FEDERATION_LANDING)}
180
- redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]
181
186
  federation-grants {
182
187
  connections {
183
188
  ${CONNECTION} {
@@ -201,26 +206,44 @@ function hoconFile(text: string): string {
201
206
  return file;
202
207
  }
203
208
 
204
- /** The composition's own files, highest first: the operator's layer, then the shipped production ones. */
205
- export function ownFiles(): string[] {
209
+ /**
210
+ * The Redis grant store's key ring, which has no environment form either, in
211
+ * a layer of its own: an operator writes it where that store is installed,
212
+ * since written for a store the composition does not load it reaches nothing
213
+ * and boot may name its section. Written once per process.
214
+ */
215
+ const GRANT_KEY_RING_LAYER: string = (() => {
216
+ const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "grant-keys.conf");
217
+ writeFileSync(
218
+ file,
219
+ `redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]\n`,
220
+ );
221
+ return file;
222
+ })();
223
+
224
+ /**
225
+ * The composition's own files under `env`, highest first: the operator's
226
+ * layers — the grant key ring among them where `env` turns federation grants
227
+ * on and puts their store on Redis — then the shipped production ones.
228
+ */
229
+ export function ownFiles(env: Readonly<Record<string, string>> = SINGLE_ENV): string[] {
206
230
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
207
- return [OPERATOR_LAYER, envConfPath, applicationConfPath];
231
+ const grantStoreOnRedis =
232
+ env.FEDERATION_GRANTS_ENABLED === "true" && env.ADAPTERS_FEDERATION_GRANT_STORE === "redis";
233
+ const keyRing = grantStoreOnRedis ? [GRANT_KEY_RING_LAYER] : [];
234
+ return [OPERATOR_LAYER, ...keyRing, envConfPath, applicationConfPath];
208
235
  }
209
236
 
210
237
  /**
211
- * Phase one, as `app.mts` reads it: the switches `buildModules` chooses
212
- * the modules by — and `reads`, what a module added to the composition reads
213
- * when it is built — from the composition's own files under `env` over core's
214
- * `reference.conf`. What the composition expects of session admission is
215
- * derived from it (`resolveForBoot`), after `config` adjusts it as an
216
- * operator's layer would.
238
+ * Phase one, as `app.mts` reads it: the switches `buildModules` chooses the
239
+ * modules by, from the composition's own files under `env` over the
240
+ * template's `reference.conf`.
217
241
  */
218
242
  export function resolveConfig(
219
243
  env: Readonly<Record<string, string>>,
220
- reads: readonly string[] = [],
221
- own: OwnLayers = readOwnLayers(ownFiles(), { env }),
244
+ own: OwnLayers = readOwnLayers(ownFiles(env), { env }),
222
245
  ): Switches {
223
- return readSwitches(own, { reads });
246
+ return readSwitches(own);
224
247
  }
225
248
 
226
249
  // ---------------------------------------------------------------------------
@@ -345,15 +368,41 @@ let upstreams: Promise<{ fakes: Upstreams; reset: () => void }> | undefined;
345
368
  * run if one did.
346
369
  */
347
370
  export async function sharedUpstreams(): Promise<Upstreams> {
371
+ const { fakes, reset } = await madeUpstreams();
372
+ reset();
373
+ return fakes;
374
+ }
375
+
376
+ /** The two fake upstreams, made on first use and never reset here. */
377
+ function madeUpstreams(): Promise<{ fakes: Upstreams; reset: () => void }> {
348
378
  upstreams ??= createUpstreams().then((fakes) => ({
349
379
  fakes,
350
380
  reset: resettable(fakes.oidc, fakes.google),
351
381
  }));
352
- const { fakes, reset } = await upstreams;
353
- reset();
354
- return fakes;
382
+ return upstreams;
355
383
  }
356
384
 
385
+ /** A `fetch` that reaches the shared fake upstream `name`. */
386
+ const upstreamFetch =
387
+ (name: keyof Upstreams): typeof fetch =>
388
+ async (input, init) =>
389
+ (await madeUpstreams()).fakes[name].fetch(input, init);
390
+
391
+ /**
392
+ * The federation type modules `buildModules` lists, by name, each built as
393
+ * the template builds it but with a `fetch` that reaches its fake upstream.
394
+ */
395
+ const UPSTREAM_TYPE_MODULES: ReadonlyMap<string, () => Module> = new Map([
396
+ [
397
+ googleFederationTypeModule().name,
398
+ () => googleFederationTypeModule({ fetch: upstreamFetch("google") }),
399
+ ],
400
+ [
401
+ oidcFederationTypeModule().name,
402
+ () => oidcFederationTypeModule({ fetch: upstreamFetch("oidc") }),
403
+ ],
404
+ ]);
405
+
357
406
  /**
358
407
  * Snapshots each fake's settings (every property that is not a function) and
359
408
  * returns what puts them back: primitives reassigned, objects restored in
@@ -432,52 +481,6 @@ async function createUpstreams(): Promise<Upstreams> {
432
481
  };
433
482
  }
434
483
 
435
- /**
436
- * What one of the template's config bridges provides for `config`. Read
437
- * through a record, not the typed slot: a program that loads this file without
438
- * a federation package's ComponentMap augmentation (`tools/composition` does)
439
- * has no key to name.
440
- */
441
- const bridged = <T,>(module: Module, slot: string, config: AppConfig): T => {
442
- const provider = (module.provides as Record<string, unknown> | undefined)?.[slot];
443
- if (typeof provider !== "function") throw new Error(`${module.name} provides no ${slot}`);
444
- return (provider as (deps: { config: AppConfig }) => T)({ config });
445
- };
446
-
447
- /**
448
- * The two federation config slots, read by the template's own bridges
449
- * (`oidcFederationConfigModule`, `googleFederationConfigModule`), with each
450
- * adapter's `fetch` pointed at its fake upstream. Only for the federations the
451
- * config enables — which is when `buildModules` lists the bridges.
452
- */
453
- async function federationOverrides(
454
- config: Switches,
455
- upstreams: Upstreams,
456
- ): Promise<Record<string, unknown>> {
457
- const overrides: Record<string, unknown> = {};
458
- const modules = buildModules(config).map((m) => m.name);
459
- if (modules.includes(oidcFederationConfigModule.name)) {
460
- const oidc = bridged<Record<string, object>>(
461
- oidcFederationConfigModule,
462
- "oidcFederationConfigs",
463
- config,
464
- );
465
- overrides.oidcFederationConfigs = Object.fromEntries(
466
- Object.entries(oidc).map(([name, entry]) => [
467
- name,
468
- { ...entry, fetch: upstreams.oidc.fetch },
469
- ]),
470
- );
471
- }
472
- if (modules.includes(googleFederationConfigModule.name)) {
473
- overrides.googleFederationConfig = {
474
- ...bridged<object>(googleFederationConfigModule, "googleFederationConfig", config),
475
- fetch: upstreams.google.fetch,
476
- };
477
- }
478
- return overrides;
479
- }
480
-
481
484
  // ---------------------------------------------------------------------------
482
485
  // Logger spy
483
486
  // ---------------------------------------------------------------------------
@@ -588,30 +591,24 @@ export interface ComposeOptions {
588
591
  readonly environment?: string;
589
592
  /** HOCON an operator writes above the composition's own files, read in both phases. */
590
593
  readonly operatorHocon?: string;
591
- /**
592
- * Paths read before boot beside the template's switches: what a module
593
- * `extraModules` adds reads when it is built (`readSwitches`'s `reads`).
594
- */
595
- readonly reads?: readonly string[];
596
594
  /** The deployment's own mail sender modules, handed to `buildModules` as `mailSenderModules`. */
597
595
  readonly mailSenderModules?: readonly Module[];
598
596
  /** Modules added after the template's own, before the order and the outage apply. */
599
597
  readonly extraModules?: (config: Switches) => readonly Module[];
600
- /** Components laid over the boot's, beside the federation config slots. */
598
+ /** Components laid over the boot's (`overrideComponents`). */
601
599
  readonly extraOverrides?: (config: Switches) => Record<string, unknown>;
602
600
  /** Client registrations beside the fixture's own, as `ClientEntrySchema` input. */
603
601
  readonly extraClients?: Readonly<Record<string, Record<string, unknown>>>;
604
602
  /** Users beside the fixture's own, keyed by username. */
605
603
  readonly extraUsers?: Readonly<Record<string, Record<string, unknown>>>;
606
604
  /**
607
- * Adjust the configuration before anything reads it, as an operator's own
608
- * layer would: applied to phase one's switches, and to what `createApp` is
609
- * handed, as resolved.
605
+ * Adjust what `createApp` is handed, as resolved, as an operator's own
606
+ * layer would.
610
607
  */
611
- readonly config?: (config: Switches) => Switches;
608
+ readonly config?: (config: AppConfig) => AppConfig;
612
609
  /**
613
- * Adjust phase one's switches alone, after `config`: what only phase one
614
- * reads, such as the Store transport settings a hand-built root passes.
610
+ * Adjust phase one's switches: what only phase one reads, such as the
611
+ * Store transport settings a hand-built root passes.
615
612
  */
616
613
  readonly switches?: (switches: Switches) => Switches;
617
614
  readonly order?: ModuleOrder;
@@ -639,7 +636,7 @@ export function composedModules(config: Switches, options: ComposeOptions = {}):
639
636
  ...(options.shippedRefreshTokenFamilyStore
640
637
  ? {}
641
638
  : { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
642
- }),
639
+ }).map((module) => UPSTREAM_TYPE_MODULES.get(module.name)?.() ?? module),
643
640
  ...(options.extraModules?.(config) ?? []),
644
641
  ];
645
642
  if (options.outage) modules = withOutage(modules, options.outage.slot, options.outage.outage);
@@ -667,35 +664,32 @@ export interface Composition {
667
664
  */
668
665
  export async function compose(options: ComposeOptions = {}): Promise<Composition> {
669
666
  const env = options.env ?? SINGLE_ENV;
670
- const adjust = <C extends AppConfig>(config: C): C =>
671
- options.config ? (options.config(config as unknown as Switches) as unknown as C) : config;
672
667
  // The composition's own layers, read once for both phases, as `app.mts`
673
668
  // reads them. Phase one: the switches the modules are chosen by.
674
669
  const own = readOwnLayers(
675
670
  options.operatorHocon === undefined
676
- ? ownFiles()
677
- : [hoconFile(options.operatorHocon), ...ownFiles()],
671
+ ? ownFiles(env)
672
+ : [hoconFile(options.operatorHocon), ...ownFiles(env)],
678
673
  { env },
679
674
  );
680
- const switches = resolveConfig(env, options.reads, own);
681
- const config = options.switches ? options.switches(adjust(switches)) : adjust(switches);
675
+ const switches = resolveConfig(env, own);
676
+ const config = options.switches ? options.switches(switches) : switches;
682
677
  const fakes = await sharedUpstreams();
683
678
  const modules = composedModules(config, options);
684
679
  const logger = createRecordingLogger();
685
680
  // Phase two: the configuration as resolved over every loaded package's
686
- // reference.conf, which createApp parses once.
687
- const resolved = adjust(resolveForBoot(own, modules, config));
681
+ // reference.conf, which createApp parses once, and its defaults.
682
+ const forBoot = resolveForBoot(own, modules, config);
683
+ const resolved = options.config ? options.config(forBoot) : forBoot;
688
684
  const handle = await createApp({
689
685
  modules,
690
686
  bootstrapComponents: {
691
687
  config: resolved,
688
+ configDefaults: configDefaultsFor(modules),
692
689
  pathResolver: (s) => s,
693
690
  logger,
694
691
  },
695
- overrideComponents: {
696
- ...(await federationOverrides(config, fakes)),
697
- ...options.extraOverrides?.(config),
698
- } as never,
692
+ overrideComponents: { ...options.extraOverrides?.(config) } as never,
699
693
  });
700
694
  const parsed = handle.components.config;
701
695
  if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
@@ -1056,6 +1050,11 @@ export interface OutageCase<C extends Composition = Composition> {
1056
1050
  readonly storeFieldNotRequired?: string;
1057
1051
  /** The predicates the composition breaks today, each naming its defect. */
1058
1052
  readonly defects?: Partial<Record<OutagePredicate, string>>;
1053
+ /**
1054
+ * The warn lines a `no-warn` defect writes, pinned exactly while it stands,
1055
+ * so the defect covers those lines and no other.
1056
+ */
1057
+ readonly defectWarns?: readonly string[];
1059
1058
  }
1060
1059
 
1061
1060
  /** The same defect text for every predicate that depends on the one missing line. */
@@ -1086,6 +1085,14 @@ export function describeOutages<C extends Composition>(
1086
1085
  ): void {
1087
1086
  describe(title, () => {
1088
1087
  for (const c of cases) {
1088
+ if (
1089
+ c.defectWarns !== undefined &&
1090
+ (c.defects?.["no-warn"] === undefined || c.defectWarns.length === 0)
1091
+ ) {
1092
+ throw new Error(
1093
+ `${c.module}: ${c.slot} down at ${c.surface}: defectWarns pins the lines of a no-warn defect, so it needs defects["no-warn"] and at least one line`,
1094
+ );
1095
+ }
1089
1096
  describe(`${c.module}: ${c.slot} down at ${c.surface}`, () => {
1090
1097
  let res: request.Response;
1091
1098
  let lines: LogLine[] = [];
@@ -1165,13 +1172,20 @@ export function describeOutages<C extends Composition>(
1165
1172
  expect(err).toMatchObject({ name: expect.any(String) });
1166
1173
  expect(err).not.toBeInstanceOf(Error);
1167
1174
  });
1168
- check("no-warn", "writes no warn line for it", () => {
1169
- const warns = lines
1175
+ const outageWarns = () =>
1176
+ lines
1170
1177
  .filter((line) => line.level === "warn")
1171
1178
  .map((line) => (typeof line.args[1] === "string" ? line.args[1] : String(line.args[0])))
1172
1179
  .filter((event) => !(c.unrelatedWarns ?? []).includes(event));
1173
- expect(warns).toEqual([]);
1180
+ check("no-warn", "writes no warn line for it", () => {
1181
+ expect(outageWarns()).toEqual([]);
1174
1182
  });
1183
+ if (c.defectWarns !== undefined) {
1184
+ const pinned = c.defectWarns;
1185
+ it(`writes exactly the warn lines its no-warn defect names: ${pinned.join(", ")}`, () => {
1186
+ expect(outageWarns()).toEqual(pinned);
1187
+ });
1188
+ }
1175
1189
  });
1176
1190
  }
1177
1191
  });
@@ -33,12 +33,14 @@ import { type Module, replicaUnsafeReason } from "@o3co/auth-provider-core";
33
33
  import * as redisPackage from "@o3co/auth-provider-redis";
34
34
  import request from "supertest";
35
35
  import { afterEach, describe, expect, it, vi } from "vitest";
36
+ import { readOwnLayers, resolveForBoot } from "#/configPath.mjs";
36
37
  import { standaloneRedisClientsModule } from "#/modules.mjs";
37
38
  import {
38
39
  type Composition,
39
40
  compose,
40
41
  composedModules,
41
42
  MULTI_ENV,
43
+ ownFiles,
42
44
  resolveConfig,
43
45
  } from "./all-modules-composition.fixture.mjs";
44
46
 
@@ -76,6 +78,10 @@ vi.mock("ioredis", () => {
76
78
  quit: async () => "OK",
77
79
  disconnect: () => undefined,
78
80
  ping: async () => "PONG",
81
+ // The Redis stores' eviction gate reads the policy from INFO memory: a
82
+ // default server's.
83
+ info: async (section: string) =>
84
+ section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
79
85
  };
80
86
  const makeMockRedis = (): object =>
81
87
  new Proxy(
@@ -115,10 +121,8 @@ const ALL_ON_REDIS_MODULES = [
115
121
  "oauth-authorization",
116
122
  "jwks",
117
123
  "session",
118
- "federation-google",
119
- "google-federation-config",
120
- "oidc-federation-config",
121
- "federation-oidc-oidc",
124
+ "federation-google-type",
125
+ "federation-oidc",
122
126
  "logging",
123
127
  "http",
124
128
  "key-store",
@@ -130,7 +134,9 @@ const ALL_ON_REDIS_MODULES = [
130
134
  "redis-federation-grant-store",
131
135
  "redis-federation-grant-intent-store",
132
136
  "redis-session-stores",
137
+ "core-session-lifecycle",
133
138
  "redis-rate-limiter",
139
+ "redis-attempt-counter",
134
140
  "redis-code-repository",
135
141
  "redis-access-token-denylist",
136
142
  "redis-replay-seen-set",
@@ -143,9 +149,16 @@ const ALL_ON_REDIS_MODULES = [
143
149
 
144
150
  describe('every module on, every shared store on Redis, core.deployment.mode = "multi"', () => {
145
151
  it("lists every module, none declaring replica-unsafe state, and boots", async () => {
146
- const modules = composedModules(resolveConfig(MULTI_ENV), MULTI);
152
+ const own = readOwnLayers(ownFiles(MULTI_ENV), { env: MULTI_ENV });
153
+ const config = resolveConfig(MULTI_ENV, own);
154
+ const modules = composedModules(config, MULTI);
147
155
  expect(modules.map((m) => m.name)).toEqual(ALL_ON_REDIS_MODULES);
148
- for (const module of modules) expect(replicaUnsafeReason(module), module.name).toBeUndefined();
156
+ // Each module's section at its name, as boot is handed it: a
157
+ // declaration made from the section is answered for it.
158
+ const sections = resolveForBoot(own, modules, config) as unknown as Record<string, unknown>;
159
+ for (const module of modules) {
160
+ expect(replicaUnsafeReason(module, sections[module.name]), module.name).toBeUndefined();
161
+ }
149
162
  current = await compose(MULTI);
150
163
  expect(current.handle.readinessProbes.map((p) => p.name)).toContain("redis");
151
164
  });
@@ -173,12 +186,16 @@ describe('every module on, every shared store on Redis, core.deployment.mode = "
173
186
  expect(current.handle.components.deploymentMode).toBe("multi");
174
187
  });
175
188
 
176
- it("boots as multi on DEPLOYMENT_MODE=multi beside CORE_DEPLOYMENT_MODE=multi, as the umbrella sets them", async () => {
177
- current = await compose({
178
- ...MULTI,
179
- env: { ...MULTI_ENV, DEPLOYMENT_MODE: "multi", CORE_DEPLOYMENT_MODE: "multi" },
189
+ it("refuses DEPLOYMENT_MODE=multi beside CORE_DEPLOYMENT_MODE=multi: the old name must be unset", async () => {
190
+ await expect(
191
+ compose({
192
+ ...MULTI,
193
+ env: { ...MULTI_ENV, DEPLOYMENT_MODE: "multi", CORE_DEPLOYMENT_MODE: "multi" },
194
+ }),
195
+ ).rejects.toMatchObject({
196
+ reason: "environment-variable-renamed",
197
+ details: { renamed: [{ from: "DEPLOYMENT_MODE", state: "different" }] },
180
198
  });
181
- expect(current.handle.components.deploymentMode).toBe("multi");
182
199
  });
183
200
 
184
201
  /**
@@ -208,6 +225,18 @@ describe('every module on, every shared store on Redis, core.deployment.mode = "
208
225
  });
209
226
  });
210
227
 
228
+ it("ADAPTERS_ATTEMPT_COUNTER=memory is refused at boot: the login would count its attempts per replica", async () => {
229
+ await expect(
230
+ compose({ ...MULTI, env: { ...MULTI_ENV, ADAPTERS_ATTEMPT_COUNTER: "memory" } }),
231
+ ).rejects.toMatchObject({
232
+ name: "BootError",
233
+ reason: "contribute-factory-failed",
234
+ cause: {
235
+ message: expect.stringContaining('no shared attemptCounter is wired for "login"'),
236
+ },
237
+ });
238
+ });
239
+
211
240
  it("names every memory store together when every switch is memory", async () => {
212
241
  const env = { ...MULTI_ENV };
213
242
  for (const [variable] of MEMORY_SWITCHES) env[variable] = "memory";