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