@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -26
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +293 -162
- package/templates/standalone/README.md +533 -199
- package/templates/standalone/config/application.conf +196 -341
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +399 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +58 -30
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
- package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
- package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
- package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
- package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
- package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
- package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
- package/templates/standalone/src/adapters.mts +149 -0
- package/templates/standalone/src/app.mts +64 -79
- package/templates/standalone/src/buildModules.mts +212 -251
- package/templates/standalone/src/configPath.mts +422 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +262 -0
- package/templates/standalone/src/modules.mts +458 -476
- package/templates/standalone/src/rootRenames.mts +79 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +366 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +2 -2
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
|
@@ -0,0 +1,183 @@
|
|
|
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 libraries' layers beneath the template's own files, for a test that
|
|
19
|
+
* resolves its configuration by hand: the `reference.conf` of every package
|
|
20
|
+
* the template composes a module from, core's last, as `app.mts` layers them;
|
|
21
|
+
* and what that resolution captures of the renamed variables.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import {
|
|
26
|
+
AppConfigSchema,
|
|
27
|
+
memoryRateLimiterModule,
|
|
28
|
+
moduleReferences,
|
|
29
|
+
} from "@o3co/auth-provider-core";
|
|
30
|
+
import { CORE_RELOCATIONS, renamedVariableCaptures } from "@o3co/auth-provider-core/testing";
|
|
31
|
+
import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
|
|
32
|
+
import {
|
|
33
|
+
oauthAuthorizationModule,
|
|
34
|
+
oauthModule,
|
|
35
|
+
oauthSessionModule,
|
|
36
|
+
} from "@o3co/auth-provider-oauth";
|
|
37
|
+
import {
|
|
38
|
+
redisCodeRepositoryModule,
|
|
39
|
+
redisFederationGrantStoreModule,
|
|
40
|
+
redisRateLimiterModule,
|
|
41
|
+
redisRefreshTokenFamilyStoreModule,
|
|
42
|
+
} from "@o3co/auth-provider-redis";
|
|
43
|
+
import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
|
|
44
|
+
import { type Config, empty, parseFile } from "@o3co/ts.hocon";
|
|
45
|
+
import { ADAPTERS_SECTION, readAdapters } from "../adapters.mjs";
|
|
46
|
+
import { MFA_SWITCH, readMfaSwitch } from "../mfaSwitch.mjs";
|
|
47
|
+
import {
|
|
48
|
+
httpModule,
|
|
49
|
+
inMemoryCodeRepositoryModule,
|
|
50
|
+
keyStoreModule,
|
|
51
|
+
loggingModule,
|
|
52
|
+
repositoriesModuleFor,
|
|
53
|
+
standaloneRedisClientsModule,
|
|
54
|
+
templateReference,
|
|
55
|
+
} from "../modules.mjs";
|
|
56
|
+
import type { Adapters, MfaSwitch } from "../sections.mjs";
|
|
57
|
+
|
|
58
|
+
/** The oauth package's modules, whose manifests read nothing of the configuration they are handed but the grant switches. */
|
|
59
|
+
const OAUTH_MODULES = [
|
|
60
|
+
oauthModule({ config: {} as never }),
|
|
61
|
+
oauthSessionModule({ config: {} as never }),
|
|
62
|
+
oauthAuthorizationModule({ config: {} as never }),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
/** The modules the template composes that declare a renamed variable. */
|
|
66
|
+
const RENAMING_MODULES = [
|
|
67
|
+
memoryRateLimiterModule,
|
|
68
|
+
redisRateLimiterModule,
|
|
69
|
+
redisRefreshTokenFamilyStoreModule,
|
|
70
|
+
redisFederationGrantStoreModule,
|
|
71
|
+
...OAUTH_MODULES,
|
|
72
|
+
sessionModule,
|
|
73
|
+
sessionStoreModule,
|
|
74
|
+
loggingModule,
|
|
75
|
+
httpModule,
|
|
76
|
+
keyStoreModule,
|
|
77
|
+
standaloneRedisClientsModule,
|
|
78
|
+
repositoriesModuleFor({ client: "yaml", user: "yaml" }),
|
|
79
|
+
inMemoryCodeRepositoryModule,
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Every package reference the template's modules declare, core's last. */
|
|
83
|
+
const LIBRARY_REFERENCES: readonly URL[] = moduleReferences([
|
|
84
|
+
redisRateLimiterModule,
|
|
85
|
+
...federationGrantsModules,
|
|
86
|
+
...OAUTH_MODULES,
|
|
87
|
+
sessionModule,
|
|
88
|
+
sessionStoreModule,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/** The libraries' references resolved under `env`, in the order `app.mts` layers them. */
|
|
92
|
+
export function libraryLayers(env: Readonly<Record<string, string>>): Config {
|
|
93
|
+
return LIBRARY_REFERENCES.reduce<Config>(
|
|
94
|
+
(layered, reference) =>
|
|
95
|
+
layered.withFallback(parseFile(fileURLToPath(reference), { env: { ...env } })),
|
|
96
|
+
empty(),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What a test that parses `layers` with `AppConfigSchema` lays beside that
|
|
102
|
+
* parse: every top-level section core's schema does not declare — the
|
|
103
|
+
* template's own modules' among them — as written, which the parse drops;
|
|
104
|
+
* not the composition root's `adapters`, which boot is never handed
|
|
105
|
+
* (`adaptersOf`).
|
|
106
|
+
*/
|
|
107
|
+
export function sectionsCoreDoesNotDeclare(layers: Config): Record<string, unknown> {
|
|
108
|
+
const raw = layers.toObject() as Record<string, unknown>;
|
|
109
|
+
return Object.fromEntries(
|
|
110
|
+
Object.entries(raw).filter(
|
|
111
|
+
([key]) =>
|
|
112
|
+
!Object.hasOwn(AppConfigSchema.shape, key) &&
|
|
113
|
+
key !== "renamed-variables" &&
|
|
114
|
+
key !== ADAPTERS_SECTION &&
|
|
115
|
+
key !== MFA_SWITCH,
|
|
116
|
+
),
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The composition root's `adapters` a resolution holds — `layers`, the
|
|
122
|
+
* template's `config/reference.conf` among them, under `env` — as phase one
|
|
123
|
+
* reads them.
|
|
124
|
+
*/
|
|
125
|
+
export function adaptersOf(layers: Config, env: Readonly<Record<string, string>>): Adapters {
|
|
126
|
+
return readAdapters(layers.toObject() as Record<string, unknown>, env);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What phase one reads from a resolution beside core's switches —
|
|
131
|
+
* `layers`, the template's `config/reference.conf` among them, under `env` —
|
|
132
|
+
* as `readSwitches` reads it: the composition root's `adapters` and
|
|
133
|
+
* `mfaMode`, and the Store transport settings.
|
|
134
|
+
*/
|
|
135
|
+
export function rootSectionsOf(
|
|
136
|
+
layers: Config,
|
|
137
|
+
env: Readonly<Record<string, string>>,
|
|
138
|
+
): { readonly adapters: Adapters; readonly mfaMode: MfaSwitch; readonly storeTransport: unknown } {
|
|
139
|
+
const raw = layers.toObject() as Record<string, unknown>;
|
|
140
|
+
const repositories = raw.repositories as { user?: { http?: unknown } } | undefined;
|
|
141
|
+
return {
|
|
142
|
+
adapters: readAdapters(raw, env),
|
|
143
|
+
mfaMode: readMfaSwitch(raw, env),
|
|
144
|
+
storeTransport: repositories?.user?.http,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The adapters the template ships, as its `config/reference.conf` sets them with no environment. */
|
|
149
|
+
export function shippedAdapters(): Adapters {
|
|
150
|
+
return adaptersOf(parseFile(fileURLToPath(templateReference()), { env: {} }), {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The shipped adapters with every store the template can hold in process there: no Redis connection needed. */
|
|
154
|
+
export function inProcessAdapters(): Adapters {
|
|
155
|
+
return {
|
|
156
|
+
...shippedAdapters(),
|
|
157
|
+
rateLimiter: "memory",
|
|
158
|
+
userSessionStores: "memory",
|
|
159
|
+
accessTokenDenylist: "memory",
|
|
160
|
+
replaySeenSet: "memory",
|
|
161
|
+
federationTokenStore: "memory",
|
|
162
|
+
federationGrantStore: "memory",
|
|
163
|
+
federationGrantIntentStore: "memory",
|
|
164
|
+
mfaFactorStore: "memory",
|
|
165
|
+
mfaTransactionStore: "memory",
|
|
166
|
+
codeRepository: "memory",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The `renamed-variables` section a resolution under `env` holds: what every
|
|
172
|
+
* module above captures, and the Redis code repository's, which the
|
|
173
|
+
* in-process one's excludes from one composition (both declare
|
|
174
|
+
* CLIENT_CODE_DEFAULT_EXPIRES_IN renamed, each to its own new name).
|
|
175
|
+
*/
|
|
176
|
+
export function capturedRenames(
|
|
177
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
178
|
+
): Record<string, string | null> {
|
|
179
|
+
return {
|
|
180
|
+
...renamedVariableCaptures({ modules: [redisCodeRepositoryModule], env }),
|
|
181
|
+
...renamedVariableCaptures({ modules: RENAMING_MODULES, core: CORE_RELOCATIONS, env }),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -16,16 +16,9 @@
|
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Starting the listener: one object-first `server_listening` line once the
|
|
19
|
-
* socket is bound
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* http://localhost:<port>`, which a log pipeline indexes as free text rather
|
|
23
|
-
* than as an event with a port. And the standalone called Express 5's
|
|
24
|
-
* `app.listen`, which hands its callback the server's `error` as well as its
|
|
25
|
-
* `listening`: a callback that ignored its argument announced a server on a
|
|
26
|
-
* port another process held, while the error that should have ended the
|
|
27
|
-
* process was swallowed. `listen` now builds the server and wires its events
|
|
28
|
-
* itself.
|
|
19
|
+
* socket is bound, and none for a port that was never bound. `listen` wires
|
|
20
|
+
* the server's events itself rather than trust `app.listen`, whose Express 5
|
|
21
|
+
* callback is handed the bind `error` as well as `listening`.
|
|
29
22
|
*/
|
|
30
23
|
|
|
31
24
|
import { createServer, type Server } from "node:http";
|
|
@@ -74,9 +67,9 @@ describe("listen", () => {
|
|
|
74
67
|
});
|
|
75
68
|
|
|
76
69
|
it("logs a server error after the bind — server_error, the projection — rather than swallowing it", async () => {
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
70
|
+
// Every `error` after the bind (accept EMFILE) is logged: a spent
|
|
71
|
+
// once-listener would lose the first, and with no listener left the
|
|
72
|
+
// second would be thrown out of the process.
|
|
80
73
|
const { logger, calls } = recordingLogger();
|
|
81
74
|
const server = await listen(express(), 0, logger);
|
|
82
75
|
opened.push(server);
|
|
@@ -45,7 +45,7 @@ const store = Object.assign(new Error("the Store could not be read", { cause: pa
|
|
|
45
45
|
const failed = Object.assign(new Error("request failed", { cause: store }), {
|
|
46
46
|
type: "upstream.failed", status: 502,
|
|
47
47
|
});
|
|
48
|
-
createAppLogger({
|
|
48
|
+
createAppLogger({ level: "info" }).error({ err: loggableError(failed) }, "unhandled_request_error");
|
|
49
49
|
`;
|
|
50
50
|
|
|
51
51
|
describe("createAppLogger writes a projected error whole", () => {
|
|
@@ -0,0 +1,134 @@
|
|
|
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 mail sender the template installs behind core's `mailSender` slot,
|
|
19
|
+
* both from `@o3co/auth-provider-standard`: the development sender where the
|
|
20
|
+
* configuration was selected as development — the name `app.mts` defaults
|
|
21
|
+
* to — the SMTP sender's module under any other name, and none when
|
|
22
|
+
* `buildModules` is told no name. A deployment's own sender modules, passed
|
|
23
|
+
* as `mailSenderModules`, replace that choice under every name. Nothing in
|
|
24
|
+
* the template reads the slot: this is the wiring alone.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { defineModule, type MailSender, type Module } from "@o3co/auth-provider-core";
|
|
28
|
+
import {
|
|
29
|
+
standardDevelopmentMailSenderModule,
|
|
30
|
+
standardSmtpMailSenderModule,
|
|
31
|
+
} from "@o3co/auth-provider-standard";
|
|
32
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
33
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
34
|
+
import {
|
|
35
|
+
type Composition,
|
|
36
|
+
compose,
|
|
37
|
+
resolveConfig,
|
|
38
|
+
SINGLE_ENV,
|
|
39
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
40
|
+
|
|
41
|
+
const DEVELOPMENT = standardDevelopmentMailSenderModule({ environment: "development" }).name;
|
|
42
|
+
const SMTP = standardSmtpMailSenderModule.name;
|
|
43
|
+
|
|
44
|
+
/** A deployment's own sender, built at boot so the slot holds it with nothing reading it. */
|
|
45
|
+
const OWN_SENDER: MailSender = {
|
|
46
|
+
kind: "deployment-own",
|
|
47
|
+
send: async () => ({ outcome: "delivered" }),
|
|
48
|
+
};
|
|
49
|
+
const ownSenderModule: Module = defineModule({
|
|
50
|
+
name: "deployment-own-mail-sender",
|
|
51
|
+
lifecycle: { mailSender: { eager: true } },
|
|
52
|
+
provides: { mailSender: () => OWN_SENDER },
|
|
53
|
+
});
|
|
54
|
+
const OWN = ownSenderModule.name;
|
|
55
|
+
|
|
56
|
+
/** The mail sender modules `buildModules` lists for `environment`. */
|
|
57
|
+
const senders = (
|
|
58
|
+
environment: string | undefined,
|
|
59
|
+
mailSenderModules?: readonly Module[],
|
|
60
|
+
): string[] =>
|
|
61
|
+
buildModules(resolveConfig(SINGLE_ENV), {
|
|
62
|
+
...(environment === undefined ? {} : { environment }),
|
|
63
|
+
...(mailSenderModules === undefined ? {} : { mailSenderModules }),
|
|
64
|
+
})
|
|
65
|
+
.map((module) => module.name)
|
|
66
|
+
.filter((name) => name === DEVELOPMENT || name === SMTP || name === OWN);
|
|
67
|
+
|
|
68
|
+
const ENVIRONMENTS = ["development", "production", "staging", "test", "Development"] as const;
|
|
69
|
+
|
|
70
|
+
describe("the template's mail sender", () => {
|
|
71
|
+
let current: Composition | undefined;
|
|
72
|
+
afterEach(async () => {
|
|
73
|
+
await current?.handle.dispose();
|
|
74
|
+
current = undefined;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("is the development sender in development, and none where no environment is named", () => {
|
|
78
|
+
expect(senders("development")).toEqual([DEVELOPMENT]);
|
|
79
|
+
expect(senders(undefined)).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("is the SMTP sender's module under any other environment", () => {
|
|
83
|
+
for (const environment of ["production", "staging", "test", "Development"]) {
|
|
84
|
+
expect(senders(environment), environment).toEqual([SMTP]);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("fills the slot with the development sender when the composition boots in development", async () => {
|
|
89
|
+
current = await compose({ environment: "development" });
|
|
90
|
+
expect((current.handle.components.mailSender as MailSender | undefined)?.kind).toBe(
|
|
91
|
+
"standard-development",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("boots in production with the SMTP sender's module installed and no SMTP settings, building no sender where nothing reads the slot", async () => {
|
|
96
|
+
current = await compose();
|
|
97
|
+
expect(current.modules.map((module) => module.name)).toContain(SMTP);
|
|
98
|
+
expect(current.handle.components.mailSender).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("given the deployment's own sender modules", () => {
|
|
102
|
+
it("installs them in place of the bundled sender, under every environment name and none", () => {
|
|
103
|
+
for (const environment of [...ENVIRONMENTS, undefined]) {
|
|
104
|
+
expect(senders(environment, [ownSenderModule]), String(environment)).toEqual([OWN]);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("installs no sender at all when the list is empty", () => {
|
|
109
|
+
for (const environment of [...ENVIRONMENTS, undefined]) {
|
|
110
|
+
expect(senders(environment, []), String(environment)).toEqual([]);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it.each(["development", "production"])(
|
|
115
|
+
"fills the slot with the deployment's sender when the composition boots in %s",
|
|
116
|
+
async (environment) => {
|
|
117
|
+
current = await compose({ environment, mailSenderModules: [ownSenderModule] });
|
|
118
|
+
const names = current.modules.map((module) => module.name);
|
|
119
|
+
expect(names).toContain(OWN);
|
|
120
|
+
expect(names).not.toContain(DEVELOPMENT);
|
|
121
|
+
expect(names).not.toContain(SMTP);
|
|
122
|
+
expect(current.handle.components.mailSender).toBe(OWN_SENDER);
|
|
123
|
+
},
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
it("boots in production with no sender when the list is empty", async () => {
|
|
127
|
+
current = await compose({ environment: "production", mailSenderModules: [] });
|
|
128
|
+
const names = current.modules.map((module) => module.name);
|
|
129
|
+
expect(names).not.toContain(DEVELOPMENT);
|
|
130
|
+
expect(names).not.toContain(SMTP);
|
|
131
|
+
expect(current.handle.components.mailSender).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,328 @@
|
|
|
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 on as the
|
|
19
|
+
* template ships it, and the Mailpit overlay, `docker-compose.mailpit.yml`: a
|
|
20
|
+
* development run whose mail goes through the SMTP sender to Mailpit. It is an
|
|
21
|
+
* overlay on `docker-compose.yml`, so a plain `docker compose up` starts no
|
|
22
|
+
* Mailpit, and the production file names none. The compose files are read as
|
|
23
|
+
* the fixed-shape files they are, and the process each run describes is booted
|
|
24
|
+
* from the shipped files, as `app.mts` boots them, under the environment the
|
|
25
|
+
* run sets: `.env.example`'s lines, then each compose file's `environment`, the
|
|
26
|
+
* overlay's last. Redis is a stand-in: nothing here issues a command.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { generateKeyPairSync } from "node:crypto";
|
|
30
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { fileURLToPath } from "node:url";
|
|
34
|
+
import {
|
|
35
|
+
createApp,
|
|
36
|
+
type MailSender,
|
|
37
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
38
|
+
} from "@o3co/auth-provider-core";
|
|
39
|
+
import {
|
|
40
|
+
standardDevelopmentMailSenderModule,
|
|
41
|
+
standardSmtpMailSenderModule,
|
|
42
|
+
} from "@o3co/auth-provider-standard";
|
|
43
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
44
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
45
|
+
import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
|
|
46
|
+
import { createRecordingLogger } from "./all-modules-composition.fixture.mjs";
|
|
47
|
+
|
|
48
|
+
vi.mock("redis", () => ({
|
|
49
|
+
createClient: vi.fn(() => ({
|
|
50
|
+
connect: vi.fn().mockResolvedValue(undefined),
|
|
51
|
+
quit: vi.fn().mockResolvedValue(undefined),
|
|
52
|
+
ping: vi.fn().mockResolvedValue("PONG"),
|
|
53
|
+
on: vi.fn(),
|
|
54
|
+
})),
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
vi.mock("connect-redis", async () => {
|
|
58
|
+
const { EventEmitter } = await import("node:events");
|
|
59
|
+
// express-session subscribes to store events, so the stand-in is an emitter.
|
|
60
|
+
return {
|
|
61
|
+
RedisStore: class MockRedisStore extends EventEmitter {
|
|
62
|
+
get(): unknown {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
set(): void {}
|
|
66
|
+
destroy(): void {}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
vi.mock("ioredis", () => {
|
|
72
|
+
const explicit: Record<string, unknown> = {
|
|
73
|
+
on: () => undefined,
|
|
74
|
+
quit: async () => "OK",
|
|
75
|
+
disconnect: () => undefined,
|
|
76
|
+
ping: async () => "PONG",
|
|
77
|
+
};
|
|
78
|
+
const makeMockRedis = (): object =>
|
|
79
|
+
new Proxy(
|
|
80
|
+
{},
|
|
81
|
+
{
|
|
82
|
+
get(_target, prop) {
|
|
83
|
+
// A function-valued `then` would make the instance a thenable.
|
|
84
|
+
if (typeof prop !== "string" || prop === "then") return undefined;
|
|
85
|
+
if (prop === "duplicate") return makeMockRedis;
|
|
86
|
+
if (prop in explicit) return explicit[prop];
|
|
87
|
+
return async () => null;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
function MockRedis(): object {
|
|
92
|
+
return makeMockRedis();
|
|
93
|
+
}
|
|
94
|
+
return { Redis: MockRedis, default: MockRedis };
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
|
|
98
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
99
|
+
const read = (rel: string): string => readFileSync(join(standaloneDir, rel), "utf8");
|
|
100
|
+
|
|
101
|
+
const OVERLAY = "docker-compose.mailpit.yml";
|
|
102
|
+
|
|
103
|
+
/** The lines of a service's block in a compose file, comments and blank lines left out. */
|
|
104
|
+
function serviceBlock(rel: string, name: string): string[] {
|
|
105
|
+
const lines = read(rel).split("\n");
|
|
106
|
+
const services = lines.findIndex((line) => /^services:\s*$/.test(line));
|
|
107
|
+
const start = lines.findIndex(
|
|
108
|
+
(line, at) => at > services && new RegExp(`^ {2}${name}:\\s*$`).test(line),
|
|
109
|
+
);
|
|
110
|
+
if (services === -1 || start === -1) return [];
|
|
111
|
+
const block: string[] = [];
|
|
112
|
+
for (const line of lines.slice(start + 1)) {
|
|
113
|
+
if (line.trim() === "" || /^\s*#/.test(line)) continue;
|
|
114
|
+
if (!/^ {4}/.test(line)) break;
|
|
115
|
+
block.push(line);
|
|
116
|
+
}
|
|
117
|
+
return block;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const unquote = (text: string): string => text.trim().replace(/^["']|["']$/g, "");
|
|
121
|
+
|
|
122
|
+
/** A scalar key of a service's block. */
|
|
123
|
+
function scalar(block: readonly string[], key: string): string | undefined {
|
|
124
|
+
for (const line of block) {
|
|
125
|
+
const match = new RegExp(`^ {4}${key}:\\s*(\\S.*)$`).exec(line);
|
|
126
|
+
if (match) return unquote(match[1] as string);
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The entries under a key of a service's block, each trimmed. */
|
|
132
|
+
function entries(block: readonly string[], key: string): string[] {
|
|
133
|
+
const at = block.findIndex((line) => new RegExp(`^ {4}${key}:\\s*$`).test(line));
|
|
134
|
+
if (at === -1) return [];
|
|
135
|
+
const found: string[] = [];
|
|
136
|
+
for (const line of block.slice(at + 1)) {
|
|
137
|
+
if (!/^ {6}/.test(line)) break;
|
|
138
|
+
found.push(line.trim());
|
|
139
|
+
}
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The `- "…"` entries of a list, unquoted. */
|
|
144
|
+
const listOf = (lines: readonly string[]): string[] =>
|
|
145
|
+
lines.map((line) => unquote(line.replace(/^-\s*/, "")));
|
|
146
|
+
|
|
147
|
+
/** `.env.example`'s uncommented lines: what a `.env` copied from it sets. */
|
|
148
|
+
function dotenvExample(): Record<string, string> {
|
|
149
|
+
const env: Record<string, string> = {};
|
|
150
|
+
for (const line of read(".env.example").split("\n")) {
|
|
151
|
+
const match = /^([A-Z][A-Z0-9_]*)=(.*)$/.exec(line);
|
|
152
|
+
if (match) env[match[1] as string] = match[2] as string;
|
|
153
|
+
}
|
|
154
|
+
return env;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A service's `environment`, interpolated as compose does from the project's
|
|
159
|
+
* `.env`: `${VAR:-default}` is the variable when it is set and not empty, the
|
|
160
|
+
* default otherwise.
|
|
161
|
+
*/
|
|
162
|
+
function environmentOf(
|
|
163
|
+
rel: string,
|
|
164
|
+
service: string,
|
|
165
|
+
dotenv: Readonly<Record<string, string>>,
|
|
166
|
+
): Record<string, string> {
|
|
167
|
+
const env: Record<string, string> = {};
|
|
168
|
+
for (const line of entries(serviceBlock(rel, service), "environment")) {
|
|
169
|
+
const match = /^([A-Z][A-Z0-9_]*):\s*(.*)$/.exec(line);
|
|
170
|
+
if (!match) continue;
|
|
171
|
+
env[match[1] as string] = unquote(match[2] as string).replace(
|
|
172
|
+
/\$\{([A-Z][A-Z0-9_]*):-([^}]*)\}/g,
|
|
173
|
+
(_, name: string, fallback: string) => (dotenv[name] ? dotenv[name] : fallback),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return env;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
describe("Mailpit runs only in the overlay", () => {
|
|
180
|
+
it("is not a service of the development or the production compose file", () => {
|
|
181
|
+
expect(serviceBlock("docker-compose.yml", "mailpit")).toEqual([]);
|
|
182
|
+
expect(serviceBlock("docker-compose.production.yml", "mailpit")).toEqual([]);
|
|
183
|
+
expect(read("docker-compose.production.yml")).not.toMatch(/mailpit/i);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("runs a pinned Mailpit image in the app's network namespace", () => {
|
|
187
|
+
const mailpit = serviceBlock(OVERLAY, "mailpit");
|
|
188
|
+
expect(scalar(mailpit, "image")).toMatch(/^axllent\/mailpit:v\d+\.\d+\.\d+$/);
|
|
189
|
+
expect(scalar(mailpit, "network_mode")).toBe("service:app");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("publishes Mailpit's web UI on loopback, from the app service that owns the namespace", () => {
|
|
193
|
+
expect(listOf(entries(serviceBlock(OVERLAY, "app"), "ports"))).toContain("127.0.0.1:8025:8025");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("points the SMTP sender at Mailpit over the app's loopback, in plaintext", () => {
|
|
197
|
+
expect(environmentOf(OVERLAY, "app", {})).toMatchObject({
|
|
198
|
+
STANDARD_SMTP_MAIL_SENDER_HOST: "localhost",
|
|
199
|
+
STANDARD_SMTP_MAIL_SENDER_PORT: "1025",
|
|
200
|
+
STANDARD_SMTP_MAIL_SENDER_SECURE: "none",
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("the process each development run describes", () => {
|
|
206
|
+
const handles: { dispose(): Promise<void> }[] = [];
|
|
207
|
+
afterEach(async () => {
|
|
208
|
+
for (const handle of handles.splice(0)) await handle.dispose();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const signingKey = generateKeyPairSync("ed25519", {
|
|
212
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
213
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
214
|
+
});
|
|
215
|
+
const clientsFile = (() => {
|
|
216
|
+
const file = join(mkdtempSync(join(tmpdir(), "mailpit-compose-")), "clients.yaml");
|
|
217
|
+
writeFileSync(file, "");
|
|
218
|
+
return file;
|
|
219
|
+
})();
|
|
220
|
+
|
|
221
|
+
/** What the operator writes into `.env` beside `.env.example`'s lines: its required blanks. */
|
|
222
|
+
const OPERATOR: Readonly<Record<string, string>> = {
|
|
223
|
+
OAUTH_JWT_ISSUER: "http://localhost:3000",
|
|
224
|
+
KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
|
|
225
|
+
KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
|
|
226
|
+
SESSION_STORE_SECRET: "mailpit-compose-session-secret.at-least-32-bytes.ok",
|
|
227
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://users.test/authenticate",
|
|
228
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL: "https://users.test/by-token",
|
|
229
|
+
REPOSITORIES_CLIENT_YAML_PATH: clientsFile,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The container's environment: `.env`, then each compose file's
|
|
234
|
+
* `environment`, the overlay's last unless `overlay` is false.
|
|
235
|
+
*/
|
|
236
|
+
function containerEnv(overlay = true): Record<string, string> {
|
|
237
|
+
const dotenv: Record<string, string> = { ...dotenvExample(), ...OPERATOR };
|
|
238
|
+
// The pem pair is given inline here; the paths name files this test has not written.
|
|
239
|
+
delete dotenv.KEY_STORE_LOCAL_PRIVATE_KEY_PATH;
|
|
240
|
+
delete dotenv.KEY_STORE_LOCAL_PUBLIC_KEY_PATH;
|
|
241
|
+
return {
|
|
242
|
+
...Object.fromEntries(Object.entries(dotenv).filter(([, value]) => value !== "")),
|
|
243
|
+
...environmentOf("docker-compose.yml", "app", dotenv),
|
|
244
|
+
...(overlay ? environmentOf(OVERLAY, "app", dotenv) : {}),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
it("boots docker-compose.yml's run with MFA required: development, the development mail sender, the MFA stores on its Redis", async () => {
|
|
249
|
+
const env = containerEnv(false);
|
|
250
|
+
// `app.mts`: CONFIG_ENV, then NODE_ENV, then development; the run sets neither.
|
|
251
|
+
expect(env.CONFIG_ENV ?? env.NODE_ENV).toBeUndefined();
|
|
252
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "development");
|
|
253
|
+
const own = readOwnLayers([envConfPath, applicationConfPath], { env });
|
|
254
|
+
const switches = readSwitches(own);
|
|
255
|
+
expect(switches.mfaMode).toBe("required");
|
|
256
|
+
// A hot reload restarts the process on every save, and Redis keeps the
|
|
257
|
+
// factors, and the sessions beside them, across it.
|
|
258
|
+
expect(switches.adapters).toMatchObject({
|
|
259
|
+
mfaFactorStore: "redis",
|
|
260
|
+
mfaTransactionStore: "redis",
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const modules = buildModules(switches, {
|
|
264
|
+
environment: "development",
|
|
265
|
+
logger: createRecordingLogger(),
|
|
266
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
267
|
+
});
|
|
268
|
+
const handle = await createApp({
|
|
269
|
+
modules,
|
|
270
|
+
bootstrapComponents: {
|
|
271
|
+
config: resolveForBoot(own, modules, switches),
|
|
272
|
+
pathResolver: (s: string) => s,
|
|
273
|
+
logger: createRecordingLogger(),
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
handles.push(handle);
|
|
277
|
+
expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe(
|
|
278
|
+
"standard-development",
|
|
279
|
+
);
|
|
280
|
+
expect((handle.components.config as { mfa?: { mode?: unknown } } | undefined)?.mfa?.mode).toBe(
|
|
281
|
+
"required",
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("selects a configuration that is not development, so the SMTP sender is installed", () => {
|
|
286
|
+
const configEnv = containerEnv().CONFIG_ENV;
|
|
287
|
+
expect(configEnv).toBeDefined();
|
|
288
|
+
expect(configEnv).not.toBe("development");
|
|
289
|
+
// A missing `{ENV}.conf` is fatal at boot.
|
|
290
|
+
expect(existsSync(resolveConfigPaths(configDir, configEnv as string).envConfPath)).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("boots with MFA on and the SMTP sender in the mailSender slot, relaying to Mailpit", async () => {
|
|
294
|
+
const env = containerEnv();
|
|
295
|
+
const configEnv = env.CONFIG_ENV as string;
|
|
296
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
|
|
297
|
+
const own = readOwnLayers([envConfPath, applicationConfPath], { env });
|
|
298
|
+
const switches = readSwitches(own);
|
|
299
|
+
expect(switches.mfaMode).toBe("required");
|
|
300
|
+
|
|
301
|
+
const modules = buildModules(switches, {
|
|
302
|
+
environment: configEnv,
|
|
303
|
+
logger: createRecordingLogger(),
|
|
304
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
305
|
+
});
|
|
306
|
+
const names = modules.map((module) => module.name);
|
|
307
|
+
expect(names).toContain(standardSmtpMailSenderModule.name);
|
|
308
|
+
expect(names).not.toContain(
|
|
309
|
+
standardDevelopmentMailSenderModule({ environment: "development" }).name,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const handle = await createApp({
|
|
313
|
+
modules,
|
|
314
|
+
bootstrapComponents: {
|
|
315
|
+
config: resolveForBoot(own, modules, switches),
|
|
316
|
+
pathResolver: (s: string) => s,
|
|
317
|
+
logger: createRecordingLogger(),
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
handles.push(handle);
|
|
321
|
+
expect((handle.components.mailSender as MailSender | undefined)?.kind).toBe("standard-smtp");
|
|
322
|
+
expect(
|
|
323
|
+
(handle.components.config as Record<string, unknown> | undefined)?.[
|
|
324
|
+
"standard-smtp-mail-sender"
|
|
325
|
+
],
|
|
326
|
+
).toMatchObject({ host: "localhost", port: 1025, secure: "none" });
|
|
327
|
+
});
|
|
328
|
+
});
|