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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,393 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- /**
18
- * The development compose run, `docker-compose.yml`, with MFA as the
19
- * configuration's switch says (on as the template ships it), and the Mailpit
20
- * overlay, `docker-compose.mailpit.yml`: a development run whose mail goes
21
- * through the SMTP sender to Mailpit. It is an overlay on
22
- * `docker-compose.yml`, so a plain `docker compose up` starts no Mailpit, and
23
- * the production file names none. The compose files are read as
24
- * the fixed-shape files they are, and the process each run describes is booted
25
- * from the shipped files, as `app.mts` boots them, under the environment the
26
- * run sets: `.env.example`'s lines, then each compose file's `environment`, the
27
- * overlay's last. Redis is a stand-in: nothing here issues a command.
28
- */
29
-
30
- import { generateKeyPairSync } from "node:crypto";
31
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
32
- import { tmpdir } from "node:os";
33
- import { join } from "node:path";
34
- import { fileURLToPath } from "node:url";
35
- import {
36
- createApp,
37
- type MailSender,
38
- memoryRefreshTokenFamilyStoreModule,
39
- } from "@o3co/auth-provider-core";
40
- import {
41
- standardDevelopmentMailSenderModule,
42
- standardSmtpMailSenderModule,
43
- } from "@o3co/auth-provider-standard";
44
- import { afterEach, describe, expect, it, vi } from "vitest";
45
- import { buildModules } from "#/buildModules.mjs";
46
- import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
47
- import { createRecordingLogger } from "./all-modules-composition.fixture.mjs";
48
- import { configuredMfaMode } from "./configured-mfa-mode.fixture.mjs";
49
-
50
- vi.mock("redis", () => ({
51
- createClient: vi.fn(() => ({
52
- connect: vi.fn().mockResolvedValue(undefined),
53
- quit: vi.fn().mockResolvedValue(undefined),
54
- ping: vi.fn().mockResolvedValue("PONG"),
55
- on: vi.fn(),
56
- })),
57
- }));
58
-
59
- vi.mock("connect-redis", async () => {
60
- const { EventEmitter } = await import("node:events");
61
- // express-session subscribes to store events, so the stand-in is an emitter.
62
- return {
63
- RedisStore: class MockRedisStore extends EventEmitter {
64
- get(): unknown {
65
- return undefined;
66
- }
67
- set(): void {}
68
- destroy(): void {}
69
- },
70
- };
71
- });
72
-
73
- vi.mock("ioredis", () => {
74
- const explicit: Record<string, unknown> = {
75
- on: () => undefined,
76
- quit: async () => "OK",
77
- disconnect: () => undefined,
78
- ping: async () => "PONG",
79
- // The Redis stores' eviction gate reads the policy from INFO memory: a
80
- // default server's.
81
- info: async (section: string) =>
82
- section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
83
- };
84
- const makeMockRedis = (): object =>
85
- new Proxy(
86
- {},
87
- {
88
- get(_target, prop) {
89
- // A function-valued `then` would make the instance a thenable.
90
- if (typeof prop !== "string" || prop === "then") return undefined;
91
- if (prop === "duplicate") return makeMockRedis;
92
- if (prop in explicit) return explicit[prop];
93
- return async () => null;
94
- },
95
- },
96
- );
97
- function MockRedis(): object {
98
- return makeMockRedis();
99
- }
100
- return { Redis: MockRedis, default: MockRedis };
101
- });
102
-
103
- const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
104
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
105
- const read = (rel: string): string => readFileSync(join(standaloneDir, rel), "utf8");
106
-
107
- const OVERLAY = "docker-compose.mailpit.yml";
108
-
109
- /** The lines of a service's block in a compose file, comments and blank lines left out. */
110
- function serviceBlock(rel: string, name: string): string[] {
111
- const lines = read(rel).split("\n");
112
- const services = lines.findIndex((line) => /^services:\s*$/.test(line));
113
- const start = lines.findIndex(
114
- (line, at) => at > services && new RegExp(`^ {2}${name}:\\s*$`).test(line),
115
- );
116
- if (services === -1 || start === -1) return [];
117
- const block: string[] = [];
118
- for (const line of lines.slice(start + 1)) {
119
- if (line.trim() === "" || /^\s*#/.test(line)) continue;
120
- if (!/^ {4}/.test(line)) break;
121
- block.push(line);
122
- }
123
- return block;
124
- }
125
-
126
- const unquote = (text: string): string => text.trim().replace(/^["']|["']$/g, "");
127
-
128
- /** A scalar key of a service's block. */
129
- function scalar(block: readonly string[], key: string): string | undefined {
130
- for (const line of block) {
131
- const match = new RegExp(`^ {4}${key}:\\s*(\\S.*)$`).exec(line);
132
- if (match) return unquote(match[1] as string);
133
- }
134
- return undefined;
135
- }
136
-
137
- /** The entries under a key of a service's block, each trimmed. */
138
- function entries(block: readonly string[], key: string): string[] {
139
- const at = block.findIndex((line) => new RegExp(`^ {4}${key}:\\s*$`).test(line));
140
- if (at === -1) return [];
141
- const found: string[] = [];
142
- for (const line of block.slice(at + 1)) {
143
- if (!/^ {6}/.test(line)) break;
144
- found.push(line.trim());
145
- }
146
- return found;
147
- }
148
-
149
- /** The `- "…"` entries of a list, unquoted. */
150
- const listOf = (lines: readonly string[]): string[] =>
151
- lines.map((line) => unquote(line.replace(/^-\s*/, "")));
152
-
153
- /** `.env.example`'s uncommented lines: what a `.env` copied from it sets. */
154
- function dotenvExample(): Record<string, string> {
155
- const env: Record<string, string> = {};
156
- for (const line of read(".env.example").split("\n")) {
157
- const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
158
- if (match) env[match[1] as string] = match[2] as string;
159
- }
160
- return env;
161
- }
162
-
163
- /**
164
- * A service's `environment`, interpolated as compose does from the project's
165
- * `.env`: `${VAR:-default}` is the variable when it is set and not empty, the
166
- * default otherwise.
167
- */
168
- function environmentOf(
169
- rel: string,
170
- service: string,
171
- dotenv: Readonly<Record<string, string>>,
172
- ): Record<string, string> {
173
- const env: Record<string, string> = {};
174
- for (const line of entries(serviceBlock(rel, service), "environment")) {
175
- const match = /^([A-Z][A-Z0-9_]*):\s*(.*)$/.exec(line);
176
- if (!match) continue;
177
- env[match[1] as string] = unquote(match[2] as string).replace(
178
- /\$\{([A-Z][A-Z0-9_]*):-([^}]*)\}/g,
179
- (_, name: string, fallback: string) => (dotenv[name] ? dotenv[name] : fallback),
180
- );
181
- }
182
- return env;
183
- }
184
-
185
- describe("Mailpit runs only in the overlay", () => {
186
- it("is not a service of the development or the production compose file", () => {
187
- expect(serviceBlock("docker-compose.yml", "mailpit")).toEqual([]);
188
- expect(serviceBlock("docker-compose.production.yml", "mailpit")).toEqual([]);
189
- expect(read("docker-compose.production.yml")).not.toMatch(/mailpit/i);
190
- });
191
-
192
- it("runs a pinned Mailpit image in the app's network namespace", () => {
193
- const mailpit = serviceBlock(OVERLAY, "mailpit");
194
- expect(scalar(mailpit, "image")).toMatch(/^axllent\/mailpit:v\d+\.\d+\.\d+$/);
195
- expect(scalar(mailpit, "network_mode")).toBe("service:app");
196
- });
197
-
198
- it("publishes Mailpit's web UI on loopback, from the app service that owns the namespace", () => {
199
- expect(listOf(entries(serviceBlock(OVERLAY, "app"), "ports"))).toContain("127.0.0.1:8025:8025");
200
- });
201
-
202
- it("points the SMTP sender at Mailpit over the app's loopback, in plaintext", () => {
203
- expect(environmentOf(OVERLAY, "app", {})).toMatchObject({
204
- STANDARD_SMTP_MAIL_SENDER_HOST: "localhost",
205
- STANDARD_SMTP_MAIL_SENDER_PORT: "1025",
206
- STANDARD_SMTP_MAIL_SENDER_SECURE: "none",
207
- });
208
- });
209
- });
210
-
211
- describe("the process each development run describes", () => {
212
- const handles: { dispose(): Promise<void> }[] = [];
213
- afterEach(async () => {
214
- for (const handle of handles.splice(0)) await handle.dispose();
215
- });
216
-
217
- const signingKey = generateKeyPairSync("ed25519", {
218
- publicKeyEncoding: { type: "spki", format: "pem" },
219
- privateKeyEncoding: { type: "pkcs8", format: "pem" },
220
- });
221
- const clientsFile = (() => {
222
- const file = join(mkdtempSync(join(tmpdir(), "mailpit-compose-")), "clients.yaml");
223
- writeFileSync(file, "");
224
- return file;
225
- })();
226
-
227
- /** What the operator writes into `.env` beside `.env.example`'s lines: its required blanks. */
228
- const OPERATOR: Readonly<Record<string, string>> = {
229
- OAUTH_JWT_ISSUER: "http://localhost:3000",
230
- KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
231
- KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
232
- SESSION_STORE_SECRET: "mailpit-compose-session-secret.at-least-32-bytes.ok",
233
- REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://users.test/authenticate",
234
- REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "https://users.test/by-token",
235
- REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
236
- };
237
-
238
- /**
239
- * The container's environment: `.env`, then each compose file's
240
- * `environment`, the overlay's last unless `overlay` is false.
241
- */
242
- function containerEnv(overlay = true): Record<string, string> {
243
- const dotenv: Record<string, string> = { ...dotenvExample(), ...OPERATOR };
244
- // The pem pair is given inline here; the paths name files this test has not written.
245
- delete dotenv.KEY_STORE_LOCAL_PRIVATE_KEY_PATH;
246
- delete dotenv.KEY_STORE_LOCAL_PUBLIC_KEY_PATH;
247
- return {
248
- ...Object.fromEntries(Object.entries(dotenv).filter(([, value]) => value !== "")),
249
- ...environmentOf("docker-compose.yml", "app", dotenv),
250
- ...(overlay ? environmentOf(OVERLAY, "app", dotenv) : {}),
251
- };
252
- }
253
-
254
- it("boots docker-compose.yml's run with MFA as the configuration's switch says: development, the development mail sender, the MFA stores on its Redis", async () => {
255
- const env = containerEnv(false);
256
- // `app.mts`: CONFIG_ENV, then NODE_ENV, then development; the run sets neither.
257
- expect(env.CONFIG_ENV ?? env.NODE_ENV).toBeUndefined();
258
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
259
- const own = readOwnLayers([envConfPath, applicationConfPath], { env });
260
- const switches = readSwitches(own);
261
- const mode = configuredMfaMode("development");
262
- expect(switches.mfaMode).toBe(mode);
263
- // A hot reload restarts the process on every save, and Redis keeps the
264
- // factors, and the sessions beside them, across it.
265
- expect(switches.adapters).toMatchObject({
266
- mfaFactorStore: "redis",
267
- mfaTransactionStore: "redis",
268
- });
269
-
270
- const modules = buildModules(switches, {
271
- environment: "development",
272
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
273
- });
274
- const handle = await createApp({
275
- modules,
276
- bootstrapComponents: {
277
- config: resolveForBoot(own, modules, switches),
278
- pathResolver: (s: string) => s,
279
- logger: createRecordingLogger(),
280
- },
281
- });
282
- handles.push(handle);
283
- expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe(
284
- "standard-development",
285
- );
286
- expect((handle.components.config as { mfa?: { mode?: unknown } } | undefined)?.mfa?.mode).toBe(
287
- mode === "off" ? undefined : mode,
288
- );
289
- });
290
-
291
- it("selects a configuration that is not development, so the SMTP sender is installed", () => {
292
- const configEnv = containerEnv().CONFIG_ENV;
293
- expect(configEnv).toBeDefined();
294
- expect(configEnv).not.toBe("development");
295
- // A missing `{ENV}.conf` is fatal at boot.
296
- expect(existsSync(resolveConfigPaths(configDir, configEnv as string).envConfPath)).toBe(true);
297
- });
298
-
299
- /** The key the operator writes into `.env` for the Mailpit run (`openssl rand -base64 32`). */
300
- const OWN_MFA_KEY = Buffer.alloc(32, 7).toString("base64");
301
-
302
- /** The Mailpit run's modules and the configuration boot is handed, under `env`. */
303
- function mailpitRun(env: Record<string, string>) {
304
- const configEnv = env.CONFIG_ENV as string;
305
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
306
- const own = readOwnLayers([envConfPath, applicationConfPath], { env });
307
- const switches = readSwitches(own);
308
- const modules = buildModules(switches, {
309
- environment: configEnv,
310
- refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
311
- });
312
- return { switches, modules, config: () => resolveForBoot(own, modules, switches) };
313
- }
314
-
315
- it("boots with the SMTP sender relaying to Mailpit: in the mailSender slot, with the operator's MFA key, while MFA is on (the configuration's switch); unread, with no mfa section, while it is off", async () => {
316
- const env: Record<string, string> = { ...containerEnv(), MFA_ENCRYPTION_KEY: OWN_MFA_KEY };
317
- const { switches, modules, config } = mailpitRun(env);
318
- const mode = configuredMfaMode(env.CONFIG_ENV as string);
319
- expect(switches.mfaMode).toBe(mode);
320
- const names = modules.map((module) => module.name);
321
- expect(names).toContain(standardSmtpMailSenderModule.name);
322
- expect(names).not.toContain(
323
- standardDevelopmentMailSenderModule({ environment: "development" }).name,
324
- );
325
-
326
- const handle = await createApp({
327
- modules,
328
- bootstrapComponents: {
329
- config: config(),
330
- pathResolver: (s: string) => s,
331
- logger: createRecordingLogger(),
332
- },
333
- });
334
- handles.push(handle);
335
- expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe(
336
- mode === "off" ? undefined : "standard-smtp",
337
- );
338
- const mfa = (
339
- handle.components.config as {
340
- mfa?: { mode?: unknown; encryptionKeys?: { key?: unknown }[] };
341
- }
342
- ).mfa;
343
- if (mode === "off") {
344
- expect(mfa).toBeUndefined();
345
- } else {
346
- expect(mfa?.mode).toBe(mode);
347
- expect(mfa?.encryptionKeys?.map((entry) => entry.key)).toEqual([OWN_MFA_KEY]);
348
- }
349
- expect(
350
- (handle.components.config as Record<string, unknown> | undefined)?.[
351
- "standard-smtp-mail-sender"
352
- ],
353
- ).toMatchObject({ host: "localhost", port: 1025, secure: "none" });
354
- });
355
-
356
- it("does not carry the development sample key: with MFA on, without MFA_ENCRYPTION_KEY the run is refused; with it off, nothing reads a key", async () => {
357
- const env = containerEnv();
358
- expect(env.MFA_ENCRYPTION_KEY).toBeUndefined();
359
- const { modules, config } = mailpitRun(env);
360
- if (configuredMfaMode(env.CONFIG_ENV as string) === "off") {
361
- const handle = await createApp({
362
- modules,
363
- bootstrapComponents: {
364
- config: config(),
365
- pathResolver: (s: string) => s,
366
- logger: createRecordingLogger(),
367
- },
368
- });
369
- handles.push(handle);
370
- expect(handle.components.config).not.toHaveProperty("mfa");
371
- return;
372
- }
373
- const refused = await (async () => {
374
- try {
375
- handles.push(
376
- await createApp({
377
- modules,
378
- bootstrapComponents: {
379
- config: config(),
380
- pathResolver: (s: string) => s,
381
- logger: createRecordingLogger(),
382
- },
383
- }),
384
- );
385
- } catch (error) {
386
- return error;
387
- }
388
- return undefined;
389
- })();
390
- expect(refused).toBeInstanceOf(Error);
391
- expect(String((refused as Error).message)).toMatch(/mfa\.encryptionKeys/);
392
- });
393
- });
@@ -1,253 +0,0 @@
1
- /*
2
- * Copyright 2026 1o1 Co. Ltd.
3
- *
4
- * Licensed under the Apache License, Version 2.0 (the "License");
5
- * you may not use this file except in compliance with the License.
6
- * You may obtain a copy of the License at
7
- *
8
- * http://www.apache.org/licenses/LICENSE-2.0
9
- *
10
- * Unless required by applicable law or agreed to in writing, software
11
- * distributed under the License is distributed on an "AS IS" BASIS,
12
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- * See the License for the specific language governing permissions and
14
- * limitations under the License.
15
- */
16
-
17
- import { request as httpRequest } from "node:http";
18
- import express from "express";
19
- import request from "supertest";
20
- import { describe, expect, it, vi } from "vitest";
21
- import { createMetrics } from "../metrics.mjs";
22
-
23
- function buildApp(probes: Parameters<ReturnType<typeof createMetrics>["route"]>[1]["probes"] = []) {
24
- const metrics = createMetrics();
25
- const app = express();
26
- app.use(metrics.middleware);
27
- app.use(metrics.route(express, { probes, probeTimeoutMs: 100 }));
28
-
29
- const api = express.Router();
30
- api.get("/widgets/:id", (_req, res) => {
31
- res.status(200).json({ ok: true });
32
- });
33
- api.get("/boom", (_req, res) => {
34
- res.status(500).json({ ok: false });
35
- });
36
- app.use("/api", api);
37
- return app;
38
- }
39
-
40
- /**
41
- * Starts `app` on 127.0.0.1, the address the raw-socket tests dial. A hostless
42
- * `listen(0)` binds `[::]`, and on macOS the port it gets can be one another
43
- * process holds on 127.0.0.1 — the request then reaches that process and can
44
- * hang until the test times out.
45
- */
46
- function listenOnLoopback(app: express.Express): Promise<ReturnType<express.Express["listen"]>> {
47
- return new Promise((resolve) => {
48
- const server = app.listen(0, "127.0.0.1", () => resolve(server));
49
- });
50
- }
51
-
52
- describe("GET /metrics", () => {
53
- it("serves the Prometheus text exposition format", async () => {
54
- const res = await request(buildApp()).get("/metrics");
55
-
56
- expect(res.status).toBe(200);
57
- expect(res.headers["content-type"]).toContain("text/plain");
58
- // Node process defaults are namespaced so they cannot collide with
59
- // anything else scraped from the same job.
60
- expect(res.text).toContain("auth_provider_process_cpu_user_seconds_total");
61
- });
62
-
63
- it("records latency by method, route and status", async () => {
64
- const app = buildApp();
65
- await request(app).get("/api/widgets/42");
66
- await request(app).get("/api/boom");
67
-
68
- const res = await request(app).get("/metrics");
69
-
70
- expect(res.text).toContain('route="/api/widgets/:id"');
71
- expect(res.text).toContain('status="200"');
72
- expect(res.text).toContain('status="500"');
73
- });
74
-
75
- it("labels by the route pattern, not the URL, so the label set stays bounded", async () => {
76
- // Labelling by path would mint a series per id and eventually take down
77
- // the system that is supposed to be watching this one.
78
- const app = buildApp();
79
- for (const id of ["1", "2", "3"]) {
80
- await request(app).get(`/api/widgets/${id}`);
81
- }
82
-
83
- const res = await request(app).get("/metrics");
84
- const series = res.text
85
- .split("\n")
86
- .filter((l) => l.startsWith("http_request_duration_seconds_count"));
87
-
88
- expect(series.some((l) => l.includes('route="/api/widgets/:id"'))).toBe(true);
89
- expect(res.text).not.toContain('route="/api/widgets/1"');
90
- });
91
-
92
- it("collapses unmatched requests into a single bucket", async () => {
93
- // 404 probes from the internet carry attacker-chosen paths.
94
- const app = buildApp();
95
- await request(app).get(`/nope/${"x".repeat(50)}`);
96
-
97
- const res = await request(app).get("/metrics");
98
-
99
- expect(res.text).toContain('route="unmatched"');
100
- expect(res.text).not.toContain("xxxxx");
101
- });
102
-
103
- it("collapses unknown HTTP methods into one label", async () => {
104
- // req.method is attacker-controlled — Node's HTTP parser accepts any
105
- // valid token, so `FOO` and `M000001` arrive as readily as `GET`. An
106
- // unbounded method label is the same cardinality bomb as an unbounded
107
- // route label, reachable without any access to /metrics. Sent over a real
108
- // socket because supertest cannot express a non-standard method.
109
- const app = buildApp();
110
- const server = await listenOnLoopback(app);
111
- const port = (server.address() as { port: number }).port;
112
- try {
113
- for (const method of ["FOO", "M000001", "PURGE"]) {
114
- await new Promise<void>((resolve) => {
115
- const req = httpRequest(
116
- { host: "127.0.0.1", port, path: "/api/widgets/1", method },
117
- (res) => {
118
- res.resume();
119
- res.once("end", resolve);
120
- },
121
- );
122
- req.once("error", () => resolve());
123
- req.end();
124
- });
125
- }
126
-
127
- const res = await request(app).get("/metrics");
128
-
129
- expect(res.text).toContain('method="other"');
130
- expect(res.text).not.toContain('method="M000001"');
131
- expect(res.text).not.toContain('method="FOO"');
132
- } finally {
133
- server.close();
134
- }
135
- });
136
-
137
- it("counts a request whose client disconnects before the response finishes", async () => {
138
- // `close` fires without `finish` when a client or proxy gives up
139
- // mid-handler, and those are disproportionately the slow and failing
140
- // requests RED metrics exist to surface. Sequenced on the server's own
141
- // events, never on a fixed wait: the abort goes out once the handler
142
- // holds the request, and /metrics is read once the response has closed.
143
- const metrics = createMetrics();
144
- const app = express();
145
- app.use(metrics.middleware);
146
- app.use(metrics.route(express, { probes: [], probeTimeoutMs: 100 }));
147
- const handling = Promise.withResolvers<void>();
148
- const closed = Promise.withResolvers<void>();
149
- app.get("/slow", (_req, res) => {
150
- // Never responds; the test aborts the socket instead. This listener is
151
- // added after the metrics middleware's, and a response emits `close`
152
- // to its listeners in the order they were added, so once it runs the
153
- // request has been observed.
154
- res.once("close", () => closed.resolve());
155
- handling.resolve();
156
- });
157
-
158
- const server = await listenOnLoopback(app);
159
- const port = (server.address() as { port: number }).port;
160
- try {
161
- const controller = new AbortController();
162
- const pending = fetch(`http://127.0.0.1:${port}/slow`, {
163
- signal: controller.signal,
164
- }).catch(() => undefined);
165
- await handling.promise;
166
- controller.abort();
167
- await pending;
168
- await closed.promise;
169
-
170
- const res = await request(app).get("/metrics");
171
- const slow = res.text
172
- .split("\n")
173
- .filter((l) => l.startsWith("http_request_duration_seconds_count"))
174
- .filter((l) => l.includes('route="/slow"'));
175
- // One request, counted once, under the route it had reached.
176
- expect(slow).toHaveLength(1);
177
- expect(slow[0]?.trim().endsWith(" 1")).toBe(true);
178
- } finally {
179
- server.close();
180
- }
181
- });
182
-
183
- it("observes a response exactly once even though both finish and close fire", async () => {
184
- const app = buildApp();
185
- await request(app).get("/api/widgets/1");
186
-
187
- const res = await request(app).get("/metrics");
188
- const line = res.text
189
- .split("\n")
190
- .find(
191
- (l) =>
192
- l.startsWith("http_request_duration_seconds_count") &&
193
- l.includes('route="/api/widgets/:id"'),
194
- );
195
- expect(line?.trim().endsWith(" 1")).toBe(true);
196
- });
197
-
198
- it("publishes a per-dependency gauge from the readiness probes", async () => {
199
- const app = buildApp([
200
- { name: "redis", check: async () => "PONG" },
201
- {
202
- name: "session-store",
203
- check: async () => {
204
- throw new Error("ECONNREFUSED");
205
- },
206
- },
207
- ]);
208
-
209
- const res = await request(app).get("/metrics");
210
-
211
- expect(res.text).toContain('auth_dependency_up{dependency="redis"} 1');
212
- expect(res.text).toContain('auth_dependency_up{dependency="session-store"} 0');
213
- });
214
-
215
- it("re-samples dependencies on every scrape instead of caching a verdict", async () => {
216
- let healthy = true;
217
- const app = buildApp([
218
- {
219
- name: "redis",
220
- check: async () => {
221
- if (!healthy) throw new Error("down");
222
- return "PONG";
223
- },
224
- },
225
- ]);
226
-
227
- expect((await request(app).get("/metrics")).text).toContain(
228
- 'auth_dependency_up{dependency="redis"} 1',
229
- );
230
- healthy = false;
231
- expect((await request(app).get("/metrics")).text).toContain(
232
- 'auth_dependency_up{dependency="redis"} 0',
233
- );
234
- });
235
-
236
- it("joins an in-flight probe rather than issuing one per scrape", async () => {
237
- // A partitioned backend never answers, and a scrape loop would otherwise
238
- // stack one command per scrape against the dependency already in trouble.
239
- const check = vi.fn(() => new Promise(() => {}));
240
- const app = buildApp([{ name: "redis", check }]);
241
-
242
- await request(app).get("/metrics");
243
- await request(app).get("/metrics");
244
-
245
- expect(check).toHaveBeenCalledTimes(1);
246
- });
247
-
248
- it("is never cached", async () => {
249
- const res = await request(buildApp()).get("/metrics");
250
-
251
- expect(res.headers["cache-control"]).toBe("no-store");
252
- });
253
- });