@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -29
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +324 -169
- package/templates/standalone/README.md +618 -208
- package/templates/standalone/config/application.conf +211 -352
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +418 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +63 -32
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
- package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
- package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
- package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
- package/templates/standalone/src/adapters.mts +162 -0
- package/templates/standalone/src/app.mts +65 -79
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +220 -309
- package/templates/standalone/src/configPath.mts +449 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +285 -0
- package/templates/standalone/src/modules.mts +423 -629
- package/templates/standalone/src/rootRenames.mts +96 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +367 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +6 -5
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
|
@@ -19,43 +19,38 @@
|
|
|
19
19
|
* template can turn on, switched on together from the shipped HOCON, and
|
|
20
20
|
* mounted the way `app.mts` mounts it.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
* `reference.conf`,
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
22
|
+
* Real: the configuration (`config/*.conf` over every loaded package's
|
|
23
|
+
* `reference.conf`, core's last, under the environment a deployment would
|
|
24
|
+
* export) read in `app.mts`'s two phases (`readSwitches`, then
|
|
25
|
+
* `resolveForBoot` for `createApp`), `buildModules`, core's `createApp`, every
|
|
26
|
+
* module and store it selects, the key store, the audit sink, `helmet` and the
|
|
27
|
+
* terminal error handler.
|
|
26
28
|
*
|
|
27
|
-
*
|
|
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.
|
|
28
39
|
*
|
|
29
|
-
*
|
|
30
|
-
* off disk; the in-memory repositories carry the registrations the flows
|
|
31
|
-
* need. `buildModules` offers this override for the purpose.
|
|
32
|
-
* - The refresh-token family store under `deployment.mode = "single"` — the
|
|
33
|
-
* shipped composition always puts it on Redis; the memory store keeps the
|
|
34
|
-
* single-replica boot free of sockets. `buildModules` offers this override.
|
|
35
|
-
* - The upstream identity providers — core's fake OpenID Provider behind a
|
|
36
|
-
* `fetch`, handed to the Google and OIDC adapters through the `fetch` option
|
|
37
|
-
* each adapter takes for exactly this. The config bridges' values are read
|
|
38
|
-
* through the template's own bridge (`googleFederationConfigModule`) and the
|
|
39
|
-
* OIDC package's reader, and only `fetch` is added.
|
|
40
|
-
* - Configuration with no environment form (a map of grant connections, a key
|
|
41
|
-
* ring, a landing URL): laid over the resolved config, as an operator would
|
|
42
|
-
* write it in `application.conf`.
|
|
43
|
-
*
|
|
44
|
-
* `tools/composition` in the monorepo imports this file and boots the same
|
|
40
|
+
* `tools/composition` in the monorepo imports this file to boot the same
|
|
45
41
|
* composition with the workspace's other modules added (`ComposeOptions`'s
|
|
46
|
-
* `
|
|
47
|
-
* `extraUsers`), so the two suites share one fixture rather than two copies
|
|
48
|
-
* that drift.
|
|
42
|
+
* `extra*` options), so the two suites share one fixture.
|
|
49
43
|
*/
|
|
50
44
|
|
|
51
45
|
import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
|
|
52
|
-
import { readFileSync } from "node:fs";
|
|
46
|
+
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
53
47
|
import http from "node:http";
|
|
54
48
|
import type { AddressInfo } from "node:net";
|
|
49
|
+
import { tmpdir } from "node:os";
|
|
50
|
+
import { join } from "node:path";
|
|
55
51
|
import { fileURLToPath } from "node:url";
|
|
56
52
|
import {
|
|
57
53
|
type AppConfig,
|
|
58
|
-
AppConfigSchema,
|
|
59
54
|
ClientEntrySchema,
|
|
60
55
|
createApp,
|
|
61
56
|
defineModule,
|
|
@@ -67,15 +62,22 @@ import {
|
|
|
67
62
|
terminalErrorHandler,
|
|
68
63
|
} from "@o3co/auth-provider-core";
|
|
69
64
|
import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
|
|
70
|
-
import {
|
|
71
|
-
import {
|
|
65
|
+
import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
|
|
66
|
+
import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
|
|
72
67
|
import express from "express";
|
|
73
68
|
import helmet from "helmet";
|
|
74
69
|
import request from "supertest";
|
|
75
70
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
76
71
|
import { buildModules } from "#/buildModules.mjs";
|
|
77
|
-
import {
|
|
78
|
-
|
|
72
|
+
import {
|
|
73
|
+
configDefaultsFor,
|
|
74
|
+
type OwnLayers,
|
|
75
|
+
readOwnLayers,
|
|
76
|
+
readSwitches,
|
|
77
|
+
resolveConfigPaths,
|
|
78
|
+
resolveForBoot,
|
|
79
|
+
type Switches,
|
|
80
|
+
} from "#/configPath.mjs";
|
|
79
81
|
|
|
80
82
|
export const ISSUER = "https://auth.test";
|
|
81
83
|
const OIDC_ISSUER = "https://idp.test";
|
|
@@ -93,47 +95,50 @@ const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
|
|
|
93
95
|
* One replica, every store in memory, every feature the template can switch
|
|
94
96
|
* on switched on: the four token grants, the consent step with Client ID
|
|
95
97
|
* Metadata Documents, federation grants, the Google federation and the
|
|
96
|
-
* shipped generic OIDC one.
|
|
98
|
+
* shipped generic OIDC one. MFA, on by default, is switched off: the suites
|
|
99
|
+
* about MFA turn it on with `MFA_MODE`.
|
|
97
100
|
*/
|
|
98
101
|
export const SINGLE_ENV: Readonly<Record<string, string>> = {
|
|
102
|
+
MFA_MODE: "off",
|
|
99
103
|
OAUTH_JWT_ISSUER: ISSUER,
|
|
100
104
|
// The shipped default algorithm (EdDSA), with the key pair inline.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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",
|
|
117
122
|
// The user repository is replaced (see the header); `yaml` keeps the
|
|
118
123
|
// shipped `http` adapter's URL requirements out of config validation.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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",
|
|
125
130
|
FEDERATION_GRANTS_ENABLED: "true",
|
|
126
131
|
FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
|
|
127
132
|
// The in-memory user repository covers no registration; `required` is a
|
|
128
133
|
// Store's statement that it does (see the federation-grants README).
|
|
129
134
|
FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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",
|
|
137
142
|
};
|
|
138
143
|
|
|
139
144
|
/**
|
|
@@ -142,20 +147,22 @@ export const SINGLE_ENV: Readonly<Record<string, string>> = {
|
|
|
142
147
|
*/
|
|
143
148
|
export const MULTI_ENV: Readonly<Record<string, string>> = {
|
|
144
149
|
...SINGLE_ENV,
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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",
|
|
155
162
|
REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
163
|
+
ADAPTERS_CONSENT_STORE: "redis",
|
|
164
|
+
ADAPTERS_FEDERATION_GRANT_STORE: "redis",
|
|
165
|
+
ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
|
|
159
166
|
};
|
|
160
167
|
|
|
161
168
|
/** The grant connection the federation-grant flows use, on the shipped `oidc` federation. */
|
|
@@ -163,46 +170,80 @@ export const CONNECTION = "calendar";
|
|
|
163
170
|
|
|
164
171
|
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
165
172
|
|
|
173
|
+
const quoted = (value: string) => JSON.stringify(value);
|
|
174
|
+
|
|
166
175
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
|
|
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`.
|
|
171
241
|
*/
|
|
172
242
|
export function resolveConfig(
|
|
173
243
|
env: Readonly<Record<string, string>>,
|
|
174
|
-
|
|
175
|
-
):
|
|
176
|
-
|
|
177
|
-
const read = (path: string) => parseFile(path, { env: { ...env } });
|
|
178
|
-
const layered = [
|
|
179
|
-
applicationConfPath,
|
|
180
|
-
...referenceConfs,
|
|
181
|
-
resolveLibraryReferenceConfPath(),
|
|
182
|
-
].reduce((config, path) => config.withFallback(read(path)), read(envConfPath));
|
|
183
|
-
const resolved = validate(layered, AppConfigSchema);
|
|
184
|
-
const federations = resolved.federations as Record<string, Record<string, unknown>>;
|
|
185
|
-
return {
|
|
186
|
-
...resolved,
|
|
187
|
-
federations: {
|
|
188
|
-
...federations,
|
|
189
|
-
google: { ...federations.google, clientUrl: FEDERATION_LANDING },
|
|
190
|
-
oidc: { ...federations.oidc, clientUrl: FEDERATION_LANDING },
|
|
191
|
-
},
|
|
192
|
-
federationGrants: {
|
|
193
|
-
...resolved.federationGrants,
|
|
194
|
-
encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
|
|
195
|
-
connections: {
|
|
196
|
-
[CONNECTION]: {
|
|
197
|
-
federation: "oidc",
|
|
198
|
-
scopes: ["openid", "offline_access", "calendar.read"],
|
|
199
|
-
boundary: "production",
|
|
200
|
-
maxAccessTokenLifetime: 3600,
|
|
201
|
-
callbackURL: `${ISSUER}/session/federation-grants/callback/${CONNECTION}`,
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
},
|
|
205
|
-
} as unknown as AppConfig;
|
|
244
|
+
own: OwnLayers = readOwnLayers(ownFiles(env), { env }),
|
|
245
|
+
): Switches {
|
|
246
|
+
return readSwitches(own);
|
|
206
247
|
}
|
|
207
248
|
|
|
208
249
|
// ---------------------------------------------------------------------------
|
|
@@ -327,30 +368,55 @@ let upstreams: Promise<{ fakes: Upstreams; reset: () => void }> | undefined;
|
|
|
327
368
|
* run if one did.
|
|
328
369
|
*/
|
|
329
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 }> {
|
|
330
378
|
upstreams ??= createUpstreams().then((fakes) => ({
|
|
331
379
|
fakes,
|
|
332
380
|
reset: resettable(fakes.oidc, fakes.google),
|
|
333
381
|
}));
|
|
334
|
-
|
|
335
|
-
reset();
|
|
336
|
-
return fakes;
|
|
382
|
+
return upstreams;
|
|
337
383
|
}
|
|
338
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
|
+
|
|
339
391
|
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
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
|
|
342
409
|
* place (a fake may hold its own reference to one), recorded requests
|
|
343
410
|
* cleared. A fake's signing key is checked, not restored: a rotated key is
|
|
344
411
|
* refused.
|
|
345
412
|
*
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
* authorizations are keyed per login
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
* makes a fake of its own with `createFakeIdp`.
|
|
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`.
|
|
354
420
|
*/
|
|
355
421
|
export function resettable(...fakes: readonly object[]): () => void {
|
|
356
422
|
const snapshots = fakes.map((fake) => ({
|
|
@@ -399,7 +465,7 @@ async function createUpstreams(): Promise<Upstreams> {
|
|
|
399
465
|
oidc: await createFakeIdp({
|
|
400
466
|
issuer: OIDC_ISSUER,
|
|
401
467
|
discovery: true,
|
|
402
|
-
clientId: SINGLE_ENV.
|
|
468
|
+
clientId: SINGLE_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID,
|
|
403
469
|
sub: OIDC_SUB,
|
|
404
470
|
}),
|
|
405
471
|
// Google's endpoints are fixed in the adapter, not discovered.
|
|
@@ -409,58 +475,12 @@ async function createUpstreams(): Promise<Upstreams> {
|
|
|
409
475
|
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
|
410
476
|
jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
|
|
411
477
|
userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
|
|
412
|
-
clientId: SINGLE_ENV.
|
|
478
|
+
clientId: SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
|
|
413
479
|
sub: GOOGLE_SUB,
|
|
414
480
|
}),
|
|
415
481
|
};
|
|
416
482
|
}
|
|
417
483
|
|
|
418
|
-
/**
|
|
419
|
-
* What one of the template's config bridges provides for `config`. Read
|
|
420
|
-
* through a record, not the typed slot: a program that loads this file without
|
|
421
|
-
* a federation package's ComponentMap augmentation (`tools/composition` does)
|
|
422
|
-
* has no key to name.
|
|
423
|
-
*/
|
|
424
|
-
const bridged = <T,>(module: Module, slot: string, config: AppConfig): T => {
|
|
425
|
-
const provider = (module.provides as Record<string, unknown> | undefined)?.[slot];
|
|
426
|
-
if (typeof provider !== "function") throw new Error(`${module.name} provides no ${slot}`);
|
|
427
|
-
return (provider as (deps: { config: AppConfig }) => T)({ config });
|
|
428
|
-
};
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* The two federation config slots, read by the template's own bridges
|
|
432
|
-
* (`oidcFederationConfigModule`, `googleFederationConfigModule`), with each
|
|
433
|
-
* adapter's `fetch` pointed at its fake upstream. Only for the federations the
|
|
434
|
-
* config enables — which is when `buildModules` lists the bridges.
|
|
435
|
-
*/
|
|
436
|
-
async function federationOverrides(
|
|
437
|
-
config: AppConfig,
|
|
438
|
-
upstreams: Upstreams,
|
|
439
|
-
): Promise<Record<string, unknown>> {
|
|
440
|
-
const overrides: Record<string, unknown> = {};
|
|
441
|
-
const modules = buildModules(config).map((m) => m.name);
|
|
442
|
-
if (modules.includes(oidcFederationConfigModule.name)) {
|
|
443
|
-
const oidc = bridged<Record<string, object>>(
|
|
444
|
-
oidcFederationConfigModule,
|
|
445
|
-
"oidcFederationConfigs",
|
|
446
|
-
config,
|
|
447
|
-
);
|
|
448
|
-
overrides.oidcFederationConfigs = Object.fromEntries(
|
|
449
|
-
Object.entries(oidc).map(([name, entry]) => [
|
|
450
|
-
name,
|
|
451
|
-
{ ...entry, fetch: upstreams.oidc.fetch },
|
|
452
|
-
]),
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
if (modules.includes(googleFederationConfigModule.name)) {
|
|
456
|
-
overrides.googleFederationConfig = {
|
|
457
|
-
...bridged<object>(googleFederationConfigModule, "googleFederationConfig", config),
|
|
458
|
-
fetch: upstreams.google.fetch,
|
|
459
|
-
};
|
|
460
|
-
}
|
|
461
|
-
return overrides;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
484
|
// ---------------------------------------------------------------------------
|
|
465
485
|
// Logger spy
|
|
466
486
|
// ---------------------------------------------------------------------------
|
|
@@ -567,18 +587,30 @@ export type ModuleOrder = typeof AS_LISTED | typeof REVERSED;
|
|
|
567
587
|
|
|
568
588
|
export interface ComposeOptions {
|
|
569
589
|
readonly env?: Readonly<Record<string, string>>;
|
|
570
|
-
/**
|
|
571
|
-
readonly
|
|
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[];
|
|
572
596
|
/** Modules added after the template's own, before the order and the outage apply. */
|
|
573
|
-
readonly extraModules?: (config:
|
|
574
|
-
/** Components laid over the boot's
|
|
575
|
-
readonly extraOverrides?: (config:
|
|
597
|
+
readonly extraModules?: (config: Switches) => readonly Module[];
|
|
598
|
+
/** Components laid over the boot's (`overrideComponents`). */
|
|
599
|
+
readonly extraOverrides?: (config: Switches) => Record<string, unknown>;
|
|
576
600
|
/** Client registrations beside the fixture's own, as `ClientEntrySchema` input. */
|
|
577
601
|
readonly extraClients?: Readonly<Record<string, Record<string, unknown>>>;
|
|
578
602
|
/** Users beside the fixture's own, keyed by username. */
|
|
579
603
|
readonly extraUsers?: Readonly<Record<string, Record<string, unknown>>>;
|
|
580
|
-
/**
|
|
604
|
+
/**
|
|
605
|
+
* Adjust what `createApp` is handed, as resolved, as an operator's own
|
|
606
|
+
* layer would.
|
|
607
|
+
*/
|
|
581
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;
|
|
582
614
|
readonly order?: ModuleOrder;
|
|
583
615
|
readonly outage?: { readonly slot: string; readonly outage: Outage };
|
|
584
616
|
/** Keep the shipped Redis refresh-token family store (the `multi` boot). */
|
|
@@ -593,15 +625,18 @@ export interface ComposeOptions {
|
|
|
593
625
|
}
|
|
594
626
|
|
|
595
627
|
/** The module list the template boots for `config`, as `app.mts` builds it. */
|
|
596
|
-
export function composedModules(config:
|
|
628
|
+
export function composedModules(config: Switches, options: ComposeOptions = {}): Module[] {
|
|
597
629
|
let modules = [
|
|
598
630
|
...buildModules(config, {
|
|
599
|
-
environment: "production",
|
|
631
|
+
environment: options.environment ?? "production",
|
|
600
632
|
repositoriesModule: testRepositoriesModule(options.extraClients, options.extraUsers),
|
|
633
|
+
...(options.mailSenderModules === undefined
|
|
634
|
+
? {}
|
|
635
|
+
: { mailSenderModules: options.mailSenderModules }),
|
|
601
636
|
...(options.shippedRefreshTokenFamilyStore
|
|
602
637
|
? {}
|
|
603
638
|
: { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
|
|
604
|
-
}),
|
|
639
|
+
}).map((module) => UPSTREAM_TYPE_MODULES.get(module.name)?.() ?? module),
|
|
605
640
|
...(options.extraModules?.(config) ?? []),
|
|
606
641
|
];
|
|
607
642
|
if (options.outage) modules = withOutage(modules, options.outage.slot, options.outage.outage);
|
|
@@ -612,7 +647,10 @@ export function composedModules(config: AppConfig, options: ComposeOptions = {})
|
|
|
612
647
|
export interface Composition {
|
|
613
648
|
readonly app: express.Express;
|
|
614
649
|
readonly handle: Awaited<ReturnType<typeof createApp>>;
|
|
650
|
+
/** The configuration boot parsed: what the handle's `config` slot holds. */
|
|
615
651
|
readonly config: AppConfig;
|
|
652
|
+
/** What `createApp` was handed: the configuration as resolved (phase two), unparsed. */
|
|
653
|
+
readonly resolved: AppConfig;
|
|
616
654
|
readonly modules: readonly Module[];
|
|
617
655
|
readonly logger: RecordingLogger;
|
|
618
656
|
readonly upstreams: Upstreams;
|
|
@@ -625,21 +663,40 @@ export interface Composition {
|
|
|
625
663
|
* boot's `logger` component.
|
|
626
664
|
*/
|
|
627
665
|
export async function compose(options: ComposeOptions = {}): Promise<Composition> {
|
|
628
|
-
const
|
|
629
|
-
|
|
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;
|
|
630
677
|
const fakes = await sharedUpstreams();
|
|
631
678
|
const modules = composedModules(config, options);
|
|
632
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;
|
|
633
684
|
const handle = await createApp({
|
|
634
685
|
modules,
|
|
635
|
-
bootstrapComponents: {
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
686
|
+
bootstrapComponents: {
|
|
687
|
+
config: resolved,
|
|
688
|
+
configDefaults: configDefaultsFor(modules),
|
|
689
|
+
pathResolver: (s) => s,
|
|
690
|
+
logger,
|
|
691
|
+
},
|
|
692
|
+
overrideComponents: { ...options.extraOverrides?.(config) } as never,
|
|
640
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");
|
|
641
698
|
const app = express();
|
|
642
|
-
app.set("trust proxy",
|
|
699
|
+
app.set("trust proxy", httpSettings.trustProxy);
|
|
643
700
|
app.use(
|
|
644
701
|
helmet({
|
|
645
702
|
contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] } },
|
|
@@ -647,7 +704,7 @@ export async function compose(options: ComposeOptions = {}): Promise<Composition
|
|
|
647
704
|
);
|
|
648
705
|
app.use(handle.router);
|
|
649
706
|
if (options.terminalErrorHandler !== false) app.use(terminalErrorHandler(logger));
|
|
650
|
-
return { app, handle, config, modules, logger, upstreams: fakes };
|
|
707
|
+
return { app, handle, config: parsed, resolved, modules, logger, upstreams: fakes };
|
|
651
708
|
}
|
|
652
709
|
|
|
653
710
|
// ---------------------------------------------------------------------------
|
|
@@ -765,12 +822,17 @@ export const DISCOVERY_PATHS = [
|
|
|
765
822
|
/**
|
|
766
823
|
* What RFC 8414 §2 and OpenID Connect Discovery §3 require of the document
|
|
767
824
|
* this composition serves, and what each advertised URL must be: https, on
|
|
768
|
-
* the issuer's origin.
|
|
825
|
+
* the issuer's origin. `authorizationCode` says whether the composition
|
|
826
|
+
* registers that grant (it does unless a test switches it off): with it the
|
|
827
|
+
* document names the authorization endpoint and lists the code response type
|
|
828
|
+
* and PKCE; without it, none of them.
|
|
769
829
|
*/
|
|
770
|
-
export function expectValidMetadata(
|
|
830
|
+
export function expectValidMetadata(
|
|
831
|
+
doc: Record<string, unknown>,
|
|
832
|
+
{ authorizationCode = true }: { readonly authorizationCode?: boolean } = {},
|
|
833
|
+
): void {
|
|
771
834
|
expect(doc.issuer).toBe(ISSUER);
|
|
772
835
|
for (const field of [
|
|
773
|
-
"authorization_endpoint",
|
|
774
836
|
"token_endpoint",
|
|
775
837
|
"jwks_uri",
|
|
776
838
|
"response_types_supported",
|
|
@@ -786,13 +848,20 @@ export function expectValidMetadata(doc: Record<string, unknown>): void {
|
|
|
786
848
|
expect(url.search + url.hash, field).toBe("");
|
|
787
849
|
}
|
|
788
850
|
if (field.endsWith("_supported") && Array.isArray(value)) {
|
|
789
|
-
expect(value.length, field).toBeGreaterThan(0);
|
|
851
|
+
if (field !== "response_types_supported") expect(value.length, field).toBeGreaterThan(0);
|
|
790
852
|
for (const entry of value) expect(typeof entry, field).toBe("string");
|
|
791
853
|
expect(new Set(value).size, `${field} repeats a value`).toBe(value.length);
|
|
792
854
|
}
|
|
793
855
|
}
|
|
794
|
-
|
|
795
|
-
|
|
856
|
+
if (authorizationCode) {
|
|
857
|
+
expect(doc.authorization_endpoint).toBe(`${ISSUER}/oauth/authorize`);
|
|
858
|
+
expect(doc.response_types_supported).toEqual(["code"]);
|
|
859
|
+
expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
|
|
860
|
+
} else {
|
|
861
|
+
expect(doc).not.toHaveProperty("authorization_endpoint");
|
|
862
|
+
expect(doc.response_types_supported).toEqual([]);
|
|
863
|
+
expect(doc).not.toHaveProperty("code_challenge_methods_supported");
|
|
864
|
+
}
|
|
796
865
|
}
|
|
797
866
|
|
|
798
867
|
// ---------------------------------------------------------------------------
|
|
@@ -937,7 +1006,7 @@ export const TOO_LARGE = { error: "invalid_request", error_description: "body_to
|
|
|
937
1006
|
// ---------------------------------------------------------------------------
|
|
938
1007
|
|
|
939
1008
|
/**
|
|
940
|
-
* The parts of the
|
|
1009
|
+
* The parts of the outage rule, each checked on its own so a case pins
|
|
941
1010
|
* only what is broken and asserts the rest:
|
|
942
1011
|
*
|
|
943
1012
|
* - `answer` — the status and error code (a redirect's location and code);
|
|
@@ -981,6 +1050,11 @@ export interface OutageCase<C extends Composition = Composition> {
|
|
|
981
1050
|
readonly storeFieldNotRequired?: string;
|
|
982
1051
|
/** The predicates the composition breaks today, each naming its defect. */
|
|
983
1052
|
readonly defects?: Partial<Record<OutagePredicate, string>>;
|
|
1053
|
+
/**
|
|
1054
|
+
* The warn lines a `no-warn` defect writes, pinned exactly while it stands,
|
|
1055
|
+
* so the defect covers those lines and no other.
|
|
1056
|
+
*/
|
|
1057
|
+
readonly defectWarns?: readonly string[];
|
|
984
1058
|
}
|
|
985
1059
|
|
|
986
1060
|
/** The same defect text for every predicate that depends on the one missing line. */
|
|
@@ -1011,6 +1085,14 @@ export function describeOutages<C extends Composition>(
|
|
|
1011
1085
|
): void {
|
|
1012
1086
|
describe(title, () => {
|
|
1013
1087
|
for (const c of cases) {
|
|
1088
|
+
if (
|
|
1089
|
+
c.defectWarns !== undefined &&
|
|
1090
|
+
(c.defects?.["no-warn"] === undefined || c.defectWarns.length === 0)
|
|
1091
|
+
) {
|
|
1092
|
+
throw new Error(
|
|
1093
|
+
`${c.module}: ${c.slot} down at ${c.surface}: defectWarns pins the lines of a no-warn defect, so it needs defects["no-warn"] and at least one line`,
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1014
1096
|
describe(`${c.module}: ${c.slot} down at ${c.surface}`, () => {
|
|
1015
1097
|
let res: request.Response;
|
|
1016
1098
|
let lines: LogLine[] = [];
|
|
@@ -1090,13 +1172,20 @@ export function describeOutages<C extends Composition>(
|
|
|
1090
1172
|
expect(err).toMatchObject({ name: expect.any(String) });
|
|
1091
1173
|
expect(err).not.toBeInstanceOf(Error);
|
|
1092
1174
|
});
|
|
1093
|
-
|
|
1094
|
-
|
|
1175
|
+
const outageWarns = () =>
|
|
1176
|
+
lines
|
|
1095
1177
|
.filter((line) => line.level === "warn")
|
|
1096
1178
|
.map((line) => (typeof line.args[1] === "string" ? line.args[1] : String(line.args[0])))
|
|
1097
1179
|
.filter((event) => !(c.unrelatedWarns ?? []).includes(event));
|
|
1098
|
-
|
|
1180
|
+
check("no-warn", "writes no warn line for it", () => {
|
|
1181
|
+
expect(outageWarns()).toEqual([]);
|
|
1099
1182
|
});
|
|
1183
|
+
if (c.defectWarns !== undefined) {
|
|
1184
|
+
const pinned = c.defectWarns;
|
|
1185
|
+
it(`writes exactly the warn lines its no-warn defect names: ${pinned.join(", ")}`, () => {
|
|
1186
|
+
expect(outageWarns()).toEqual(pinned);
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1100
1189
|
});
|
|
1101
1190
|
}
|
|
1102
1191
|
});
|