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