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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -0,0 +1,1457 @@
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 template's own modules: each owns the section named after it, with its
19
+ * defaults in the template's `config/reference.conf`, and reads it through
20
+ * `deps.section`, never the whole configuration. What each section sets
21
+ * reaches the process, for the shipped configuration and for an operator's
22
+ * environment and HOCON overrides, through the template's real path:
23
+ * `readOwnLayers`, then `resolveForBoot` over every loaded module's
24
+ * reference, then `createApp`. A path a section moved from refuses boot
25
+ * naming its new path, and a variable renamed with it refuses boot unless its
26
+ * new name carries the same value.
27
+ */
28
+
29
+ import { generateKeyPairSync } from "node:crypto";
30
+ import { once } from "node:events";
31
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
32
+ import { type AddressInfo, createServer, type Socket } from "node:net";
33
+ import { tmpdir } from "node:os";
34
+ import { join } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import {
37
+ type AppHandle,
38
+ BootError,
39
+ createApp,
40
+ DEFAULT_SIGNING_ALGORITHM,
41
+ defineModule,
42
+ InMemoryClientRepository,
43
+ InMemoryUserRepository,
44
+ type Logger,
45
+ type Module,
46
+ memoryRefreshTokenFamilyStoreModule,
47
+ moduleReferences,
48
+ supportsMfaEnrollmentWitness,
49
+ } from "@o3co/auth-provider-core";
50
+ import { httpSettingsContract, packageReferenceProblems } from "@o3co/auth-provider-core/testing";
51
+ import { HttpUserRepository } from "@o3co/auth-provider-foundation";
52
+ import { parseFile } from "@o3co/ts.hocon";
53
+ import express from "express";
54
+ import request from "supertest";
55
+ import { afterAll, describe, expect, it, vi } from "vitest";
56
+ import { ADAPTERS_SECTION } from "../adapters.mjs";
57
+ import { buildModules } from "../buildModules.mjs";
58
+ import {
59
+ readLogging,
60
+ readOwnLayers,
61
+ readSwitches,
62
+ resolveConfigPaths,
63
+ resolveForBoot,
64
+ SWITCHES,
65
+ } from "../configPath.mjs";
66
+ import { createAppLogger } from "../logger.mjs";
67
+ import { MFA_SWITCH } from "../mfaSwitch.mjs";
68
+ import {
69
+ auditSinkModuleFor,
70
+ httpModule,
71
+ inMemoryCodeRepositoryModule,
72
+ keyStoreModule,
73
+ loggingModule,
74
+ repositoriesModuleFor,
75
+ standaloneRedisClientsModule,
76
+ } from "../modules.mjs";
77
+ import { repositoriesSectionSchema } from "../sections.mjs";
78
+
79
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
80
+ /** The template's own defaults: what its modules declare as their sections' reference. */
81
+ const TEMPLATE_REFERENCE = new URL("../../config/reference.conf", import.meta.url);
82
+
83
+ const signingKey = generateKeyPairSync("ed25519", {
84
+ publicKeyEncoding: { type: "spki", format: "pem" },
85
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
86
+ });
87
+
88
+ /**
89
+ * What the shipped configuration needs set to boot, every store in memory
90
+ * but where a test asks for Redis: the issuer, an Ed25519 key pair inline for
91
+ * the shipped algorithm, and the session secret.
92
+ */
93
+ const BASE_ENV: Readonly<Record<string, string>> = {
94
+ OAUTH_JWT_ISSUER: "https://auth.test",
95
+ KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
96
+ KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
97
+ SESSION_STORE_SECRET: "own-modules-session-secret.at-least-32-bytes.ok",
98
+ SESSION_STORE_SECURE: "false",
99
+ SESSION_STORE_NAME: "auth.session",
100
+ SESSION_STORE_STORAGE_TYPE: "memory",
101
+ ADAPTERS_USER_REPOSITORY: "yaml",
102
+ CORE_DEPLOYMENT_MODE: "single",
103
+ ADAPTERS_CODE_REPOSITORY: "memory",
104
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
105
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
106
+ };
107
+
108
+ /** A logger that writes nothing. */
109
+ const silentLogger = (): Logger => {
110
+ const ignore = () => {};
111
+ const logger = {
112
+ trace: ignore,
113
+ debug: ignore,
114
+ info: ignore,
115
+ warn: ignore,
116
+ error: ignore,
117
+ fatal: ignore,
118
+ child: () => logger,
119
+ };
120
+ return logger as unknown as Logger;
121
+ };
122
+
123
+ /** In-memory repositories in place of the YAML files and the Store the shipped ones read. */
124
+ const testRepositoriesModule = defineModule({
125
+ name: "test:repositories",
126
+ provides: {
127
+ clientRepository: () => new InMemoryClientRepository(new Map()),
128
+ userRepository: () => new InMemoryUserRepository(new Map()),
129
+ },
130
+ });
131
+
132
+ interface BootOptions {
133
+ /** Variables laid over `BASE_ENV`; `undefined` unsets one. */
134
+ readonly env?: Readonly<Record<string, string | undefined>>;
135
+ /** HOCON an operator writes, a layer above the template's own files. */
136
+ readonly hocon?: string;
137
+ /** The composition's own files in place of the shipped ones (`withApplicationValues`). */
138
+ readonly files?: readonly string[];
139
+ /** Keep the shipped Redis refresh-token family store, and with it the shared Redis clients. */
140
+ readonly redis?: boolean;
141
+ /** Changes the resolved configuration before `createApp` parses it, as a hand-built root may. */
142
+ readonly adjust?: (resolved: Record<string, unknown>) => Record<string, unknown>;
143
+ /** Components a host lays over the modules' (`overrideComponents`). */
144
+ readonly overrides?: Record<string, unknown>;
145
+ /** Keep the template's own `repositories` module, reading its section, rather than in-memory ones. */
146
+ readonly repositories?: boolean;
147
+ }
148
+
149
+ /** The directories the operator layers are written to, removed after the suite. */
150
+ const operatorDirs: string[] = [];
151
+ afterAll(() => {
152
+ for (const dir of operatorDirs) rmSync(dir, { recursive: true, force: true });
153
+ });
154
+
155
+ /** The template's own files for the development environment, under an operator's layer when given. */
156
+ function ownFiles(hocon?: string): string[] {
157
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
158
+ if (hocon === undefined) return [envConfPath, applicationConfPath];
159
+ const dir = mkdtempSync(join(tmpdir(), "own-modules-"));
160
+ operatorDirs.push(dir);
161
+ const file = join(dir, "operator.conf");
162
+ writeFileSync(file, hocon);
163
+ return [file, envConfPath, applicationConfPath];
164
+ }
165
+
166
+ /** Boots the template as `app.mts` does, from its own files under `env` and `hocon`. */
167
+ async function bootTemplate(options: BootOptions = {}): Promise<AppHandle> {
168
+ const env = Object.fromEntries(
169
+ Object.entries({ ...BASE_ENV, ...options.env }).filter(
170
+ (entry): entry is [string, string] => entry[1] !== undefined,
171
+ ),
172
+ );
173
+ const own = readOwnLayers(options.files ?? ownFiles(options.hocon), { env });
174
+ const switches = readSwitches(own);
175
+ const modules = buildModules(switches, {
176
+ environment: "development",
177
+ ...(options.repositories ? {} : { repositoriesModule: testRepositoriesModule }),
178
+ ...(options.redis ? {} : { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
179
+ });
180
+ const resolved = resolveForBoot(own, modules, switches);
181
+ return createApp({
182
+ modules,
183
+ bootstrapComponents: {
184
+ config: (options.adjust
185
+ ? options.adjust({ ...(resolved as unknown as Record<string, unknown>) })
186
+ : resolved) as typeof resolved,
187
+ pathResolver: (s: string) => s,
188
+ logger: silentLogger(),
189
+ },
190
+ ...(options.overrides === undefined
191
+ ? {}
192
+ : {
193
+ overrideComponents: options.overrides as Parameters<
194
+ typeof createApp
195
+ >[0]["overrideComponents"],
196
+ }),
197
+ });
198
+ }
199
+
200
+ /** The module in `modules` named `name`. */
201
+ const named = (modules: readonly Module[], name: string): Module => {
202
+ const found = modules.find((module) => module.name === name);
203
+ if (found === undefined) throw new Error(`no module named ${name}`);
204
+ return found;
205
+ };
206
+
207
+ /** Every module whose section the template's reference holds. */
208
+ const REFERENCED_MODULES = [
209
+ loggingModule,
210
+ httpModule,
211
+ keyStoreModule,
212
+ standaloneRedisClientsModule,
213
+ repositoriesModuleFor({ client: "yaml", user: "http" }),
214
+ inMemoryCodeRepositoryModule,
215
+ auditSinkModuleFor("logger"),
216
+ ];
217
+
218
+ describe("the template's config/reference.conf", () => {
219
+ it("holds only its own modules' sections and the composition root's adapters and mfaMode, each module's parsing its part without losing a path", () => {
220
+ expect(
221
+ packageReferenceProblems({
222
+ reference: TEMPLATE_REFERENCE,
223
+ modules: REFERENCED_MODULES,
224
+ // `adapters` and `mfaMode` are the composition root's own,
225
+ // which phase one reads with its own schema
226
+ // (`adapters.test.mts`, `mfa-switch.test.mts`).
227
+ read: (path, env) => {
228
+ const {
229
+ [ADAPTERS_SECTION]: _adapters,
230
+ [MFA_SWITCH]: _mfaMode,
231
+ ...tree
232
+ } = parseFile(path, {
233
+ env: { ...env },
234
+ }).toObject() as Record<string, unknown>;
235
+ return tree;
236
+ },
237
+ }),
238
+ ).toEqual([]);
239
+ });
240
+
241
+ it.each(REFERENCED_MODULES)("is among the references $name alone brings", (module) => {
242
+ expect(moduleReferences([module]).map((url) => url.href)).toContain(TEMPLATE_REFERENCE.href);
243
+ });
244
+ });
245
+
246
+ describe("logging", () => {
247
+ it("owns logging, and requires nothing", () => {
248
+ expect(loggingModule.name).toBe("logging");
249
+ expect(loggingModule.section?.at).toBeUndefined();
250
+ expect(loggingModule.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
251
+ expect(loggingModule.requires ?? []).toEqual([]);
252
+ expect(loggingModule.optional ?? []).toEqual([]);
253
+ });
254
+
255
+ it("is loaded by the shipped composition", () => {
256
+ const own = readOwnLayers(ownFiles(), { env: BASE_ENV });
257
+ const modules = buildModules(readSwitches(own), { environment: "development" });
258
+ expect(named(modules, "logging")).toBe(loggingModule);
259
+ });
260
+
261
+ it("has boot refuse a level its schema does not know, naming logging.level", async () => {
262
+ await expect(bootTemplate({ env: { LOGGING_LEVEL: "verbose" } })).rejects.toMatchObject({
263
+ reason: "config-validation-failed",
264
+ message: expect.stringMatching(/logging\.level/),
265
+ });
266
+ });
267
+
268
+ it("is not among SWITCHES", () => {
269
+ expect(SWITCHES).not.toContain("logging");
270
+ });
271
+
272
+ it.each([
273
+ ["the shipped default", {}, undefined, "info"],
274
+ ["LOGGING_LEVEL", { LOGGING_LEVEL: "debug" }, undefined, "debug"],
275
+ ["HOCON an operator writes", {}, 'logging.level = "warn"\n', "warn"],
276
+ ["HOCON over LOGGING_LEVEL", { LOGGING_LEVEL: "debug" }, 'logging.level = "error"\n', "error"],
277
+ ])("gives the logger the level %s sets", (_name, env, hocon, level) => {
278
+ const own = readOwnLayers(ownFiles(hocon), { env: { ...BASE_ENV, ...env } });
279
+ expect(readLogging(own)).toEqual({ level });
280
+ const logger = createAppLogger(readLogging(own)) as unknown as { readonly level: string };
281
+ expect(logger.level).toBe(level);
282
+ });
283
+
284
+ it("refuses a level it does not know before boot, naming logging.level", () => {
285
+ const own = readOwnLayers(ownFiles(), { env: { ...BASE_ENV, LOGGING_LEVEL: "verbose" } });
286
+ expect(() => readLogging(own)).toThrow(/logging\.level/);
287
+ });
288
+
289
+ it("has boot refuse LOG_LEVEL set alone, naming LOGGING_LEVEL and logging.level", async () => {
290
+ await expect(bootTemplate({ env: { LOG_LEVEL: "debug" } })).rejects.toMatchObject({
291
+ details: {
292
+ reason: "environment-variable-renamed",
293
+ renamed: [
294
+ {
295
+ module: "logging",
296
+ from: "LOG_LEVEL",
297
+ to: "LOGGING_LEVEL",
298
+ path: "logging.level",
299
+ state: "unset",
300
+ },
301
+ ],
302
+ },
303
+ });
304
+ });
305
+
306
+ it("boots with LOG_LEVEL beside LOGGING_LEVEL at the same value", async () => {
307
+ const handle = await bootTemplate({ env: { LOG_LEVEL: "debug", LOGGING_LEVEL: "debug" } });
308
+ await handle.dispose();
309
+ });
310
+ });
311
+
312
+ /**
313
+ * The template booted, and mounted as `app.mts` mounts it: `trust proxy` from
314
+ * the `httpSettings` slot, the composed router, and a route answering the
315
+ * address Express makes of the request, standing in for what every IP-keyed
316
+ * limit reads.
317
+ */
318
+ async function mountTemplate(options: BootOptions = {}) {
319
+ const handle = await bootTemplate(options);
320
+ const settings = handle.components.httpSettings;
321
+ if (settings === undefined) throw new Error("booted without httpSettings");
322
+ const app = express();
323
+ app.set("trust proxy", settings.trustProxy);
324
+ app.get("/__ip__", (req, res) => {
325
+ res.json({ ip: req.ip });
326
+ });
327
+ app.use(handle.router);
328
+ return { app, handle };
329
+ }
330
+
331
+ /** A CORS preflight for the token endpoint from `origin`. */
332
+ const preflight = (app: express.Express, origin: string) =>
333
+ request(app)
334
+ .options("/oauth/token")
335
+ .set("Origin", origin)
336
+ .set("Access-Control-Request-Method", "POST");
337
+
338
+ describe("http", () => {
339
+ it("owns http, the CORS list included, and requires nothing", () => {
340
+ expect(httpModule.name).toBe("http");
341
+ expect(httpModule.section?.at).toBeUndefined();
342
+ expect(httpModule.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
343
+ expect(httpModule.requires ?? []).toEqual([]);
344
+ expect(httpModule.optional ?? []).toEqual([]);
345
+ });
346
+
347
+ it("provides core's httpSettings, authoritative, and the host's own settings, both eagerly", () => {
348
+ expect(Object.keys(httpModule.provides ?? {}).sort()).toEqual([
349
+ "httpHostSettings",
350
+ "httpSettings",
351
+ ]);
352
+ expect(httpModule.authoritative).toEqual(["httpSettings"]);
353
+ expect(httpModule.lifecycle?.httpSettings?.eager).toBe(true);
354
+ expect(httpModule.lifecycle?.httpHostSettings?.eager).toBe(true);
355
+ });
356
+
357
+ it("is loaded by the shipped composition, and no module owns a top-level cors", () => {
358
+ const own = readOwnLayers(ownFiles(), { env: BASE_ENV });
359
+ const modules = buildModules(readSwitches(own), { environment: "development" });
360
+ expect(named(modules, "http")).toBe(httpModule);
361
+ expect(modules.map((module) => module.name)).not.toContain("cors");
362
+ });
363
+
364
+ describe("the httpSettings it provides keeps the slot's contract", () => {
365
+ for (const [name, env] of [
366
+ ["for the shipped configuration", {}],
367
+ [
368
+ "for an operator's overrides",
369
+ {
370
+ HTTP_TRUST_PROXY: "10.0.0.0/8,loopback",
371
+ HTTP_CORS_ALLOWED_ORIGINS: "https://app.example.com,http://localhost:5173",
372
+ },
373
+ ],
374
+ ] as const) {
375
+ it(name, async () => {
376
+ const handle = await bootTemplate({ env });
377
+ try {
378
+ const settings = handle.components.httpSettings;
379
+ if (settings === undefined) throw new Error("booted without httpSettings");
380
+ for (const contractCase of httpSettingsContract({ build: () => settings })) {
381
+ await contractCase.run();
382
+ }
383
+ } finally {
384
+ await handle.dispose();
385
+ }
386
+ });
387
+ }
388
+ });
389
+
390
+ it.each([
391
+ ["the shipped defaults", {}, undefined, { port: 3000, readinessTimeoutMs: 1000 }],
392
+ [
393
+ "HTTP_PORT and HTTP_READINESS_TIMEOUT_MS",
394
+ { HTTP_PORT: "8080", HTTP_READINESS_TIMEOUT_MS: "1500" },
395
+ undefined,
396
+ { port: 8080, readinessTimeoutMs: 1500 },
397
+ ],
398
+ [
399
+ "HOCON an operator writes",
400
+ { HTTP_PORT: "8080" },
401
+ "http { port = 4000, readinessTimeoutMs = 2500 }\n",
402
+ { port: 4000, readinessTimeoutMs: 2500 },
403
+ ],
404
+ ])(
405
+ "hands the host the port and the readiness deadline %s set",
406
+ async (_name, env, hocon, host) => {
407
+ const handle = await bootTemplate({ env, hocon });
408
+ try {
409
+ expect(handle.components.httpHostSettings).toEqual(host);
410
+ } finally {
411
+ await handle.dispose();
412
+ }
413
+ },
414
+ );
415
+
416
+ it.each([
417
+ ["the shipped default", {}, undefined, false],
418
+ [
419
+ "HTTP_TRUST_PROXY",
420
+ { HTTP_TRUST_PROXY: "10.0.0.0/8,loopback" },
421
+ undefined,
422
+ ["10.0.0.0/8", "loopback"],
423
+ ],
424
+ ["HOCON an operator writes", {}, "http.trustProxy = 1\n", 1],
425
+ ])("trusts the forwarding hops %s names", async (_name, env, hocon, trustProxy) => {
426
+ const handle = await bootTemplate({ env, hocon });
427
+ try {
428
+ expect(handle.components.httpSettings?.trustProxy).toEqual(trustProxy);
429
+ } finally {
430
+ await handle.dispose();
431
+ }
432
+ });
433
+
434
+ it("makes req.ip the forwarded client address only through a trusted hop", async () => {
435
+ const trusting = await mountTemplate({ env: { HTTP_TRUST_PROXY: "loopback" } });
436
+ const shipped = await mountTemplate();
437
+ try {
438
+ const forwarded = await request(trusting.app)
439
+ .get("/__ip__")
440
+ .set("X-Forwarded-For", "203.0.113.9");
441
+ expect(forwarded.body.ip).toBe("203.0.113.9");
442
+ const direct = await request(shipped.app)
443
+ .get("/__ip__")
444
+ .set("X-Forwarded-For", "203.0.113.9");
445
+ expect(direct.body.ip).not.toBe("203.0.113.9");
446
+ } finally {
447
+ await trusting.handle.dispose();
448
+ await shipped.handle.dispose();
449
+ }
450
+ });
451
+
452
+ it("refuses an exported-but-empty HTTP_PORT at boot, naming http.port and HTTP_PORT", async () => {
453
+ const booting = bootTemplate({ env: { HTTP_PORT: "" } });
454
+ await expect(booting).rejects.toThrow(/http\.port/);
455
+ await expect(booting).rejects.toThrow(/HTTP_PORT/);
456
+ });
457
+
458
+ it("hands the host an explicit HTTP_PORT=0, the OS choosing a free port", async () => {
459
+ const handle = await bootTemplate({ env: { HTTP_PORT: "0" } });
460
+ try {
461
+ expect(handle.components.httpHostSettings?.port).toBe(0);
462
+ } finally {
463
+ await handle.dispose();
464
+ }
465
+ });
466
+
467
+ it("refuses a trust proxy entry that is not an address, a range or a named range at boot, naming http.trustProxy", async () => {
468
+ await expect(bootTemplate({ env: { HTTP_TRUST_PROXY: "proxy.internal" } })).rejects.toThrow(
469
+ /http\.trustProxy/,
470
+ );
471
+ });
472
+ });
473
+
474
+ describe("http.cors", () => {
475
+ it("lets no origin read with the shipped default: no CORS headers, not even Vary", async () => {
476
+ const { app, handle } = await mountTemplate();
477
+ try {
478
+ const res = await preflight(app, "https://app.example.com");
479
+ expect(res.headers["access-control-allow-origin"]).toBeUndefined();
480
+ expect(res.headers.vary ?? "").not.toMatch(/Origin/);
481
+ } finally {
482
+ await handle.dispose();
483
+ }
484
+ });
485
+
486
+ it.each([
487
+ [
488
+ "HTTP_CORS_ALLOWED_ORIGINS",
489
+ { HTTP_CORS_ALLOWED_ORIGINS: "https://app.example.com" },
490
+ undefined,
491
+ ],
492
+ ["HOCON an operator writes", {}, 'http.cors.allowedOrigins = ["https://app.example.com"]\n'],
493
+ ])(
494
+ "lets the origin %s lists read the token endpoint, and no other",
495
+ async (_name, env, hocon) => {
496
+ const { app, handle } = await mountTemplate({ env, hocon });
497
+ try {
498
+ const listed = await preflight(app, "https://app.example.com");
499
+ expect(listed.status).toBe(204);
500
+ expect(listed.headers["access-control-allow-origin"]).toBe("https://app.example.com");
501
+ const other = await preflight(app, "https://other.example.com");
502
+ expect(other.headers["access-control-allow-origin"]).toBeUndefined();
503
+ } finally {
504
+ await handle.dispose();
505
+ }
506
+ },
507
+ );
508
+
509
+ it("refuses an origin that could never match at boot, naming http.cors.allowedOrigins", async () => {
510
+ await expect(
511
+ bootTemplate({ env: { HTTP_CORS_ALLOWED_ORIGINS: "https://app.example.com/" } }),
512
+ ).rejects.toThrow(/http\.cors\.allowedOrigins/);
513
+ });
514
+
515
+ it("has boot refuse cors.allowedOrigins, the path it moved from, naming the new path and its variable", async () => {
516
+ await expect(
517
+ bootTemplate({ hocon: 'cors.allowedOrigins = ["https://app.example.com"]\n' }),
518
+ ).rejects.toMatchObject({
519
+ details: {
520
+ reason: "config-path-relocated",
521
+ relocated: [
522
+ {
523
+ module: "http",
524
+ from: "cors.allowedOrigins",
525
+ to: "http.cors.allowedOrigins",
526
+ environmentVariable: "HTTP_CORS_ALLOWED_ORIGINS",
527
+ },
528
+ ],
529
+ },
530
+ });
531
+ });
532
+
533
+ it("has boot refuse CORS_ALLOWED_ORIGINS set alone, naming HTTP_CORS_ALLOWED_ORIGINS", async () => {
534
+ await expect(
535
+ bootTemplate({ env: { CORS_ALLOWED_ORIGINS: "https://app.example.com" } }),
536
+ ).rejects.toMatchObject({
537
+ details: {
538
+ reason: "environment-variable-renamed",
539
+ renamed: [
540
+ {
541
+ module: "http",
542
+ from: "CORS_ALLOWED_ORIGINS",
543
+ to: "HTTP_CORS_ALLOWED_ORIGINS",
544
+ path: "http.cors.allowedOrigins",
545
+ state: "unset",
546
+ },
547
+ ],
548
+ },
549
+ });
550
+ });
551
+ });
552
+
553
+ /**
554
+ * The template's own files with `hocon` written into its `application.conf`,
555
+ * above the shipped content: a value an operator sets there, above the
556
+ * lines that bind the environment variables.
557
+ */
558
+ function withApplicationValues(hocon: string): string[] {
559
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
560
+ const dir = mkdtempSync(join(tmpdir(), "own-modules-application-"));
561
+ operatorDirs.push(dir);
562
+ const application = join(dir, "application.conf");
563
+ writeFileSync(application, `${hocon}\n${readFileSync(applicationConfPath, "utf8")}`);
564
+ const development = join(dir, "development.conf");
565
+ writeFileSync(development, readFileSync(envConfPath, "utf8"));
566
+ return [development, application];
567
+ }
568
+
569
+ describe("an environment variable wins over a value the template's application.conf sets", () => {
570
+ it("HTTP_PORT over http.port", async () => {
571
+ const handle = await bootTemplate({
572
+ files: withApplicationValues("http.port = 4000"),
573
+ env: { HTTP_PORT: "8080" },
574
+ });
575
+ try {
576
+ expect(handle.components.httpHostSettings?.port).toBe(8080);
577
+ } finally {
578
+ await handle.dispose();
579
+ }
580
+ });
581
+
582
+ it("HTTP_TRUST_PROXY over http.trustProxy", async () => {
583
+ const handle = await bootTemplate({
584
+ files: withApplicationValues("http.trustProxy = 1"),
585
+ env: { HTTP_TRUST_PROXY: "loopback" },
586
+ });
587
+ try {
588
+ expect(handle.components.httpSettings?.trustProxy).toEqual(["loopback"]);
589
+ } finally {
590
+ await handle.dispose();
591
+ }
592
+ });
593
+
594
+ it("HTTP_CORS_ALLOWED_ORIGINS over http.cors.allowedOrigins", async () => {
595
+ const handle = await bootTemplate({
596
+ files: withApplicationValues('http.cors.allowedOrigins = ["https://file.example.com"]'),
597
+ env: { HTTP_CORS_ALLOWED_ORIGINS: "https://env.example.com" },
598
+ });
599
+ try {
600
+ expect(handle.components.httpSettings?.cors.allowedOrigins).toEqual([
601
+ "https://env.example.com",
602
+ ]);
603
+ } finally {
604
+ await handle.dispose();
605
+ }
606
+ });
607
+
608
+ it("REDIS_CLIENTS_URL over redis-clients.url", async () => {
609
+ const redis = await listeningRedis();
610
+ const handle = await bootTemplate({
611
+ redis: true,
612
+ files: withApplicationValues('redis-clients.url = "redis://127.0.0.1:9"'),
613
+ env: {
614
+ REDIS_CLIENTS_URL: `redis://127.0.0.1:${redis.port}`,
615
+ REDIS_CLIENTS_PASSWORD: "url-test-password",
616
+ },
617
+ });
618
+ try {
619
+ await vi.waitFor(() => expect(redis.received()).toContain("url-test-password"), {
620
+ timeout: 10_000,
621
+ });
622
+ } finally {
623
+ await handle.dispose().catch(() => {});
624
+ await redis.close();
625
+ }
626
+ });
627
+
628
+ it("REDIS_CLIENTS_PASSWORD over redis-clients.password", async () => {
629
+ const redis = await listeningRedis();
630
+ const handle = await bootTemplate({
631
+ redis: true,
632
+ files: withApplicationValues('redis-clients.password = "from-the-file"'),
633
+ env: {
634
+ REDIS_CLIENTS_URL: `redis://127.0.0.1:${redis.port}`,
635
+ REDIS_CLIENTS_PASSWORD: "from-the-environment",
636
+ },
637
+ });
638
+ try {
639
+ await vi.waitFor(() => expect(redis.received()).toContain("from-the-environment"), {
640
+ timeout: 10_000,
641
+ });
642
+ expect(redis.received()).not.toContain("from-the-file");
643
+ } finally {
644
+ await handle.dispose().catch(() => {});
645
+ await redis.close();
646
+ }
647
+ });
648
+ });
649
+
650
+ /** The marker of the block of variable bindings `application.conf` ships last. */
651
+ const BINDINGS_BLOCK = "# The variables that set keys of the template's own modules";
652
+
653
+ /**
654
+ * The template's own files with its `application.conf` trimmed of the block
655
+ * of variable bindings it ships last, as a deployment's own copy may be.
656
+ */
657
+ function withoutApplicationBindings(): string[] {
658
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
659
+ const shipped = readFileSync(applicationConfPath, "utf8");
660
+ const at = shipped.indexOf(BINDINGS_BLOCK);
661
+ if (at < 0) throw new Error("application.conf ships no bindings block");
662
+ const dir = mkdtempSync(join(tmpdir(), "own-modules-trimmed-"));
663
+ operatorDirs.push(dir);
664
+ const application = join(dir, "application.conf");
665
+ writeFileSync(application, shipped.slice(0, at));
666
+ const development = join(dir, "development.conf");
667
+ writeFileSync(development, readFileSync(envConfPath, "utf8"));
668
+ return [development, application];
669
+ }
670
+
671
+ describe("a value the template's application.conf sets wins over a variable only its reference.conf binds", () => {
672
+ it("logging.level over LOG_LEVEL", () => {
673
+ const own = readOwnLayers(withApplicationValues('logging.level = "warn"'), {
674
+ env: { ...BASE_ENV, LOG_LEVEL: "debug" },
675
+ });
676
+ expect(readLogging(own)).toEqual({ level: "warn" });
677
+ });
678
+
679
+ it("http.readinessTimeoutMs over HTTP_READINESS_TIMEOUT_MS", async () => {
680
+ const handle = await bootTemplate({
681
+ files: withApplicationValues("http.readinessTimeoutMs = 2500"),
682
+ env: { HTTP_READINESS_TIMEOUT_MS: "1500" },
683
+ });
684
+ try {
685
+ expect(handle.components.httpHostSettings?.readinessTimeoutMs).toBe(2500);
686
+ } finally {
687
+ await handle.dispose();
688
+ }
689
+ });
690
+
691
+ it("key-store.local.kid over KEY_STORE_LOCAL_KID", async () => {
692
+ const handle = await bootTemplate({
693
+ files: withApplicationValues('key-store.local.kid = "k-file"'),
694
+ env: { KEY_STORE_LOCAL_KID: "k-env" },
695
+ });
696
+ try {
697
+ expect(handle.components.keyStore?.getSigningKidFallback()).toBe("k-file");
698
+ } finally {
699
+ await handle.dispose();
700
+ }
701
+ });
702
+ });
703
+
704
+ describe("an environment variable takes effect though application.conf does not bind it", () => {
705
+ it("HTTP_PORT and HTTP_TRUST_PROXY", async () => {
706
+ const handle = await bootTemplate({
707
+ files: withoutApplicationBindings(),
708
+ env: { HTTP_PORT: "8080", HTTP_TRUST_PROXY: "loopback" },
709
+ });
710
+ try {
711
+ expect(handle.components.httpHostSettings?.port).toBe(8080);
712
+ expect(handle.components.httpSettings?.trustProxy).toEqual(["loopback"]);
713
+ } finally {
714
+ await handle.dispose();
715
+ }
716
+ });
717
+
718
+ it("HTTP_CORS_ALLOWED_ORIGINS", async () => {
719
+ const handle = await bootTemplate({
720
+ files: withoutApplicationBindings(),
721
+ env: { HTTP_CORS_ALLOWED_ORIGINS: "https://env.example.com" },
722
+ });
723
+ try {
724
+ expect(handle.components.httpSettings?.cors.allowedOrigins).toEqual([
725
+ "https://env.example.com",
726
+ ]);
727
+ } finally {
728
+ await handle.dispose();
729
+ }
730
+ });
731
+
732
+ it("REDIS_CLIENTS_URL and REDIS_CLIENTS_PASSWORD", async () => {
733
+ const redis = await listeningRedis();
734
+ const handle = await bootTemplate({
735
+ redis: true,
736
+ files: withoutApplicationBindings(),
737
+ env: {
738
+ REDIS_CLIENTS_URL: `redis://127.0.0.1:${redis.port}`,
739
+ REDIS_CLIENTS_PASSWORD: "trimmed-file-password",
740
+ },
741
+ });
742
+ try {
743
+ await vi.waitFor(() => expect(redis.received()).toContain("trimmed-file-password"), {
744
+ timeout: 10_000,
745
+ });
746
+ } finally {
747
+ await handle.dispose().catch(() => {});
748
+ await redis.close();
749
+ }
750
+ });
751
+ });
752
+
753
+ describe("key-store", () => {
754
+ it("owns key-store, and reads it as its section rather than the configuration", () => {
755
+ expect(keyStoreModule.name).toBe("key-store");
756
+ expect(keyStoreModule.section?.at).toBeUndefined();
757
+ expect(keyStoreModule.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
758
+ expect(keyStoreModule.requires ?? []).not.toContain("config");
759
+ expect(keyStoreModule.optional ?? []).not.toContain("config");
760
+ });
761
+
762
+ it("builds the shipped EdDSA key store, kid v0, from the key pair the environment supplies", async () => {
763
+ const handle = await bootTemplate();
764
+ try {
765
+ expect(handle.components.keyStore?.algorithm).toBe("EdDSA");
766
+ expect(handle.components.keyStore?.getSigningKidFallback()).toBe("v0");
767
+ } finally {
768
+ await handle.dispose();
769
+ }
770
+ });
771
+
772
+ it("builds an HS256 key store under KEY_STORE_LOCAL_ALGORITHM and KEY_STORE_LOCAL_SECRET, with the kid KEY_STORE_LOCAL_KID names", async () => {
773
+ const handle = await bootTemplate({
774
+ env: {
775
+ KEY_STORE_LOCAL_PRIVATE_KEY: undefined,
776
+ KEY_STORE_LOCAL_PUBLIC_KEY: undefined,
777
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
778
+ KEY_STORE_LOCAL_SECRET: "own-modules-hs256-secret.at-least-32-bytes.ok",
779
+ KEY_STORE_LOCAL_KID: "k-env",
780
+ },
781
+ });
782
+ try {
783
+ expect(handle.components.keyStore?.algorithm).toBe("HS256");
784
+ expect(handle.components.keyStore?.getSigningKidFallback()).toBe("k-env");
785
+ } finally {
786
+ await handle.dispose();
787
+ }
788
+ });
789
+
790
+ it("takes a kid an operator writes in HOCON", async () => {
791
+ const handle = await bootTemplate({ hocon: 'key-store.local.kid = "k-hocon"\n' });
792
+ try {
793
+ expect(handle.components.keyStore?.getSigningKidFallback()).toBe("k-hocon");
794
+ } finally {
795
+ await handle.dispose();
796
+ }
797
+ });
798
+
799
+ it("ships core's default algorithm, and no key material", () => {
800
+ const shipped = parseFile(fileURLToPath(TEMPLATE_REFERENCE), { env: {} }).toObject() as {
801
+ "key-store": { provider: unknown; local: Record<string, unknown> };
802
+ };
803
+ expect(shipped["key-store"].provider).toBe("local");
804
+ expect(shipped["key-store"].local).toEqual({
805
+ algorithm: DEFAULT_SIGNING_ALGORITHM,
806
+ kid: "v0",
807
+ });
808
+ });
809
+
810
+ it("refuses to boot with no key material, naming the variables to set and how to make a key pair", async () => {
811
+ const booting = bootTemplate({
812
+ env: { KEY_STORE_LOCAL_PRIVATE_KEY: undefined, KEY_STORE_LOCAL_PUBLIC_KEY: undefined },
813
+ });
814
+ await expect(booting).rejects.toThrow(/KEY_STORE_LOCAL_PRIVATE_KEY_PATH/);
815
+ await expect(booting).rejects.toThrow(/KEY_STORE_LOCAL_PUBLIC_KEY_PATH/);
816
+ await expect(booting).rejects.toThrow(/key-store\.local\.privateKeyPath/);
817
+ await expect(booting).rejects.toThrow(/openssl genpkey -algorithm ed25519/i);
818
+ });
819
+
820
+ it("refuses to boot on KEY_STORE_LOCAL_SECRET alone, saying how to opt into HS256", async () => {
821
+ await expect(
822
+ bootTemplate({
823
+ env: {
824
+ KEY_STORE_LOCAL_PRIVATE_KEY: undefined,
825
+ KEY_STORE_LOCAL_PUBLIC_KEY: undefined,
826
+ KEY_STORE_LOCAL_SECRET: "own-modules-hs256-secret.at-least-32-bytes.ok",
827
+ },
828
+ }),
829
+ ).rejects.toThrow(/KEY_STORE_LOCAL_ALGORITHM=HS256/);
830
+ });
831
+
832
+ it("refuses to boot on an HS256 secret below 32 bytes, naming the key and its variable", async () => {
833
+ const booting = bootTemplate({
834
+ env: {
835
+ KEY_STORE_LOCAL_PRIVATE_KEY: undefined,
836
+ KEY_STORE_LOCAL_PUBLIC_KEY: undefined,
837
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
838
+ KEY_STORE_LOCAL_SECRET: "too-short-a-secret",
839
+ },
840
+ });
841
+ await expect(booting).rejects.toThrow(/at least 32 bytes/i);
842
+ await expect(booting).rejects.toThrow(/key-store\.local\.secret/);
843
+ await expect(booting).rejects.toThrow(/KEY_STORE_LOCAL_SECRET/);
844
+ });
845
+
846
+ it("refuses a key store section its schema refuses at boot, naming the operator's path", async () => {
847
+ await expect(bootTemplate({ hocon: 'key-store.local.algorithm = "none"\n' })).rejects.toThrow(
848
+ /key-store\.local/,
849
+ );
850
+ });
851
+
852
+ it("has boot refuse each key at oauth.jwt.signingKey, the path it moved from, naming its new path and variable", async () => {
853
+ await expect(
854
+ bootTemplate({
855
+ hocon: 'oauth.jwt.signingKey { provider = "local", local.kid = "k-old" }\n',
856
+ }),
857
+ ).rejects.toMatchObject({
858
+ details: {
859
+ reason: "config-path-relocated",
860
+ relocated: expect.arrayContaining([
861
+ {
862
+ module: "key-store",
863
+ from: "oauth.jwt.signingKey.provider",
864
+ to: "key-store.provider",
865
+ environmentVariable: "KEY_STORE_PROVIDER",
866
+ },
867
+ {
868
+ module: "key-store",
869
+ from: "oauth.jwt.signingKey.local.kid",
870
+ to: "key-store.local.kid",
871
+ environmentVariable: "KEY_STORE_LOCAL_KID",
872
+ },
873
+ ]),
874
+ },
875
+ });
876
+ });
877
+
878
+ it.each([
879
+ ["OAUTH_JWT_SIGNING_KEY_PROVIDER", "KEY_STORE_PROVIDER", "key-store.provider", "local"],
880
+ ["OAUTH_JWT_ALGORITHM", "KEY_STORE_LOCAL_ALGORITHM", "key-store.local.algorithm", "EdDSA"],
881
+ ["OAUTH_JWT_KID", "KEY_STORE_LOCAL_KID", "key-store.local.kid", "k-renamed"],
882
+ [
883
+ "OAUTH_JWT_SECRET",
884
+ "KEY_STORE_LOCAL_SECRET",
885
+ "key-store.local.secret",
886
+ "own-modules-hs256-secret.at-least-32-bytes.ok",
887
+ ],
888
+ [
889
+ "OAUTH_JWT_PRIVATE_KEY_PATH",
890
+ "KEY_STORE_LOCAL_PRIVATE_KEY_PATH",
891
+ "key-store.local.privateKeyPath",
892
+ "/keys/private.pem",
893
+ ],
894
+ [
895
+ "OAUTH_JWT_PUBLIC_KEY_PATH",
896
+ "KEY_STORE_LOCAL_PUBLIC_KEY_PATH",
897
+ "key-store.local.publicKeyPath",
898
+ "/keys/public.pem",
899
+ ],
900
+ [
901
+ "OAUTH_JWT_PRIVATE_KEY",
902
+ "KEY_STORE_LOCAL_PRIVATE_KEY",
903
+ "key-store.local.privateKey",
904
+ signingKey.privateKey,
905
+ ],
906
+ [
907
+ "OAUTH_JWT_PUBLIC_KEY",
908
+ "KEY_STORE_LOCAL_PUBLIC_KEY",
909
+ "key-store.local.publicKey",
910
+ signingKey.publicKey,
911
+ ],
912
+ ])("has boot refuse %s set alone, naming %s", async (from, to, path, value) => {
913
+ await expect(bootTemplate({ env: { [from]: value, [to]: undefined } })).rejects.toMatchObject({
914
+ details: {
915
+ reason: "environment-variable-renamed",
916
+ renamed: [{ module: "key-store", from, to, path, state: "unset" }],
917
+ },
918
+ });
919
+ });
920
+
921
+ it("boots with each old name beside its new one at the same value", async () => {
922
+ const handle = await bootTemplate({
923
+ env: {
924
+ OAUTH_JWT_PRIVATE_KEY: signingKey.privateKey,
925
+ OAUTH_JWT_PUBLIC_KEY: signingKey.publicKey,
926
+ OAUTH_JWT_KID: "k-both",
927
+ KEY_STORE_LOCAL_KID: "k-both",
928
+ },
929
+ });
930
+ try {
931
+ expect(handle.components.keyStore?.getSigningKidFallback()).toBe("k-both");
932
+ } finally {
933
+ await handle.dispose();
934
+ }
935
+ });
936
+ });
937
+
938
+ /**
939
+ * A TCP server standing in for Redis: it records what each connection sends
940
+ * and answers nothing, so a client dialled at it connects and writes its
941
+ * handshake.
942
+ */
943
+ async function listeningRedis(): Promise<{
944
+ readonly port: number;
945
+ readonly received: () => string;
946
+ readonly close: () => Promise<void>;
947
+ }> {
948
+ const sockets = new Set<Socket>();
949
+ let received = "";
950
+ const server = createServer((socket) => {
951
+ sockets.add(socket);
952
+ socket.on("close", () => sockets.delete(socket));
953
+ socket.on("data", (chunk) => {
954
+ received += chunk.toString("utf8");
955
+ });
956
+ });
957
+ server.listen(0, "127.0.0.1");
958
+ await once(server, "listening");
959
+ const { port } = server.address() as AddressInfo;
960
+ return {
961
+ port,
962
+ received: () => received,
963
+ close: async () => {
964
+ for (const socket of sockets) socket.destroy();
965
+ await new Promise<void>((resolve) => server.close(() => resolve()));
966
+ },
967
+ };
968
+ }
969
+
970
+ describe("redis-clients", () => {
971
+ it("owns redis-clients, and reads it as its section rather than the configuration", () => {
972
+ expect(standaloneRedisClientsModule.name).toBe("redis-clients");
973
+ expect(standaloneRedisClientsModule.section?.at).toBeUndefined();
974
+ expect(standaloneRedisClientsModule.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
975
+ expect(standaloneRedisClientsModule.requires ?? []).not.toContain("config");
976
+ expect(standaloneRedisClientsModule.optional ?? []).not.toContain("config");
977
+ });
978
+
979
+ it("is loaded by the shipped composition, whose refresh-token family store is on Redis", () => {
980
+ const own = readOwnLayers(ownFiles(), { env: BASE_ENV });
981
+ const modules = buildModules(readSwitches(own), { environment: "development" });
982
+ expect(named(modules, "redis-clients")).toBe(standaloneRedisClientsModule);
983
+ });
984
+
985
+ it("dials the URL REDIS_CLIENTS_URL names, with the password REDIS_CLIENTS_PASSWORD names", async () => {
986
+ const redis = await listeningRedis();
987
+ const password = "own-modules-redis-password";
988
+ const handle = await bootTemplate({
989
+ redis: true,
990
+ env: {
991
+ REDIS_CLIENTS_URL: `redis://127.0.0.1:${redis.port}`,
992
+ REDIS_CLIENTS_PASSWORD: password,
993
+ },
994
+ });
995
+ try {
996
+ await vi.waitFor(() => expect(redis.received()).toContain(password), { timeout: 10_000 });
997
+ } finally {
998
+ await handle.dispose().catch(() => {});
999
+ await redis.close();
1000
+ }
1001
+ });
1002
+
1003
+ it("dials the URL an operator writes in HOCON", async () => {
1004
+ const redis = await listeningRedis();
1005
+ const handle = await bootTemplate({
1006
+ redis: true,
1007
+ hocon: `redis-clients { url = "redis://127.0.0.1:${redis.port}", password = "from-hocon-password" }\n`,
1008
+ });
1009
+ try {
1010
+ await vi.waitFor(() => expect(redis.received()).toContain("from-hocon-password"), {
1011
+ timeout: 10_000,
1012
+ });
1013
+ } finally {
1014
+ await handle.dispose().catch(() => {});
1015
+ await redis.close();
1016
+ }
1017
+ });
1018
+
1019
+ it("refuses a null URL at boot, naming the key", async () => {
1020
+ await expect(
1021
+ bootTemplate({ redis: true, hocon: "redis-clients.url = null\n" }),
1022
+ ).rejects.toThrow(/redis-clients\.url/);
1023
+ });
1024
+
1025
+ it("refuses a configuration without its section at boot, naming the section: its own parse", async () => {
1026
+ await expect(
1027
+ bootTemplate({
1028
+ redis: true,
1029
+ adjust: ({ "redis-clients": _dropped, ...rest }) => rest,
1030
+ }),
1031
+ ).rejects.toThrow(/redis-clients/);
1032
+ });
1033
+
1034
+ it("refuses an empty URL when a client is built, naming the key and its variable", async () => {
1035
+ await expect(bootTemplate({ redis: true, env: { REDIS_CLIENTS_URL: "" } })).rejects.toThrow(
1036
+ /redis-clients\.url.*REDIS_CLIENTS_URL/s,
1037
+ );
1038
+ });
1039
+
1040
+ it("has boot refuse refreshTokenFamilyStore.redis, the path it moved from, naming the new paths and variables", async () => {
1041
+ const err = await bootTemplate({
1042
+ redis: true,
1043
+ hocon: 'refreshTokenFamilyStore.redis { url = "redis://127.0.0.1:9", password = "p" }\n',
1044
+ }).then(
1045
+ () => undefined,
1046
+ (thrown: unknown) => thrown,
1047
+ );
1048
+ expect(err).toBeInstanceOf(BootError);
1049
+ expect((err as BootError).details).toEqual({
1050
+ reason: "config-path-relocated",
1051
+ relocated: [
1052
+ {
1053
+ module: "redis-clients",
1054
+ from: "refreshTokenFamilyStore.redis.url",
1055
+ to: "redis-clients.url",
1056
+ environmentVariable: "REDIS_CLIENTS_URL",
1057
+ },
1058
+ {
1059
+ module: "redis-clients",
1060
+ from: "refreshTokenFamilyStore.redis.password",
1061
+ to: "redis-clients.password",
1062
+ environmentVariable: "REDIS_CLIENTS_PASSWORD",
1063
+ },
1064
+ ],
1065
+ });
1066
+ });
1067
+
1068
+ it.each([
1069
+ ["REFRESH_TOKEN_FAMILY_STORE_REDIS_URL", "REDIS_CLIENTS_URL", "redis-clients.url"],
1070
+ [
1071
+ "REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD",
1072
+ "REDIS_CLIENTS_PASSWORD",
1073
+ "redis-clients.password",
1074
+ ],
1075
+ ])("has boot refuse %s set alone, naming %s", async (from, to, path) => {
1076
+ await expect(
1077
+ bootTemplate({ redis: true, env: { [from]: "redis://127.0.0.1:9" } }),
1078
+ ).rejects.toMatchObject({
1079
+ details: {
1080
+ reason: "environment-variable-renamed",
1081
+ renamed: [{ module: "redis-clients", from, to, path, state: "unset" }],
1082
+ },
1083
+ });
1084
+ });
1085
+ });
1086
+
1087
+ /** A YAML file of `text` in a directory removed after the suite. */
1088
+ function yamlFile(name: string, text: string): string {
1089
+ const dir = mkdtempSync(join(tmpdir(), "own-modules-yaml-"));
1090
+ operatorDirs.push(dir);
1091
+ const file = join(dir, name);
1092
+ writeFileSync(file, text);
1093
+ return file;
1094
+ }
1095
+
1096
+ /** The value at a dotted path of the configuration boot parsed. */
1097
+ function parsedAt(handle: AppHandle, path: string): unknown {
1098
+ let cursor: unknown = handle.components.config;
1099
+ for (const key of path.split(".")) {
1100
+ if (typeof cursor !== "object" || cursor === null) return undefined;
1101
+ cursor = (cursor as Record<string, unknown>)[key];
1102
+ }
1103
+ return cursor;
1104
+ }
1105
+
1106
+ /** Each variable the repositories module renamed: old name, new name, path, a value. */
1107
+ const REPOSITORY_RENAMES = [
1108
+ ["CLIENT_PATH", "REPOSITORIES_CLIENT_YAML_PATH", "repositories.client.yaml.path", "./x.yaml"],
1109
+ ["CLIENT_USER_PATH", "REPOSITORIES_USER_YAML_PATH", "repositories.user.yaml.path", "./y.yaml"],
1110
+ ...(
1111
+ [
1112
+ ["AUTHENTICATE_URL", "authenticateUrl", "https://store.example/authenticate"],
1113
+ ["AUTHENTICATE_BY_TOKEN_URL", "authenticateByTokenUrl", "https://store.example/by-token"],
1114
+ ["LINK_FEDERATED_IDENTITY_URL", "linkFederatedIdentityUrl", "https://store.example/link"],
1115
+ [
1116
+ "FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL",
1117
+ "findSubjectByFederatedIdentityUrl",
1118
+ "https://store.example/find",
1119
+ ],
1120
+ [
1121
+ "BEARER_TOKEN",
1122
+ "bearerToken",
1123
+ "0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
1124
+ ],
1125
+ ["TIMEOUT", "timeout", "3000"],
1126
+ ["MAX_RESPONSE_BYTES", "maxResponseBytes", "2048"],
1127
+ ] as const
1128
+ ).map(
1129
+ ([name, key, value]) =>
1130
+ [
1131
+ `CLIENT_USER_${name}`,
1132
+ `REPOSITORIES_USER_HTTP_${name}`,
1133
+ `repositories.user.http.${key}`,
1134
+ value,
1135
+ ] as const,
1136
+ ),
1137
+ ] as const;
1138
+
1139
+ describe("repositories", () => {
1140
+ it("owns repositories, and reads it as its section rather than the configuration", async () => {
1141
+ const own = readOwnLayers(ownFiles(), { env: BASE_ENV });
1142
+ const modules = buildModules(readSwitches(own), { environment: "development" });
1143
+ const repositories = named(modules, "repositories");
1144
+ expect(repositories.section?.at).toBeUndefined();
1145
+ expect(repositories.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
1146
+ expect(repositories.requires ?? []).not.toContain("config");
1147
+ expect(repositories.optional ?? []).not.toContain("config");
1148
+ });
1149
+
1150
+ it("reads the client registry and the users from the YAML files REPOSITORIES_*_YAML_PATH name", async () => {
1151
+ const clients = yamlFile(
1152
+ "clients.yaml",
1153
+ "yaml-client:\n tokenEndpointAuthMethod: none\n allowedRedirectUris: []\n",
1154
+ );
1155
+ const users = yamlFile("users.yaml", "");
1156
+ const handle = await bootTemplate({
1157
+ repositories: true,
1158
+ env: { REPOSITORIES_CLIENT_YAML_PATH: clients, REPOSITORIES_USER_YAML_PATH: users },
1159
+ });
1160
+ try {
1161
+ expect(parsedAt(handle, "repositories.client.yaml.path")).toBe(clients);
1162
+ expect(await handle.components.clientRepository?.findById("yaml-client")).toBeDefined();
1163
+ } finally {
1164
+ await handle.dispose();
1165
+ }
1166
+ });
1167
+
1168
+ it("builds the Store's HTTP user repository under ADAPTERS_USER_REPOSITORY=http, from REPOSITORIES_USER_HTTP_*", async () => {
1169
+ const clients = yamlFile("clients.yaml", "");
1170
+ const handle = await bootTemplate({
1171
+ repositories: true,
1172
+ env: {
1173
+ ADAPTERS_USER_REPOSITORY: "http",
1174
+ REPOSITORIES_CLIENT_YAML_PATH: clients,
1175
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://store.example/authenticate",
1176
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "https://store.example/by-token",
1177
+ REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL: "https://store.example/link",
1178
+ },
1179
+ });
1180
+ try {
1181
+ const users = handle.components.userRepository;
1182
+ expect(users).toBeInstanceOf(HttpUserRepository);
1183
+ expect((users as HttpUserRepository).linkFederatedIdentity).toBeDefined();
1184
+ expect(parsedAt(handle, "repositories.user.http.timeout")).toBe(5000);
1185
+ } finally {
1186
+ await handle.dispose();
1187
+ }
1188
+ });
1189
+
1190
+ /** `repositories` as the template's reference resolves it under `env`, parsed with the module's schema. */
1191
+ const referenced = (env: Record<string, string> = {}) =>
1192
+ repositoriesSectionSchema.parse(
1193
+ (parseFile(fileURLToPath(TEMPLATE_REFERENCE), { env }).toObject() as Record<string, unknown>)
1194
+ .repositories,
1195
+ ).user.http;
1196
+
1197
+ it.each([
1198
+ [
1199
+ "REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL",
1200
+ "linkFederatedIdentityUrl",
1201
+ "https://store.example/link",
1202
+ ],
1203
+ [
1204
+ "REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL",
1205
+ "findSubjectByFederatedIdentityUrl",
1206
+ "https://store.example/identity",
1207
+ ],
1208
+ [
1209
+ "REPOSITORIES_USER_HTTP_BEARER_TOKEN",
1210
+ "bearerToken",
1211
+ "0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
1212
+ ],
1213
+ [
1214
+ "REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL",
1215
+ "markMfaEnrolledUrl",
1216
+ "https://store.example/mfa/enrolled",
1217
+ ],
1218
+ ])(
1219
+ "binds %s at repositories.user.http.%s, and leaves the key absent while it is unset",
1220
+ (variable, key, value) => {
1221
+ // Absent, not blank: the repository defines the seam a URL enables only
1222
+ // when the URL is there, and refuses a blank credential.
1223
+ expect(referenced({ [variable]: value })).toHaveProperty(key, value);
1224
+ expect(referenced()).not.toHaveProperty(key);
1225
+ },
1226
+ );
1227
+
1228
+ it("builds a user repository that writes the MFA enrollment witness with REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL, and one that does not without it", async () => {
1229
+ const clients = yamlFile("clients.yaml", "");
1230
+ const env = {
1231
+ ADAPTERS_USER_REPOSITORY: "http",
1232
+ REPOSITORIES_CLIENT_YAML_PATH: clients,
1233
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://store.example/authenticate",
1234
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "https://store.example/by-token",
1235
+ };
1236
+ for (const [url, writes] of [
1237
+ ["https://store.example/mfa/enrolled", true],
1238
+ [undefined, false],
1239
+ ] as const) {
1240
+ const handle = await bootTemplate({
1241
+ repositories: true,
1242
+ env: { ...env, REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL: url },
1243
+ });
1244
+ try {
1245
+ const users = handle.components.userRepository;
1246
+ expect(users).toBeInstanceOf(HttpUserRepository);
1247
+ expect(supportsMfaEnrollmentWitness(users as HttpUserRepository), String(url)).toBe(writes);
1248
+ } finally {
1249
+ await handle.dispose();
1250
+ }
1251
+ }
1252
+ });
1253
+
1254
+ it("refuses the boot for a witness URL that is not https or loopback http, naming markMfaEnrolledUrl and quoting no value", async () => {
1255
+ const clients = yamlFile("clients.yaml", "");
1256
+ for (const url of [
1257
+ "http://store.internal/mfa/enrolled?key=QUERY-SECRET",
1258
+ "https://user:QUERY-SECRET@store.example/mfa/enrolled",
1259
+ ]) {
1260
+ const refused = await bootTemplate({
1261
+ repositories: true,
1262
+ env: {
1263
+ ADAPTERS_USER_REPOSITORY: "http",
1264
+ REPOSITORIES_CLIENT_YAML_PATH: clients,
1265
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://store.example/authenticate",
1266
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "https://store.example/by-token",
1267
+ REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL: url,
1268
+ },
1269
+ }).then(
1270
+ async (handle) => {
1271
+ await handle.dispose();
1272
+ return undefined;
1273
+ },
1274
+ (error: unknown) => error,
1275
+ );
1276
+ expect(refused, url).toBeInstanceOf(BootError);
1277
+ expect((refused as BootError).reason, url).toBe("provides-factory-failed");
1278
+ const text = `${(refused as BootError).message} ${String(((refused as BootError).cause as Error | undefined)?.message)}`;
1279
+ expect(text, url).toContain('"markMfaEnrolledUrl"');
1280
+ expect(text, url).not.toContain("QUERY-SECRET");
1281
+ }
1282
+ });
1283
+
1284
+ it("ships an empty identity-lookup coverage declaration, which reaches the factory as a list", () => {
1285
+ expect(referenced().federatedIdentityLookupCoverage).toEqual([]);
1286
+ });
1287
+
1288
+ it("reads the client registry and the users under static, core's alias of yaml, from its own blocks", async () => {
1289
+ const clients = yamlFile(
1290
+ "clients.yaml",
1291
+ "static-client:\n tokenEndpointAuthMethod: none\n allowedRedirectUris: []\n",
1292
+ );
1293
+ const users = yamlFile("users.yaml", "");
1294
+ const handle = await bootTemplate({
1295
+ repositories: true,
1296
+ env: { ADAPTERS_CLIENT_REPOSITORY: "static", ADAPTERS_USER_REPOSITORY: "static" },
1297
+ hocon: `repositories.client.static.path = "${clients}"\nrepositories.user.static.path = "${users}"\n`,
1298
+ });
1299
+ try {
1300
+ expect(parsedAt(handle, "repositories.client.static.path")).toBe(clients);
1301
+ expect(await handle.components.clientRepository?.findById("static-client")).toBeDefined();
1302
+ expect(handle.components.userRepository).toBeDefined();
1303
+ } finally {
1304
+ await handle.dispose();
1305
+ }
1306
+ });
1307
+
1308
+ it.each([
1309
+ ["ADAPTERS_CLIENT_REPOSITORY", "repositories.client.static.path"],
1310
+ ["ADAPTERS_USER_REPOSITORY", "repositories.user.static.path"],
1311
+ ])(
1312
+ "refuses %s=static without %s at the section's parse, before any repository is built, naming it",
1313
+ async (variable, path) => {
1314
+ // The client registry's file is absent, as in a fresh copy of the
1315
+ // template: the refusal comes before any repository reads a file.
1316
+ await expect(
1317
+ bootTemplate({
1318
+ repositories: true,
1319
+ env: { [variable]: "static", REPOSITORIES_CLIENT_YAML_PATH: "/nonexistent/clients.yaml" },
1320
+ }),
1321
+ ).rejects.toMatchObject({
1322
+ reason: "config-validation-failed",
1323
+ message: expect.stringContaining(path),
1324
+ });
1325
+ },
1326
+ );
1327
+
1328
+ it("refuses a key the section does not declare, naming it", async () => {
1329
+ await expect(
1330
+ bootTemplate({ repositories: true, hocon: 'repositories.user.ldap.url = "ldap://x"\n' }),
1331
+ ).rejects.toMatchObject({
1332
+ reason: "config-validation-failed",
1333
+ message: expect.stringContaining('"ldap"'),
1334
+ });
1335
+ });
1336
+
1337
+ it.each(REPOSITORY_RENAMES)(
1338
+ "has boot refuse %s set alone, naming %s and %s",
1339
+ async (from, to, path, value) => {
1340
+ await expect(
1341
+ bootTemplate({ repositories: true, env: { [from]: value } }),
1342
+ ).rejects.toMatchObject({
1343
+ details: {
1344
+ reason: "environment-variable-renamed",
1345
+ renamed: [{ module: "repositories", from, to, path, state: "unset" }],
1346
+ },
1347
+ });
1348
+ },
1349
+ );
1350
+ });
1351
+
1352
+ describe("standalone-in-memory-code-repository", () => {
1353
+ it("reads its own section, which STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN sets", async () => {
1354
+ const handle = await bootTemplate({
1355
+ env: { STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN: "900" },
1356
+ });
1357
+ try {
1358
+ expect(parsedAt(handle, "standalone-in-memory-code-repository.defaultExpiresIn")).toBe(900);
1359
+ } finally {
1360
+ await handle.dispose();
1361
+ }
1362
+ });
1363
+
1364
+ it("refuses a lifetime that is not positive whole seconds, naming the key", async () => {
1365
+ await expect(
1366
+ bootTemplate({ env: { STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN: "0" } }),
1367
+ ).rejects.toThrow(/standalone-in-memory-code-repository\.defaultExpiresIn/);
1368
+ });
1369
+
1370
+ it("has boot refuse repositories.code.memory, the path it moved from, and repositories.code.redis, removed", async () => {
1371
+ await expect(
1372
+ bootTemplate({
1373
+ hocon:
1374
+ 'repositories.code { memory.defaultExpiresIn = 900, redis.endpointUri = "redis://x" }\n',
1375
+ }),
1376
+ ).rejects.toMatchObject({
1377
+ details: {
1378
+ reason: "config-path-relocated",
1379
+ relocated: [
1380
+ {
1381
+ module: "standalone-in-memory-code-repository",
1382
+ from: "repositories.code.memory.defaultExpiresIn",
1383
+ to: "standalone-in-memory-code-repository.defaultExpiresIn",
1384
+ environmentVariable: "STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN",
1385
+ },
1386
+ {
1387
+ module: "standalone-in-memory-code-repository",
1388
+ from: "repositories.code.redis.endpointUri",
1389
+ to: null,
1390
+ },
1391
+ ],
1392
+ },
1393
+ });
1394
+ });
1395
+
1396
+ it.each([
1397
+ [
1398
+ "CLIENT_CODE_DEFAULT_EXPIRES_IN",
1399
+ "900",
1400
+ {
1401
+ to: "STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN",
1402
+ path: "standalone-in-memory-code-repository.defaultExpiresIn",
1403
+ state: "unset",
1404
+ },
1405
+ ],
1406
+ ["CLIENT_CODE_ENDPOINT_URI", "redis://x", { to: null, path: null, state: "removed" }],
1407
+ ["CLIENT_CODE_PASSWORD", "p", { to: null, path: null, state: "removed" }],
1408
+ ])("has boot refuse %s", async (from, value, expected) => {
1409
+ await expect(bootTemplate({ env: { [from]: value } })).rejects.toMatchObject({
1410
+ details: {
1411
+ reason: "environment-variable-renamed",
1412
+ renamed: [{ module: "standalone-in-memory-code-repository", from, ...expected }],
1413
+ },
1414
+ });
1415
+ });
1416
+ });
1417
+
1418
+ describe("audit-sink", () => {
1419
+ it("builds the sink ADAPTERS_AUDIT_SINK names, the template's logger by default", async () => {
1420
+ const shipped = await bootTemplate();
1421
+ const console = await bootTemplate({ env: { ADAPTERS_AUDIT_SINK: "console" } });
1422
+ try {
1423
+ expect(shipped.components.auditSink?.kind).toBe("logger");
1424
+ expect(console.components.auditSink?.kind).toBe("console");
1425
+ } finally {
1426
+ await shipped.dispose();
1427
+ await console.dispose();
1428
+ }
1429
+ });
1430
+
1431
+ it("reads its own section, audit-sink, and requires no configuration", () => {
1432
+ const own = readOwnLayers(ownFiles(), { env: BASE_ENV });
1433
+ const sink = named(
1434
+ buildModules(readSwitches(own), { environment: "development" }),
1435
+ "audit-sink",
1436
+ );
1437
+ expect(sink.section?.at).toBeUndefined();
1438
+ expect(sink.requires ?? []).not.toContain("config");
1439
+ });
1440
+
1441
+ it("has boot refuse a sink's options at audit.sink, the path they moved from, naming audit-sink", async () => {
1442
+ await expect(
1443
+ bootTemplate({ hocon: "audit.sink.console.pretty = true\n" }),
1444
+ ).rejects.toMatchObject({
1445
+ details: {
1446
+ reason: "config-path-relocated",
1447
+ relocated: [
1448
+ {
1449
+ module: "audit-sink",
1450
+ from: "audit.sink.console.pretty",
1451
+ to: "audit-sink.console.pretty",
1452
+ },
1453
+ ],
1454
+ },
1455
+ });
1456
+ });
1457
+ });