@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.
- package/README.ja.md +3 -1
- package/README.md +5 -2
- package/package.json +1 -1
- package/templates/standalone/.env.example +7 -5
- package/templates/standalone/README.ja.md +53 -25
- package/templates/standalone/README.md +140 -58
- package/templates/standalone/config/application.conf +41 -36
- package/templates/standalone/config/clients.yaml.example +8 -0
- package/templates/standalone/config/development.conf +2 -2
- package/templates/standalone/config/mailpit.conf +10 -5
- package/templates/standalone/config/reference.conf +30 -5
- package/templates/standalone/docker-compose.mailpit.yml +3 -0
- package/templates/standalone/docker-compose.production.yml +5 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +92 -25
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +40 -11
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
- package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
- package/templates/standalone/src/__tests__/config-notices.test.mts +157 -0
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +57 -31
- package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +434 -136
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +19 -21
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -16
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +149 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +78 -23
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
- package/templates/standalone/src/__tests__/replica-safety.test.mts +131 -19
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +25 -23
- package/templates/standalone/src/__tests__/sections.test.mts +24 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +274 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
- package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +58 -38
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +243 -76
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
- package/templates/standalone/src/adapters.mts +31 -18
- package/templates/standalone/src/app.mts +6 -5
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +46 -96
- package/templates/standalone/src/configPath.mts +180 -153
- package/templates/standalone/src/mfaSwitch.mts +49 -27
- package/templates/standalone/src/modules.mts +37 -219
- package/templates/standalone/src/rootRenames.mts +38 -22
- package/templates/standalone/src/sections.mts +13 -2
- package/templates/standalone/tests/index.test.js +4 -3
- package/templates/versions.json +18 -18
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
- 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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
170
|
-
*
|
|
171
|
-
*
|
|
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
|
-
/**
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
213
|
-
*
|
|
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
|
-
|
|
221
|
-
own: OwnLayers = readOwnLayers(ownFiles(), { env }),
|
|
244
|
+
own: OwnLayers = readOwnLayers(ownFiles(env), { env }),
|
|
222
245
|
): Switches {
|
|
223
|
-
return readSwitches(own
|
|
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
|
-
|
|
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
|
|
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
|
|
608
|
-
* layer would
|
|
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:
|
|
608
|
+
readonly config?: (config: AppConfig) => AppConfig;
|
|
612
609
|
/**
|
|
613
|
-
* Adjust phase one's switches
|
|
614
|
-
*
|
|
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,
|
|
681
|
-
const config = options.switches ? options.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
|
|
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
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
-
|
|
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("
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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";
|