@o3co/create-auth-provider 0.14.0 → 0.16.0
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 +93 -47
- package/README.md +92 -46
- package/package.json +1 -1
- package/templates/standalone/.env.example +34 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +565 -130
- package/templates/standalone/README.md +356 -92
- package/templates/standalone/config/application.conf +77 -2
- package/templates/standalone/docker-compose.production.yml +7 -0
- package/templates/standalone/docker-compose.yml +7 -0
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +3 -2
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +49 -15
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1103 -0
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +241 -0
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1100 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +40 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +445 -0
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +446 -0
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +133 -0
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +85 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +304 -0
- package/templates/standalone/src/__tests__/host-routes.test.mts +141 -0
- package/templates/standalone/src/__tests__/listen.test.mts +128 -0
- package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +20 -5
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +187 -0
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +139 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +13 -3
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +149 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +14 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +253 -22
- package/templates/standalone/src/__tests__/smoke.test.mts +39 -20
- package/templates/standalone/src/__tests__/store-credential.test.mts +276 -0
- package/templates/standalone/src/app.mts +30 -51
- package/templates/standalone/src/buildModules.mts +94 -12
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +118 -12
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +90 -18
- package/templates/standalone/vitest.supertest-loopback.mts +22 -3
- package/templates/versions.json +15 -14
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -0,0 +1,304 @@
|
|
|
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
|
+
* A browser session with Google linked, on the standalone as a deployment
|
|
19
|
+
* composes it: `oauthModule`, the session module and the real Google adapter,
|
|
20
|
+
* booted through `createApp` under the shipped configuration.
|
|
21
|
+
*
|
|
22
|
+
* The routes that act on a session's federation — the two logout routes and
|
|
23
|
+
* `POST /oauth/federation/:name/token` — live in `@o3co/auth-provider-oauth`,
|
|
24
|
+
* which depends on no adapter, so its own tests stand one in. What they hand
|
|
25
|
+
* the adapter, and what they make of what its library raises, can only be
|
|
26
|
+
* seen here.
|
|
27
|
+
*
|
|
28
|
+
* Not `all-modules-composition.fixture.mts`: that one switches every module
|
|
29
|
+
* on and signs with a key it keeps to itself. The tests here present an
|
|
30
|
+
* `id_token_hint` and an access token of their own — a caller's, with the
|
|
31
|
+
* claims the case needs — so the deployment signs with an HS256 secret this
|
|
32
|
+
* file holds, and nothing but Google is federated.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { createHmac } from "node:crypto";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
import {
|
|
38
|
+
type AppConfig,
|
|
39
|
+
AppConfigSchema,
|
|
40
|
+
createApp,
|
|
41
|
+
createKeyStoreFactory,
|
|
42
|
+
defineModule,
|
|
43
|
+
type FederationTokenStore,
|
|
44
|
+
type FederationTokens,
|
|
45
|
+
InMemoryClientRepository,
|
|
46
|
+
InMemoryUserRepository,
|
|
47
|
+
memoryRefreshTokenFamilyStoreModule,
|
|
48
|
+
registerBuiltinKeyStores,
|
|
49
|
+
type SessionFederationIndex,
|
|
50
|
+
type UserSessionStore,
|
|
51
|
+
} from "@o3co/auth-provider-core";
|
|
52
|
+
import {
|
|
53
|
+
type GoogleProviderConfig,
|
|
54
|
+
googleFederationModule,
|
|
55
|
+
} from "@o3co/auth-provider-federation-google";
|
|
56
|
+
import { parseFile } from "@o3co/ts.hocon";
|
|
57
|
+
import { validate } from "@o3co/ts.hocon/zod";
|
|
58
|
+
import express from "express";
|
|
59
|
+
import request from "supertest";
|
|
60
|
+
import { expect } from "vitest";
|
|
61
|
+
import { buildModules } from "#/buildModules.mjs";
|
|
62
|
+
import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "#/configPath.mjs";
|
|
63
|
+
|
|
64
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
65
|
+
|
|
66
|
+
export const ISSUER = "https://auth.test";
|
|
67
|
+
const JWT_SECRET = "google-session.fixture.at-least-32-bytes.ok";
|
|
68
|
+
|
|
69
|
+
/** The one client: first-party, allowed federation tokens, one post-logout URI registered. */
|
|
70
|
+
export const CLIENT_ID = "rp";
|
|
71
|
+
const CLIENT_SECRET = "rp-secret-value";
|
|
72
|
+
const BASIC = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`;
|
|
73
|
+
export const REGISTERED_POST_LOGOUT_REDIRECT_URI = "https://rp.example/signed-out";
|
|
74
|
+
|
|
75
|
+
const USERNAME = "alice";
|
|
76
|
+
const PASSWORD = "correct-horse-battery-staple";
|
|
77
|
+
const SUB = "u-alice";
|
|
78
|
+
|
|
79
|
+
/** The Google client the adapter is configured with. */
|
|
80
|
+
export const GOOGLE_CLIENT_ID = "google-client";
|
|
81
|
+
const GOOGLE_CLIENT_SECRET = "google-secret";
|
|
82
|
+
const GOOGLE_CALLBACK = `${ISSUER}/session/oauth/federation/google/callback`;
|
|
83
|
+
|
|
84
|
+
const ENV: Readonly<Record<string, string>> = {
|
|
85
|
+
OAUTH_JWT_ALGORITHM: "HS256",
|
|
86
|
+
OAUTH_JWT_SECRET: JWT_SECRET,
|
|
87
|
+
OAUTH_JWT_ISSUER: ISSUER,
|
|
88
|
+
SESSION_SECRET: "google-session.fixture-session.at-least-32-bytes.ok",
|
|
89
|
+
SESSION_SECURE: "false",
|
|
90
|
+
SESSION_NAME: "auth.session",
|
|
91
|
+
SESSION_STORAGE_TYPE: "memory",
|
|
92
|
+
CLIENT_USER_TYPE: "yaml",
|
|
93
|
+
REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
|
|
94
|
+
USER_SESSION_STORES_ADAPTER: "memory",
|
|
95
|
+
RATE_LIMITER_ADAPTER: "memory",
|
|
96
|
+
OAUTH_CODE_ADAPTER: "memory",
|
|
97
|
+
ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
|
|
98
|
+
REPLAY_SEEN_SET_ADAPTER: "memory",
|
|
99
|
+
FEDERATION_TOKEN_STORE_TYPE: "memory",
|
|
100
|
+
CONSENT_STORE_ADAPTER: "none",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `"shipped"`: Google switched on the way an operator does, through its
|
|
105
|
+
* environment variables and the template's own configuration bridge — which
|
|
106
|
+
* has no setting for an end-session endpoint or a `fetch`. Otherwise the
|
|
107
|
+
* adapter's configuration beyond its credentials, composed with
|
|
108
|
+
* `googleFederationModule` directly.
|
|
109
|
+
*/
|
|
110
|
+
export type GoogleWiring =
|
|
111
|
+
| "shipped"
|
|
112
|
+
| Omit<GoogleProviderConfig, "clientId" | "clientSecret" | "callbackURL">;
|
|
113
|
+
|
|
114
|
+
function resolveConfig(google: GoogleWiring): AppConfig {
|
|
115
|
+
const env: Record<string, string> =
|
|
116
|
+
google === "shipped"
|
|
117
|
+
? {
|
|
118
|
+
...ENV,
|
|
119
|
+
FEDERATIONS_GOOGLE_ENABLED: "true",
|
|
120
|
+
FEDERATIONS_GOOGLE_CLIENT_ID: GOOGLE_CLIENT_ID,
|
|
121
|
+
FEDERATIONS_GOOGLE_CLIENT_SECRET: GOOGLE_CLIENT_SECRET,
|
|
122
|
+
FEDERATIONS_GOOGLE_CALLBACK_URL: GOOGLE_CALLBACK,
|
|
123
|
+
}
|
|
124
|
+
: { ...ENV };
|
|
125
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
|
|
126
|
+
return validate(
|
|
127
|
+
parseFile(envConfPath, { env })
|
|
128
|
+
.withFallback(parseFile(applicationConfPath, { env }))
|
|
129
|
+
.withFallback(parseFile(resolveLibraryReferenceConfPath(), { env })),
|
|
130
|
+
AppConfigSchema,
|
|
131
|
+
) as AppConfig;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const testRepositoriesModule = defineModule({
|
|
135
|
+
name: "test:repositories",
|
|
136
|
+
provides: {
|
|
137
|
+
clientRepository: () =>
|
|
138
|
+
new InMemoryClientRepository(
|
|
139
|
+
new Map([
|
|
140
|
+
[
|
|
141
|
+
CLIENT_ID,
|
|
142
|
+
{
|
|
143
|
+
tokenEndpointAuthMethod: "client_secret_basic",
|
|
144
|
+
clientSecret: CLIENT_SECRET,
|
|
145
|
+
allowedRedirectUris: [],
|
|
146
|
+
allowedScopes: ["openid", "profile"],
|
|
147
|
+
allowedAudiences: [],
|
|
148
|
+
allowedGrantTypes: ["session"],
|
|
149
|
+
postLogoutRedirectUris: [REGISTERED_POST_LOGOUT_REDIRECT_URI],
|
|
150
|
+
backchannelLogoutSessionRequired: true,
|
|
151
|
+
frontchannelLogoutSessionRequired: true,
|
|
152
|
+
allowedAzpForFederationToken: true,
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
]),
|
|
156
|
+
),
|
|
157
|
+
userRepository: () =>
|
|
158
|
+
new InMemoryUserRepository(
|
|
159
|
+
new Map([[USERNAME, { id: SUB, password: PASSWORD, email: "alice@example.com" }]]),
|
|
160
|
+
),
|
|
161
|
+
} as never,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const testKeyStoreModule = defineModule({
|
|
165
|
+
name: "test:key-store",
|
|
166
|
+
requires: ["config"] as const,
|
|
167
|
+
provides: {
|
|
168
|
+
keyStore: async ({ config: c }) => {
|
|
169
|
+
const factory = createKeyStoreFactory();
|
|
170
|
+
registerBuiltinKeyStores(factory);
|
|
171
|
+
return factory.create({
|
|
172
|
+
type: "local",
|
|
173
|
+
...((c as AppConfig).oauth.jwt.signingKey.local ?? {}),
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const base64url = (value: unknown): string =>
|
|
180
|
+
Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
181
|
+
|
|
182
|
+
/** A JWT signed with the deployment's own HS256 key: what its keystore verifies. */
|
|
183
|
+
function signed(typ: string, claims: Record<string, unknown>): string {
|
|
184
|
+
const header = base64url({ alg: "HS256", kid: "v0", typ });
|
|
185
|
+
const now = Math.floor(Date.now() / 1000);
|
|
186
|
+
const payload = base64url({ iss: ISSUER, sub: SUB, iat: now, exp: now + 3600, ...claims });
|
|
187
|
+
const signature = createHmac("sha256", JWT_SECRET)
|
|
188
|
+
.update(`${header}.${payload}`)
|
|
189
|
+
.digest("base64url");
|
|
190
|
+
return `${header}.${payload}.${signature}`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A fresh id_token for the session, issued to the client: what an RP — or
|
|
195
|
+
* anyone holding an id_token of their own — presents as `id_token_hint`.
|
|
196
|
+
*/
|
|
197
|
+
export const idTokenHint = (sid: string): string => signed("JWT", { aud: CLIENT_ID, sid });
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* An access token for the session as the authorization-code grant issues it
|
|
201
|
+
* to the client: `azp`, `sid` and a refresh-token `family_id`. The `session`
|
|
202
|
+
* grant's token names no family, and the federation token route asks for one.
|
|
203
|
+
*/
|
|
204
|
+
export const accessTokenWithFamily = (sid: string): string =>
|
|
205
|
+
signed("at+jwt", { azp: CLIENT_ID, sid, family_id: "family-1", scope: "openid" });
|
|
206
|
+
|
|
207
|
+
const claimsOf = (jwt: string): Record<string, unknown> =>
|
|
208
|
+
JSON.parse(Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8")) as Record<
|
|
209
|
+
string,
|
|
210
|
+
unknown
|
|
211
|
+
>;
|
|
212
|
+
|
|
213
|
+
export interface GoogleSession {
|
|
214
|
+
readonly app: express.Express;
|
|
215
|
+
readonly sid: string;
|
|
216
|
+
/** The access token the `session` grant minted for the session; it carries `azp`. */
|
|
217
|
+
readonly accessToken: string;
|
|
218
|
+
readonly userSessionStore: UserSessionStore;
|
|
219
|
+
readonly sessionFederationIndex: SessionFederationIndex;
|
|
220
|
+
readonly federationTokenStore: FederationTokenStore;
|
|
221
|
+
readonly dispose: () => Promise<void>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Boots the deployment, signs alice in with her password, and links Google to
|
|
226
|
+
* her session as a federated sign-in would: the session's federation index
|
|
227
|
+
* names it and the token store holds `tokens`.
|
|
228
|
+
*/
|
|
229
|
+
export async function signInLinkedToGoogle(options: {
|
|
230
|
+
readonly google: GoogleWiring;
|
|
231
|
+
readonly tokens: FederationTokens;
|
|
232
|
+
}): Promise<GoogleSession> {
|
|
233
|
+
const { google, tokens } = options;
|
|
234
|
+
const config = resolveConfig(google);
|
|
235
|
+
const googleModules =
|
|
236
|
+
google === "shipped"
|
|
237
|
+
? []
|
|
238
|
+
: [
|
|
239
|
+
googleFederationModule,
|
|
240
|
+
defineModule({
|
|
241
|
+
name: "test:google-federation-config",
|
|
242
|
+
provides: {
|
|
243
|
+
googleFederationConfig: (): GoogleProviderConfig => ({
|
|
244
|
+
clientId: GOOGLE_CLIENT_ID,
|
|
245
|
+
clientSecret: GOOGLE_CLIENT_SECRET,
|
|
246
|
+
callbackURL: GOOGLE_CALLBACK,
|
|
247
|
+
...google,
|
|
248
|
+
}),
|
|
249
|
+
},
|
|
250
|
+
}),
|
|
251
|
+
];
|
|
252
|
+
const handle = await createApp({
|
|
253
|
+
modules: [
|
|
254
|
+
...buildModules(config, {
|
|
255
|
+
keyStoreModule: testKeyStoreModule,
|
|
256
|
+
repositoriesModule: testRepositoriesModule,
|
|
257
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
258
|
+
}),
|
|
259
|
+
...googleModules,
|
|
260
|
+
],
|
|
261
|
+
bootstrapComponents: { config, pathResolver: (s) => s },
|
|
262
|
+
});
|
|
263
|
+
try {
|
|
264
|
+
const components = handle.components as {
|
|
265
|
+
userSessionStore: UserSessionStore;
|
|
266
|
+
sessionFederationIndex: SessionFederationIndex;
|
|
267
|
+
federationTokenStore: FederationTokenStore;
|
|
268
|
+
};
|
|
269
|
+
const app = express();
|
|
270
|
+
app.use(handle.router);
|
|
271
|
+
|
|
272
|
+
const csrfRes = await request(app).get("/session/csrf");
|
|
273
|
+
expect(csrfRes.status).toBe(200);
|
|
274
|
+
const loginRes = await request(app)
|
|
275
|
+
.post("/session/login")
|
|
276
|
+
.set("Cookie", csrfRes.headers["set-cookie"] as unknown as string[])
|
|
277
|
+
.set(csrfRes.body.header_name as string, csrfRes.body.csrf_token as string)
|
|
278
|
+
.type("form")
|
|
279
|
+
.send({ username: USERNAME, password: PASSWORD });
|
|
280
|
+
expect(loginRes.status).toBe(200);
|
|
281
|
+
|
|
282
|
+
const tokenRes = await request(app)
|
|
283
|
+
.post("/oauth/token")
|
|
284
|
+
.set("Authorization", BASIC)
|
|
285
|
+
.set("Cookie", loginRes.headers["set-cookie"] as unknown as string[])
|
|
286
|
+
.type("form")
|
|
287
|
+
.send({ grant_type: "session" });
|
|
288
|
+
expect(tokenRes.status).toBe(200);
|
|
289
|
+
const accessToken = tokenRes.body.access_token as string;
|
|
290
|
+
const claims = claimsOf(accessToken);
|
|
291
|
+
expect(claims.azp).toBe(CLIENT_ID);
|
|
292
|
+
const sid = claims.sid as string;
|
|
293
|
+
expect(typeof sid).toBe("string");
|
|
294
|
+
|
|
295
|
+
const session = await components.userSessionStore.get(sid);
|
|
296
|
+
if (session === null) throw new Error("fixture: the sign-in left no session");
|
|
297
|
+
await components.sessionFederationIndex.addFederation(sid, "google", session.expiresAt);
|
|
298
|
+
await components.federationTokenStore.attach(sid, "google", tokens);
|
|
299
|
+
return { app, sid, accessToken, ...components, dispose: () => handle.dispose() };
|
|
300
|
+
} catch (error) {
|
|
301
|
+
await handle.dispose();
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
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 host's own routes — liveness, readiness, the metrics scrape — mounted
|
|
19
|
+
* as the process mounts them (`mountRoutes`), and what an error one of them
|
|
20
|
+
* lets through is answered with.
|
|
21
|
+
*
|
|
22
|
+
* The composed router answers its own errors with core's terminal handler.
|
|
23
|
+
* The host's routes sat in front of a handler of the template's own, which
|
|
24
|
+
* answered in other words ("Internal server error"), took any 4xx `status`
|
|
25
|
+
* for the client's mistake and answered it as one, handed an error on to
|
|
26
|
+
* Express once the headers were out (whose final handler prints the stack),
|
|
27
|
+
* and logged the path as it arrived. Core's handler is now the one after
|
|
28
|
+
* them too, so a deployment answers every route's error the same way.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { Logger, ReadinessProbe } from "@o3co/auth-provider-core";
|
|
32
|
+
import express, { Router } from "express";
|
|
33
|
+
import request from "supertest";
|
|
34
|
+
import { describe, expect, it, vi } from "vitest";
|
|
35
|
+
import { createMetrics } from "#/metrics.mjs";
|
|
36
|
+
import { mountRoutes } from "#/routes.mjs";
|
|
37
|
+
|
|
38
|
+
const spyLogger = () => {
|
|
39
|
+
const logger = {
|
|
40
|
+
trace: vi.fn(),
|
|
41
|
+
debug: vi.fn(),
|
|
42
|
+
info: vi.fn(),
|
|
43
|
+
warn: vi.fn(),
|
|
44
|
+
error: vi.fn(),
|
|
45
|
+
fatal: vi.fn(),
|
|
46
|
+
child: () => logger,
|
|
47
|
+
};
|
|
48
|
+
return logger;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A probe that breaks its own contract: reading its `name` throws. */
|
|
52
|
+
const probeWhoseNameThrows = (thrown: Error): ReadinessProbe =>
|
|
53
|
+
({
|
|
54
|
+
get name(): string {
|
|
55
|
+
throw thrown;
|
|
56
|
+
},
|
|
57
|
+
check: async () => undefined,
|
|
58
|
+
}) as ReadinessProbe;
|
|
59
|
+
|
|
60
|
+
const host = (probes: readonly ReadinessProbe[]) => {
|
|
61
|
+
const logger = spyLogger();
|
|
62
|
+
const app = express();
|
|
63
|
+
mountRoutes(app, {
|
|
64
|
+
router: Router(),
|
|
65
|
+
probes,
|
|
66
|
+
readinessTimeoutMs: 1_000,
|
|
67
|
+
metrics: createMetrics(),
|
|
68
|
+
logger: logger as unknown as Logger,
|
|
69
|
+
});
|
|
70
|
+
return { app, logger };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Every call on a level other than `error`. */
|
|
74
|
+
const otherLevels = (logger: ReturnType<typeof spyLogger>) =>
|
|
75
|
+
[logger.trace, logger.debug, logger.info, logger.warn, logger.fatal].flatMap(
|
|
76
|
+
(level) => level.mock.calls,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
describe("the host's routes, mounted as the process mounts them", () => {
|
|
80
|
+
it("/_healthcheck answers 200, and nothing is logged", async () => {
|
|
81
|
+
const { app, logger } = host([]);
|
|
82
|
+
const res = await request(app).get("/_healthcheck");
|
|
83
|
+
expect(res.status).toBe(200);
|
|
84
|
+
expect(res.body).toEqual({ status: "ok" });
|
|
85
|
+
expect(logger.error).not.toHaveBeenCalled();
|
|
86
|
+
expect(otherLevels(logger)).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it.each(["/readyz", "/metrics"])(
|
|
90
|
+
"%s: an error it lets through is 500 server_error in the envelope, no-store, logged once with its projection",
|
|
91
|
+
async (path) => {
|
|
92
|
+
const { app, logger } = host([
|
|
93
|
+
probeWhoseNameThrows(new Error("probe registry at internal-host-marker is gone")),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const res = await request(app).get(path);
|
|
97
|
+
|
|
98
|
+
expect(res.status).toBe(500);
|
|
99
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
100
|
+
expect(res.headers["cache-control"]).toBe("no-store");
|
|
101
|
+
expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
|
|
102
|
+
expect(res.text).not.toContain("internal-host-marker");
|
|
103
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
104
|
+
const [fields, event, ...rest] = logger.error.mock.calls[0] as unknown[];
|
|
105
|
+
expect(event).toBe("unhandled_request_error");
|
|
106
|
+
expect(rest).toEqual([]);
|
|
107
|
+
expect(fields).toEqual({
|
|
108
|
+
endpoint: path,
|
|
109
|
+
err: expect.objectContaining({
|
|
110
|
+
name: "Error",
|
|
111
|
+
detail: "probe registry at internal-host-marker is gone",
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
114
|
+
expect((fields as { err: unknown }).err).not.toBeInstanceOf(Error);
|
|
115
|
+
expect(otherLevels(logger)).toEqual([]);
|
|
116
|
+
},
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
it("takes a 4xx status an error did not mark as the client's for the server's: 500, logged", async () => {
|
|
120
|
+
// A store's or a library's error can carry an HTTP status of its own —
|
|
121
|
+
// the answer it got — which is not this server's answer to the client.
|
|
122
|
+
const { app, logger } = host([
|
|
123
|
+
probeWhoseNameThrows(
|
|
124
|
+
Object.assign(new Error("the probe registry refused this deployment"), { status: 403 }),
|
|
125
|
+
),
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
const res = await request(app).get("/readyz");
|
|
129
|
+
|
|
130
|
+
expect(res.status).toBe(500);
|
|
131
|
+
expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
|
|
132
|
+
expect(logger.error).toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
134
|
+
{
|
|
135
|
+
endpoint: "/readyz",
|
|
136
|
+
err: expect.objectContaining({ name: "Error", status: 403 }),
|
|
137
|
+
},
|
|
138
|
+
"unhandled_request_error",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
* Starting the listener: one object-first `server_listening` line once the
|
|
19
|
+
* socket is bound — and none for a port that was never bound.
|
|
20
|
+
*
|
|
21
|
+
* The line used to be a template string, `Server is running on
|
|
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.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createServer, type Server } from "node:http";
|
|
32
|
+
import type { AddressInfo } from "node:net";
|
|
33
|
+
import type { Logger } from "@o3co/auth-provider-core";
|
|
34
|
+
import express from "express";
|
|
35
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
36
|
+
import { listen } from "#/listen.mjs";
|
|
37
|
+
|
|
38
|
+
/** A logger that records what each level is handed. */
|
|
39
|
+
function recordingLogger(): { logger: Logger; calls: Array<{ level: string; args: unknown[] }> } {
|
|
40
|
+
const calls: Array<{ level: string; args: unknown[] }> = [];
|
|
41
|
+
const record =
|
|
42
|
+
(level: string) =>
|
|
43
|
+
(...args: unknown[]): void => {
|
|
44
|
+
calls.push({ level, args });
|
|
45
|
+
};
|
|
46
|
+
const logger: Logger = {
|
|
47
|
+
trace: record("trace"),
|
|
48
|
+
debug: record("debug"),
|
|
49
|
+
info: record("info"),
|
|
50
|
+
warn: record("warn"),
|
|
51
|
+
error: record("error"),
|
|
52
|
+
fatal: record("fatal"),
|
|
53
|
+
child: () => logger,
|
|
54
|
+
};
|
|
55
|
+
return { logger, calls };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const opened: Server[] = [];
|
|
59
|
+
|
|
60
|
+
afterEach(async () => {
|
|
61
|
+
await Promise.all(
|
|
62
|
+
opened.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))),
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("listen", () => {
|
|
67
|
+
it("says server_listening once, object-first, with the port the socket holds", async () => {
|
|
68
|
+
const { logger, calls } = recordingLogger();
|
|
69
|
+
const server = await listen(express(), 0, logger);
|
|
70
|
+
opened.push(server);
|
|
71
|
+
const { port } = server.address() as AddressInfo;
|
|
72
|
+
expect(port).toBeGreaterThan(0);
|
|
73
|
+
expect(calls).toEqual([{ level: "info", args: [{ port }, "server_listening"] }]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("logs a server error after the bind — server_error, the projection — rather than swallowing it", async () => {
|
|
77
|
+
// Once the socket is bound, a later `error` (accept EMFILE) went to
|
|
78
|
+
// Express 5's spent listen callback and was lost — and the one after
|
|
79
|
+
// it, with no listener left, threw out of the process.
|
|
80
|
+
const { logger, calls } = recordingLogger();
|
|
81
|
+
const server = await listen(express(), 0, logger);
|
|
82
|
+
opened.push(server);
|
|
83
|
+
calls.length = 0;
|
|
84
|
+
|
|
85
|
+
const failure = Object.assign(new Error("accept EMFILE"), { code: "EMFILE" });
|
|
86
|
+
server.emit("error", failure);
|
|
87
|
+
server.emit("error", failure);
|
|
88
|
+
|
|
89
|
+
expect(calls).toEqual([
|
|
90
|
+
{
|
|
91
|
+
level: "error",
|
|
92
|
+
args: [{ err: expect.objectContaining({ name: "Error", code: "EMFILE" }) }, "server_error"],
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
level: "error",
|
|
96
|
+
args: [{ err: expect.objectContaining({ name: "Error", code: "EMFILE" }) }, "server_error"],
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
const line = calls[0]?.args[0] as { err: unknown };
|
|
100
|
+
expect(line.err).not.toBeInstanceOf(Error);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("leaves the bound server one error listener, server_error's: the bind's own is removed", async () => {
|
|
104
|
+
// The listener that turns a bind failure into a rejection has done its
|
|
105
|
+
// job once the socket is bound. Left attached, it would take every
|
|
106
|
+
// later error beside server_error's — a callback already spent.
|
|
107
|
+
const { logger } = recordingLogger();
|
|
108
|
+
const server = await listen(express(), 0, logger);
|
|
109
|
+
opened.push(server);
|
|
110
|
+
|
|
111
|
+
expect(server.listenerCount("error")).toBe(1);
|
|
112
|
+
// And nothing of `listen`'s own left on `listening`: an http.Server
|
|
113
|
+
// keeps one there itself (its connection tracking).
|
|
114
|
+
expect(server.listenerCount("listening")).toBe(createServer().listenerCount("listening"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("rejects with the server's error for a port already bound, and announces nothing", async () => {
|
|
118
|
+
const holder = createServer();
|
|
119
|
+
// Every interface, as `server.listen(port)` binds, so the two collide.
|
|
120
|
+
await new Promise<void>((resolve) => holder.listen(0, resolve));
|
|
121
|
+
opened.push(holder);
|
|
122
|
+
const { port } = holder.address() as AddressInfo;
|
|
123
|
+
|
|
124
|
+
const { logger, calls } = recordingLogger();
|
|
125
|
+
await expect(listen(express(), port, logger)).rejects.toMatchObject({ code: "EADDRINUSE" });
|
|
126
|
+
expect(calls).toEqual([]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
* What the template's own logger writes for a projected error — the line on
|
|
19
|
+
* stdout, from `createAppLogger` in a process of its own, as the service
|
|
20
|
+
* writes it. Every field of core's `loggableError` projection must reach
|
|
21
|
+
* it, at every level of the cause chain: the logger's `err` serializer must
|
|
22
|
+
* not take the projection for an Error and fold its causes into one message.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execFileSync } from "node:child_process";
|
|
26
|
+
import { createRequire } from "node:module";
|
|
27
|
+
import { fileURLToPath } from "node:url";
|
|
28
|
+
import { describe, expect, it } from "vitest";
|
|
29
|
+
|
|
30
|
+
const templateRoot = fileURLToPath(new URL("../..", import.meta.url));
|
|
31
|
+
const tsxCli = createRequire(import.meta.url).resolve("tsx/cli");
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A request failure caused by a Store failure caused by a parse failure,
|
|
35
|
+
* logged through `createAppLogger` in a child process; the line it wrote.
|
|
36
|
+
*/
|
|
37
|
+
const SCRIPT = `
|
|
38
|
+
const { createAppLogger } = await import("./src/logger.mts");
|
|
39
|
+
const { loggableError } = await import("@o3co/auth-provider-core");
|
|
40
|
+
let parse;
|
|
41
|
+
try { JSON.parse('{"a":1,'); } catch (err) { parse = err; }
|
|
42
|
+
const store = Object.assign(new Error("the Store could not be read", { cause: parse }), {
|
|
43
|
+
name: "StoreTransportError", code: "ECONNREFUSED", reason: "unreachable", storeStatus: 503,
|
|
44
|
+
});
|
|
45
|
+
const failed = Object.assign(new Error("request failed", { cause: store }), {
|
|
46
|
+
type: "upstream.failed", status: 502,
|
|
47
|
+
});
|
|
48
|
+
createAppLogger({ logging: { level: "info" } }).error({ err: loggableError(failed) }, "unhandled_request_error");
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
describe("createAppLogger writes a projected error whole", () => {
|
|
52
|
+
it("keeps every field of every level of the cause chain on the line", () => {
|
|
53
|
+
const stdout = execFileSync(
|
|
54
|
+
process.execPath,
|
|
55
|
+
[tsxCli, "--input-type=module", "--eval", SCRIPT],
|
|
56
|
+
{ cwd: templateRoot, encoding: "utf8" },
|
|
57
|
+
);
|
|
58
|
+
const line = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}") as {
|
|
59
|
+
msg?: string;
|
|
60
|
+
err?: Record<string, unknown>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
expect(line.msg).toBe("unhandled_request_error");
|
|
64
|
+
expect(line.err).toMatchObject({
|
|
65
|
+
name: "Error",
|
|
66
|
+
type: "upstream.failed",
|
|
67
|
+
status: 502,
|
|
68
|
+
cause: {
|
|
69
|
+
name: "StoreTransportError",
|
|
70
|
+
code: "ECONNREFUSED",
|
|
71
|
+
reason: "unreachable",
|
|
72
|
+
storeStatus: 503,
|
|
73
|
+
cause: { name: "SyntaxError", position: 7 },
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|