@pxp-nd/core 1.2.90 → 1.2.92

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.md CHANGED
@@ -1,6 +1,42 @@
1
1
  # PXP-ND core
2
2
  - Core module for pxp-nd
3
3
 
4
+ ## 1.2.92
5
+
6
+ Destino del audit de request. **Sin configurar nada, el comportamiento es idéntico a 1.2.91**: el default sigue siendo insertar en `tsec_log`.
7
+
8
+ ### `logSink` (opcional)
9
+
10
+ ```ts
11
+ new PxpApp({
12
+ logSink: 'stdout', // 'db' | 'stdout' | 'both'
13
+ });
14
+ ```
15
+
16
+ - `db` (default): insert en `tsec_log` por `DB_LOG_CONNECTION_NAME`. El `logId` del JSON de error es el id numérico.
17
+ - `stdout`: una línea JSON por request (`pxpLog: true`). El `logId` es un uuid, generado antes de escribir, y no se toca la base. `request`, `response` y `query` se cortan a 8 KB (`truncated: true`). CloudWatch descarta un evento de más de 256 KB.
18
+ - `both`: la línea JSON y, en segundo plano, el insert. El uuid viaja dentro del request guardado como `__logId`. La respuesta no espera a la base.
19
+
20
+ Las máscaras de `@Log` (`cc_mask`, `complete_mask`, campo en null) se aplican en los tres modos. Los decoradores existentes no hay que tocarlos.
21
+
22
+ ## 1.2.91
23
+
24
+ Correcciones encontradas en las pruebas de carga con la base de datos caída. Sin cambios de API ni de configuración.
25
+
26
+ ### A. La respuesta de error ya no se cuelga si falla el log
27
+
28
+ Antes, en ambos handlers de ruta (con y sin autenticación), el `insertLog` del error se esperaba antes de llamar a `errorMiddleware`. Con la base caída `insertLog` fallaba, la excepción escapaba del `catch`, nunca se enviaba la respuesta y la request quedaba colgada hasta el timeout del cliente/ALB (en el log: `unhandledRejection PxpError: connect ECONNREFUSED`).
29
+
30
+ Ahora el log de error va en su propio `try/catch`: `errorMiddleware` **siempre** responde con el error original. Si el log falla se escribe `[pxp-core] error log failed <mensaje>` en consola y la respuesta sale sin `logId`.
31
+
32
+ ### B. El log de éxito ya no genera `unhandledRejection`
33
+
34
+ El `insertLog` del camino de éxito de `ormMethodWrapper` no se espera (fire-and-forget) y no tenía `.catch`: si fallaba, el rechazo quedaba sin manejar y en Node ≥15 sin handler mata el proceso. Ahora tiene `.catch` que escribe `[pxp-core] success log failed <mensaje>`. (Los wrappers `Procedure` y `Sql` no registran log, no aplica.)
35
+
36
+ ### C. La conexión que llega tarde tras un acquire timeout vuelve al pool
37
+
38
+ Con `db.acquireTimeoutMs`, cuando la conexión llegaba después del timeout se destruía. Esa conexión está limpia (nunca se abrió transacción), así que destruirla obligaba a mysql2 a abrir otra y dejaba una cola residual (observado: health en 503 unos 6 s después de una ráfaga de 40 requests). Ahora se devuelve al pool con `queryRunner.release()`. El timeout de request (`@Timeout`) sigue destruyendo la conexión, porque ahí sí puede haber una transacción abierta.
39
+
4
40
  ## 1.2.90
5
41
 
6
42
  Release de resiliencia de conexiones a base de datos. **Sin configurar nada, el comportamiento es idéntico a 1.2.89**: todas las opciones nuevas son opt-in.
@@ -61,3 +97,17 @@ Dependen de core con `^1.2.x`, así que tomarán esta versión en su próximo `n
61
97
  - cleaners-pxp-nd
62
98
 
63
99
  Sin configurar `db.acquireTimeoutMs` ni `@Timeout`/`timeoutMs`, ninguno de ellos cambia de comportamiento respecto a 1.2.89.
100
+
101
+ ### Tests (`npm test`)
102
+
103
+ `test/db-timeouts.test.ts` (jest + ts-jest) prueba contra un MySQL **real y desechable**: comportamiento legado sin `db`, `db.acquireTimeoutMs` con un pool `write` de 1 conexión, y `@Timeout` (503, sin transacción abierta en `innodb_trx`, nada commiteado, el pool se recupera).
104
+
105
+ ```bash
106
+ docker run --rm -d --name pxp-core-test-mysql -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pxptest -p 33306:3306 mysql:8
107
+ npm test
108
+ docker stop pxp-core-test-mysql
109
+ ```
110
+
111
+ Variables: `TEST_DB_HOST` (127.0.0.1), `TEST_DB_PORT` (33306), `TEST_DB_USER` (root), `TEST_DB_PASSWORD` (test), `TEST_DB_NAME` (pxptest). Usar solo una BD desechable: los tests crean las tablas `tsec_log` y `pxp_timeout_probe` y ocupan el pool con `SLEEP`, así que no apuntarlos a dev/beta/prod compartidas. Si la BD no responde, la suite se marca `skipped` (nunca falla por falta de BD).
112
+
113
+ `test/error-path.test.ts` no necesita MySQL: `insertLog` está mockeado para fallar y verifica que una ruta que lanza `PxpError(400)` responde 400 con el mensaje original (sin colgarse) y que el fallo del log de éxito no produce `unhandledRejection`.
@@ -32,10 +32,23 @@ declare interface IModules {
32
32
  declare interface IConfigExpress {
33
33
  [name: string]: any;
34
34
  }
35
+ /**
36
+ * Where a request audit line is written.
37
+ * `db` (default) inserts into tsec_log and returns the numeric id.
38
+ * `stdout` writes one JSON line and returns a uuid. No database.
39
+ * `both` writes the line and inserts in the background; the uuid is stored
40
+ * inside the row's request JSON as `__logId`.
41
+ */
42
+ export declare type LogSink = 'db' | 'stdout' | 'both';
35
43
  export interface IConfigPxpApp {
36
44
  defaultDbSettings?: string;
37
45
  apiPrefix?: string;
38
46
  logDuration?: boolean;
47
+ /**
48
+ * Audit destination. Omitted or `db` matches <=1.2.91 (insert into tsec_log).
49
+ * `stdout` is one JSON line per request. `both` is the line plus a background insert.
50
+ */
51
+ logSink?: LogSink;
39
52
  showRoutes?: boolean;
40
53
  controllers?: any[];
41
54
  entities?: any;
@@ -14,6 +14,8 @@
14
14
  * Last modified : 2021-05-01 18:35:31 - Favio Figueroa
15
15
  * Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
16
16
  * Last modified : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
17
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 log failures never block or crash the response (1.2.91)
18
+ * Last modified : 2026-10-21 - logSink db | stdout | both (1.2.92)
17
19
  */
18
20
  import { EntityManager } from 'typeorm';
19
21
  import { Request, Response, NextFunction } from 'express';
@@ -39,6 +39,8 @@ exports.Controller = void 0;
39
39
  * Last modified : 2021-05-01 18:35:31 - Favio Figueroa
40
40
  * Last modified : 2026-10-08 - Favio Figueroa - EF-23 per-request controller clone (from 1.2.89), startTransaction inside try
41
41
  * Last modified : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
42
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 log failures never block or crash the response (1.2.91)
43
+ * Last modified : 2026-10-21 - logSink db | stdout | both (1.2.92)
42
44
  */
43
45
  const typeorm_1 = require("typeorm");
44
46
  const class_validator_1 = require("class-validator");
@@ -188,8 +190,15 @@ class Controller {
188
190
  const now = new Date();
189
191
  const iniAt = req.start;
190
192
  const endsAt = now.valueOf() - iniAt.valueOf();
191
- res.logId = await index_1.__(index_1.insertLog('nouser', 'mac', req.ip, 'error', ex.tecMessage, controllerForRequest.module, controllerForRequest.transactionCode, '', // query
192
- params, ex.stack, ex.statusCode, endsAt, logValue));
193
+ // A failing error log (e.g. database down) must never prevent the response.
194
+ try {
195
+ res.logId = await index_1.__(index_1.insertLog('nouser', 'mac', req.ip, 'error', ex.tecMessage, controllerForRequest.module, controllerForRequest.transactionCode, '', // query
196
+ params, ex.stack, ex.statusCode, endsAt, logValue, controllerForRequest.config.logSink));
197
+ }
198
+ catch (logErr) {
199
+ // __ wraps driver errors in PxpError(500) with a generic message; the real cause is tecMessage.
200
+ console.error('[pxp-core] error log failed', logErr && (logErr.tecMessage || logErr.message));
201
+ }
193
202
  index_1.errorMiddleware(ex, req, res);
194
203
  }
195
204
  });
@@ -222,8 +231,15 @@ class Controller {
222
231
  const now = new Date();
223
232
  const iniAt = req.start;
224
233
  const endsAt = now.valueOf() - iniAt.valueOf();
225
- res.logId = (await index_1.__(index_1.insertLog(controllerForRequest.user && controllerForRequest.user.username ? controllerForRequest.user.username : 'nouser', 'mac', req.ip, 'error', ex.tecMessage, controllerForRequest.module, controllerForRequest.transactionCode, '', // query
226
- params, ex.stack, ex.statusCode, endsAt, logValue)));
234
+ // A failing error log (e.g. database down) must never prevent the response.
235
+ try {
236
+ res.logId = (await index_1.__(index_1.insertLog(controllerForRequest.user && controllerForRequest.user.username ? controllerForRequest.user.username : 'nouser', 'mac', req.ip, 'error', ex.tecMessage, controllerForRequest.module, controllerForRequest.transactionCode, '', // query
237
+ params, ex.stack, ex.statusCode, endsAt, logValue, controllerForRequest.config.logSink)));
238
+ }
239
+ catch (logErr) {
240
+ // __ wraps driver errors in PxpError(500) with a generic message; the real cause is tecMessage.
241
+ console.error('[pxp-core] error log failed', logErr && (logErr.tecMessage || logErr.message));
242
+ }
227
243
  index_1.errorMiddleware(ex, req, res);
228
244
  }
229
245
  });
@@ -313,7 +329,8 @@ class Controller {
313
329
  const now = new Date();
314
330
  const iniAt = req.start;
315
331
  const endsAt = now.valueOf() - iniAt.valueOf();
316
- index_1.__(index_1.insertLog(this.user && this.user.username ? this.user.username : 'nouser', 'mac', req.ip, 'success', 'successful transaction', this.module, this.transactionCode, '', params, JSON.stringify(metResponse), '200', endsAt, logConfig));
332
+ // Fire-and-forget: without the catch a failing log is an unhandledRejection (fatal on Node >= 15).
333
+ index_1.__(index_1.insertLog(this.user && this.user.username ? this.user.username : 'nouser', 'mac', req.ip, 'success', 'successful transaction', this.module, this.transactionCode, '', params, JSON.stringify(metResponse), '200', endsAt, logConfig, this.config.logSink)).catch(err => console.error('[pxp-core] success log failed', err && (err.tecMessage || err.message)));
317
334
  }
318
335
  //PARA REPORTES
319
336
  if (req.report && req.report.type === 'pdf') {
@@ -9,6 +9,7 @@
9
9
  * @author Favio Figueroa
10
10
  *
11
11
  * Created at : 2026-10-08 - Favio Figueroa - EF-23 acquire timeout + @Timeout decorator
12
+ * Last modified : 2026-10-08 - Favio Figueroa - EF-23 late acquired connection is released to the pool, not destroyed (1.2.91)
12
13
  */
13
14
  import { QueryRunner } from 'typeorm';
14
15
  import { PxpError } from '../PxpError';
@@ -52,8 +52,20 @@ async function connectWithTimeout(queryRunner, ms, transactionCode) {
52
52
  }
53
53
  catch (err) {
54
54
  if (timedOut) {
55
- // A late connection must be destroyed, not leaked into the pool.
56
- connectPromise.then(() => destroyQueryRunner(queryRunner)).catch(() => { });
55
+ // A late connection is pristine (no transaction was started on it), so it is returned
56
+ // to the pool instead of destroyed: destroying it would force the driver to open a new
57
+ // one and keep a residual queue after a burst. destroyQueryRunner stays for the
58
+ // request-timeout path, where a transaction may still be open.
59
+ connectPromise
60
+ .then(async () => {
61
+ try {
62
+ await queryRunner.release();
63
+ }
64
+ catch (releaseErr) {
65
+ console.error('[pxp-core] late connection release failed', releaseErr && releaseErr.message);
66
+ }
67
+ })
68
+ .catch(() => { });
57
69
  }
58
70
  throw err;
59
71
  }
@@ -13,11 +13,14 @@
13
13
  */
14
14
  import 'reflect-metadata';
15
15
  import { EntityManager } from 'typeorm';
16
+ import { LogSink } from '../../../interfaces';
17
+ /** Max characters kept for request, response and query on a stdout line. CloudWatch drops an event past 256 KB. */
18
+ declare const STDOUT_LOG_FIELD_MAX = 8192;
16
19
  interface ScriptInterface {
17
20
  scriptCode: string;
18
21
  scriptFunction: (manager: EntityManager) => void;
19
22
  }
20
23
  declare const userHasPermission: (user: any, transaction: string, permissionFunction: boolean | ((user: any, transaction: string) => Promise<boolean>)) => Promise<boolean>;
21
- declare const insertLog: (username: string, macaddres: string, ip: string, logType: string, desc: string, proc: string, trans: string, query: string, req: Record<string, unknown>, resp: string, err: string, timeEx: number, logConfig: Record<string, unknown>) => Promise<number>;
22
- export { userHasPermission, insertLog };
24
+ declare const insertLog: (username: string, macaddres: string, ip: string, logType: string, desc: string, proc: string, trans: string, query: string, req: Record<string, unknown>, resp: string, err: string, timeEx: number, logConfig: Record<string, unknown>, logSink?: LogSink) => Promise<number | string>;
25
+ export { userHasPermission, insertLog, STDOUT_LOG_FIELD_MAX };
23
26
  export type { ScriptInterface };
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.insertLog = exports.userHasPermission = void 0;
6
+ exports.STDOUT_LOG_FIELD_MAX = exports.insertLog = exports.userHasPermission = void 0;
7
7
  /**
8
8
  * Kplian Ltda 2020
9
9
  *
@@ -18,9 +18,13 @@ exports.insertLog = exports.userHasPermission = void 0;
18
18
  * Last modified : 2020-10-20 17:46:24
19
19
  */
20
20
  require("reflect-metadata");
21
+ const crypto_1 = __importDefault(require("crypto"));
21
22
  const typeorm_1 = require("typeorm");
22
23
  // import { Role } from '@pxp-nd/entities';
23
24
  const Log_1 = __importDefault(require("../../../entities/Log"));
25
+ /** Max characters kept for request, response and query on a stdout line. CloudWatch drops an event past 256 KB. */
26
+ const STDOUT_LOG_FIELD_MAX = 8192;
27
+ exports.STDOUT_LOG_FIELD_MAX = STDOUT_LOG_FIELD_MAX;
24
28
  const userHasPermission = async (user, transaction, permissionFunction) => {
25
29
  if (typeof permissionFunction === 'function') {
26
30
  return await permissionFunction(user, transaction);
@@ -42,24 +46,71 @@ const userHasPermission = async (user, transaction, permissionFunction) => {
42
46
  }
43
47
  };
44
48
  exports.userHasPermission = userHasPermission;
45
- const insertLog = async (username, macaddres, ip, logType, desc, proc, trans, query, req, resp, err, timeEx, logConfig) => {
46
- if (logConfig) {
47
- for (const [key, value] of Object.entries(logConfig)) {
48
- if (key in req) {
49
- if (!value) {
50
- delete req[key];
51
- }
52
- else if (value === 'cc_mask') {
53
- const aux = req[key];
54
- req[key] = '************' + aux.substr(-4);
55
- }
56
- else if (value === 'complete_mask') {
57
- const aux = req[key];
58
- req[key] = aux.replace(/./g, '*');
59
- }
49
+ /** uuid v4. @types/node 15 does not declare crypto.randomUUID. */
50
+ const newLogId = () => {
51
+ const bytes = crypto_1.default.randomBytes(16);
52
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
53
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
54
+ const hex = bytes.toString('hex');
55
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
56
+ };
57
+ const applyLogMask = (req, logConfig) => {
58
+ if (!logConfig || !req) {
59
+ return;
60
+ }
61
+ for (const [key, value] of Object.entries(logConfig)) {
62
+ if (key in req) {
63
+ if (!value) {
64
+ delete req[key];
65
+ }
66
+ else if (value === 'cc_mask') {
67
+ const aux = req[key];
68
+ req[key] = '************' + aux.substr(-4);
69
+ }
70
+ else if (value === 'complete_mask') {
71
+ const aux = req[key];
72
+ req[key] = aux.replace(/./g, '*');
60
73
  }
61
74
  }
62
75
  }
76
+ };
77
+ const capField = (value) => {
78
+ const text = value == null ? '' : String(value);
79
+ if (text.length <= STDOUT_LOG_FIELD_MAX) {
80
+ return { text, truncated: false };
81
+ }
82
+ return { text: text.slice(0, STDOUT_LOG_FIELD_MAX), truncated: true };
83
+ };
84
+ const serializeForLog = (value) => {
85
+ try {
86
+ return JSON.stringify(value);
87
+ }
88
+ catch (_a) {
89
+ return '{"error":"unserializable"}';
90
+ }
91
+ };
92
+ const emitStdoutLog = (logId, username, ip, logType, desc, proc, trans, query, req, resp, err, timeEx) => {
93
+ const request = capField(serializeForLog(req));
94
+ const response = capField(resp);
95
+ const queryField = capField(query);
96
+ console.log(JSON.stringify({
97
+ pxpLog: true,
98
+ logId,
99
+ type: logType,
100
+ user: username,
101
+ ip,
102
+ module: proc,
103
+ transaction: trans,
104
+ status: err,
105
+ ms: timeEx,
106
+ message: desc,
107
+ query: queryField.text,
108
+ request: request.text,
109
+ response: response.text,
110
+ truncated: request.truncated || response.truncated || queryField.truncated,
111
+ }));
112
+ };
113
+ const insertLogRow = async (username, macaddres, ip, logType, desc, proc, trans, query, req, resp, err, timeEx) => {
63
114
  const log = new Log_1.default();
64
115
  log.username = username;
65
116
  log.macaddres = macaddres;
@@ -74,7 +125,7 @@ const insertLog = async (username, macaddres, ip, logType, desc, proc, trans, qu
74
125
  log.execTime = timeEx;
75
126
  log.errorCode = err;
76
127
  log.shortRequest = JSON.stringify(req).substring(0, 150);
77
- log.shortResponse = resp.substring(0, 150);
128
+ log.shortResponse = (resp || '').substring(0, 150);
78
129
  const logIds = await typeorm_1.getManager(process.env.DB_LOG_CONNECTION_NAME)
79
130
  .createQueryBuilder()
80
131
  .insert()
@@ -83,4 +134,21 @@ const insertLog = async (username, macaddres, ip, logType, desc, proc, trans, qu
83
134
  .execute();
84
135
  return logIds.identifiers[0].logId;
85
136
  };
137
+ const insertLog = async (username, macaddres, ip, logType, desc, proc, trans, query, req, resp, err, timeEx, logConfig, logSink = 'db') => {
138
+ const sink = logSink || 'db';
139
+ if (sink === 'stdout' || sink === 'both') {
140
+ const snapshot = Object.assign({}, (req || {}));
141
+ applyLogMask(snapshot, logConfig);
142
+ const logId = newLogId();
143
+ emitStdoutLog(logId, username, ip, logType, desc, proc, trans, query, snapshot, resp, err, timeEx);
144
+ if (sink === 'both') {
145
+ const dbReq = Object.assign(Object.assign({}, snapshot), { __logId: logId });
146
+ insertLogRow(username, macaddres, ip, logType, desc, proc, trans, query, dbReq, resp, err, timeEx)
147
+ .catch((dbErr) => console.error('[pxp-core] db log failed', dbErr && (dbErr.tecMessage || dbErr.message)));
148
+ }
149
+ return logId;
150
+ }
151
+ applyLogMask(req, logConfig);
152
+ return insertLogRow(username, macaddres, ip, logType, desc, proc, trans, query, req, resp, err, timeEx);
153
+ };
86
154
  exports.insertLog = insertLog;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pxp-nd/core",
3
- "version": "1.2.90",
3
+ "version": "1.2.92",
4
4
  "description": "Contains main files for pxp-nd",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,7 +12,7 @@
12
12
  "prod:publish": "npm publish",
13
13
  "start": "npm run build && node dist/index.js",
14
14
  "dev": "tsc-watch --target es2017 --outDir ./dist --onSuccess \"node .\"",
15
- "test": "echo \"Error: no test specified\" && exit 1"
15
+ "test": "jest --runInBand"
16
16
  },
17
17
  "keywords": [
18
18
  "pxp-nd"
@@ -28,6 +28,7 @@
28
28
  "@types/express-fileupload": "^1.1.6",
29
29
  "@types/express-session": "^1.17.3",
30
30
  "@types/figlet": "^1.5.1",
31
+ "@types/jest": "^27.5.2",
31
32
  "@types/joi": "^17.2.3",
32
33
  "@types/lodash": "^4.14.170",
33
34
  "@types/node": "^15.3.1",
@@ -35,7 +36,10 @@
35
36
  "@types/socket.io": "^3.0.2",
36
37
  "@types/swagger-jsdoc": "^6.0.1",
37
38
  "@types/swagger-ui-express": "^4.1.3",
39
+ "jest": "^27.5.1",
40
+ "mysql2": "^2.3.3",
38
41
  "rimraf": "^3.0.2",
42
+ "ts-jest": "^27.1.5",
39
43
  "tsc-watch": "^4.2.9",
40
44
  "typescript": "^4.2.4"
41
45
  },
@@ -61,5 +65,23 @@
61
65
  "typeorm-store": "^2.0.0",
62
66
  "xlsx": "^0.17.0"
63
67
  },
64
- "peerDependencies": {}
68
+ "jest": {
69
+ "preset": "ts-jest",
70
+ "testEnvironment": "node",
71
+ "roots": [
72
+ "<rootDir>/test"
73
+ ],
74
+ "testTimeout": 30000,
75
+ "globals": {
76
+ "ts-jest": {
77
+ "tsconfig": "<rootDir>/test/tsconfig.json"
78
+ }
79
+ },
80
+ "moduleNameMapper": {
81
+ "^(\\.{1,2}/.*)\\.js$": "$1"
82
+ }
83
+ },
84
+ "overrides": {
85
+ "@types/babel__traverse": "7.17.1"
86
+ }
65
87
  }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Regression and timeout tests for write transactions in ormMethodWrapper:
3
+ * 1. no `db` config and no @Timeout: legacy behaviour (connect -> transaction -> commit -> release)
4
+ * 2. `db.acquireTimeoutMs`: waiting for a busy pool fails with 503 and the pool recovers
5
+ * 3. `@Timeout(ms)`: the request fails with 503, no transaction stays open, nothing is committed
6
+ *
7
+ * Requires a REAL, disposable MySQL. Connection settings (defaults target a local throwaway container):
8
+ * TEST_DB_HOST (127.0.0.1) TEST_DB_PORT (33306) TEST_DB_USER (root) TEST_DB_PASSWORD (test) TEST_DB_NAME (pxptest)
9
+ * docker run --rm -d -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pxptest -p 33306:3306 mysql:8
10
+ * If the database is not reachable the whole suite is skipped (it never fails for lack of a database).
11
+ *
12
+ * Harness: a Controller subclass mounted on a bare express app. A full PxpApp is not used because
13
+ * its run() pulls in sessions, auth modules, swagger and dist/modules folder scanning, none of which
14
+ * is involved in the code under test (Controller.initializeRoutes + ormMethodWrapper).
15
+ */
16
+ import 'reflect-metadata';
17
+ import { execFileSync } from 'child_process';
18
+ import http from 'http';
19
+ import { AddressInfo } from 'net';
20
+ import express from 'express';
21
+ import { createConnections, Connection, EntityManager, getConnection } from 'typeorm';
22
+ import { Controller } from '../src/lib/pxp/Controller';
23
+ import { Post, ReadOnly, Timeout } from '../src/lib/pxp/Decorators';
24
+ import { IConfigPxpApp } from '../src/interfaces';
25
+ import Log from '../src/entities/Log';
26
+
27
+ const DB = {
28
+ host: process.env.TEST_DB_HOST || '127.0.0.1',
29
+ port: Number(process.env.TEST_DB_PORT || 33306),
30
+ username: process.env.TEST_DB_USER || 'root',
31
+ password: process.env.TEST_DB_PASSWORD || 'test',
32
+ database: process.env.TEST_DB_NAME || 'pxptest',
33
+ };
34
+
35
+ // Synchronous reachability probe so the suite can be declared with describe.skip.
36
+ const probeDb = (): string | null => {
37
+ const script = `
38
+ const mysql = require('mysql2');
39
+ const c = mysql.createConnection({ host: process.argv[1], port: +process.argv[2], user: process.argv[3],
40
+ password: process.argv[4], database: process.argv[5], connectTimeout: 3000 });
41
+ c.query('SELECT 1', (err) => { c.destroy(); if (err) { console.error(err.message); process.exit(1); } process.exit(0); });
42
+ `;
43
+ try {
44
+ execFileSync(process.execPath, ['-e', script, DB.host, String(DB.port), DB.username, DB.password, DB.database], {
45
+ cwd: __dirname,
46
+ stdio: 'pipe',
47
+ timeout: 10000,
48
+ });
49
+ return null;
50
+ } catch (e) {
51
+ return String((e as any).stderr || (e as any).message).trim();
52
+ }
53
+ };
54
+
55
+ const dbError = probeDb();
56
+ if (dbError) {
57
+ // eslint-disable-next-line no-console
58
+ console.warn(
59
+ `[db-timeouts.test] SKIPPED: test MySQL not reachable at ${DB.host}:${DB.port}/${DB.database} (${dbError}). ` +
60
+ 'Start one with: docker run --rm -d -e MYSQL_ROOT_PASSWORD=test -e MYSQL_DATABASE=pxptest -p 33306:3306 mysql:8'
61
+ );
62
+ }
63
+ const describeDb = dbError ? describe.skip : describe;
64
+
65
+ class Slow extends Controller {
66
+ @Post()
67
+ @ReadOnly(false)
68
+ async hold(params: any, manager: EntityManager) {
69
+ await manager.query('SELECT SLEEP(?)', [Number(params.secs)]);
70
+ return { ok: true };
71
+ }
72
+
73
+ // Writes a row first so the transaction is a real InnoDB transaction (visible in innodb_trx),
74
+ // then outlives its @Timeout.
75
+ @Post()
76
+ @ReadOnly(false)
77
+ @Timeout(500)
78
+ async fast(params: any, manager: EntityManager) {
79
+ await manager.query('INSERT INTO pxp_timeout_probe (marker) VALUES (?)', [String(params.marker)]);
80
+ await manager.query('SELECT SLEEP(2)');
81
+ return { ok: true };
82
+ }
83
+ }
84
+
85
+ const baseConfig: IConfigPxpApp = {
86
+ apiPrefix: '/api',
87
+ defaultDbSettings: 'Orm',
88
+ middlewares: [],
89
+ entities: {},
90
+ };
91
+
92
+ const startApp = async (config: IConfigPxpApp) => {
93
+ const app = express();
94
+ app.use(express.json());
95
+ app.use((req: any, _res, next) => {
96
+ req.start = new Date();
97
+ next();
98
+ });
99
+ const controller = new Slow('test', null, config);
100
+ app.use('/', controller.router);
101
+ const server = http.createServer(app);
102
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
103
+ const { port } = server.address() as AddressInfo;
104
+ return { server, base: `http://127.0.0.1:${port}/api/test/Slow` };
105
+ };
106
+
107
+ // Plain http client (the jest node environment does not expose global fetch).
108
+ const call = (url: string, body: Record<string, unknown>) =>
109
+ new Promise<{ status: number; json: any; ms: number }>((resolve, reject) => {
110
+ const t0 = Date.now();
111
+ const payload = JSON.stringify(body);
112
+ const req = http.request(
113
+ url,
114
+ { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
115
+ (res) => {
116
+ let raw = '';
117
+ res.setEncoding('utf8');
118
+ res.on('data', (chunk) => (raw += chunk));
119
+ res.on('end', () => resolve({ status: res.statusCode as number, json: JSON.parse(raw), ms: Date.now() - t0 }));
120
+ }
121
+ );
122
+ req.on('error', reject);
123
+ req.end(payload);
124
+ });
125
+
126
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
127
+
128
+ describeDb('ormMethodWrapper db timeouts (real MySQL)', () => {
129
+ let connections: Connection[] = [];
130
+ const servers: http.Server[] = [];
131
+ const timings: string[] = [];
132
+
133
+ beforeAll(async () => {
134
+ process.env.DB_WRITE_CONNECTION_NAME = 'write';
135
+ process.env.DB_LOG_CONNECTION_NAME = 'default';
136
+ connections = await createConnections([
137
+ { name: 'default', type: 'mysql', ...DB, entities: [Log], synchronize: true, logging: false },
138
+ { name: 'write', type: 'mysql', ...DB, entities: [], synchronize: false, logging: false, extra: { connectionLimit: 1 } },
139
+ ]);
140
+ const def = getConnection('default');
141
+ await def.query(
142
+ 'CREATE TABLE IF NOT EXISTS pxp_timeout_probe (id INT AUTO_INCREMENT PRIMARY KEY, marker VARCHAR(64) NOT NULL) ENGINE=InnoDB'
143
+ );
144
+ });
145
+
146
+ afterAll(async () => {
147
+ for (const s of servers) await new Promise((r) => s.close(r));
148
+ // ormMethodWrapper writes the success log fire-and-forget (not awaited); let in-flight inserts finish
149
+ // before closing the log connection, otherwise they reject unhandled and crash the runner.
150
+ await sleep(1000);
151
+ for (const c of connections) if (c.isConnected) await c.close();
152
+ // eslint-disable-next-line no-console
153
+ console.log(`[db-timeouts.test] timings:\n ${timings.join('\n ')}`);
154
+ });
155
+
156
+ test('1. legacy: no db config, no @Timeout -> hold secs=1 returns 200 in ~1 s', async () => {
157
+ const { server, base } = await startApp({ ...baseConfig });
158
+ servers.push(server);
159
+
160
+ const r = await call(`${base}/hold`, { secs: 1 });
161
+ timings.push(`case1 hold(1): ${r.status} in ${r.ms} ms`);
162
+
163
+ expect(r.status).toBe(200);
164
+ expect(r.json).toEqual({ ok: true });
165
+ expect(r.ms).toBeGreaterThanOrEqual(950);
166
+ expect(r.ms).toBeLessThan(2000);
167
+ });
168
+
169
+ test('2. db.acquireTimeoutMs=1000 -> second write on a busy 1-connection pool gets 503 in ~1 s; pool recovers', async () => {
170
+ const { server, base } = await startApp({ ...baseConfig, db: { acquireTimeoutMs: 1000 } });
171
+ servers.push(server);
172
+
173
+ const first = call(`${base}/hold`, { secs: 5 });
174
+ await sleep(200);
175
+ const second = await call(`${base}/hold`, { secs: 0 });
176
+ timings.push(`case2 second hold(0): ${second.status} in ${second.ms} ms -> ${second.json?.error?.message}`);
177
+
178
+ expect(second.status).toBe(503);
179
+ expect(second.json.error.message).toBe('Database acquire timeout after 1000 ms');
180
+ expect(second.ms).toBeGreaterThanOrEqual(950);
181
+ expect(second.ms).toBeLessThan(1800);
182
+
183
+ const firstRes = await first;
184
+ timings.push(`case2 first hold(5): ${firstRes.status} in ${firstRes.ms} ms`);
185
+ expect(firstRes.status).toBe(200);
186
+ expect(firstRes.ms).toBeGreaterThanOrEqual(4500);
187
+
188
+ // The late connection handed to the timed-out waiter is released back to the pool (not leaked): a new call succeeds.
189
+ const third = await call(`${base}/hold`, { secs: 0 });
190
+ timings.push(`case2 third hold(0): ${third.status} in ${third.ms} ms`);
191
+ expect(third.status).toBe(200);
192
+ expect(third.ms).toBeLessThan(1000);
193
+
194
+ const pool: any = (getConnection('write').driver as any).pool;
195
+ timings.push(
196
+ `case2 write pool after: all=${pool._allConnections.length} free=${pool._freeConnections.length} queued=${pool._connectionQueue.length}`
197
+ );
198
+ expect(pool._connectionQueue.length).toBe(0);
199
+ expect(pool._allConnections.length).toBeLessThanOrEqual(1);
200
+ expect(pool._freeConnections.length).toBe(pool._allConnections.length);
201
+ });
202
+
203
+ test('3. @Timeout(500) -> 503 in ~0.5 s, no open transaction, nothing committed, pool recovers', async () => {
204
+ const { server, base } = await startApp({ ...baseConfig });
205
+ servers.push(server);
206
+ const warn = jest.spyOn(console, 'warn');
207
+ const marker = `t${Date.now()}`;
208
+
209
+ const r = await call(`${base}/fast`, { marker });
210
+ timings.push(`case3 fast: ${r.status} in ${r.ms} ms -> ${r.json?.error?.message}`);
211
+
212
+ expect(r.status).toBe(503);
213
+ expect(r.json.error.message).toBe('Database request timeout after 500 ms');
214
+ expect(r.ms).toBeGreaterThanOrEqual(450);
215
+ expect(r.ms).toBeLessThan(1200);
216
+ expect(warn).toHaveBeenCalledWith('[pxp-core] timeout-abandoned test.slow.fast');
217
+ warn.mockRestore();
218
+
219
+ const def = getConnection('default');
220
+ let open = -1;
221
+ const t0 = Date.now();
222
+ while (Date.now() - t0 < 3000) {
223
+ const [row] = await def.query('SELECT COUNT(*) AS n FROM information_schema.innodb_trx');
224
+ open = Number(row.n);
225
+ if (open === 0) break;
226
+ await sleep(100);
227
+ }
228
+ timings.push(`case3 innodb_trx open after ${Date.now() - t0} ms: ${open}`);
229
+ expect(open).toBe(0);
230
+
231
+ // MySQL rolled the abandoned transaction back server-side: the row was never committed.
232
+ const [committed] = await def.query('SELECT COUNT(*) AS n FROM pxp_timeout_probe WHERE marker = ?', [marker]);
233
+ expect(Number(committed.n)).toBe(0);
234
+
235
+ const after = await call(`${base}/hold`, { secs: 0 });
236
+ timings.push(`case3 hold(0) after: ${after.status} in ${after.ms} ms`);
237
+ expect(after.status).toBe(200);
238
+ });
239
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Error/log path tests for Controller route handlers. No database needed.
3
+ *
4
+ * `insertLog` (src/lib/pxp/utils/Security) is mocked to reject, simulating a log database that is down:
5
+ * 1. a route that throws PxpError(400) must still answer 400 with the original message, in both
6
+ * the unauthenticated and the authenticated route handler (before 1.2.91 the request hung forever)
7
+ * 2. a successful route must answer 200 and the failed fire-and-forget success log must not become
8
+ * an unhandledRejection
9
+ *
10
+ * Harness: same as db-timeouts.test.ts (Controller subclass mounted on a bare express app). All routes
11
+ * are @ReadOnly(true), so ormMethodWrapper never touches a connection.
12
+ */
13
+ import 'reflect-metadata';
14
+ import http from 'http';
15
+ import { AddressInfo } from 'net';
16
+ import express from 'express';
17
+
18
+ jest.mock('../src/lib/pxp/utils/Security', () => ({
19
+ ...jest.requireActual('../src/lib/pxp/utils/Security'),
20
+ insertLog: jest.fn(),
21
+ }));
22
+
23
+ import { insertLog } from '../src/lib/pxp/utils/Security';
24
+ import { Controller } from '../src/lib/pxp/Controller';
25
+ import { Authentication, Post, ReadOnly } from '../src/lib/pxp/Decorators';
26
+ import { PxpError } from '../src/lib/pxp/PxpError';
27
+ import { IConfigPxpApp } from '../src/interfaces';
28
+
29
+ const insertLogMock = insertLog as jest.MockedFunction<typeof insertLog>;
30
+
31
+ class Broken extends Controller {
32
+ @Post()
33
+ @ReadOnly(true)
34
+ @Authentication(false)
35
+ async publicFail() {
36
+ throw new PxpError(400, 'public route failed on purpose');
37
+ }
38
+
39
+ @Post()
40
+ @ReadOnly(true)
41
+ async privateFail() {
42
+ throw new PxpError(400, 'private route failed on purpose');
43
+ }
44
+
45
+ @Post()
46
+ @ReadOnly(true)
47
+ async ok() {
48
+ return { ok: true };
49
+ }
50
+ }
51
+
52
+ const config: IConfigPxpApp = {
53
+ apiPrefix: '/api',
54
+ defaultDbSettings: 'Orm',
55
+ middlewares: [],
56
+ entities: {},
57
+ };
58
+
59
+ // Plain http client with a hard deadline so a hanging handler fails the test instead of the runner.
60
+ const call = (url: string, deadlineMs = 2000) =>
61
+ new Promise<{ status: number; json: any; ms: number }>((resolve, reject) => {
62
+ const t0 = Date.now();
63
+ const payload = '{}';
64
+ const req = http.request(
65
+ url,
66
+ { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
67
+ (res) => {
68
+ let raw = '';
69
+ res.setEncoding('utf8');
70
+ res.on('data', (chunk) => (raw += chunk));
71
+ res.on('end', () => resolve({ status: res.statusCode as number, json: JSON.parse(raw), ms: Date.now() - t0 }));
72
+ }
73
+ );
74
+ req.setTimeout(deadlineMs, () => {
75
+ req.destroy(new Error(`no response within ${deadlineMs} ms (handler hung)`));
76
+ });
77
+ req.on('error', reject);
78
+ req.end(payload);
79
+ });
80
+
81
+ describe('Controller error path with a failing insertLog (no database)', () => {
82
+ let server: http.Server;
83
+ let base: string;
84
+ const unhandled: unknown[] = [];
85
+ const onUnhandled = (reason: unknown) => unhandled.push(reason);
86
+ let consoleError: jest.SpyInstance;
87
+
88
+ beforeAll(async () => {
89
+ process.on('unhandledRejection', onUnhandled);
90
+ const app = express();
91
+ app.use(express.json());
92
+ app.use((req: any, _res, next) => {
93
+ req.start = new Date();
94
+ next();
95
+ });
96
+ app.use('/', new Broken('test', null, config).router);
97
+ server = http.createServer(app);
98
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
99
+ const { port } = server.address() as AddressInfo;
100
+ base = `http://127.0.0.1:${port}/api/test/Broken`;
101
+ });
102
+
103
+ beforeEach(() => {
104
+ insertLogMock.mockReset();
105
+ insertLogMock.mockRejectedValue(new Error('connect ECONNREFUSED 127.0.0.1:3306'));
106
+ consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
107
+ });
108
+
109
+ afterEach(() => consoleError.mockRestore());
110
+
111
+ afterAll(async () => {
112
+ process.removeListener('unhandledRejection', onUnhandled);
113
+ await new Promise((r) => server.close(r));
114
+ });
115
+
116
+ test.each([
117
+ ['unauthenticated handler', 'publicFail', 'public route failed on purpose'],
118
+ ['authenticated handler', 'privateFail', 'private route failed on purpose'],
119
+ ])('%s: PxpError(400) is answered with 400 and the original message', async (_label, path, message) => {
120
+ const r = await call(`${base}/${path}`);
121
+
122
+ expect(r.status).toBe(400);
123
+ expect(r.json.error.code).toBe(400);
124
+ expect(r.json.error.message).toBe(message);
125
+ expect(r.json.error.logId).toBeUndefined();
126
+ expect(r.ms).toBeLessThan(2000);
127
+ expect(insertLogMock).toHaveBeenCalledTimes(1);
128
+ expect(insertLogMock.mock.calls[0][3]).toBe('error');
129
+ expect(consoleError).toHaveBeenCalledWith('[pxp-core] error log failed', 'connect ECONNREFUSED 127.0.0.1:3306');
130
+ });
131
+
132
+ test('success path: 200 and the failed success log is caught (no unhandledRejection)', async () => {
133
+ const r = await call(`${base}/ok`);
134
+
135
+ expect(r.status).toBe(200);
136
+ expect(r.json).toEqual({ ok: true });
137
+ // let the rejected fire-and-forget log settle
138
+ await new Promise((resolve) => setImmediate(resolve));
139
+ await new Promise((resolve) => setTimeout(resolve, 50));
140
+ expect(insertLogMock).toHaveBeenCalledTimes(1);
141
+ expect(insertLogMock.mock.calls[0][3]).toBe('success');
142
+ expect(consoleError).toHaveBeenCalledWith('[pxp-core] success log failed', 'connect ECONNREFUSED 127.0.0.1:3306');
143
+ expect(unhandled).toEqual([]);
144
+ });
145
+ });
@@ -0,0 +1,242 @@
1
+ /**
2
+ * logSink tests. No database.
3
+ *
4
+ * stdout writes one JSON line and returns a uuid without calling getManager.
5
+ * both writes the line and inserts in the background; the caller is not blocked on the insert.
6
+ * db (the default) still inserts and returns the numeric id.
7
+ */
8
+ import 'reflect-metadata';
9
+ import http from 'http';
10
+ import { AddressInfo } from 'net';
11
+ import express from 'express';
12
+ import * as typeorm from 'typeorm';
13
+ import { insertLog, STDOUT_LOG_FIELD_MAX } from '../src/lib/pxp/utils/Security';
14
+ import { Controller } from '../src/lib/pxp/Controller';
15
+ import { Authentication, Post, ReadOnly } from '../src/lib/pxp/Decorators';
16
+ import { PxpError } from '../src/lib/pxp/PxpError';
17
+ import { IConfigPxpApp } from '../src/interfaces';
18
+
19
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
+
21
+ const baseArgs = (req: Record<string, unknown>, logConfig: Record<string, unknown> = {}) =>
22
+ ['user', 'mac', '127.0.0.1', 'success', 'ok', 'mod', 'mod.tx', '', req, '{"ok":true}', '200', 12, logConfig] as const;
23
+
24
+ const pxpLines = (spy: jest.SpyInstance): any[] =>
25
+ spy.mock.calls
26
+ .map((call) => call[0])
27
+ .filter((line) => typeof line === 'string' && line.includes('"pxpLog":true'))
28
+ .map((line) => JSON.parse(line));
29
+
30
+ describe('insertLog sinks', () => {
31
+ let logSpy: jest.SpyInstance;
32
+ let getManager: jest.SpyInstance;
33
+ let execute: jest.Mock;
34
+ let values: jest.Mock;
35
+
36
+ beforeEach(() => {
37
+ logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
38
+ execute = jest.fn().mockResolvedValue({ identifiers: [{ logId: 7 }] });
39
+ values = jest.fn();
40
+ const qb = {
41
+ insert: jest.fn(),
42
+ into: jest.fn(),
43
+ values,
44
+ execute,
45
+ };
46
+ qb.insert.mockReturnValue(qb);
47
+ qb.into.mockReturnValue(qb);
48
+ values.mockReturnValue(qb);
49
+ getManager = jest.spyOn(typeorm, 'getManager').mockReturnValue({ createQueryBuilder: () => qb } as any);
50
+ });
51
+
52
+ afterEach(() => {
53
+ logSpy.mockRestore();
54
+ getManager.mockRestore();
55
+ });
56
+
57
+ test('stdout returns a uuid, writes one line, and does not touch the database', async () => {
58
+ const req = { card: '4111111111111111', keep: 'yes' };
59
+ const id = await insertLog(...baseArgs(req, { card: 'cc_mask' }), 'stdout');
60
+
61
+ expect(id).toMatch(UUID);
62
+ expect(getManager).not.toHaveBeenCalled();
63
+ expect(req).toEqual({ card: '4111111111111111', keep: 'yes' });
64
+
65
+ const [line] = pxpLines(logSpy);
66
+ expect(line.logId).toBe(id);
67
+ expect(line.type).toBe('success');
68
+ expect(line.user).toBe('user');
69
+ expect(line.transaction).toBe('mod.tx');
70
+ expect(line.status).toBe('200');
71
+ expect(line.truncated).toBe(false);
72
+ expect(line.request).toContain('************1111');
73
+ expect(line.request).not.toContain('4111111111111111');
74
+ expect(line.request).toContain('yes');
75
+ });
76
+
77
+ test('stdout drops masked-out fields and caps oversized bodies', async () => {
78
+ const req = { favicon: 'png', note: 'x'.repeat(STDOUT_LOG_FIELD_MAX + 500) };
79
+ await insertLog(...baseArgs(req, { favicon: null }), 'stdout');
80
+
81
+ const [line] = pxpLines(logSpy);
82
+ expect(line.request.startsWith('{"note":')).toBe(true);
83
+ expect(line.request).not.toContain('favicon');
84
+ expect(line.request.length).toBe(STDOUT_LOG_FIELD_MAX);
85
+ expect(line.truncated).toBe(true);
86
+ expect(req.favicon).toBe('png');
87
+ });
88
+
89
+ test('both returns the uuid before the insert finishes and stores __logId on the row', async () => {
90
+ let release: (value: unknown) => void = () => undefined;
91
+ execute.mockReturnValue(new Promise((resolve) => {
92
+ release = resolve;
93
+ }));
94
+
95
+ const id = await Promise.race([
96
+ insertLog(...baseArgs({ a: 1 }), 'both'),
97
+ new Promise((_resolve, reject) => setTimeout(() => reject(new Error('waited for the database')), 200)),
98
+ ]);
99
+
100
+ expect(id).toMatch(UUID);
101
+ const [line] = pxpLines(logSpy);
102
+ expect(line.logId).toBe(id);
103
+ expect(execute).toHaveBeenCalledTimes(1);
104
+
105
+ const row = values.mock.calls[0][0][0];
106
+ expect(JSON.parse(row.request).__logId).toBe(id);
107
+
108
+ release({ identifiers: [{ logId: 7 }] });
109
+ });
110
+
111
+ test('db stays on the numeric id and writes no stdout line', async () => {
112
+ const id = await insertLog(...baseArgs({ a: 1 }));
113
+
114
+ expect(id).toBe(7);
115
+ expect(getManager).toHaveBeenCalledTimes(1);
116
+ expect(pxpLines(logSpy)).toEqual([]);
117
+ });
118
+
119
+ test('a failing background insert does not reject', async () => {
120
+ execute.mockRejectedValue(new Error('connect ECONNREFUSED'));
121
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
122
+ const unhandled: unknown[] = [];
123
+ const onUnhandled = (reason: unknown) => unhandled.push(reason);
124
+ process.on('unhandledRejection', onUnhandled);
125
+
126
+ const id = await insertLog(...baseArgs({ a: 1 }), 'both');
127
+ await new Promise((resolve) => setImmediate(resolve));
128
+
129
+ expect(id).toMatch(UUID);
130
+ expect(errorSpy).toHaveBeenCalledWith('[pxp-core] db log failed', 'connect ECONNREFUSED');
131
+ expect(unhandled).toEqual([]);
132
+
133
+ process.removeListener('unhandledRejection', onUnhandled);
134
+ errorSpy.mockRestore();
135
+ });
136
+ });
137
+
138
+ class Audited extends Controller {
139
+ @Post()
140
+ @ReadOnly(true)
141
+ @Authentication(false)
142
+ async fail() {
143
+ throw new PxpError(400, 'audit me');
144
+ }
145
+
146
+ @Post()
147
+ @ReadOnly(true)
148
+ @Authentication(false)
149
+ async ok() {
150
+ return { ok: true };
151
+ }
152
+ }
153
+
154
+ const call = (url: string) =>
155
+ new Promise<{ status: number; json: any }>((resolve, reject) => {
156
+ const payload = '{}';
157
+ const req = http.request(
158
+ url,
159
+ { method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } },
160
+ (res) => {
161
+ let raw = '';
162
+ res.setEncoding('utf8');
163
+ res.on('data', (chunk) => (raw += chunk));
164
+ res.on('end', () => resolve({ status: res.statusCode as number, json: JSON.parse(raw) }));
165
+ }
166
+ );
167
+ req.on('error', reject);
168
+ req.end(payload);
169
+ });
170
+
171
+ describe('controller with logSink stdout', () => {
172
+ let server: http.Server;
173
+ let base: string;
174
+ let logSpy: jest.SpyInstance;
175
+ let getManager: jest.SpyInstance;
176
+
177
+ beforeAll(async () => {
178
+ const config: IConfigPxpApp = {
179
+ apiPrefix: '/api',
180
+ defaultDbSettings: 'Orm',
181
+ middlewares: [],
182
+ entities: {},
183
+ logSink: 'stdout',
184
+ };
185
+ const app = express();
186
+ app.use(express.json());
187
+ app.use((req: any, _res, next) => {
188
+ req.start = new Date();
189
+ next();
190
+ });
191
+ app.use('/', new Audited('test', null, config).router);
192
+ server = http.createServer(app);
193
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
194
+ const { port } = server.address() as AddressInfo;
195
+ base = `http://127.0.0.1:${port}/api/test/Audited`;
196
+ });
197
+
198
+ beforeEach(() => {
199
+ logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined);
200
+ getManager = jest.spyOn(typeorm, 'getManager').mockImplementation(() => {
201
+ throw new Error('stdout sink must not ask for a connection');
202
+ });
203
+ });
204
+
205
+ afterEach(() => {
206
+ logSpy.mockRestore();
207
+ getManager.mockRestore();
208
+ });
209
+
210
+ afterAll(async () => {
211
+ await new Promise((resolve) => server.close(resolve));
212
+ });
213
+
214
+ test('an error response carries the same uuid that was written to stdout', async () => {
215
+ const r = await call(`${base}/fail`);
216
+
217
+ expect(r.status).toBe(400);
218
+ expect(r.json.error.message).toBe('audit me');
219
+ expect(r.json.error.logId).toMatch(UUID);
220
+ expect(getManager).not.toHaveBeenCalled();
221
+
222
+ const [line] = pxpLines(logSpy);
223
+ expect(line.logId).toBe(r.json.error.logId);
224
+ expect(line.type).toBe('error');
225
+ expect(line.status).toBe(400);
226
+ });
227
+
228
+ test('a successful request is logged and still answers 200', async () => {
229
+ const r = await call(`${base}/ok`);
230
+ await new Promise((resolve) => setImmediate(resolve));
231
+
232
+ expect(r.status).toBe(200);
233
+ expect(r.json).toEqual({ ok: true });
234
+ expect(r.json.logId).toBeUndefined();
235
+ expect(getManager).not.toHaveBeenCalled();
236
+
237
+ const [line] = pxpLines(logSpy);
238
+ expect(line.logId).toMatch(UUID);
239
+ expect(line.type).toBe('success');
240
+ expect(line.status).toBe('200');
241
+ });
242
+ });