@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -0,0 +1,1178 @@
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
+ * The composition `all-modules-composition*.test.mts` boot: every module this
19
+ * template can turn on, switched on together from the shipped HOCON, and
20
+ * mounted the way `app.mts` mounts it.
21
+ *
22
+ * Real: the configuration (`config/*.conf` over every loaded package's
23
+ * `reference.conf`, core's last, under the environment a deployment would
24
+ * export) read in `app.mts`'s two phases (`readSwitches`, then
25
+ * `resolveForBoot` for `createApp`), `buildModules`, core's `createApp`, every
26
+ * module and store it selects, the key store, the audit sink, `helmet` and the
27
+ * terminal error handler.
28
+ *
29
+ * Substituted (the first two are overrides `buildModules` offers):
30
+ * - client and user repositories: in memory, not YAML off disk;
31
+ * - the refresh-token family store under `core.deployment.mode = "single"`: memory,
32
+ * not the shipped Redis, so the single-replica boot opens no sockets;
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;
37
+ * - configuration with no environment form (grant connections, a key ring, a
38
+ * landing URL): one more HOCON layer above the shipped ones.
39
+ *
40
+ * `tools/composition` in the monorepo imports this file to boot the same
41
+ * composition with the workspace's other modules added (`ComposeOptions`'s
42
+ * `extra*` options), so the two suites share one fixture.
43
+ */
44
+
45
+ import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
46
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
47
+ import http from "node:http";
48
+ import type { AddressInfo } from "node:net";
49
+ import { tmpdir } from "node:os";
50
+ import { join } from "node:path";
51
+ import { fileURLToPath } from "node:url";
52
+ import {
53
+ type AppConfig,
54
+ ClientEntrySchema,
55
+ createApp,
56
+ defineModule,
57
+ InMemoryClientRepository,
58
+ InMemoryUserRepository,
59
+ type Logger,
60
+ type Module,
61
+ memoryRefreshTokenFamilyStoreModule,
62
+ terminalErrorHandler,
63
+ } from "@o3co/auth-provider-core";
64
+ import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
65
+ import express from "express";
66
+ import helmet from "helmet";
67
+ import request from "supertest";
68
+ import { beforeAll, describe, expect, it } from "vitest";
69
+ import { buildModules } from "#/buildModules.mjs";
70
+ import {
71
+ type OwnLayers,
72
+ readOwnLayers,
73
+ readSwitches,
74
+ resolveConfigPaths,
75
+ resolveForBoot,
76
+ type Switches,
77
+ } from "#/configPath.mjs";
78
+ import { googleFederationConfigModule, oidcFederationConfigModule } from "#/modules.mjs";
79
+
80
+ export const ISSUER = "https://auth.test";
81
+ const OIDC_ISSUER = "https://idp.test";
82
+ /** Where a federated login lands the browser when the start carried no `redirect_to`. */
83
+ export const FEDERATION_LANDING = "https://app.test/home";
84
+
85
+ const signingKey = generateKeyPairSync("ed25519", {
86
+ publicKeyEncoding: { type: "spki", format: "pem" },
87
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
88
+ });
89
+ /** 32 bytes, base64 — the shape every at-rest encryption key here takes. */
90
+ const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
91
+
92
+ /**
93
+ * One replica, every store in memory, every feature the template can switch
94
+ * on switched on: the four token grants, the consent step with Client ID
95
+ * Metadata Documents, federation grants, the Google federation and the
96
+ * shipped generic OIDC one. MFA, on by default, is switched off: the suites
97
+ * about MFA turn it on with `MFA_MODE`.
98
+ */
99
+ export const SINGLE_ENV: Readonly<Record<string, string>> = {
100
+ MFA_MODE: "off",
101
+ OAUTH_JWT_ISSUER: ISSUER,
102
+ // The shipped default algorithm (EdDSA), with the key pair inline.
103
+ KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
104
+ KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
105
+ SESSION_STORE_SECRET: "all-modules-composition-session.at-least-32-bytes.ok",
106
+ SESSION_STORE_SECURE: "false",
107
+ SESSION_STORE_NAME: "auth.session",
108
+ CORE_DEPLOYMENT_MODE: "single",
109
+ SESSION_STORE_STORAGE_TYPE: "memory",
110
+ ADAPTERS_USER_SESSION_STORES: "memory",
111
+ ADAPTERS_RATE_LIMITER: "memory",
112
+ ADAPTERS_CODE_REPOSITORY: "memory",
113
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
114
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
115
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
116
+ ADAPTERS_CONSENT_STORE: "memory",
117
+ ADAPTERS_FEDERATION_GRANT_STORE: "memory",
118
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
119
+ // The user repository is replaced (see the header); `yaml` keeps the
120
+ // shipped `http` adapter's URL requirements out of config validation.
121
+ ADAPTERS_USER_REPOSITORY: "yaml",
122
+ OAUTH_SESSION_ENABLED: "true",
123
+ OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
124
+ OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
125
+ OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
126
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "true",
127
+ FEDERATION_GRANTS_ENABLED: "true",
128
+ FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
129
+ // The in-memory user repository covers no registration; `required` is a
130
+ // Store's statement that it does (see the federation-grants README).
131
+ FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
132
+ CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
133
+ CORE_FEDERATIONS_GOOGLE_CLIENT_ID: "google-client",
134
+ CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-secret",
135
+ CORE_FEDERATIONS_OIDC_ENABLED: "true",
136
+ CORE_FEDERATIONS_OIDC_ISSUER: OIDC_ISSUER,
137
+ CORE_FEDERATIONS_OIDC_CLIENT_ID: "oidc-client",
138
+ CORE_FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-secret",
139
+ };
140
+
141
+ /**
142
+ * The same deployment on more than one replica: every shared store on Redis,
143
+ * express-session's own included (the umbrella E2E's shape).
144
+ */
145
+ export const MULTI_ENV: Readonly<Record<string, string>> = {
146
+ ...SINGLE_ENV,
147
+ CORE_DEPLOYMENT_MODE: "multi",
148
+ SESSION_STORE_STORAGE_TYPE: "redis",
149
+ SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
150
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
151
+ ADAPTERS_USER_SESSION_STORES: "redis",
152
+ ADAPTERS_RATE_LIMITER: "redis",
153
+ ADAPTERS_CODE_REPOSITORY: "redis",
154
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
155
+ ADAPTERS_REPLAY_SEEN_SET: "redis",
156
+ ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
157
+ REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
158
+ ADAPTERS_CONSENT_STORE: "redis",
159
+ ADAPTERS_FEDERATION_GRANT_STORE: "redis",
160
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
161
+ };
162
+
163
+ /** The grant connection the federation-grant flows use, on the shipped `oidc` federation. */
164
+ export const CONNECTION = "calendar";
165
+
166
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
167
+
168
+ /**
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.
172
+ */
173
+ const OPERATOR_LAYER: string = (() => {
174
+ const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "operator.conf");
175
+ const quoted = (value: string) => JSON.stringify(value);
176
+ writeFileSync(
177
+ file,
178
+ `core.federations.google.clientUrl = ${quoted(FEDERATION_LANDING)}
179
+ core.federations.oidc.clientUrl = ${quoted(FEDERATION_LANDING)}
180
+ redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]
181
+ federation-grants {
182
+ connections {
183
+ ${CONNECTION} {
184
+ federation = "oidc"
185
+ scopes = ["openid", "offline_access", "calendar.read"]
186
+ boundary = "production"
187
+ maxAccessTokenLifetime = 3600
188
+ callbackURL = ${quoted(`${ISSUER}/session/federation-grants/callback/${CONNECTION}`)}
189
+ }
190
+ }
191
+ }
192
+ `,
193
+ );
194
+ return file;
195
+ })();
196
+
197
+ /** `text` in a file of its own, for a layer above the composition's files. */
198
+ function hoconFile(text: string): string {
199
+ const file = join(mkdtempSync(join(tmpdir(), "all-modules-operator-")), "operator.conf");
200
+ writeFileSync(file, text);
201
+ return file;
202
+ }
203
+
204
+ /** The composition's own files, highest first: the operator's layer, then the shipped production ones. */
205
+ export function ownFiles(): string[] {
206
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
207
+ return [OPERATOR_LAYER, envConfPath, applicationConfPath];
208
+ }
209
+
210
+ /**
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.
217
+ */
218
+ export function resolveConfig(
219
+ env: Readonly<Record<string, string>>,
220
+ reads: readonly string[] = [],
221
+ own: OwnLayers = readOwnLayers(ownFiles(), { env }),
222
+ ): Switches {
223
+ return readSwitches(own, { reads });
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Registrations
228
+ // ---------------------------------------------------------------------------
229
+
230
+ export const WEB = { id: "web", secret: "web-secret", redirectUri: "https://rp.test/cb" } as const;
231
+ export const M2M = { id: "m2m", secret: "m2m-secret" } as const;
232
+ export const WORKER = {
233
+ id: "worker",
234
+ secret: "worker-secret",
235
+ redirectUri: "https://worker.test/connected",
236
+ } as const;
237
+ /** Not first-party: `/authorize` sends its user through the consent step. */
238
+ export const THIRD = {
239
+ id: "third",
240
+ secret: "third-secret",
241
+ redirectUri: "https://third.test/cb",
242
+ } as const;
243
+
244
+ export const ALICE = { username: "alice", password: "correct-horse-battery", sub: "u-alice" };
245
+ /** The subjects the fake IdPs sign in, as the Store has them linked. */
246
+ const OIDC_SUB = "idp-sub";
247
+ const GOOGLE_SUB = "google-sub";
248
+
249
+ type ClientEntry = ReturnType<typeof ClientEntrySchema.parse>;
250
+
251
+ const clientEntries = (
252
+ extra: Readonly<Record<string, Record<string, unknown>>> = {},
253
+ ): Map<string, ClientEntry> =>
254
+ new Map<string, ClientEntry>([
255
+ ...Object.entries(extra).map(
256
+ ([id, entry]) => [id, ClientEntrySchema.parse(entry)] as [string, ClientEntry],
257
+ ),
258
+ [
259
+ WEB.id,
260
+ ClientEntrySchema.parse({
261
+ tokenEndpointAuthMethod: "client_secret_basic",
262
+ clientSecret: WEB.secret,
263
+ allowedRedirectUris: [WEB.redirectUri],
264
+ allowedScopes: ["openid", "profile", "email", "offline_access"],
265
+ allowedGrantTypes: ["authorization_code", "refresh_token", "session"],
266
+ firstParty: true,
267
+ }),
268
+ ],
269
+ [
270
+ M2M.id,
271
+ ClientEntrySchema.parse({
272
+ tokenEndpointAuthMethod: "client_secret_basic",
273
+ clientSecret: M2M.secret,
274
+ allowedScopes: ["api.read"],
275
+ allowedGrantTypes: ["client_credentials"],
276
+ }),
277
+ ],
278
+ [
279
+ WORKER.id,
280
+ ClientEntrySchema.parse({
281
+ tokenEndpointAuthMethod: "client_secret_basic",
282
+ clientSecret: WORKER.secret,
283
+ allowedScopes: ["openid"],
284
+ allowedGrantTypes: [],
285
+ allowedFederationGrantConnections: [CONNECTION],
286
+ federationGrantRedirectUris: [WORKER.redirectUri],
287
+ }),
288
+ ],
289
+ [
290
+ THIRD.id,
291
+ ClientEntrySchema.parse({
292
+ tokenEndpointAuthMethod: "client_secret_basic",
293
+ clientSecret: THIRD.secret,
294
+ allowedRedirectUris: [THIRD.redirectUri],
295
+ allowedScopes: ["openid"],
296
+ allowedGrantTypes: ["authorization_code"],
297
+ }),
298
+ ],
299
+ ]);
300
+
301
+ const testRepositoriesModule = (
302
+ extraClients: Readonly<Record<string, Record<string, unknown>>> = {},
303
+ extraUsers: Readonly<Record<string, Record<string, unknown>>> = {},
304
+ ): Module =>
305
+ defineModule({
306
+ name: "test:repositories",
307
+ provides: {
308
+ clientRepository: () => new InMemoryClientRepository(clientEntries(extraClients)),
309
+ userRepository: () =>
310
+ new InMemoryUserRepository(
311
+ new Map([
312
+ [
313
+ ALICE.username,
314
+ {
315
+ id: ALICE.sub,
316
+ password: ALICE.password,
317
+ email: "alice@example.com",
318
+ token: `oidc:${OIDC_SUB}`,
319
+ },
320
+ ],
321
+ ["bob", { id: "u-bob", password: "bob-password-long", token: `google:${GOOGLE_SUB}` }],
322
+ ...Object.entries(extraUsers),
323
+ ]) as ConstructorParameters<typeof InMemoryUserRepository>[0],
324
+ ),
325
+ },
326
+ });
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Upstream identity providers
330
+ // ---------------------------------------------------------------------------
331
+
332
+ export interface Upstreams {
333
+ readonly oidc: FakeIdp;
334
+ readonly google: FakeIdp;
335
+ }
336
+
337
+ let upstreams: Promise<{ fakes: Upstreams; reset: () => void }> | undefined;
338
+
339
+ /**
340
+ * The two fake upstreams, made once per test file — each generates an RSA key,
341
+ * and a login's state lives in the authorization it recorded, so boots can
342
+ * share them — and put back as they were made before every boot (`compose`
343
+ * calls this). A test may set a fake's knobs for its own boot; it may not
344
+ * rotate a fake's key, which cannot be put back, and the next boot refuses to
345
+ * run if one did.
346
+ */
347
+ export async function sharedUpstreams(): Promise<Upstreams> {
348
+ upstreams ??= createUpstreams().then((fakes) => ({
349
+ fakes,
350
+ reset: resettable(fakes.oidc, fakes.google),
351
+ }));
352
+ const { fakes, reset } = await upstreams;
353
+ reset();
354
+ return fakes;
355
+ }
356
+
357
+ /**
358
+ * Snapshots each fake's settings (every property that is not a function) and
359
+ * returns what puts them back: primitives reassigned, objects restored in
360
+ * place (a fake may hold its own reference to one), recorded requests
361
+ * cleared. A fake's signing key is checked, not restored: a rotated key is
362
+ * refused.
363
+ *
364
+ * A fake's closure is not restored: core's fake IdP remembers whether consent
365
+ * was granted (`consentGranted`), how many codes it issued (`codesIssued`) and
366
+ * every authorization it recorded.
367
+ * Codes and authorizations are keyed per login; consent is not, and it decides
368
+ * Google's refresh token only under `refreshTokenOnlyOnConsent`. That knob is
369
+ * therefore forbidden on a shared fake (the next reset refuses to run if a test
370
+ * set it); a test that needs it makes its own fake with `createFakeIdp`.
371
+ */
372
+ export function resettable(...fakes: readonly object[]): () => void {
373
+ const snapshots = fakes.map((fake) => ({
374
+ fake: fake as Record<string, unknown>,
375
+ clone: new Map(
376
+ Object.entries(fake)
377
+ .filter(([name, value]) => typeof value !== "function" && name !== "requests")
378
+ .map(([name, value]) => [name, structuredClone(value)]),
379
+ ),
380
+ kid: currentKidOf(fake),
381
+ }));
382
+ return () => {
383
+ for (const { fake, clone, kid } of snapshots) {
384
+ if (fake.refreshTokenOnlyOnConsent === true) {
385
+ throw new Error(
386
+ "a test set refreshTokenOnlyOnConsent on a shared fake upstream: the consent it reads is closure state no reset restores; make a fake of its own instead",
387
+ );
388
+ }
389
+ if (currentKidOf(fake) !== kid) {
390
+ throw new Error(
391
+ "a test rotated a shared fake upstream's key: the fakes are shared by every boot in the file; make a fake of its own instead",
392
+ );
393
+ }
394
+ for (const [name, original] of clone) {
395
+ const current = fake[name];
396
+ if (typeof current === "object" && current !== null && !Array.isArray(current)) {
397
+ for (const key of Object.keys(current)) delete (current as Record<string, unknown>)[key];
398
+ Object.assign(current, structuredClone(original));
399
+ } else {
400
+ fake[name] = structuredClone(original);
401
+ }
402
+ }
403
+ const requests = fake.requests;
404
+ if (Array.isArray(requests)) requests.splice(0);
405
+ }
406
+ };
407
+ }
408
+
409
+ const currentKidOf = (fake: object): string | undefined => {
410
+ const currentKid = (fake as { currentKid?: unknown }).currentKid;
411
+ return typeof currentKid === "function" ? (currentKid as () => string)() : undefined;
412
+ };
413
+
414
+ async function createUpstreams(): Promise<Upstreams> {
415
+ return {
416
+ oidc: await createFakeIdp({
417
+ issuer: OIDC_ISSUER,
418
+ discovery: true,
419
+ clientId: SINGLE_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID,
420
+ sub: OIDC_SUB,
421
+ }),
422
+ // Google's endpoints are fixed in the adapter, not discovered.
423
+ google: await createFakeIdp({
424
+ issuer: "https://accounts.google.com",
425
+ authorizationEndpoint: "https://accounts.google.com/o/oauth2/v2/auth",
426
+ tokenEndpoint: "https://oauth2.googleapis.com/token",
427
+ jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
428
+ userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
429
+ clientId: SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
430
+ sub: GOOGLE_SUB,
431
+ }),
432
+ };
433
+ }
434
+
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
+ // ---------------------------------------------------------------------------
482
+ // Logger spy
483
+ // ---------------------------------------------------------------------------
484
+
485
+ export interface LogLine {
486
+ readonly level: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
487
+ readonly args: readonly unknown[];
488
+ }
489
+
490
+ export interface RecordingLogger extends Logger {
491
+ readonly lines: LogLine[];
492
+ }
493
+
494
+ /** A `Logger` that records every call, its children's included, in one list. */
495
+ export function createRecordingLogger(): RecordingLogger {
496
+ const lines: LogLine[] = [];
497
+ const make = (bindings: Record<string, unknown>): Logger => {
498
+ const at =
499
+ (level: LogLine["level"]) =>
500
+ (...args: unknown[]): void => {
501
+ const [first, ...rest] = args;
502
+ lines.push({
503
+ level,
504
+ args:
505
+ typeof first === "object" && first !== null && Object.keys(bindings).length > 0
506
+ ? [{ ...bindings, ...first }, ...rest]
507
+ : args,
508
+ });
509
+ };
510
+ return {
511
+ trace: at("trace"),
512
+ debug: at("debug"),
513
+ info: at("info"),
514
+ warn: at("warn"),
515
+ error: at("error"),
516
+ fatal: at("fatal"),
517
+ child: (more) => make({ ...bindings, ...more }),
518
+ } as Logger;
519
+ };
520
+ return Object.assign(make({}), { lines }) as RecordingLogger;
521
+ }
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // Composition
525
+ // ---------------------------------------------------------------------------
526
+
527
+ /** A switch an outage wrapper reads on every call. */
528
+ export interface Outage {
529
+ down: boolean;
530
+ }
531
+
532
+ /** What a store adapter's client throws when its connection is refused. */
533
+ const connectionRefused = (): Error =>
534
+ Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:6379"), { code: "ECONNREFUSED" });
535
+
536
+ /** Every method of `target` rejects while `outage.down`, and is the real one otherwise. */
537
+ function failWhileDown<T extends object>(target: T, outage: Outage): T {
538
+ return new Proxy(target, {
539
+ get(object, property) {
540
+ const value = Reflect.get(object, property, object);
541
+ if (typeof value !== "function") return value;
542
+ return (...args: unknown[]): unknown =>
543
+ outage.down
544
+ ? Promise.reject(connectionRefused())
545
+ : (value as (...a: unknown[]) => unknown).apply(object, args);
546
+ },
547
+ });
548
+ }
549
+
550
+ /**
551
+ * `modules` with the provider of `slot` wrapped by {@link failWhileDown}: the
552
+ * real module, named and declared as it is, handing out the real store.
553
+ */
554
+ function withOutage(modules: readonly Module[], slot: string, outage: Outage): Module[] {
555
+ let found = false;
556
+ const wrapped = modules.map((module) => {
557
+ const provides = module.provides as
558
+ | Record<string, (deps: unknown) => unknown | Promise<unknown>>
559
+ | undefined;
560
+ const provider = provides?.[slot];
561
+ if (provider === undefined) return module;
562
+ found = true;
563
+ return {
564
+ ...module,
565
+ provides: {
566
+ ...provides,
567
+ [slot]: async (deps: unknown) => failWhileDown((await provider(deps)) as object, outage),
568
+ },
569
+ } as Module;
570
+ });
571
+ if (!found) throw new Error(`no module in the composition provides ${slot}`);
572
+ return wrapped;
573
+ }
574
+
575
+ /** The order `buildModules` lists the modules in. */
576
+ export const AS_LISTED = "as listed";
577
+ /**
578
+ * Every module after the first in reverse. The first, express-session's
579
+ * middleware, is placed by list position alone (`buildModules` says so), so
580
+ * it stays first; everything else changes place.
581
+ */
582
+ export const REVERSED = "reversed";
583
+ export type ModuleOrder = typeof AS_LISTED | typeof REVERSED;
584
+
585
+ export interface ComposeOptions {
586
+ readonly env?: Readonly<Record<string, string>>;
587
+ /** The name the configuration was selected by, as `app.mts` hands `buildModules` it; `production` by default. */
588
+ readonly environment?: string;
589
+ /** HOCON an operator writes above the composition's own files, read in both phases. */
590
+ 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
+ /** The deployment's own mail sender modules, handed to `buildModules` as `mailSenderModules`. */
597
+ readonly mailSenderModules?: readonly Module[];
598
+ /** Modules added after the template's own, before the order and the outage apply. */
599
+ readonly extraModules?: (config: Switches) => readonly Module[];
600
+ /** Components laid over the boot's, beside the federation config slots. */
601
+ readonly extraOverrides?: (config: Switches) => Record<string, unknown>;
602
+ /** Client registrations beside the fixture's own, as `ClientEntrySchema` input. */
603
+ readonly extraClients?: Readonly<Record<string, Record<string, unknown>>>;
604
+ /** Users beside the fixture's own, keyed by username. */
605
+ readonly extraUsers?: Readonly<Record<string, Record<string, unknown>>>;
606
+ /**
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.
610
+ */
611
+ readonly config?: (config: Switches) => Switches;
612
+ /**
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.
615
+ */
616
+ readonly switches?: (switches: Switches) => Switches;
617
+ readonly order?: ModuleOrder;
618
+ readonly outage?: { readonly slot: string; readonly outage: Outage };
619
+ /** Keep the shipped Redis refresh-token family store (the `multi` boot). */
620
+ readonly shippedRefreshTokenFamilyStore?: boolean;
621
+ /**
622
+ * Mount core's terminal error handler after the composed router again, as
623
+ * `app.mts` does for its host routes (the default). `false` mounts the
624
+ * router alone, as a composition root that copies nothing of `app.mts`
625
+ * does.
626
+ */
627
+ readonly terminalErrorHandler?: boolean;
628
+ }
629
+
630
+ /** The module list the template boots for `config`, as `app.mts` builds it. */
631
+ export function composedModules(config: Switches, options: ComposeOptions = {}): Module[] {
632
+ let modules = [
633
+ ...buildModules(config, {
634
+ environment: options.environment ?? "production",
635
+ repositoriesModule: testRepositoriesModule(options.extraClients, options.extraUsers),
636
+ ...(options.mailSenderModules === undefined
637
+ ? {}
638
+ : { mailSenderModules: options.mailSenderModules }),
639
+ ...(options.shippedRefreshTokenFamilyStore
640
+ ? {}
641
+ : { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
642
+ }),
643
+ ...(options.extraModules?.(config) ?? []),
644
+ ];
645
+ if (options.outage) modules = withOutage(modules, options.outage.slot, options.outage.outage);
646
+ if (options.order === REVERSED) modules = [modules[0], ...modules.slice(1).reverse()];
647
+ return modules;
648
+ }
649
+
650
+ export interface Composition {
651
+ readonly app: express.Express;
652
+ readonly handle: Awaited<ReturnType<typeof createApp>>;
653
+ /** The configuration boot parsed: what the handle's `config` slot holds. */
654
+ readonly config: AppConfig;
655
+ /** What `createApp` was handed: the configuration as resolved (phase two), unparsed. */
656
+ readonly resolved: AppConfig;
657
+ readonly modules: readonly Module[];
658
+ readonly logger: RecordingLogger;
659
+ readonly upstreams: Upstreams;
660
+ }
661
+
662
+ /**
663
+ * Boots the composition and mounts it as `app.mts` does: `helmet`, the
664
+ * composed router, and the terminal error handler last (unless
665
+ * `terminalErrorHandler` is `false`), all on one logger that is also the
666
+ * boot's `logger` component.
667
+ */
668
+ export async function compose(options: ComposeOptions = {}): Promise<Composition> {
669
+ 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
+ // The composition's own layers, read once for both phases, as `app.mts`
673
+ // reads them. Phase one: the switches the modules are chosen by.
674
+ const own = readOwnLayers(
675
+ options.operatorHocon === undefined
676
+ ? ownFiles()
677
+ : [hoconFile(options.operatorHocon), ...ownFiles()],
678
+ { env },
679
+ );
680
+ const switches = resolveConfig(env, options.reads, own);
681
+ const config = options.switches ? options.switches(adjust(switches)) : adjust(switches);
682
+ const fakes = await sharedUpstreams();
683
+ const modules = composedModules(config, options);
684
+ const logger = createRecordingLogger();
685
+ // 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));
688
+ const handle = await createApp({
689
+ modules,
690
+ bootstrapComponents: {
691
+ config: resolved,
692
+ pathResolver: (s) => s,
693
+ logger,
694
+ },
695
+ overrideComponents: {
696
+ ...(await federationOverrides(config, fakes)),
697
+ ...options.extraOverrides?.(config),
698
+ } as never,
699
+ });
700
+ const parsed = handle.components.config;
701
+ if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
702
+ const httpSettings = handle.components.httpSettings;
703
+ if (httpSettings === undefined) throw new Error("createApp booted without httpSettings");
704
+ const app = express();
705
+ app.set("trust proxy", httpSettings.trustProxy);
706
+ app.use(
707
+ helmet({
708
+ contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] } },
709
+ }),
710
+ );
711
+ app.use(handle.router);
712
+ if (options.terminalErrorHandler !== false) app.use(terminalErrorHandler(logger));
713
+ return { app, handle, config: parsed, resolved, modules, logger, upstreams: fakes };
714
+ }
715
+
716
+ // ---------------------------------------------------------------------------
717
+ // Flows
718
+ // ---------------------------------------------------------------------------
719
+
720
+ export const basic = (client: { id: string; secret: string }): string =>
721
+ `Basic ${Buffer.from(`${client.id}:${client.secret}`).toString("base64")}`;
722
+
723
+ const cookiesOf = (res: request.Response): string[] =>
724
+ (res.headers["set-cookie"] as unknown as string[] | undefined) ?? [];
725
+
726
+ /** The browser half: a CSRF pair, then the password login. */
727
+ export async function login(
728
+ app: express.Express,
729
+ ): Promise<{ readonly res: request.Response; readonly cookies: string[] }> {
730
+ const csrf = await request(app).get("/session/csrf");
731
+ const res = await request(app)
732
+ .post("/session/login")
733
+ .set("Cookie", cookiesOf(csrf))
734
+ .set(csrf.body.header_name as string, csrf.body.csrf_token as string)
735
+ .type("form")
736
+ .send({ username: ALICE.username, password: ALICE.password });
737
+ return { res, cookies: res.status === 200 ? cookiesOf(res) : cookiesOf(csrf) };
738
+ }
739
+
740
+ export const PKCE = (() => {
741
+ const verifier = randomBytes(32).toString("base64url");
742
+ return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url") };
743
+ })();
744
+
745
+ export function authorize(
746
+ app: express.Express,
747
+ cookies: readonly string[],
748
+ client: { id: string; redirectUri: string } = WEB,
749
+ ): request.Test {
750
+ return request(app)
751
+ .get("/oauth/authorize")
752
+ .set("Cookie", [...cookies])
753
+ .query({
754
+ response_type: "code",
755
+ client_id: client.id,
756
+ redirect_uri: client.redirectUri,
757
+ scope: client === WEB ? "openid profile offline_access" : "openid",
758
+ state: "af0ifjsldkj",
759
+ nonce: "n-0S6_WzA2Mj",
760
+ code_challenge: PKCE.challenge,
761
+ code_challenge_method: "S256",
762
+ });
763
+ }
764
+
765
+ export const codeFrom = (res: request.Response): string => {
766
+ const code = new URL(res.headers.location as string).searchParams.get("code");
767
+ if (code === null) throw new Error(`no code in ${res.status} ${res.headers.location}`);
768
+ return code;
769
+ };
770
+
771
+ export function redeem(app: express.Express, code: string): request.Test {
772
+ return request(app).post("/oauth/token").set("Authorization", basic(WEB)).type("form").send({
773
+ grant_type: "authorization_code",
774
+ code,
775
+ redirect_uri: WEB.redirectUri,
776
+ code_verifier: PKCE.verifier,
777
+ });
778
+ }
779
+
780
+ /** Log in, authorize with PKCE, redeem: the web client's tokens. */
781
+ export async function webTokens(app: express.Express): Promise<Record<string, string>> {
782
+ const { cookies } = await login(app);
783
+ const res = await redeem(app, codeFrom(await authorize(app, cookies)));
784
+ if (res.status !== 200) throw new Error(`code exchange answered ${res.status}`);
785
+ return res.body as Record<string, string>;
786
+ }
787
+
788
+ /**
789
+ * Start a federated login and play the upstream. What comes back sends the
790
+ * callback when called — a function, because a supertest request is a
791
+ * thenable, and returning one from an async function would send it.
792
+ */
793
+ export async function federatedCallback(
794
+ app: express.Express,
795
+ name: "oidc" | "google",
796
+ upstream: FakeIdp,
797
+ ): Promise<() => request.Test> {
798
+ const start = await request(app).get(`/session/oauth/federation/${name}`);
799
+ if (start.status !== 302) throw new Error(`federation start answered ${start.status}`);
800
+ const answer = upstream.authorize(start.headers.location as string);
801
+ return () =>
802
+ request(app)
803
+ .get(`/session/oauth/federation/${name}/callback`)
804
+ .set("Cookie", cookiesOf(start))
805
+ .query({ code: answer.code, state: answer.state ?? "", iss: answer.iss });
806
+ }
807
+
808
+ export function lodgeGrant(app: express.Express): request.Test {
809
+ return request(app).post("/oauth/federation-grants").set("Authorization", basic(WORKER)).send({
810
+ connection: CONNECTION,
811
+ sub: ALICE.sub,
812
+ redirect_uri: WORKER.redirectUri,
813
+ state: "worker-state",
814
+ });
815
+ }
816
+
817
+ export { cookiesOf };
818
+
819
+ // ---------------------------------------------------------------------------
820
+ // Discovery
821
+ // ---------------------------------------------------------------------------
822
+
823
+ export const DISCOVERY_PATHS = [
824
+ "/.well-known/openid-configuration",
825
+ "/.well-known/oauth-authorization-server",
826
+ ] as const;
827
+
828
+ /**
829
+ * What RFC 8414 §2 and OpenID Connect Discovery §3 require of the document
830
+ * this composition serves, and what each advertised URL must be: https, on
831
+ * the issuer's origin. `authorizationCode` says whether the composition
832
+ * registers that grant (it does unless a test switches it off): with it the
833
+ * document names the authorization endpoint and lists the code response type
834
+ * and PKCE; without it, none of them.
835
+ */
836
+ export function expectValidMetadata(
837
+ doc: Record<string, unknown>,
838
+ { authorizationCode = true }: { readonly authorizationCode?: boolean } = {},
839
+ ): void {
840
+ expect(doc.issuer).toBe(ISSUER);
841
+ for (const field of [
842
+ "token_endpoint",
843
+ "jwks_uri",
844
+ "response_types_supported",
845
+ "subject_types_supported",
846
+ "id_token_signing_alg_values_supported",
847
+ ]) {
848
+ expect(doc[field], field).toBeDefined();
849
+ }
850
+ for (const [field, value] of Object.entries(doc)) {
851
+ if (field.endsWith("_endpoint") || field === "jwks_uri") {
852
+ const url = new URL(value as string);
853
+ expect(url.origin, field).toBe(ISSUER);
854
+ expect(url.search + url.hash, field).toBe("");
855
+ }
856
+ if (field.endsWith("_supported") && Array.isArray(value)) {
857
+ if (field !== "response_types_supported") expect(value.length, field).toBeGreaterThan(0);
858
+ for (const entry of value) expect(typeof entry, field).toBe("string");
859
+ expect(new Set(value).size, `${field} repeats a value`).toBe(value.length);
860
+ }
861
+ }
862
+ if (authorizationCode) {
863
+ expect(doc.authorization_endpoint).toBe(`${ISSUER}/oauth/authorize`);
864
+ expect(doc.response_types_supported).toEqual(["code"]);
865
+ expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
866
+ } else {
867
+ expect(doc).not.toHaveProperty("authorization_endpoint");
868
+ expect(doc.response_types_supported).toEqual([]);
869
+ expect(doc).not.toHaveProperty("code_challenge_methods_supported");
870
+ }
871
+ }
872
+
873
+ // ---------------------------------------------------------------------------
874
+ // Known defects
875
+ // ---------------------------------------------------------------------------
876
+
877
+ const templateManifest = JSON.parse(
878
+ readFileSync(new URL("../../package.json", import.meta.url), "utf8"),
879
+ ) as { dependencies: Record<string, string> };
880
+
881
+ /** The template's `@o3co/auth-provider-*` dependencies, as its `package.json` names them. */
882
+ export const TEMPLATE_DEPENDENCIES: readonly string[] = Object.keys(
883
+ templateManifest.dependencies,
884
+ ).filter((name) => name.startsWith("@o3co/auth-provider-"));
885
+
886
+ /**
887
+ * Inside the monorepo the template names its siblings `workspace:*`; a
888
+ * scaffold, and CI's packed-tarball run, name versions or tarballs.
889
+ */
890
+ export const inMonorepo =
891
+ templateManifest.dependencies["@o3co/auth-provider-core"]?.startsWith("workspace:") === true;
892
+
893
+ /**
894
+ * A contract the composition breaks today. `it.fails` in the monorepo, where
895
+ * the fix lands beside this file and flips it; skipped in a scaffold, which
896
+ * pins released packages and would otherwise turn red on the upgrade that
897
+ * carries the fix.
898
+ */
899
+ export const knownDefect = (inMonorepo ? it.fails : it.skip) as unknown as typeof it;
900
+
901
+ // ---------------------------------------------------------------------------
902
+ // Manifests
903
+ // ---------------------------------------------------------------------------
904
+
905
+ /** The names a module contributes under `kind`: its own name for a list-shaped kind. */
906
+ export const contributionNames = (module: Module, kind: string): string[] => {
907
+ const contribution = (module.contributes as Record<string, unknown> | undefined)?.[kind];
908
+ if (contribution === undefined) return [];
909
+ return Array.isArray(contribution) ? [module.name] : Object.keys(contribution as object);
910
+ };
911
+
912
+ // ---------------------------------------------------------------------------
913
+ // Bodies
914
+ // ---------------------------------------------------------------------------
915
+
916
+ export const KIB = 1024;
917
+ export const JSON_TYPE = "application/json";
918
+ export const FORM_TYPE = "application/x-www-form-urlencoded";
919
+
920
+ export type Send = (
921
+ app: express.Express,
922
+ path: string,
923
+ contentType: string,
924
+ body: string,
925
+ headers?: Record<string, string>,
926
+ ) => Promise<{ status: number; body: Record<string, unknown> }>;
927
+
928
+ /** A POST with its `Content-Length` declared, through supertest. */
929
+ export const withLength: Send = async (app, path, contentType, body, headers = {}) => {
930
+ const res = await request(app)
931
+ .post(path)
932
+ .set(headers)
933
+ .set("Content-Type", contentType)
934
+ .send(body);
935
+ return { status: res.status, body: res.body as Record<string, unknown> };
936
+ };
937
+
938
+ /**
939
+ * A POST whose body has no `Content-Length` — `Transfer-Encoding: chunked`,
940
+ * one KiB a chunk — so only a parser's own running count can bound it.
941
+ * supertest always sets the length, hence a raw request on a real socket.
942
+ */
943
+ export const postChunked: Send = async (app, path, contentType, body, headers = {}) => {
944
+ const server = http.createServer(app);
945
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
946
+ try {
947
+ const { port } = server.address() as AddressInfo;
948
+ return await new Promise((resolve, reject) => {
949
+ let answered = false;
950
+ const req = http.request(
951
+ {
952
+ host: "127.0.0.1",
953
+ port,
954
+ path,
955
+ method: "POST",
956
+ // One connection per request, closed after it: nothing keeps the server open.
957
+ agent: false,
958
+ headers: { ...headers, "content-type": contentType, "transfer-encoding": "chunked" },
959
+ },
960
+ (res) => {
961
+ answered = true;
962
+ let text = "";
963
+ res.setEncoding("utf8");
964
+ res.on("data", (chunk: string) => {
965
+ text += chunk;
966
+ });
967
+ res.on("end", () => {
968
+ let parsed: Record<string, unknown> = {};
969
+ try {
970
+ parsed = JSON.parse(text) as Record<string, unknown>;
971
+ } catch {
972
+ // Not JSON: the status carries the verdict.
973
+ }
974
+ resolve({ status: res.statusCode ?? 0, body: parsed });
975
+ });
976
+ },
977
+ );
978
+ // A server that answers 413 mid-body may close the socket while the
979
+ // rest is still being written; that is the answer, not a failure.
980
+ req.on("error", (err) => {
981
+ if (!answered) reject(err);
982
+ });
983
+ for (let at = 0; at < body.length; at += KIB) req.write(body.slice(at, at + KIB));
984
+ req.end();
985
+ });
986
+ } finally {
987
+ await new Promise<void>((resolve) => server.close(() => resolve()));
988
+ }
989
+ };
990
+
991
+ export const TRANSFERS: ReadonlyArray<readonly [string, Send]> = [
992
+ ["with Content-Length", withLength],
993
+ ["chunked", postChunked],
994
+ ];
995
+
996
+ /** A JSON body: `fields`, and `bytes` of padding. */
997
+ export const padJson = (bytes: number, fields: Record<string, unknown> = {}): string =>
998
+ JSON.stringify({ ...fields, pad: "a".repeat(bytes) });
999
+
1000
+ /** A form body: `fields`, and `bytes` of padding. */
1001
+ export const padForm = (bytes: number, fields: string): string =>
1002
+ `${fields}&pad=${"a".repeat(bytes)}`;
1003
+
1004
+ /**
1005
+ * What a parser's refusal of an oversized body is answered with on a route
1006
+ * of the composed router — by core's terminal handler, which ends it.
1007
+ */
1008
+ export const TOO_LARGE = { error: "invalid_request", error_description: "body_too_large" };
1009
+
1010
+ // ---------------------------------------------------------------------------
1011
+ // Outages
1012
+ // ---------------------------------------------------------------------------
1013
+
1014
+ /**
1015
+ * The parts of the outage rule, each checked on its own so a case pins
1016
+ * only what is broken and asserts the rest:
1017
+ *
1018
+ * - `answer` — the status and error code (a redirect's location and code);
1019
+ * - `one-error-line` — exactly one error-level line, object-first with a name;
1020
+ * - `event-name` — that name (checked only where the case names one);
1021
+ * - `store-field` — a `store`, `step` or `site` field naming what failed;
1022
+ * - `projection` — its `err` is core's `loggableError` projection;
1023
+ * - `no-warn` — no warn line for the outage beside it.
1024
+ */
1025
+ export type OutagePredicate =
1026
+ | "answer"
1027
+ | "one-error-line"
1028
+ | "event-name"
1029
+ | "store-field"
1030
+ | "projection"
1031
+ | "no-warn";
1032
+
1033
+ export interface OutageCase<C extends Composition = Composition> {
1034
+ /** The module whose route answers. */
1035
+ readonly module: string;
1036
+ /** The ComponentMap slot whose store goes down. */
1037
+ readonly slot: string;
1038
+ readonly surface: string;
1039
+ /** Drives the route, taking the store down at the step under test. */
1040
+ readonly run: (app: express.Express, outage: Outage, c: C) => Promise<request.Response>;
1041
+ readonly answer:
1042
+ | { readonly status: number; readonly error?: string }
1043
+ | { readonly redirect: string; readonly error: string };
1044
+ /**
1045
+ * The one line's name. Absent where the composition writes no such line
1046
+ * today — and then `event-name` is not checked at all, so the fix that
1047
+ * flips such a row adds its `event` in the same change.
1048
+ */
1049
+ readonly event?: string;
1050
+ /** Warn lines this route writes whatever the store does — not the outage's. */
1051
+ readonly unrelatedWarns?: readonly string[];
1052
+ /**
1053
+ * Why `store-field` does not apply: the line is not an outage's 503, the
1054
+ * request succeeds. Absent, the predicate is checked.
1055
+ */
1056
+ readonly storeFieldNotRequired?: string;
1057
+ /** The predicates the composition breaks today, each naming its defect. */
1058
+ readonly defects?: Partial<Record<OutagePredicate, string>>;
1059
+ }
1060
+
1061
+ /** The same defect text for every predicate that depends on the one missing line. */
1062
+ export const withoutTheLine = (
1063
+ defect: string,
1064
+ ): Pick<Record<OutagePredicate, string>, "one-error-line" | "store-field" | "projection"> => ({
1065
+ "one-error-line": defect,
1066
+ "store-field": `no error line to carry it: ${defect}`,
1067
+ projection: `no error line to carry it: ${defect}`,
1068
+ });
1069
+
1070
+ /** The object argument of a log line, when the line is object-first. */
1071
+ export const fieldsOf = (line: LogLine | undefined): Record<string, unknown> =>
1072
+ typeof line?.args[0] === "object" && line.args[0] !== null
1073
+ ? (line.args[0] as Record<string, unknown>)
1074
+ : {};
1075
+
1076
+ /**
1077
+ * One `describe` per case: the case's composition is booted and its flow run
1078
+ * once, the store taken down at the step under test, and each predicate is its
1079
+ * own test — a plain `it` where the composition keeps it, `knownDefect` where
1080
+ * it does not.
1081
+ */
1082
+ export function describeOutages<C extends Composition>(
1083
+ title: string,
1084
+ cases: readonly OutageCase<C>[],
1085
+ boot: (outage: { readonly slot: string; readonly outage: Outage }) => Promise<C>,
1086
+ ): void {
1087
+ describe(title, () => {
1088
+ for (const c of cases) {
1089
+ describe(`${c.module}: ${c.slot} down at ${c.surface}`, () => {
1090
+ let res: request.Response;
1091
+ let lines: LogLine[] = [];
1092
+ let tookDown = false;
1093
+
1094
+ beforeAll(async () => {
1095
+ let composition: C | undefined;
1096
+ let from = -1;
1097
+ let down = false;
1098
+ const outage: Outage = {
1099
+ get down() {
1100
+ return down;
1101
+ },
1102
+ set down(value: boolean) {
1103
+ if (value && from < 0) from = composition?.logger.lines.length ?? 0;
1104
+ down = value;
1105
+ },
1106
+ };
1107
+ composition = await boot({ slot: c.slot, outage });
1108
+ try {
1109
+ res = await c.run(composition.app, outage, composition);
1110
+ tookDown = from >= 0;
1111
+ // Everything logged from the moment the store went down is the outage's.
1112
+ lines = composition.logger.lines.slice(Math.max(from, 0));
1113
+ } finally {
1114
+ await composition.handle.dispose();
1115
+ }
1116
+ });
1117
+
1118
+ const errors = () => lines.filter((line) => line.level === "error");
1119
+ const check = (predicate: OutagePredicate, name: string, assertion: () => void) => {
1120
+ const defect = c.defects?.[predicate];
1121
+ (defect === undefined ? it : knownDefect)(name, assertion);
1122
+ };
1123
+
1124
+ // Plain whatever the case pins: a pinned predicate must fail for its
1125
+ // defect, not because the flow never reached the step under test.
1126
+ it("reaches the step under test and takes the store down there", () => {
1127
+ expect(tookDown).toBe(true);
1128
+ });
1129
+ check("answer", "answers as an outage", () => {
1130
+ if ("redirect" in c.answer) {
1131
+ expect(res.status).toBe(302);
1132
+ const location = new URL(res.headers.location as string);
1133
+ expect(`${location.origin}${location.pathname}`).toBe(c.answer.redirect);
1134
+ expect(location.searchParams.get("error")).toBe(c.answer.error);
1135
+ } else {
1136
+ expect(res.status).toBe(c.answer.status);
1137
+ expect(res.body.error).toBe(c.answer.error);
1138
+ expect(res.headers["www-authenticate"]).toBeUndefined();
1139
+ }
1140
+ });
1141
+ check("one-error-line", "logs exactly one error line, object-first with a name", () => {
1142
+ expect(
1143
+ errors().map((line) => line.args[1] ?? line.args[0]),
1144
+ "exactly one error line",
1145
+ ).toHaveLength(1);
1146
+ expect(typeof errors()[0]?.args[0]).toBe("object");
1147
+ expect(typeof errors()[0]?.args[1]).toBe("string");
1148
+ });
1149
+ if (c.event !== undefined) {
1150
+ const event = c.event;
1151
+ check("event-name", `names it ${event}`, () => {
1152
+ expect(errors().map((line) => line.args[1])).toEqual([event]);
1153
+ });
1154
+ }
1155
+ if (c.storeFieldNotRequired === undefined) {
1156
+ check("store-field", "names what failed (store, step or site)", () => {
1157
+ const fields = fieldsOf(errors()[0]);
1158
+ expect(
1159
+ ["store", "step", "site"].filter((field) => typeof fields[field] === "string"),
1160
+ ).not.toEqual([]);
1161
+ });
1162
+ }
1163
+ check("projection", "carries the error's projection", () => {
1164
+ const err = fieldsOf(errors()[0]).err;
1165
+ expect(err).toMatchObject({ name: expect.any(String) });
1166
+ expect(err).not.toBeInstanceOf(Error);
1167
+ });
1168
+ check("no-warn", "writes no warn line for it", () => {
1169
+ const warns = lines
1170
+ .filter((line) => line.level === "warn")
1171
+ .map((line) => (typeof line.args[1] === "string" ? line.args[1] : String(line.args[0])))
1172
+ .filter((event) => !(c.unrelatedWarns ?? []).includes(event));
1173
+ expect(warns).toEqual([]);
1174
+ });
1175
+ });
1176
+ }
1177
+ });
1178
+ }