@o3co/create-auth-provider 0.15.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 +12 -0
- package/templates/standalone/Dockerfile +1 -1
- package/templates/standalone/README.ja.md +516 -154
- package/templates/standalone/README.md +218 -91
- package/templates/standalone/config/application.conf +26 -3
- package/templates/standalone/gitignore +3 -2
- package/templates/standalone/package.json +1 -1
- 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 +19 -5
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +64 -3
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +15 -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 +28 -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__/shutdown.test.mts +157 -20
- 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 +21 -50
- package/templates/standalone/src/buildModules.mts +33 -16
- package/templates/standalone/src/listen.mts +66 -0
- package/templates/standalone/src/logger.mts +7 -4
- package/templates/standalone/src/modules.mts +42 -8
- package/templates/standalone/src/routes.mts +95 -0
- package/templates/standalone/src/shutdown.mts +20 -14
- package/templates/versions.json +15 -15
- package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
- package/templates/standalone/src/terminalError.mts +0 -60
|
@@ -0,0 +1,139 @@
|
|
|
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 shared Redis connection logs when the server refuses
|
|
19
|
+
* it: the real ioredis the template ships, against a TCP server that answers
|
|
20
|
+
* the way Redis answers a password it no longer accepts. ioredis puts the
|
|
21
|
+
* handshake it sent — `AUTH` and the password — on the error it emits, so
|
|
22
|
+
* the `error` listener must log core's `loggableError` projection, never
|
|
23
|
+
* the error. The logger here serialises every own property of what it is
|
|
24
|
+
* handed, as a deployment's logger may.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { once } from "node:events";
|
|
28
|
+
import { type AddressInfo, createServer, type Socket } from "node:net";
|
|
29
|
+
import type { LifecycleRegistrar, Logger } from "@o3co/auth-provider-core";
|
|
30
|
+
import { describe, expect, it, vi } from "vitest";
|
|
31
|
+
import { standaloneRedisClientsModule } from "#/modules.mjs";
|
|
32
|
+
|
|
33
|
+
/** Redis's reply to a handshake whose password it does not accept. */
|
|
34
|
+
const WRONGPASS = "-WRONGPASS invalid username-password pair or user is disabled.\r\n";
|
|
35
|
+
|
|
36
|
+
/** The password the deployment is configured with, and the server no longer takes. */
|
|
37
|
+
const STALE_PASSWORD = "stale-s3cret-redis-password";
|
|
38
|
+
|
|
39
|
+
/** A TCP server that answers everything sent to it as Redis answers a refused password. */
|
|
40
|
+
async function refusingRedis(): Promise<{ port: number; close: () => Promise<void> }> {
|
|
41
|
+
const sockets = new Set<Socket>();
|
|
42
|
+
const server = createServer((socket) => {
|
|
43
|
+
sockets.add(socket);
|
|
44
|
+
socket.on("close", () => sockets.delete(socket));
|
|
45
|
+
socket.on("data", () => {
|
|
46
|
+
socket.write(WRONGPASS);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
server.listen(0, "127.0.0.1");
|
|
50
|
+
await once(server, "listening");
|
|
51
|
+
const { port } = server.address() as AddressInfo;
|
|
52
|
+
return {
|
|
53
|
+
port,
|
|
54
|
+
close: async () => {
|
|
55
|
+
for (const socket of sockets) socket.destroy();
|
|
56
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A logger that serialises every own property of what it is handed, `cause`
|
|
63
|
+
* and non-enumerable fields included. `lines` is what it wrote; its levels
|
|
64
|
+
* are spies.
|
|
65
|
+
*/
|
|
66
|
+
const serialiseEverythingLogger = () => {
|
|
67
|
+
const lines: string[] = [];
|
|
68
|
+
const walk = (value: unknown, seen = new WeakSet<object>()): unknown => {
|
|
69
|
+
if (typeof value !== "object" || value === null) return value;
|
|
70
|
+
if (seen.has(value)) return "[circular]";
|
|
71
|
+
seen.add(value);
|
|
72
|
+
const out: Record<string, unknown> = {};
|
|
73
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
74
|
+
out[key] = walk((value as Record<string, unknown>)[key], seen);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
const record = (level: string) =>
|
|
79
|
+
vi.fn((...args: unknown[]): void => {
|
|
80
|
+
lines.push(JSON.stringify({ level, args: walk(args) }));
|
|
81
|
+
});
|
|
82
|
+
const logger = {
|
|
83
|
+
trace: record("trace"),
|
|
84
|
+
debug: record("debug"),
|
|
85
|
+
info: record("info"),
|
|
86
|
+
warn: record("warn"),
|
|
87
|
+
error: record("error"),
|
|
88
|
+
fatal: record("fatal"),
|
|
89
|
+
child: () => logger,
|
|
90
|
+
};
|
|
91
|
+
return { logger: logger as typeof logger & Logger, lines };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
describe("standaloneRedisClientsModule: the shared connection refused by the server", () => {
|
|
95
|
+
it("logs the connection's error as loggableError's projection, never the AUTH arguments ioredis puts on it", async () => {
|
|
96
|
+
const redis = await refusingRedis();
|
|
97
|
+
const cleanups: Array<() => Promise<void>> = [];
|
|
98
|
+
const lifecycleRegistrar: LifecycleRegistrar = {
|
|
99
|
+
register: (cleanup) => {
|
|
100
|
+
cleanups.push(cleanup);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
const { logger, lines } = serialiseEverythingLogger();
|
|
104
|
+
const provides = (
|
|
105
|
+
standaloneRedisClientsModule as unknown as {
|
|
106
|
+
provides: Record<string, (deps: Record<string, unknown>) => Promise<unknown>>;
|
|
107
|
+
}
|
|
108
|
+
).provides;
|
|
109
|
+
try {
|
|
110
|
+
await provides.refreshTokenFamilyClient({
|
|
111
|
+
config: {
|
|
112
|
+
refreshTokenFamilyStore: {
|
|
113
|
+
redis: { url: `redis://127.0.0.1:${redis.port}`, password: STALE_PASSWORD },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
lifecycleRegistrar,
|
|
117
|
+
logger,
|
|
118
|
+
});
|
|
119
|
+
await vi.waitFor(() => expect(logger.error).toHaveBeenCalled(), { timeout: 10_000 });
|
|
120
|
+
} finally {
|
|
121
|
+
// `io.quit()`, as `handle.dispose()` runs it.
|
|
122
|
+
await Promise.all(cleanups.map((cleanup) => cleanup().catch(() => {})));
|
|
123
|
+
await redis.close();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
127
|
+
{
|
|
128
|
+
err: expect.objectContaining({
|
|
129
|
+
name: "ReplyError",
|
|
130
|
+
detail: expect.stringMatching(/^WRONGPASS /),
|
|
131
|
+
// Which command the server refused, and nothing it carried.
|
|
132
|
+
command: { name: "hello" },
|
|
133
|
+
}),
|
|
134
|
+
},
|
|
135
|
+
"standalone_redis_clients_error",
|
|
136
|
+
);
|
|
137
|
+
for (const line of lines) expect(line).not.toContain(STALE_PASSWORD);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -510,12 +510,22 @@ describe("#473: the Redis federation store's plaintext guard, booted from the sh
|
|
|
510
510
|
const config = resolveConfig({ ...PLAINTEXT_ENV, DEPLOYMENT_MODE: "single" });
|
|
511
511
|
handleRef = await boot(config, "development");
|
|
512
512
|
expect(handleRef.components.federationTokenStore?.kind).toBe("redis");
|
|
513
|
-
expect(warnSpy).toHaveBeenCalledWith(
|
|
513
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
514
|
+
{ store: "federation-tokens", mode: "allow-plaintext" },
|
|
515
|
+
"federation_store_plaintext",
|
|
516
|
+
);
|
|
514
517
|
});
|
|
515
518
|
|
|
516
|
-
it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, at
|
|
519
|
+
it("keeps the FEDERATION_TOKENS_ALLOW_INSECURE=1 escape hatch under multi, logged at error", async () => {
|
|
517
520
|
process.env.FEDERATION_TOKENS_ALLOW_INSECURE = "1";
|
|
518
521
|
handleRef = await boot(resolveConfig(PLAINTEXT_ENV), "development");
|
|
519
|
-
expect(errorSpy).toHaveBeenCalledWith(
|
|
522
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
523
|
+
expect.objectContaining({
|
|
524
|
+
store: "federation-tokens",
|
|
525
|
+
deploymentMode: "multi",
|
|
526
|
+
override: "FEDERATION_TOKENS_ALLOW_INSECURE",
|
|
527
|
+
}),
|
|
528
|
+
"federation_store_plaintext_override",
|
|
529
|
+
);
|
|
520
530
|
});
|
|
521
531
|
});
|
|
@@ -0,0 +1,149 @@
|
|
|
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's composition, booted through core's `createApp` and mounted
|
|
19
|
+
* with no terminal handler of the template's after it — the router alone, as
|
|
20
|
+
* a composition root that copies nothing of `app.mts` mounts it.
|
|
21
|
+
*
|
|
22
|
+
* The OAuth and session routers parse their own bodies and have no error
|
|
23
|
+
* handler: a body parser's refusal on `/oauth/token`, `/oauth/introspect` or
|
|
24
|
+
* `/session/login`, and any error a route let through, went on to whatever
|
|
25
|
+
* the host had after the router. Only `app.mts` had a handler there, so any
|
|
26
|
+
* other host answered with Express's final handler — an HTML page, with the
|
|
27
|
+
* stack outside production, where V8's JSON error quotes the body it could
|
|
28
|
+
* not parse. The router core returns now answers them itself.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { defineModule } from "@o3co/auth-provider-core";
|
|
32
|
+
import { Router } from "express";
|
|
33
|
+
import request from "supertest";
|
|
34
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
35
|
+
import {
|
|
36
|
+
basic,
|
|
37
|
+
type Composition,
|
|
38
|
+
compose,
|
|
39
|
+
JSON_TYPE,
|
|
40
|
+
KIB,
|
|
41
|
+
M2M,
|
|
42
|
+
padJson,
|
|
43
|
+
} from "./all-modules-composition.fixture.mjs";
|
|
44
|
+
|
|
45
|
+
let current: Composition | undefined;
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
await current?.handle.dispose();
|
|
49
|
+
current = undefined;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** A route that lets an error through, contributed as a deployment adds one. */
|
|
53
|
+
const throwingRouteModule = defineModule({
|
|
54
|
+
name: "test:throwing-route",
|
|
55
|
+
contributes: {
|
|
56
|
+
routes: [
|
|
57
|
+
() => {
|
|
58
|
+
const router = Router();
|
|
59
|
+
router.get("/", () => {
|
|
60
|
+
throw new Error("a store said something: escaped-error-marker");
|
|
61
|
+
});
|
|
62
|
+
return { id: "test:throwing-route", mountPath: "/throws", handler: router };
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/** The composition, the router alone mounted. */
|
|
69
|
+
const boot = async (): Promise<Composition> => {
|
|
70
|
+
current = await compose({
|
|
71
|
+
terminalErrorHandler: false,
|
|
72
|
+
extraModules: () => [throwingRouteModule],
|
|
73
|
+
});
|
|
74
|
+
return current;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The routes that parse a body, and what each needs to reach its parser. */
|
|
78
|
+
const PARSING_ROUTES = [
|
|
79
|
+
["/oauth/token", { authorization: basic(M2M) }],
|
|
80
|
+
["/oauth/introspect", { authorization: basic(M2M) }],
|
|
81
|
+
["/session/login", {}],
|
|
82
|
+
] as const;
|
|
83
|
+
|
|
84
|
+
const errorLines = (c: Composition) => c.logger.lines.filter((line) => line.level === "error");
|
|
85
|
+
|
|
86
|
+
describe("the composed router, mounted alone, answers a body parser's refusal", () => {
|
|
87
|
+
it.each(PARSING_ROUTES)(
|
|
88
|
+
"%s: malformed JSON is 400 in the envelope, nothing logged",
|
|
89
|
+
async (path, headers) => {
|
|
90
|
+
const c = await boot();
|
|
91
|
+
const res = await request(c.app)
|
|
92
|
+
.post(path)
|
|
93
|
+
.set(headers)
|
|
94
|
+
.set("Content-Type", JSON_TYPE)
|
|
95
|
+
.send('{"password":"body-secret-marker');
|
|
96
|
+
|
|
97
|
+
expect(res.status).toBe(400);
|
|
98
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
99
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "malformed_body" });
|
|
100
|
+
expect(res.text).not.toContain("body-secret-marker");
|
|
101
|
+
expect(res.text).not.toMatch(/ at /);
|
|
102
|
+
expect(errorLines(c)).toEqual([]);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
it.each(PARSING_ROUTES)(
|
|
107
|
+
"%s: a body over the parser's limit is 413 in the envelope, nothing logged",
|
|
108
|
+
async (path, headers) => {
|
|
109
|
+
const c = await boot();
|
|
110
|
+
const res = await request(c.app)
|
|
111
|
+
.post(path)
|
|
112
|
+
.set(headers)
|
|
113
|
+
.set("Content-Type", JSON_TYPE)
|
|
114
|
+
.send(padJson(200 * KIB));
|
|
115
|
+
|
|
116
|
+
expect(res.status).toBe(413);
|
|
117
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
118
|
+
expect(res.body).toEqual({ error: "invalid_request", error_description: "body_too_large" });
|
|
119
|
+
expect(errorLines(c)).toEqual([]);
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("the composed router, mounted alone, answers an error a route let through", () => {
|
|
125
|
+
it("is 500 server_error in the envelope, logged once at error", async () => {
|
|
126
|
+
const c = await boot();
|
|
127
|
+
const res = await request(c.app).get("/throws");
|
|
128
|
+
|
|
129
|
+
expect(res.status).toBe(500);
|
|
130
|
+
expect(res.headers["content-type"]).toMatch(/^application\/json/);
|
|
131
|
+
expect(res.body).toEqual({ error: "server_error", error_description: "unexpected_error" });
|
|
132
|
+
expect(res.text).not.toContain("escaped-error-marker");
|
|
133
|
+
expect(errorLines(c)).toEqual([
|
|
134
|
+
{
|
|
135
|
+
level: "error",
|
|
136
|
+
args: [
|
|
137
|
+
{
|
|
138
|
+
endpoint: "/throws",
|
|
139
|
+
err: expect.objectContaining({
|
|
140
|
+
name: "Error",
|
|
141
|
+
detail: "a store said something: escaped-error-marker",
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
"unhandled_request_error",
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -30,8 +30,15 @@
|
|
|
30
30
|
* and these tests are the contract the issue said was missing.
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
|
-
import type
|
|
34
|
-
import
|
|
33
|
+
import { createServer, type Server } from "node:http";
|
|
34
|
+
import {
|
|
35
|
+
type BootstrapMap,
|
|
36
|
+
createApp,
|
|
37
|
+
createMemoryReplaySeenSet,
|
|
38
|
+
defineModule,
|
|
39
|
+
type Logger,
|
|
40
|
+
} from "@o3co/auth-provider-core";
|
|
41
|
+
import { makeValidCoreConfig } from "@o3co/auth-provider-core/testing";
|
|
35
42
|
import { describe, expect, it, vi } from "vitest";
|
|
36
43
|
import {
|
|
37
44
|
cleanupAllowanceFor,
|
|
@@ -85,16 +92,53 @@ const makeLogger = () => {
|
|
|
85
92
|
return spy satisfies Logger;
|
|
86
93
|
};
|
|
87
94
|
|
|
95
|
+
/**
|
|
96
|
+
* A logger that serialises every own property of what it is handed, `cause`
|
|
97
|
+
* and non-enumerable fields included — a deployment is free to install one.
|
|
98
|
+
* `lines` is what it wrote; its levels are spies.
|
|
99
|
+
*/
|
|
100
|
+
const serialiseEverythingLogger = () => {
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
const walk = (value: unknown, seen = new WeakSet<object>()): unknown => {
|
|
103
|
+
if (typeof value !== "object" || value === null) return value;
|
|
104
|
+
if (seen.has(value)) return "[circular]";
|
|
105
|
+
seen.add(value);
|
|
106
|
+
const out: Record<string, unknown> = {};
|
|
107
|
+
for (const key of Object.getOwnPropertyNames(value)) {
|
|
108
|
+
out[key] = walk((value as Record<string, unknown>)[key], seen);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
};
|
|
112
|
+
const record = (level: string) =>
|
|
113
|
+
vi.fn((...args: unknown[]): void => {
|
|
114
|
+
lines.push(JSON.stringify({ level, args: walk(args) }));
|
|
115
|
+
});
|
|
116
|
+
const logger = {
|
|
117
|
+
trace: record("trace"),
|
|
118
|
+
debug: record("debug"),
|
|
119
|
+
info: record("info"),
|
|
120
|
+
warn: record("warn"),
|
|
121
|
+
error: record("error"),
|
|
122
|
+
fatal: record("fatal"),
|
|
123
|
+
child: vi.fn(() => logger as unknown as Logger),
|
|
124
|
+
};
|
|
125
|
+
return { logger: logger satisfies Logger, lines };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** A logged projection's `stack`: frames only, from the first. */
|
|
129
|
+
const FRAMES = expect.stringMatching(/^ {4}at /);
|
|
130
|
+
|
|
88
131
|
/** Drive one shutdown without touching the real `process` or exiting. */
|
|
89
132
|
function install(
|
|
90
133
|
opts: {
|
|
91
134
|
cleanup?: () => void | Promise<void>;
|
|
92
135
|
drainTimeoutMs?: number;
|
|
93
136
|
cleanupTimeoutMs?: number;
|
|
137
|
+
logger?: ReturnType<typeof makeLogger>;
|
|
94
138
|
} = {},
|
|
95
139
|
) {
|
|
96
140
|
const { server, spies, finishDraining, failClose } = makeServer();
|
|
97
|
-
const logger = makeLogger();
|
|
141
|
+
const logger = opts.logger ?? makeLogger();
|
|
98
142
|
const exit = vi.fn();
|
|
99
143
|
const signals = new Map<string, () => void>();
|
|
100
144
|
|
|
@@ -172,7 +216,12 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
172
216
|
signals.get("SIGTERM")?.();
|
|
173
217
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
174
218
|
expect(exit).toHaveBeenCalledWith(1);
|
|
175
|
-
|
|
219
|
+
// Each stage is an event: object-first, a snake_case name.
|
|
220
|
+
expect(logger.info).toHaveBeenCalledWith({ drainTimeoutMs: 5_000 }, "shutdown_draining");
|
|
221
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
222
|
+
{ drainTimeoutMs: 5_000 },
|
|
223
|
+
"shutdown_drain_deadline_exceeded",
|
|
224
|
+
);
|
|
176
225
|
} finally {
|
|
177
226
|
vi.useRealTimers();
|
|
178
227
|
}
|
|
@@ -193,21 +242,91 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
193
242
|
}
|
|
194
243
|
});
|
|
195
244
|
|
|
196
|
-
it("reports a cleanup failure through the app logger, not console", async () => {
|
|
245
|
+
it("reports a cleanup failure through the app logger, not console, as loggableError's projection", async () => {
|
|
197
246
|
// Every other line this service emits is NDJSON through pino; a bare
|
|
198
247
|
// console.error on the shutdown path is the one a log pipeline drops.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
248
|
+
// What a real `handle.dispose()` rejects with is an AggregateError of
|
|
249
|
+
// every cleanup's own error. The line names each member and its code,
|
|
250
|
+
// and carries nothing of what they hold: an OAuth library's refusal
|
|
251
|
+
// keeps the refresh answer it refused on a non-Error cause, an ioredis
|
|
252
|
+
// reply the write it refused.
|
|
253
|
+
const refusedRefresh = Object.assign(
|
|
254
|
+
new Error("invalid response encountered", {
|
|
255
|
+
cause: { body: { refresh_token: "1//0g-UPSTREAM-S3CRET" } },
|
|
256
|
+
}),
|
|
257
|
+
{ name: "ClientError", code: "OAUTH_INVALID_RESPONSE" },
|
|
258
|
+
);
|
|
259
|
+
const refusedWrite = Object.assign(
|
|
260
|
+
new Error("READONLY You can't write against a read only replica."),
|
|
261
|
+
{
|
|
262
|
+
name: "ReplyError",
|
|
263
|
+
command: { name: "set", args: ["fg:credential:grant-1", "1//0g-ARGS-S3CRET"] },
|
|
264
|
+
},
|
|
265
|
+
);
|
|
266
|
+
const failingCleanups = defineModule({
|
|
267
|
+
name: "test:failing-cleanups",
|
|
268
|
+
provides: {
|
|
269
|
+
replaySeenSet: () => createMemoryReplaySeenSet(),
|
|
270
|
+
challengeStore: () => ({}) as never,
|
|
271
|
+
},
|
|
272
|
+
lifecycle: {
|
|
273
|
+
replaySeenSet: {
|
|
274
|
+
eager: true,
|
|
275
|
+
cleanup: () => {
|
|
276
|
+
throw refusedRefresh;
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
challengeStore: {
|
|
280
|
+
eager: true,
|
|
281
|
+
cleanup: () => {
|
|
282
|
+
throw refusedWrite;
|
|
283
|
+
},
|
|
284
|
+
},
|
|
202
285
|
},
|
|
203
286
|
});
|
|
287
|
+
const handle = await createApp({
|
|
288
|
+
modules: [failingCleanups],
|
|
289
|
+
bootstrapComponents: {
|
|
290
|
+
config: makeValidCoreConfig(),
|
|
291
|
+
pathResolver: (path: string) => path,
|
|
292
|
+
} as unknown as BootstrapMap,
|
|
293
|
+
});
|
|
294
|
+
const { logger, lines } = serialiseEverythingLogger();
|
|
295
|
+
const { exit, signals, finishDraining } = install({
|
|
296
|
+
logger,
|
|
297
|
+
cleanup: () => handle.dispose(),
|
|
298
|
+
});
|
|
204
299
|
signals.get("SIGTERM")?.();
|
|
205
300
|
finishDraining();
|
|
206
301
|
await vi.waitFor(() => expect(exit).toHaveBeenCalled());
|
|
207
302
|
expect(logger.error).toHaveBeenCalledWith(
|
|
208
|
-
|
|
209
|
-
|
|
303
|
+
{
|
|
304
|
+
err: {
|
|
305
|
+
name: "AggregateError",
|
|
306
|
+
detail: expect.stringMatching(/^AppHandle\.dispose: 2 cleanup errors /),
|
|
307
|
+
stack: FRAMES,
|
|
308
|
+
aggregateErrors: expect.arrayContaining([
|
|
309
|
+
{
|
|
310
|
+
name: "ClientError",
|
|
311
|
+
detail: "invalid response encountered",
|
|
312
|
+
code: "OAUTH_INVALID_RESPONSE",
|
|
313
|
+
stack: FRAMES,
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
name: "ReplyError",
|
|
317
|
+
detail: "READONLY You can't write against a read only replica.",
|
|
318
|
+
command: { name: "set" },
|
|
319
|
+
stack: FRAMES,
|
|
320
|
+
},
|
|
321
|
+
]),
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
"shutdown_cleanup_failed",
|
|
210
325
|
);
|
|
326
|
+
for (const line of lines) {
|
|
327
|
+
expect(line).not.toContain("S3CRET");
|
|
328
|
+
expect(line).not.toContain("refresh_token");
|
|
329
|
+
}
|
|
211
330
|
});
|
|
212
331
|
|
|
213
332
|
it("still exits when cleanup throws — a failed dispose must not wedge the process", async () => {
|
|
@@ -221,19 +340,37 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
221
340
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
|
222
341
|
});
|
|
223
342
|
|
|
224
|
-
it("does not report a failed close as a clean drain", async () => {
|
|
343
|
+
it("does not report a failed close as a clean drain, and logs the failure as loggableError's projection", async () => {
|
|
225
344
|
// `server.close` reports through its callback -- "Server is not running"
|
|
226
345
|
// is the common one, but any listener teardown failure lands there.
|
|
227
346
|
// Exiting 0 on it would tell an orchestrator the listener came down
|
|
228
|
-
// when it did not.
|
|
229
|
-
|
|
347
|
+
// when it did not. A real server that is not listening gives Node's
|
|
348
|
+
// own error; the line keeps its name, message, code and frames.
|
|
349
|
+
const server = createServer();
|
|
350
|
+
const { logger, lines } = serialiseEverythingLogger();
|
|
351
|
+
const exit = vi.fn();
|
|
352
|
+
const signals = new Map<string, () => void>();
|
|
353
|
+
installGracefulShutdown(server, {
|
|
354
|
+
logger,
|
|
355
|
+
exit,
|
|
356
|
+
onSignal: (name, handler) => signals.set(name, handler),
|
|
357
|
+
offSignal: (name) => signals.delete(name),
|
|
358
|
+
});
|
|
230
359
|
signals.get("SIGTERM")?.();
|
|
231
|
-
failClose(new Error("Server is not running"));
|
|
232
360
|
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1));
|
|
233
361
|
expect(logger.error).toHaveBeenCalledWith(
|
|
234
|
-
|
|
235
|
-
|
|
362
|
+
{
|
|
363
|
+
err: {
|
|
364
|
+
name: "Error",
|
|
365
|
+
detail: "Server is not running.",
|
|
366
|
+
code: "ERR_SERVER_NOT_RUNNING",
|
|
367
|
+
stack: FRAMES,
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
"shutdown_server_close_failed",
|
|
236
371
|
);
|
|
372
|
+
// The frames, never the header line that repeats the message.
|
|
373
|
+
for (const line of lines) expect(line).not.toContain("Error [ERR_SERVER_NOT_RUNNING]");
|
|
237
374
|
});
|
|
238
375
|
|
|
239
376
|
it("still runs cleanup when close reports a failure", async () => {
|
|
@@ -263,8 +400,8 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
263
400
|
finishDraining();
|
|
264
401
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
265
402
|
expect(logger.error).toHaveBeenCalledWith(
|
|
266
|
-
|
|
267
|
-
|
|
403
|
+
{ cleanupTimeoutMs: 5_000 },
|
|
404
|
+
"shutdown_cleanup_timed_out",
|
|
268
405
|
);
|
|
269
406
|
expect(exit).toHaveBeenCalledWith(1);
|
|
270
407
|
} finally {
|
|
@@ -311,7 +448,7 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
311
448
|
await new Promise((resolve) => setImmediate(resolve));
|
|
312
449
|
expect(logger.info).toHaveBeenCalledWith(
|
|
313
450
|
{ reason: "cleanup-failed", drain: "drained", exitCode: 1 },
|
|
314
|
-
"
|
|
451
|
+
"shutdown_complete",
|
|
315
452
|
);
|
|
316
453
|
expect(exit).toHaveBeenCalledWith(1);
|
|
317
454
|
});
|
|
@@ -323,7 +460,7 @@ describe("installGracefulShutdown (#290)", () => {
|
|
|
323
460
|
await new Promise((resolve) => setImmediate(resolve));
|
|
324
461
|
expect(logger.info).toHaveBeenCalledWith(
|
|
325
462
|
{ reason: "drained", drain: "drained", exitCode: 0 },
|
|
326
|
-
"
|
|
463
|
+
"shutdown_complete",
|
|
327
464
|
);
|
|
328
465
|
});
|
|
329
466
|
|
|
@@ -618,27 +618,46 @@ describe("standalone smoke test", () => {
|
|
|
618
618
|
expect(names).toContain("standalone:redis-clients");
|
|
619
619
|
});
|
|
620
620
|
|
|
621
|
-
it("buildModules honors legacy repositories.code.type='redis' with deprecation warn when oauth.code.adapter is absent", () => {
|
|
622
|
-
const
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
621
|
+
it("buildModules honors legacy repositories.code.type='redis' with one object-first deprecation warn when oauth.code.adapter is absent", () => {
|
|
622
|
+
const warn = vi.fn();
|
|
623
|
+
const logger = {
|
|
624
|
+
trace: vi.fn(),
|
|
625
|
+
debug: vi.fn(),
|
|
626
|
+
info: vi.fn(),
|
|
627
|
+
warn,
|
|
628
|
+
error: vi.fn(),
|
|
629
|
+
fatal: vi.fn(),
|
|
630
|
+
child: () => logger,
|
|
631
|
+
};
|
|
632
|
+
const legacyConfig = {
|
|
633
|
+
...config,
|
|
634
|
+
oauth: { ...config.oauth, code: undefined },
|
|
635
|
+
repositories: {
|
|
636
|
+
...config.repositories,
|
|
637
|
+
code: { type: "redis" as const, defaultExpiresIn: 600 },
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
const modules = buildModules(legacyConfig, {
|
|
641
|
+
keyStoreModule: testKeyStoreModule,
|
|
642
|
+
repositoriesModule: testRepositoriesModule,
|
|
643
|
+
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
644
|
+
logger,
|
|
645
|
+
});
|
|
646
|
+
const names = modules.map((m) => m.name);
|
|
647
|
+
expect(names).toContain("redis-code-repository");
|
|
648
|
+
expect(warn.mock.calls).toEqual([
|
|
649
|
+
[
|
|
650
|
+
{
|
|
651
|
+
key: "repositories.code.type",
|
|
652
|
+
env: "CLIENT_CODE_TYPE",
|
|
653
|
+
replacement: "oauth.code.adapter",
|
|
654
|
+
replacementEnv: "OAUTH_CODE_ADAPTER",
|
|
630
655
|
},
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
});
|
|
637
|
-
const names = modules.map((m) => m.name);
|
|
638
|
-
expect(names).toContain("redis-code-repository");
|
|
639
|
-
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("repositories.code.type"));
|
|
640
|
-
} finally {
|
|
641
|
-
warnSpy.mockRestore();
|
|
656
|
+
"config_key_deprecated",
|
|
657
|
+
],
|
|
658
|
+
]);
|
|
659
|
+
for (const level of ["trace", "debug", "info", "error", "fatal"] as const) {
|
|
660
|
+
expect(logger[level]).not.toHaveBeenCalled();
|
|
642
661
|
}
|
|
643
662
|
});
|
|
644
663
|
|