@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.
Files changed (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  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 +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  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 +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -15,16 +15,14 @@
15
15
  */
16
16
 
17
17
  /**
18
- * #287 — the audit-event pipeline existed and the scaffold wired no sink, so
19
- * every security-relevant event the routes emit (`token.issued.failure`,
20
- * `authorize.rejected`, `rate_limit.unavailable`, …) was dropped by the
21
- * artifact operators actually deploy. `emitAuditEvent` is a no-op when the
22
- * slot is empty, so nothing failed and nothing warned: the deployment simply
23
- * had no audit trail.
18
+ * The scaffold wires an audit sink. `emitAuditEvent` is a no-op when the slot
19
+ * is empty, so without one every security-relevant event the routes emit
20
+ * (`token.issued.failure`, `authorize.rejected`, `rate_limit.unavailable`, …)
21
+ * is dropped, and nothing fails or warns.
24
22
  *
25
- * These tests pin the closure from three sides — the sink implementation, the
26
- * module that resolves it from config, and the manifest that must contain
27
- * exactly one provider for the slot.
23
+ * Pinned from three sides: the sink implementation, the module that resolves
24
+ * it from config, and the manifest, which must contain exactly one provider
25
+ * for the slot.
28
26
  */
29
27
 
30
28
  import { generateKeyPairSync } from "node:crypto";
@@ -42,46 +40,67 @@ import {
42
40
  memoryRefreshTokenFamilyStoreModule,
43
41
  registerBuiltinKeyStores,
44
42
  } from "@o3co/auth-provider-core";
43
+ import { coreConfigForTests } from "@o3co/auth-provider-core/testing";
45
44
  import express from "express";
46
45
  import request from "supertest";
47
46
  import { afterEach, describe, expect, it, vi } from "vitest";
48
47
  import { buildModules } from "../buildModules.mjs";
48
+ import type { Switches } from "../configPath.mjs";
49
49
  import { createAppLogger, createAuditLogger, createLoggerAuditSink } from "../logger.mjs";
50
- import { auditSinkModule } from "../modules.mjs";
50
+ import { auditSinkModuleFor } from "../modules.mjs";
51
+ import { capturedRenames, shippedAdapters } from "./library-references.fixture.mjs";
51
52
 
52
53
  const keyPair = generateKeyPairSync("ed25519", {
53
54
  publicKeyEncoding: { type: "spki", format: "pem" },
54
55
  privateKeyEncoding: { type: "pkcs8", format: "pem" },
55
56
  });
56
57
 
57
- const baseConfig: AppConfig = {
58
- http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000 },
58
+ /**
59
+ * Phase one's switches beside the configuration boot is handed; the template
60
+ * modules' sections sit beside core's, so the configuration is wider than
61
+ * `AppConfig`.
62
+ */
63
+ const baseConfig: Switches & AppConfig & Record<string, unknown> = {
64
+ // The shipped selections, every store in this process.
65
+ adapters: {
66
+ ...shippedAdapters(),
67
+ accessTokenDenylist: "memory",
68
+ replaySeenSet: "memory",
69
+ codeRepository: "memory",
70
+ userRepository: "yaml",
71
+ },
72
+ mfaMode: "off",
73
+ storeTransport: undefined,
74
+ // What a resolution under an environment that sets none captures of
75
+ // core's renamed variables.
76
+ ...{
77
+ "renamed-variables": capturedRenames({}),
78
+ },
79
+ http: { port: 0, trustProxy: false, readinessTimeoutMs: 1000, cors: { allowedOrigins: [] } },
59
80
  logging: { level: "silent" },
81
+ "key-store": {
82
+ provider: "local",
83
+ local: {
84
+ algorithm: "EdDSA",
85
+ kid: "v0",
86
+ privateKey: keyPair.privateKey,
87
+ publicKey: keyPair.publicKey,
88
+ previousKeys: [],
89
+ },
90
+ },
91
+ // The shipped `application.conf` expects no session requirement (ADR
92
+ // 2026-09-28-session-admission).
93
+ ...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
60
94
  oauth: {
61
95
  jwt: {
62
96
  issuer: "https://auth.test",
63
- signingKey: {
64
- provider: "local",
65
- local: {
66
- algorithm: "EdDSA",
67
- kid: "v0",
68
- privateKey: keyPair.privateKey,
69
- publicKey: keyPair.publicKey,
70
- previousKeys: [],
71
- },
72
- },
73
- },
74
- accessToken: { expiresIn: 3600 },
75
- refreshToken: {
76
- expiresIn: 86400,
77
- unknownFamilyPolicy: "reject" as const,
78
- legacyRtPolicy: "reject" as const,
79
97
  },
98
+ accessToken: { defaultExpiresIn: 3600 },
99
+ refreshToken: { expiresIn: 86400 },
80
100
  grants: {},
81
101
  oidcMode: "oidc-required",
82
- code: { adapter: "memory" as const },
83
102
  },
84
- session: {
103
+ "session-store": {
85
104
  secret: "test-session-secret.at-least-32-bytes.ok",
86
105
  name: "auth.sid",
87
106
  maxAge: 3600000,
@@ -90,19 +109,12 @@ const baseConfig: AppConfig = {
90
109
  domain: null,
91
110
  storage: { type: "memory", redis: { url: "redis://localhost:6379" } },
92
111
  },
93
- rateLimit: {
94
- login: { windowMs: 60000, limit: 10 },
95
- failMode: "open",
96
- },
97
- federations: { google: { enabled: false } },
98
- repositories: {
99
- client: { type: "yaml", path: "./config/clients.yaml" },
100
- user: { type: "yaml", path: "./config/users.yaml", timeout: 5000 },
101
- code: { type: "memory", defaultExpiresIn: 600 },
112
+ session: {
113
+ loginPage: { url: "/login" },
114
+ rateLimit: { login: { windowMs: 60000, limit: 10 } },
102
115
  },
103
- endpoints: { login: { url: "/login" } },
104
- cors: { allowedOrigins: [] },
105
- audit: { sink: { type: "logger" } },
116
+ rateLimit: { failMode: "open" },
117
+ "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
106
118
  };
107
119
 
108
120
  const CLIENT_ID = "audit-client";
@@ -143,7 +155,7 @@ const testKeyStoreModule = defineModule({
143
155
  registerBuiltinKeyStores(factory);
144
156
  return factory.create({
145
157
  type: "local",
146
- ...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
158
+ ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
147
159
  });
148
160
  },
149
161
  },
@@ -163,14 +175,14 @@ function fakeLogger(): Logger & { info: ReturnType<typeof vi.fn> } {
163
175
  return self as unknown as Logger & { info: ReturnType<typeof vi.fn> };
164
176
  }
165
177
 
166
- /** Resolve `auditSink` the way the boot planner does: call the module's provider. */
167
- async function resolveSink(config: AppConfig): Promise<AuditSink> {
168
- const provider = auditSinkModule.provides?.auditSink;
169
- if (!provider) throw new Error("auditSinkModule must provide the auditSink slot");
170
- return (await provider({ config } as never)) as AuditSink;
178
+ /** Resolve `auditSink` the way the boot planner does: call the module's provider with its section. */
179
+ async function resolveSink(sink: string, section?: Record<string, unknown>): Promise<AuditSink> {
180
+ const provider = auditSinkModuleFor(sink).provides?.auditSink;
181
+ if (!provider) throw new Error("the audit-sink module must provide the auditSink slot");
182
+ return (await provider({ section } as never)) as AuditSink;
171
183
  }
172
184
 
173
- describe("#287: the template's audit sink", () => {
185
+ describe("the template's audit sink", () => {
174
186
  describe("createLoggerAuditSink — one event, one line, through the app's own JSON stream", () => {
175
187
  it("records the event through the injected logger", async () => {
176
188
  const logger = fakeLogger();
@@ -218,11 +230,10 @@ describe("#287: the template's audit sink", () => {
218
230
 
219
231
  describe("the audit trail is not gated by logging.level", () => {
220
232
  it("keeps the audit logger at info while the app logger is silenced", () => {
221
- // An audit trail is evidence, not diagnostics. `LOG_LEVEL=warn` is an
222
- // ordinary production setting and `silent` is a legitimate one; if
223
- // either silenced audit events, #287 would be back — the same silent
224
- // drop, reached from the operator's side instead of the scaffold's.
225
- const appLogger = createAppLogger({ ...baseConfig, logging: { level: "silent" } });
233
+ // An audit trail is evidence, not diagnostics. `LOGGING_LEVEL=warn` is an
234
+ // ordinary production setting and `silent` is a legitimate one;
235
+ // neither may silently drop audit events.
236
+ const appLogger = createAppLogger({ level: "silent" });
226
237
  const auditLogger = createAuditLogger();
227
238
  expect((appLogger as unknown as { level: string }).level).toBe("silent");
228
239
  expect((auditLogger as unknown as { level: string }).level).toBe("info");
@@ -236,31 +247,24 @@ describe("#287: the template's audit sink", () => {
236
247
  });
237
248
  });
238
249
 
239
- describe("auditSinkModule — resolves the sink from config", () => {
240
- it("resolves the logger sink for the shipped default", async () => {
241
- expect((await resolveSink(baseConfig)).kind).toBe("logger");
250
+ describe("the audit-sink module — builds the sink adapters.auditSink selects", () => {
251
+ it("builds the logger sink for the shipped selection", async () => {
252
+ expect((await resolveSink(baseConfig.adapters.auditSink)).kind).toBe("logger");
242
253
  });
243
254
 
244
- it("resolves core's built-in console sink when selected", async () => {
245
- const sink = await resolveSink({ ...baseConfig, audit: { sink: { type: "console" } } });
246
- expect(sink.kind).toBe("console");
255
+ it("builds core's built-in console sink when selected", async () => {
256
+ expect((await resolveSink("console")).kind).toBe("console");
247
257
  });
248
258
 
249
- it("still produces a sink when the audit section is absent entirely", async () => {
250
- // The failure #287 describes is silent absence. A config that says
251
- // nothing about auditing must land on a sink, not on `undefined`.
252
- const { audit: _audit, ...withoutAudit } = baseConfig;
253
- const sink = await resolveSink(withoutAudit as AppConfig);
254
- expect(sink.kind).toBe("logger");
259
+ it("builds a sink with no audit-sink section at all", async () => {
260
+ expect((await resolveSink("logger", undefined)).kind).toBe("logger");
255
261
  });
256
262
 
257
- it("refuses an unknown sink type at boot, naming what is registered", async () => {
258
- // There is no "none" — #304's sink policy. An operator who writes one
259
- // gets a boot failure naming the sinks that exist, not a silent
260
- // deployment with no audit trail.
261
- await expect(
262
- resolveSink({ ...baseConfig, audit: { sink: { type: "none" } } }),
263
- ).rejects.toThrow(/none/);
263
+ it("refuses a sink no builder is registered under, naming it", async () => {
264
+ // There is no "none" sink. An operator who selects one gets a boot
265
+ // failure naming the sinks that exist, not a silent deployment with
266
+ // no audit trail.
267
+ await expect(resolveSink("none")).rejects.toThrow(/none/);
264
268
  });
265
269
  });
266
270
 
@@ -273,13 +277,13 @@ describe("#287: the template's audit sink", () => {
273
277
  });
274
278
  const providers = modules.filter((m) => Object.keys(m.provides ?? {}).includes("auditSink"));
275
279
  expect(providers).toHaveLength(1);
276
- expect(providers[0]?.name).toBe("standalone:audit-sink");
280
+ expect(providers[0]?.name).toBe("audit-sink");
277
281
  });
278
282
 
279
- it("the shipped application.conf selects a sink, and never 'none'", () => {
280
- const conf = readFileSync(new URL("../../config/application.conf", import.meta.url), "utf8");
281
- expect(conf).toMatch(/audit\s*\{[\s\S]*?sink\s*\{[\s\S]*?type\s*=\s*"logger"/);
282
- expect(conf).not.toMatch(/type\s*=\s*"none"/);
283
+ it("the shipped reference.conf selects a sink, and never 'none'", () => {
284
+ const conf = readFileSync(new URL("../../config/reference.conf", import.meta.url), "utf8");
285
+ expect(conf).toMatch(/adapters\s*\{[\s\S]*?auditSink\s*=\s*"logger"/);
286
+ expect(conf).not.toMatch(/auditSink\s*=\s*"none"/);
283
287
  });
284
288
  });
285
289
 
@@ -292,11 +296,10 @@ describe("#287: the template's audit sink", () => {
292
296
  });
293
297
 
294
298
  it("records token.issued.failure for an unsupported grant_type", async () => {
295
- // Before #287 this assertion could not fail: nothing filled the slot,
296
- // so `emitAuditEvent` returned without recording anything.
299
+ // `emitAuditEvent` records nothing when the slot is empty.
297
300
  const logger = fakeLogger();
298
301
  const spyAuditModule = defineModule({
299
- name: "standalone:audit-sink",
302
+ name: "audit-sink",
300
303
  provides: { auditSink: () => createLoggerAuditSink(logger) },
301
304
  });
302
305
  const handle = await createApp({
@@ -0,0 +1,154 @@
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
+ * Core's notices about configuration nothing loaded reads, through the
19
+ * template's own reading: the template hands boot the configuration's
20
+ * defaults (`configDefaultsFor`), and core alone decides what is named. A
21
+ * section the template's `reference.conf` sets for a module the composition
22
+ * does not load is silent while it is as that file sets it, and
23
+ * `config_sections_not_loaded` once the operator's layer or the environment
24
+ * changes it; a section no loaded package sets is `config_sections_ignored`;
25
+ * a renamed variable no loaded module declares is
26
+ * `environment_variables_not_applied`.
27
+ */
28
+
29
+ import { fileURLToPath } from "node:url";
30
+ import { moduleReferences } from "@o3co/auth-provider-core";
31
+ import { parseFile } from "@o3co/ts.hocon";
32
+ import { afterEach, describe, expect, it, vi } from "vitest";
33
+ import { configDefaultsFor } from "#/configPath.mjs";
34
+ import {
35
+ type ComposeOptions,
36
+ type Composition,
37
+ compose,
38
+ composedModules,
39
+ type RecordingLogger,
40
+ resolveConfig,
41
+ SINGLE_ENV,
42
+ } from "./all-modules-composition.fixture.mjs";
43
+
44
+ let current: Composition | undefined;
45
+
46
+ afterEach(async () => {
47
+ await current?.handle.dispose();
48
+ current = undefined;
49
+ vi.unstubAllEnvs();
50
+ });
51
+
52
+ const boot = async (options: ComposeOptions): Promise<Composition> => {
53
+ current = await compose(options);
54
+ return current;
55
+ };
56
+
57
+ /** What each warn line named `event` names: its `sections` or `variables`. */
58
+ const named = (logger: RecordingLogger, event: string): unknown[] =>
59
+ logger.lines
60
+ .filter((line) => line.level === "warn" && line.args[1] === event)
61
+ .map((line) => line.args[0]);
62
+
63
+ /** `redis-clients` is the template's module, which no store on Redis loads under SINGLE_ENV. */
64
+ const NOT_LOADED = "redis-clients";
65
+
66
+ describe("a section the template's reference.conf sets for a module the composition does not load", () => {
67
+ it("is handed to boot and named by nothing while it is as that file sets it", async () => {
68
+ const { logger, resolved, modules } = await boot({ env: SINGLE_ENV });
69
+ expect(modules.map((m) => m.name)).not.toContain(NOT_LOADED);
70
+ expect(resolved).toHaveProperty([NOT_LOADED]);
71
+ expect(named(logger, "config_sections_not_loaded")).toEqual([]);
72
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
73
+ });
74
+
75
+ it("is config_sections_not_loaded once the operator's layer changes it", async () => {
76
+ const { logger } = await boot({
77
+ env: SINGLE_ENV,
78
+ operatorHocon: `${NOT_LOADED}.url = "redis://elsewhere.test:6379"\n`,
79
+ });
80
+ expect(named(logger, "config_sections_not_loaded")).toEqual([{ sections: [NOT_LOADED] }]);
81
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
82
+ });
83
+
84
+ it("is config_sections_not_loaded once the environment changes it, and the variable environment_variables_not_applied", async () => {
85
+ const { logger } = await boot({
86
+ env: { ...SINGLE_ENV, REDIS_CLIENTS_URL: "redis://elsewhere.test:6379" },
87
+ });
88
+ expect(named(logger, "config_sections_not_loaded")).toEqual([{ sections: [NOT_LOADED] }]);
89
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
90
+ expect(named(logger, "environment_variables_not_applied")).toEqual([
91
+ { variables: ["REDIS_CLIENTS_URL"] },
92
+ ]);
93
+ });
94
+ });
95
+
96
+ describe("the limiter sections the template's reference.conf writes for both limiters", () => {
97
+ it.each(["memory", "redis"] as const)(
98
+ "are named by nothing with the %s limiter wired",
99
+ async (rateLimiter) => {
100
+ const { logger, modules } = await boot({
101
+ env: { ...SINGLE_ENV, ADAPTERS_RATE_LIMITER: rateLimiter },
102
+ });
103
+ expect(modules.map((m) => m.name)).toContain(
104
+ rateLimiter === "redis" ? "redis-rate-limiter" : "core-rate-limiter-memory",
105
+ );
106
+ expect(named(logger, "config_sections_not_loaded")).toEqual([]);
107
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
108
+ },
109
+ );
110
+ });
111
+
112
+ describe("a section no loaded package's reference.conf sets", () => {
113
+ it("is config_sections_ignored: a misspelt section name", async () => {
114
+ const { logger } = await boot({
115
+ env: SINGLE_ENV,
116
+ operatorHocon: 'redis-client.url = "redis://elsewhere.test:6379"\n',
117
+ });
118
+ expect(named(logger, "config_sections_ignored")).toEqual([{ sections: ["redis-client"] }]);
119
+ expect(named(logger, "config_sections_not_loaded")).toEqual([]);
120
+ });
121
+ });
122
+
123
+ describe("a renamed variable a loaded package captures and no loaded module declares", () => {
124
+ it("is environment_variables_not_applied: RATE_LIMIT_FAIL_MODE with the Redis package loaded but not its rate limiter", async () => {
125
+ const { logger, modules } = await boot({
126
+ env: { ...SINGLE_ENV, ADAPTERS_CODE_REPOSITORY: "redis", RATE_LIMIT_FAIL_MODE: "closed" },
127
+ });
128
+ expect(modules.map((m) => m.name)).not.toContain("redis-rate-limiter");
129
+ expect(named(logger, "environment_variables_not_applied")).toEqual([
130
+ { variables: ["RATE_LIMIT_FAIL_MODE"] },
131
+ ]);
132
+ expect(named(logger, "config_sections_not_loaded")).toEqual([]);
133
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
134
+ });
135
+ });
136
+
137
+ describe("configDefaultsFor", () => {
138
+ const modules = composedModules(resolveConfig(SINGLE_ENV));
139
+
140
+ it("is every loaded package's reference.conf, core's last, resolved with no environment", () => {
141
+ const references = moduleReferences(modules).map((reference) =>
142
+ parseFile(fileURLToPath(reference), { env: {} }),
143
+ );
144
+ const expected = references
145
+ .slice(1)
146
+ .reduce((config, next) => config.withFallback(next), references[0]);
147
+ expect(configDefaultsFor(modules)).toEqual(expected?.toObject());
148
+ });
149
+
150
+ it("takes nothing from the process's environment", () => {
151
+ vi.stubEnv("REDIS_CLIENTS_URL", "redis://operator.test:6379");
152
+ expect(configDefaultsFor(modules)[NOT_LOADED]).toEqual({ url: "redis://localhost:6379" });
153
+ });
154
+ });
@@ -16,8 +16,10 @@
16
16
 
17
17
  import { existsSync } from "node:fs";
18
18
  import { isAbsolute } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { coreReference } from "@o3co/auth-provider-core";
19
21
  import { describe, expect, it } from "vitest";
20
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
22
+ import { resolveConfigPaths } from "../configPath.mjs";
21
23
 
22
24
  describe("resolveConfigPaths", () => {
23
25
  it("accepts a configDirPath with a trailing slash (regression: fileURLToPath preserves trailing /)", () => {
@@ -51,17 +53,17 @@ describe("resolveConfigPaths", () => {
51
53
  });
52
54
  });
53
55
 
54
- describe("resolveLibraryReferenceConfPath", () => {
55
- it("returns an absolute path ending in reference.conf", () => {
56
- const path = resolveLibraryReferenceConfPath();
57
- expect(path.endsWith("reference.conf")).toBe(true);
58
- // Use `isAbsolute` rather than `startsWith("/")` so the assertion
59
- // holds on Windows (e.g. `C:\...`) as well as POSIX paths.
56
+ describe("core's reference.conf, as the template layers it", () => {
57
+ // `coreReference()` names the file `@o3co/auth-provider-core` exports as
58
+ // `./reference.conf`: the one a composition root that installed the
59
+ // package would resolve, and the bottom of every layering the template
60
+ // makes (`readSwitches`, `resolveForBoot`).
61
+ it("is the file the package exports as ./reference.conf, and it exists", () => {
62
+ const path = fileURLToPath(coreReference());
60
63
  expect(isAbsolute(path)).toBe(true);
61
- });
62
-
63
- it("points to a file that exists on disk", () => {
64
- const path = resolveLibraryReferenceConfPath();
65
64
  expect(existsSync(path)).toBe(true);
65
+ expect(path).toBe(
66
+ fileURLToPath(import.meta.resolve("@o3co/auth-provider-core/reference.conf")),
67
+ );
66
68
  });
67
69
  });