@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.
- package/README.md +2 -198
- package/main.js +4 -0
- package/package.json +8 -30
- package/README.ja.md +0 -200
- package/dist/cli.d.mts +0 -3
- package/dist/cli.d.mts.map +0 -1
- package/dist/cli.mjs +0 -18
- package/dist/index.d.mts +0 -48
- package/dist/index.d.mts.map +0 -1
- package/dist/index.mjs +0 -325
- package/dist/internal/template-filter.d.mts +0 -14
- package/dist/internal/template-filter.d.mts.map +0 -1
- package/dist/internal/template-filter.mjs +0 -42
- package/templates/standalone/.dockerignore +0 -23
- package/templates/standalone/.env.example +0 -152
- package/templates/standalone/Dockerfile +0 -125
- package/templates/standalone/Makefile +0 -12
- package/templates/standalone/README.ja.md +0 -912
- package/templates/standalone/README.md +0 -1536
- package/templates/standalone/config/application.conf +0 -351
- package/templates/standalone/config/clients.yaml +0 -4
- package/templates/standalone/config/clients.yaml.example +0 -124
- package/templates/standalone/config/development.conf +0 -17
- package/templates/standalone/config/mailpit.conf +0 -13
- package/templates/standalone/config/production.conf +0 -15
- package/templates/standalone/config/reference.conf +0 -427
- package/templates/standalone/docker-compose.mailpit.yml +0 -39
- package/templates/standalone/docker-compose.production.yml +0 -229
- package/templates/standalone/docker-compose.test.yml +0 -16
- package/templates/standalone/docker-compose.yml +0 -67
- package/templates/standalone/gitignore +0 -23
- package/templates/standalone/package.json +0 -56
- package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
- package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
- package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
- package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
- package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
- package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
- package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
- package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
- package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
- package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
- package/templates/standalone/src/__tests__/listen.test.mts +0 -121
- package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
- package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
- package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
- package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
- package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
- package/templates/standalone/src/__tests__/sections.test.mts +0 -516
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
- package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
- package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
- package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
- package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
- package/templates/standalone/src/adapters.mts +0 -162
- package/templates/standalone/src/app.mts +0 -127
- package/templates/standalone/src/bootRefusal.mts +0 -117
- package/templates/standalone/src/buildModules.mts +0 -358
- package/templates/standalone/src/configPath.mts +0 -487
- package/templates/standalone/src/listen.mts +0 -60
- package/templates/standalone/src/logger.mts +0 -80
- package/templates/standalone/src/metrics.mts +0 -167
- package/templates/standalone/src/mfaSwitch.mts +0 -284
- package/templates/standalone/src/modules.mts +0 -705
- package/templates/standalone/src/rootRenames.mts +0 -95
- package/templates/standalone/src/routes.mts +0 -89
- package/templates/standalone/src/sections.mts +0 -378
- package/templates/standalone/src/securityHeaders.mts +0 -36
- package/templates/standalone/src/shutdown.mts +0 -213
- package/templates/standalone/tests/Makefile +0 -5
- package/templates/standalone/tests/index.test.js +0 -283
- package/templates/standalone/tests/package-lock.json +0 -1617
- package/templates/standalone/tests/package.json +0 -16
- package/templates/standalone/tests/vitest.config.mjs +0 -26
- package/templates/standalone/tsconfig.json +0 -31
- package/templates/standalone/vitest.config.mts +0 -31
- package/templates/standalone/vitest.supertest-loopback.mts +0 -278
- 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
|
-
}
|