@o3co/create-auth-provider 0.14.0 → 0.16.0

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