@lenne.tech/nest-server 11.31.3 → 11.32.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/.claude/rules/versioning.md +5 -8
- package/CLAUDE.md +3 -3
- package/FRAMEWORK-API.md +4 -2
- package/README.md +1 -0
- package/dist/core/common/helpers/process-diagnostics.helper.d.ts +18 -0
- package/dist/core/common/helpers/process-diagnostics.helper.js +88 -0
- package/dist/core/common/helpers/process-diagnostics.helper.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +3 -0
- package/dist/core/common/services/brevo.service.d.ts +7 -1
- package/dist/core/common/services/brevo.service.js +37 -16
- package/dist/core/common/services/brevo.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +9 -4
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +5 -2
- package/dist/main.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +1 -0
- package/docs/brevo-manual-test.md +166 -0
- package/docs/security-overrides.md +90 -0
- package/migration-guides/11.31.3-to-11.32.0.md +254 -0
- package/migration-guides/11.32.0-to-11.32.1.md +84 -0
- package/package.json +15 -14
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +310 -0
- package/src/core/common/helpers/process-diagnostics.helper.ts +321 -0
- package/src/core/common/interfaces/server-options.interface.ts +32 -0
- package/src/core/common/services/brevo.service.spec.ts +266 -0
- package/src/core/common/services/brevo.service.ts +100 -17
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +14 -4
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +8 -1
- package/src/core/modules/migrate/migration-runner.ts +17 -0
- package/src/index.ts +1 -0
- package/src/main.ts +22 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.32.1",
|
|
4
4
|
"description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"node",
|
|
@@ -23,20 +23,21 @@
|
|
|
23
23
|
"build:dev": "pnpm run build",
|
|
24
24
|
"c": "pnpm run check",
|
|
25
25
|
"check": "node scripts/check.mjs",
|
|
26
|
-
"check:raw": "pnpm install --frozen-lockfile && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
27
|
-
"check:fix": "pnpm install && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
28
|
-
"check:naf": "pnpm install && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
26
|
+
"check:raw": "pnpm install --frozen-lockfile && pnpm run spectaql:sync && pnpm audit && pnpm run format:check && pnpm run lint && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
27
|
+
"check:fix": "pnpm install && pnpm run spectaql:sync && pnpm audit --fix && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
28
|
+
"check:naf": "pnpm install && pnpm run spectaql:sync && pnpm run format && pnpm run lint:fix && pnpm run typecheck:tests && pnpm run check:swc-tdz && pnpm test && pnpm run build && pnpm run check:manifest && bash scripts/check-server-start.sh",
|
|
29
29
|
"check:manifest": "node scripts/check-package-manifest.mjs",
|
|
30
30
|
"check:swc-tdz": "nest build -b swc -p tsconfig.swc-tdz.json && node scripts/check-swc-tdz.mjs",
|
|
31
|
+
"spectaql:sync": "node extras/update-spectaql-version.mjs",
|
|
31
32
|
"cf": "pnpm run check:fix",
|
|
32
33
|
"cnaf": "pnpm run check:naf",
|
|
33
34
|
"docs": "pnpm run docs:ci && open http://127.0.0.1:8080/ && open ./public/index.html && compodoc -p tsconfig.json -s ",
|
|
34
35
|
"docs:bootstrap": "node extras/update-spectaql-version.mjs && node scripts/run-spectaql.mjs",
|
|
35
36
|
"docs:ci": "ts-node ./scripts/init-server.ts && pnpm run docs:bootstrap && compodoc -p tsconfig.json",
|
|
36
|
-
"format": "oxfmt --write src/",
|
|
37
|
-
"format:check": "oxfmt --check src/",
|
|
38
|
-
"lint": "oxlint --ignore-path .oxlintignore src/ tests/",
|
|
39
|
-
"lint:fix": "oxlint --fix --fix-suggestions --ignore-path .oxlintignore src/ tests/",
|
|
37
|
+
"format": "oxfmt --write src/ scripts/",
|
|
38
|
+
"format:check": "oxfmt --check src/ scripts/",
|
|
39
|
+
"lint": "oxlint --ignore-path .oxlintignore src/ tests/ scripts/",
|
|
40
|
+
"lint:fix": "oxlint --fix --fix-suggestions --ignore-path .oxlintignore src/ tests/ scripts/",
|
|
40
41
|
"prestart:prod": "pnpm run build",
|
|
41
42
|
"reinit": "rimraf pnpm-lock.yaml && rimraf node_modules && pnpm install && pnpm run lint && pnpm run test:e2e && pnpm run test:ci && pnpm run build",
|
|
42
43
|
"reinit:clean": "rimraf pnpm-lock.yaml && rimraf node_modules && pnpm store prune && pnpm install && pnpm run test:e2e && pnpm run build",
|
|
@@ -85,7 +86,7 @@
|
|
|
85
86
|
"@apollo/server": "5.5.1",
|
|
86
87
|
"@as-integrations/express5": "1.1.2",
|
|
87
88
|
"@better-auth/passkey": "1.6.23",
|
|
88
|
-
"@getbrevo/brevo": "
|
|
89
|
+
"@getbrevo/brevo": "6.0.2",
|
|
89
90
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
90
91
|
"@nestjs/apollo": "13.4.2",
|
|
91
92
|
"@nestjs/common": "11.1.28",
|
|
@@ -99,7 +100,7 @@
|
|
|
99
100
|
"@nestjs/swagger": "11.4.6",
|
|
100
101
|
"@nestjs/terminus": "11.1.1",
|
|
101
102
|
"@tus/file-store": "2.1.0",
|
|
102
|
-
"@tus/server": "2.4.
|
|
103
|
+
"@tus/server": "2.4.2",
|
|
103
104
|
"@types/supertest": "7.2.1",
|
|
104
105
|
"bcrypt": "6.0.0",
|
|
105
106
|
"better-auth": "1.6.23",
|
|
@@ -117,11 +118,11 @@
|
|
|
117
118
|
"graphql-upload": "15.0.2",
|
|
118
119
|
"graphql-ws": "6.1.0",
|
|
119
120
|
"jose": "6.2.3",
|
|
120
|
-
"js-sha256": "0.
|
|
121
|
+
"js-sha256": "0.12.0",
|
|
121
122
|
"json-to-graphql-query": "2.3.0",
|
|
122
123
|
"lodash": "4.18.1",
|
|
123
|
-
"mongodb": "7.
|
|
124
|
-
"mongoose": "9.
|
|
124
|
+
"mongodb": "7.5.0",
|
|
125
|
+
"mongoose": "9.8.0",
|
|
125
126
|
"multer": "2.2.0",
|
|
126
127
|
"node-mailjet": "6.0.11",
|
|
127
128
|
"nodemailer": "9.0.3",
|
|
@@ -141,7 +142,7 @@
|
|
|
141
142
|
"@nestjs/schematics": "11.1.0",
|
|
142
143
|
"@nestjs/testing": "11.1.28",
|
|
143
144
|
"@swc/cli": "0.8.1",
|
|
144
|
-
"@swc/core": "1.15.
|
|
145
|
+
"@swc/core": "1.15.46",
|
|
145
146
|
"@types/compression": "1.8.1",
|
|
146
147
|
"@types/cookie-parser": "1.4.10",
|
|
147
148
|
"@types/ejs": "3.1.5",
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { handleFatalBootstrapError, installProcessDiagnostics } from './process-diagnostics.helper';
|
|
5
|
+
|
|
6
|
+
// `vi.spyOn(fs, 'writeSync')` cannot work here — an ESM module namespace is not configurable, so
|
|
7
|
+
// the property cannot be redefined. Mocking the module is the only way to observe the default
|
|
8
|
+
// sink, and the helper imports nothing else from `node:fs`.
|
|
9
|
+
const { writeSyncMock } = vi.hoisted(() => ({ writeSyncMock: vi.fn<(fd: number, data: string) => number>() }));
|
|
10
|
+
vi.mock('node:fs', () => ({ writeSync: writeSyncMock }));
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Regression guard for the "silent exit" class of failures: a Node API under a ts-node dev
|
|
14
|
+
* runner can die printing nothing but `app crashed` — no stacktrace, which reads like a
|
|
15
|
+
* product bug and costs long, misdirected debugging sessions. The diagnostics helper makes
|
|
16
|
+
* the exit reason visible — a rejected fire-and-forget promise no longer takes the server
|
|
17
|
+
* down, an uncaught exception is logged with a clear marker before it exits, and an EXTERNAL
|
|
18
|
+
* termination signal (another tool's pkill, `lt dev down`, an OS OOM SIGTERM, Ctrl-C) is
|
|
19
|
+
* logged as such instead of masquerading as an in-process crash.
|
|
20
|
+
*
|
|
21
|
+
* The installer is tested against an injected EventEmitter so the real process (and the
|
|
22
|
+
* Vitest runner it lives in) is never signalled or exited.
|
|
23
|
+
*
|
|
24
|
+
* Repo-wiring assertions (src/main.ts, src/index.ts, nodemon.json) deliberately live in
|
|
25
|
+
* `tests/unit/process-diagnostics-wiring.spec.ts`, NOT here: this file ships inside `src/core/`
|
|
26
|
+
* and is copied verbatim into vendor-mode consumer projects, where those repo-root paths do not
|
|
27
|
+
* exist. Keeping this spec free of `process.cwd()` is what makes it portable.
|
|
28
|
+
*/
|
|
29
|
+
function setup(
|
|
30
|
+
preRegister?: Partial<Record<string, () => void>>,
|
|
31
|
+
options?: Parameters<typeof installProcessDiagnostics>[0],
|
|
32
|
+
) {
|
|
33
|
+
const target = new EventEmitter();
|
|
34
|
+
target.setMaxListeners(50);
|
|
35
|
+
const errors: string[] = [];
|
|
36
|
+
const warnings: string[] = [];
|
|
37
|
+
const logger = {
|
|
38
|
+
error: (message: string) => errors.push(message),
|
|
39
|
+
warn: (message: string) => warnings.push(message),
|
|
40
|
+
};
|
|
41
|
+
const exit = vi.fn();
|
|
42
|
+
const reraise = vi.fn();
|
|
43
|
+
if (preRegister) {
|
|
44
|
+
for (const [event, fn] of Object.entries(preRegister)) {
|
|
45
|
+
target.on(event, fn);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
installProcessDiagnostics({ exit, logger, reraise, target, ...options });
|
|
49
|
+
return { errors, exit, reraise, target, warnings };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.restoreAllMocks();
|
|
54
|
+
vi.useRealTimers();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('installProcessDiagnostics', () => {
|
|
58
|
+
it('logs an unhandled rejection but keeps the process alive', () => {
|
|
59
|
+
const { exit, target, warnings } = setup();
|
|
60
|
+
target.emit('unhandledRejection', new Error('SMTP down'));
|
|
61
|
+
expect(warnings.some((line) => line.includes('[unhandledRejection]') && line.includes('SMTP down'))).toBe(true);
|
|
62
|
+
expect(exit).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('routes an unhandled rejection to the NON-blocking sink', () => {
|
|
66
|
+
// An unhandled rejection can fire once per request while the server keeps serving. A
|
|
67
|
+
// synchronous write would block the whole event loop until stderr drains, so this path must
|
|
68
|
+
// use `warn` (async) and never `error` (sync). Nothing is terminating, so there is no
|
|
69
|
+
// last-gasp guarantee worth blocking for.
|
|
70
|
+
const { errors, target, warnings } = setup();
|
|
71
|
+
target.emit('unhandledRejection', new Error('per-request failure'));
|
|
72
|
+
expect(warnings).toHaveLength(1);
|
|
73
|
+
expect(errors).toHaveLength(0);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('formats a non-Error rejection reason via String()', () => {
|
|
77
|
+
const { target, warnings } = setup();
|
|
78
|
+
target.emit('unhandledRejection', 'plain string reason');
|
|
79
|
+
expect(warnings.some((line) => line.includes('[unhandledRejection]') && line.includes('plain string reason'))).toBe(
|
|
80
|
+
true,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('exits on an unhandled rejection when onUnhandledRejection is "exit"', () => {
|
|
85
|
+
// Restores Node >= 15's own `--unhandled-rejections=throw` default for deployments that
|
|
86
|
+
// prefer a clean restart over serving from an unknown state.
|
|
87
|
+
const { errors, exit, target } = setup(undefined, { onUnhandledRejection: 'exit' });
|
|
88
|
+
target.emit('unhandledRejection', new Error('inconsistent state'));
|
|
89
|
+
expect(errors.some((line) => line.includes('[unhandledRejection]'))).toBe(true);
|
|
90
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('logs an uncaught exception with a marker and exits with code 1', () => {
|
|
94
|
+
const { errors, exit, target } = setup();
|
|
95
|
+
target.emit('uncaughtException', new Error('boom'));
|
|
96
|
+
expect(errors.some((line) => line.includes('[uncaughtException]') && line.includes('boom'))).toBe(true);
|
|
97
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('does NOT print the error message twice', () => {
|
|
101
|
+
// `error.stack` already begins with `${name}: ${message}`. Prefixing the message again put the
|
|
102
|
+
// same line in the output twice, which reads like two distinct failures.
|
|
103
|
+
const { errors, target } = setup();
|
|
104
|
+
const error = new Error('duplicated');
|
|
105
|
+
target.emit('uncaughtException', error);
|
|
106
|
+
const occurrences = errors[0].split('duplicated').length - 1;
|
|
107
|
+
expect(occurrences).toBe(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('falls back to name + message when an Error carries no stack', () => {
|
|
111
|
+
const { errors, target } = setup();
|
|
112
|
+
const error = new Error('no stack here');
|
|
113
|
+
error.stack = undefined;
|
|
114
|
+
target.emit('uncaughtException', error);
|
|
115
|
+
expect(errors[0]).toContain('Error: no stack here');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does NOT register a process warning handler', () => {
|
|
119
|
+
// Node prints process warnings to stderr itself and adding a listener does not replace that —
|
|
120
|
+
// it appends a second, strictly worse copy (Node's own line carries the warning `name` and the
|
|
121
|
+
// `--trace-warnings` hint). Duplicating it doubles the volume on a path that fires during
|
|
122
|
+
// normal operation.
|
|
123
|
+
const { target } = setup();
|
|
124
|
+
expect(target.listenerCount('warning')).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('logs a non-zero exit code', () => {
|
|
128
|
+
const { errors, target } = setup();
|
|
129
|
+
target.emit('exit', 143);
|
|
130
|
+
expect(errors.some((line) => line.includes('[exit]') && line.includes('143'))).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('stays silent on a clean exit', () => {
|
|
134
|
+
// A clean exit is not a diagnostic. Logging it under every CLI script and every graceful
|
|
135
|
+
// shutdown trains readers to ignore the marker.
|
|
136
|
+
const { errors, target } = setup();
|
|
137
|
+
target.emit('exit', 0);
|
|
138
|
+
expect(errors).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('logs SIGTERM as external termination and re-raises it when it is the only listener', () => {
|
|
142
|
+
const { errors, exit, reraise, target } = setup();
|
|
143
|
+
target.emit('SIGTERM', 'SIGTERM');
|
|
144
|
+
expect(errors.some((line) => line.includes('[signal]') && line.includes('SIGTERM'))).toBe(true);
|
|
145
|
+
expect(reraise).toHaveBeenCalledWith('SIGTERM');
|
|
146
|
+
expect(exit).not.toHaveBeenCalled();
|
|
147
|
+
// The handler MUST remove itself before re-raising — otherwise the re-raised signal
|
|
148
|
+
// re-enters the same handler forever (infinite log-and-re-raise loop, never terminates).
|
|
149
|
+
expect(target.listenerCount('SIGTERM')).toBe(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('logs SIGINT and re-raises it when it is the only listener', () => {
|
|
153
|
+
const { errors, reraise, target } = setup();
|
|
154
|
+
target.emit('SIGINT', 'SIGINT');
|
|
155
|
+
expect(errors.some((line) => line.includes('[signal]') && line.includes('SIGINT'))).toBe(true);
|
|
156
|
+
expect(reraise).toHaveBeenCalledWith('SIGINT');
|
|
157
|
+
expect(target.listenerCount('SIGINT')).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it.each(['SIGHUP', 'SIGQUIT'] as const)('labels %s as an external termination too', (signal) => {
|
|
161
|
+
// A closed terminal (SIGHUP) or a SIGQUIT from an orchestrator used to terminate unannotated,
|
|
162
|
+
// producing exactly the stackless death this helper exists to explain.
|
|
163
|
+
const { errors, reraise, target } = setup();
|
|
164
|
+
target.emit(signal, signal);
|
|
165
|
+
expect(errors.some((line) => line.includes('[signal]') && line.includes(signal))).toBe(true);
|
|
166
|
+
expect(reraise).toHaveBeenCalledWith(signal);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('does NOT touch SIGUSR2', () => {
|
|
170
|
+
// nodemon restarts the app with SIGUSR2. Attaching a listener overrides its disposition, and
|
|
171
|
+
// labelling a restart is not worth changing how restarts behave.
|
|
172
|
+
const { target } = setup();
|
|
173
|
+
expect(target.listenerCount('SIGUSR2')).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('does NOT re-raise a signal when a graceful-shutdown handler is also registered', () => {
|
|
177
|
+
// `app.enableShutdownHooks()` registers its own SIGTERM/SIGINT listener. Re-raising on top of
|
|
178
|
+
// it would kill the process mid-shutdown and defeat the graceful teardown.
|
|
179
|
+
const other = vi.fn();
|
|
180
|
+
const { errors, reraise, target } = setup({ SIGTERM: other }, { shutdownTimeoutMs: 0 });
|
|
181
|
+
target.emit('SIGTERM', 'SIGTERM');
|
|
182
|
+
expect(errors.some((line) => line.includes('[signal]'))).toBe(true);
|
|
183
|
+
expect(errors.some((line) => line.includes('another handler owns the shutdown'))).toBe(true);
|
|
184
|
+
expect(reraise).not.toHaveBeenCalled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('forces an exit when the co-listener never finishes the shutdown', () => {
|
|
188
|
+
// Abdicating unconditionally means a co-listener that never terminates makes SIGTERM a
|
|
189
|
+
// permanent no-op while the log line claims the process is going down — only SIGKILL would
|
|
190
|
+
// still work. The watchdog bounds that.
|
|
191
|
+
vi.useFakeTimers();
|
|
192
|
+
const { errors, exit, target } = setup({ SIGTERM: vi.fn() }, { shutdownTimeoutMs: 30_000 });
|
|
193
|
+
target.emit('SIGTERM', 'SIGTERM');
|
|
194
|
+
expect(exit).not.toHaveBeenCalled();
|
|
195
|
+
vi.advanceTimersByTime(30_000);
|
|
196
|
+
expect(errors.some((line) => line.includes('shutdown watchdog expired'))).toBe(true);
|
|
197
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('does not arm the watchdog when shutdownTimeoutMs is 0', () => {
|
|
201
|
+
vi.useFakeTimers();
|
|
202
|
+
const { exit, target } = setup({ SIGTERM: vi.fn() }, { shutdownTimeoutMs: 0 });
|
|
203
|
+
target.emit('SIGTERM', 'SIGTERM');
|
|
204
|
+
vi.advanceTimersByTime(120_000);
|
|
205
|
+
expect(exit).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('is idempotent — installing twice does not stack duplicate handlers', () => {
|
|
209
|
+
const target = new EventEmitter();
|
|
210
|
+
const logger = { error: () => undefined };
|
|
211
|
+
const exit = vi.fn();
|
|
212
|
+
const reraise = vi.fn();
|
|
213
|
+
installProcessDiagnostics({ exit, logger, reraise, target });
|
|
214
|
+
installProcessDiagnostics({ exit, logger, reraise, target });
|
|
215
|
+
expect(target.listenerCount('uncaughtException')).toBe(1);
|
|
216
|
+
expect(target.listenerCount('unhandledRejection')).toBe(1);
|
|
217
|
+
expect(target.listenerCount('SIGTERM')).toBe(1);
|
|
218
|
+
expect(target.listenerCount('SIGINT')).toBe(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('falls back to the error sink when a custom logger provides no warn', () => {
|
|
222
|
+
const target = new EventEmitter();
|
|
223
|
+
const errors: string[] = [];
|
|
224
|
+
installProcessDiagnostics({
|
|
225
|
+
exit: vi.fn(),
|
|
226
|
+
logger: { error: (message: string) => errors.push(message) },
|
|
227
|
+
reraise: vi.fn(),
|
|
228
|
+
target,
|
|
229
|
+
});
|
|
230
|
+
target.emit('unhandledRejection', new Error('no warn sink'));
|
|
231
|
+
expect(errors.some((line) => line.includes('[unhandledRejection]'))).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe('redaction and truncation', () => {
|
|
236
|
+
it('masks a JWT and truncates an oversized line', () => {
|
|
237
|
+
const target = new EventEmitter();
|
|
238
|
+
const warnings: string[] = [];
|
|
239
|
+
installProcessDiagnostics({
|
|
240
|
+
exit: vi.fn(),
|
|
241
|
+
logger: { error: () => undefined, warn: (message: string) => warnings.push(message) },
|
|
242
|
+
reraise: vi.fn(),
|
|
243
|
+
target,
|
|
244
|
+
});
|
|
245
|
+
const jwt = `eyJhbGciOiJIUzI1NiJ9.${'a'.repeat(40)}.${'b'.repeat(20)}`;
|
|
246
|
+
const error = new Error(`token=${jwt} ${'x'.repeat(30_000)}`);
|
|
247
|
+
error.stack = `Error: token=${jwt} ${'x'.repeat(30_000)}`;
|
|
248
|
+
target.emit('unhandledRejection', error);
|
|
249
|
+
|
|
250
|
+
expect(warnings).toHaveLength(1);
|
|
251
|
+
expect(warnings[0]).not.toContain(jwt);
|
|
252
|
+
expect(warnings[0]).toContain('[truncated]');
|
|
253
|
+
expect(warnings[0].length).toBeLessThan(20_000);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('defaultDiagnosticsLogger', () => {
|
|
258
|
+
it('writes synchronously to fd 2 so a last-gasp line cannot be truncated', () => {
|
|
259
|
+
// console.error is asynchronous on a pipe, so a line written immediately before process.exit()
|
|
260
|
+
// can be dropped — silently losing the very line this helper exists to emit.
|
|
261
|
+
writeSyncMock.mockReset();
|
|
262
|
+
writeSyncMock.mockReturnValue(0);
|
|
263
|
+
const target = new EventEmitter();
|
|
264
|
+
installProcessDiagnostics({ exit: vi.fn(), reraise: vi.fn(), target });
|
|
265
|
+
target.emit('uncaughtException', new Error('sync sink'));
|
|
266
|
+
|
|
267
|
+
expect(writeSyncMock).toHaveBeenCalled();
|
|
268
|
+
const [fd, payload] = writeSyncMock.mock.calls[0];
|
|
269
|
+
expect(fd).toBe(2);
|
|
270
|
+
expect(String(payload)).toContain('[uncaughtException]');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('survives a broken stderr instead of escalating', () => {
|
|
274
|
+
// EBADF (fd 2 closed), EPIPE (reader exited) and EAGAIN (non-blocking pipe) all make writeSync
|
|
275
|
+
// throw. A throw raised INSIDE the uncaughtException handler makes Node exit 7 and print its
|
|
276
|
+
// own "throw inside handler" message — losing the original error entirely.
|
|
277
|
+
writeSyncMock.mockReset();
|
|
278
|
+
writeSyncMock.mockImplementation(() => {
|
|
279
|
+
throw Object.assign(new Error('EBADF: bad file descriptor'), { code: 'EBADF' });
|
|
280
|
+
});
|
|
281
|
+
const exit = vi.fn();
|
|
282
|
+
const target = new EventEmitter();
|
|
283
|
+
installProcessDiagnostics({ exit, reraise: vi.fn(), target });
|
|
284
|
+
|
|
285
|
+
expect(() => target.emit('uncaughtException', new Error('original failure'))).not.toThrow();
|
|
286
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('handleFatalBootstrapError', () => {
|
|
291
|
+
it('logs a failed bootstrap with a marker and exits with code 1', () => {
|
|
292
|
+
const errors: string[] = [];
|
|
293
|
+
const logger = { error: (message: string) => errors.push(message) };
|
|
294
|
+
const exit = vi.fn();
|
|
295
|
+
handleFatalBootstrapError(new Error('listen EADDRINUSE: address already in use'), { exit, logger });
|
|
296
|
+
expect(errors.some((line) => line.includes('[bootstrap]') && line.includes('EADDRINUSE'))).toBe(true);
|
|
297
|
+
expect(exit).toHaveBeenCalledWith(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('redacts secrets out of a startup error before logging it', () => {
|
|
301
|
+
// The documented motivating failures ("DB unreachable") carry connection strings.
|
|
302
|
+
const errors: string[] = [];
|
|
303
|
+
const exit = vi.fn();
|
|
304
|
+
handleFatalBootstrapError(new Error('connect failed: password=hunter2'), {
|
|
305
|
+
exit,
|
|
306
|
+
logger: { error: (message: string) => errors.push(message) },
|
|
307
|
+
});
|
|
308
|
+
expect(errors[0]).not.toContain('hunter2');
|
|
309
|
+
});
|
|
310
|
+
});
|