@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -29
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +324 -169
- package/templates/standalone/README.md +618 -208
- package/templates/standalone/config/application.conf +211 -352
- 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 +418 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +63 -32
- 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__/adapters.test.mts +419 -0
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
- package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -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 +326 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
- package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
- 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 +116 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
- package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
- package/templates/standalone/src/adapters.mts +162 -0
- package/templates/standalone/src/app.mts +65 -79
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +220 -309
- package/templates/standalone/src/configPath.mts +449 -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 +285 -0
- package/templates/standalone/src/modules.mts +423 -629
- package/templates/standalone/src/rootRenames.mts +96 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +367 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +6 -5
- 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
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The template with core's session lifecycle module: a close whose
|
|
19
|
+
* relying-party notice fails stays pending, its user session still there,
|
|
20
|
+
* and from its commit no token reader treats the session as live —
|
|
21
|
+
* introspection, userinfo and the session grant all refuse it.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { SessionLifecycle, UserSessionStore } from "@o3co/auth-provider-core";
|
|
25
|
+
import request from "supertest";
|
|
26
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
27
|
+
import {
|
|
28
|
+
authorize,
|
|
29
|
+
basic,
|
|
30
|
+
codeFrom,
|
|
31
|
+
compose,
|
|
32
|
+
login,
|
|
33
|
+
redeem,
|
|
34
|
+
WEB,
|
|
35
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vi.restoreAllMocks();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** A JWT's claims, unverified. */
|
|
42
|
+
const claimsOf = (token: string): Record<string, unknown> =>
|
|
43
|
+
JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
44
|
+
|
|
45
|
+
describe("a session whose close is pending on a failed relying-party notice", () => {
|
|
46
|
+
it("is refused by introspection, userinfo and the session grant, its user session still there", async () => {
|
|
47
|
+
const { app, handle } = await compose({
|
|
48
|
+
extraClients: {
|
|
49
|
+
"rp-down": {
|
|
50
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
51
|
+
clientSecret: "rp-down-secret-long-enough",
|
|
52
|
+
allowedRedirectUris: ["https://rp-down.test/cb"],
|
|
53
|
+
allowedScopes: ["openid"],
|
|
54
|
+
allowedGrantTypes: ["authorization_code"],
|
|
55
|
+
backchannelLogoutUri: "https://rp-down.test/logout",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
try {
|
|
60
|
+
const components = handle.components as Record<string, unknown>;
|
|
61
|
+
const lifecycle = components.sessionLifecycle as SessionLifecycle;
|
|
62
|
+
const sessions = components.userSessionStore as UserSessionStore;
|
|
63
|
+
const { cookies } = await login(app);
|
|
64
|
+
const sessionGrant = () =>
|
|
65
|
+
request(app)
|
|
66
|
+
.post("/oauth/token")
|
|
67
|
+
.set("Authorization", basic(WEB))
|
|
68
|
+
.set("Cookie", cookies)
|
|
69
|
+
.type("form")
|
|
70
|
+
.send({ grant_type: "session", scope: "openid profile" });
|
|
71
|
+
const issued = await sessionGrant();
|
|
72
|
+
expect(issued.status).toBe(200);
|
|
73
|
+
const accessToken = issued.body.access_token as string;
|
|
74
|
+
const sid = String(claimsOf(accessToken).sid);
|
|
75
|
+
const introspect = () =>
|
|
76
|
+
request(app)
|
|
77
|
+
.post("/oauth/introspect")
|
|
78
|
+
.set("Authorization", basic(WEB))
|
|
79
|
+
.type("form")
|
|
80
|
+
.send({ token: accessToken });
|
|
81
|
+
const userinfo = () =>
|
|
82
|
+
request(app).get("/oauth/userinfo").set("Authorization", `Bearer ${accessToken}`);
|
|
83
|
+
expect((await introspect()).body.active).toBe(true);
|
|
84
|
+
expect((await userinfo()).status).toBe(200);
|
|
85
|
+
|
|
86
|
+
// A relying party joins with its family; its back-channel endpoint is
|
|
87
|
+
// down, so the close commits with its notice pending.
|
|
88
|
+
expect(
|
|
89
|
+
await lifecycle.join(sid, {
|
|
90
|
+
rp: {
|
|
91
|
+
clientId: "rp-down",
|
|
92
|
+
backchannelLogoutUri: "https://rp-down.test/logout",
|
|
93
|
+
backchannelLogoutSessionRequired: true,
|
|
94
|
+
frontchannelLogoutUri: undefined,
|
|
95
|
+
frontchannelLogoutSessionRequired: undefined,
|
|
96
|
+
registeredAt: new Date(),
|
|
97
|
+
},
|
|
98
|
+
familyId: "family-down",
|
|
99
|
+
}),
|
|
100
|
+
).toEqual({ outcome: "joined" });
|
|
101
|
+
const posted = vi
|
|
102
|
+
.spyOn(globalThis, "fetch")
|
|
103
|
+
.mockImplementation(async () => new Response(null, { status: 503 }));
|
|
104
|
+
expect((await lifecycle.close(sid, "rp_logout")).outcome).toBe("pending");
|
|
105
|
+
expect(posted).toHaveBeenCalled();
|
|
106
|
+
expect(await sessions.get(sid)).not.toBeNull();
|
|
107
|
+
|
|
108
|
+
const afterIntrospect = await introspect();
|
|
109
|
+
expect(afterIntrospect.status).toBe(200);
|
|
110
|
+
expect(afterIntrospect.body).toEqual({ active: false });
|
|
111
|
+
|
|
112
|
+
const afterUserinfo = await userinfo();
|
|
113
|
+
expect(afterUserinfo.status).toBe(401);
|
|
114
|
+
expect(afterUserinfo.body.error_description).toBe("session_invalid");
|
|
115
|
+
|
|
116
|
+
const again = await sessionGrant();
|
|
117
|
+
expect(again.status).toBe(400);
|
|
118
|
+
expect(again.body).toMatchObject({
|
|
119
|
+
error: "invalid_grant",
|
|
120
|
+
error_description: "session_invalid",
|
|
121
|
+
});
|
|
122
|
+
} finally {
|
|
123
|
+
await handle.dispose();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("an RP-initiated logout whose notice fails answers success, and its session is refused by introspection, userinfo and the session grant", async () => {
|
|
128
|
+
const { app, handle } = await compose({
|
|
129
|
+
extraClients: {
|
|
130
|
+
"rp-down": {
|
|
131
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
132
|
+
clientSecret: "rp-down-secret-long-enough",
|
|
133
|
+
allowedRedirectUris: ["https://rp-down.test/cb"],
|
|
134
|
+
allowedScopes: ["openid"],
|
|
135
|
+
allowedGrantTypes: ["authorization_code"],
|
|
136
|
+
backchannelLogoutUri: "https://rp-down.test/logout",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const components = handle.components as Record<string, unknown>;
|
|
142
|
+
const lifecycle = components.sessionLifecycle as SessionLifecycle;
|
|
143
|
+
const sessions = components.userSessionStore as UserSessionStore;
|
|
144
|
+
const { cookies } = await login(app);
|
|
145
|
+
// The code exchange joins the session; its id_token is the logout's hint.
|
|
146
|
+
const exchanged = await redeem(app, codeFrom(await authorize(app, cookies)));
|
|
147
|
+
expect(exchanged.status).toBe(200);
|
|
148
|
+
const idToken = exchanged.body.id_token as string;
|
|
149
|
+
const sid = String(claimsOf(idToken).sid);
|
|
150
|
+
const sessionGrant = () =>
|
|
151
|
+
request(app)
|
|
152
|
+
.post("/oauth/token")
|
|
153
|
+
.set("Authorization", basic(WEB))
|
|
154
|
+
.set("Cookie", cookies)
|
|
155
|
+
.type("form")
|
|
156
|
+
.send({ grant_type: "session", scope: "openid profile" });
|
|
157
|
+
const issued = await sessionGrant();
|
|
158
|
+
expect(issued.status).toBe(200);
|
|
159
|
+
// A session-grant token carries the session and no family.
|
|
160
|
+
const accessToken = issued.body.access_token as string;
|
|
161
|
+
expect(claimsOf(accessToken)).toMatchObject({ sid });
|
|
162
|
+
expect(claimsOf(accessToken).family_id).toBeUndefined();
|
|
163
|
+
|
|
164
|
+
// A relying party whose back-channel endpoint is down joins the session.
|
|
165
|
+
expect(
|
|
166
|
+
await lifecycle.join(sid, {
|
|
167
|
+
rp: {
|
|
168
|
+
clientId: "rp-down",
|
|
169
|
+
backchannelLogoutUri: "https://rp-down.test/logout",
|
|
170
|
+
backchannelLogoutSessionRequired: true,
|
|
171
|
+
frontchannelLogoutUri: undefined,
|
|
172
|
+
frontchannelLogoutSessionRequired: undefined,
|
|
173
|
+
registeredAt: new Date(),
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
).toEqual({ outcome: "joined" });
|
|
177
|
+
const posted = vi
|
|
178
|
+
.spyOn(globalThis, "fetch")
|
|
179
|
+
.mockImplementation(async () => new Response(null, { status: 503 }));
|
|
180
|
+
|
|
181
|
+
const logout = await request(app)
|
|
182
|
+
.post("/oauth/logout")
|
|
183
|
+
.type("form")
|
|
184
|
+
.send({ id_token_hint: idToken });
|
|
185
|
+
|
|
186
|
+
// The close committed with the notice pending: the logout succeeded.
|
|
187
|
+
expect(logout.status).toBe(200);
|
|
188
|
+
expect(logout.body).toEqual({ logged_out: true });
|
|
189
|
+
expect(posted).toHaveBeenCalled();
|
|
190
|
+
expect(await sessions.get(sid)).not.toBeNull();
|
|
191
|
+
|
|
192
|
+
const introspected = await request(app)
|
|
193
|
+
.post("/oauth/introspect")
|
|
194
|
+
.set("Authorization", basic(WEB))
|
|
195
|
+
.type("form")
|
|
196
|
+
.send({ token: accessToken });
|
|
197
|
+
expect(introspected.status).toBe(200);
|
|
198
|
+
expect(introspected.body).toEqual({ active: false });
|
|
199
|
+
|
|
200
|
+
const userinfo = await request(app)
|
|
201
|
+
.get("/oauth/userinfo")
|
|
202
|
+
.set("Authorization", `Bearer ${accessToken}`);
|
|
203
|
+
expect(userinfo.status).toBe(401);
|
|
204
|
+
expect(userinfo.body.error_description).toBe("session_invalid");
|
|
205
|
+
|
|
206
|
+
const again = await sessionGrant();
|
|
207
|
+
expect(again.status).toBe(400);
|
|
208
|
+
expect(again.body).toMatchObject({
|
|
209
|
+
error: "invalid_grant",
|
|
210
|
+
error_description: "session_invalid",
|
|
211
|
+
});
|
|
212
|
+
} finally {
|
|
213
|
+
await handle.dispose();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -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
|
+
});
|