@lenne.tech/nest-server 11.32.4 → 11.33.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.
Files changed (260) hide show
  1. package/.claude/rules/architecture.md +16 -0
  2. package/.claude/rules/configurable-features.md +11 -3
  3. package/.claude/rules/testing.md +101 -1
  4. package/CLAUDE.md +5 -0
  5. package/FRAMEWORK-API.md +58 -3
  6. package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
  7. package/dist/core/common/helpers/content-disposition.helper.js +14 -0
  8. package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
  9. package/dist/core/common/helpers/file.helper.d.ts +7 -0
  10. package/dist/core/common/helpers/file.helper.js +21 -4
  11. package/dist/core/common/helpers/file.helper.js.map +1 -1
  12. package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
  13. package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
  14. package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
  15. package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
  16. package/dist/core/common/helpers/project-name.helper.js +38 -0
  17. package/dist/core/common/helpers/project-name.helper.js.map +1 -0
  18. package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
  19. package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
  20. package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
  21. package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
  22. package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
  23. package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
  24. package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
  25. package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
  26. package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
  27. package/dist/core/common/services/core-cron-jobs.service.js +278 -27
  28. package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
  29. package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
  30. package/dist/core/common/services/core-redis-pubsub.js +112 -0
  31. package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
  32. package/dist/core/common/services/core-redis.service.d.ts +25 -0
  33. package/dist/core/common/services/core-redis.service.js +154 -0
  34. package/dist/core/common/services/core-redis.service.js.map +1 -0
  35. package/dist/core/common/services/core-s3.service.d.ts +51 -0
  36. package/dist/core/common/services/core-s3.service.js +261 -0
  37. package/dist/core/common/services/core-s3.service.js.map +1 -0
  38. package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
  39. package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
  40. package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
  41. package/dist/core/common/services/rate-limit-store.d.ts +61 -0
  42. package/dist/core/common/services/rate-limit-store.js +235 -0
  43. package/dist/core/common/services/rate-limit-store.js.map +1 -0
  44. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
  45. package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
  46. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  47. package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
  48. package/dist/core/modules/ai/services/core-ai.service.js +24 -20
  49. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  50. package/dist/core/modules/auth/core-auth.module.js +4 -1
  51. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  52. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
  53. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
  54. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
  55. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
  56. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
  57. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
  58. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
  59. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
  60. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  61. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
  62. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
  63. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  64. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
  65. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
  66. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  67. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
  68. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  69. package/dist/core/modules/file/core-file.controller.d.ts +11 -2
  70. package/dist/core/modules/file/core-file.controller.js +52 -15
  71. package/dist/core/modules/file/core-file.controller.js.map +1 -1
  72. package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
  73. package/dist/core/modules/file/core-file.resolver.js +23 -16
  74. package/dist/core/modules/file/core-file.resolver.js.map +1 -1
  75. package/dist/core/modules/file/core-file.service.d.ts +41 -7
  76. package/dist/core/modules/file/core-file.service.js +227 -7
  77. package/dist/core/modules/file/core-file.service.js.map +1 -1
  78. package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
  79. package/dist/core/modules/file/file-metadata.helper.js +33 -0
  80. package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
  81. package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
  82. package/dist/core/modules/file/file-roles.helper.js +53 -0
  83. package/dist/core/modules/file/file-roles.helper.js.map +1 -0
  84. package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
  85. package/dist/core/modules/file/file-storage.helper.js +65 -0
  86. package/dist/core/modules/file/file-storage.helper.js.map +1 -0
  87. package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
  88. package/dist/core/modules/file/filesystem-file.helper.js +77 -0
  89. package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
  90. package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
  91. package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
  92. package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
  93. package/dist/core/modules/file/s3-file.helper.js +81 -0
  94. package/dist/core/modules/file/s3-file.helper.js.map +1 -0
  95. package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
  96. package/dist/core/modules/hub/core-hub.controller.js +24 -22
  97. package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
  98. package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
  99. package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
  100. package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
  101. package/dist/core/modules/hub/hub-buffer.js +101 -0
  102. package/dist/core/modules/hub/hub-buffer.js.map +1 -0
  103. package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
  104. package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
  105. package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
  106. package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
  107. package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
  108. package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
  109. package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
  110. package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
  111. package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
  112. package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
  113. package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
  114. package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
  115. package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
  116. package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
  117. package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
  118. package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
  119. package/dist/core/modules/migrate/migration-runner.js +4 -0
  120. package/dist/core/modules/migrate/migration-runner.js.map +1 -1
  121. package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
  122. package/dist/core/modules/migrate/mongo-state-store.js +98 -9
  123. package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
  124. package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
  125. package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
  126. package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
  127. package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
  128. package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
  129. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  130. package/dist/core/modules/tenant/core-tenant.module.js +3 -4
  131. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  132. package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
  133. package/dist/core/modules/tus/core-tus.controller.js +29 -3
  134. package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
  135. package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
  136. package/dist/core/modules/tus/core-tus.service.js +182 -28
  137. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  138. package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
  139. package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
  140. package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
  141. package/dist/core/modules/tus/tus-redis-locker.js +87 -0
  142. package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
  143. package/dist/core/modules/tus/tus.module.d.ts +1 -0
  144. package/dist/core/modules/tus/tus.module.js +30 -4
  145. package/dist/core/modules/tus/tus.module.js.map +1 -1
  146. package/dist/core.module.js +18 -1
  147. package/dist/core.module.js.map +1 -1
  148. package/dist/index.d.ts +10 -0
  149. package/dist/index.js +10 -0
  150. package/dist/index.js.map +1 -1
  151. package/dist/main.js +2 -1
  152. package/dist/main.js.map +1 -1
  153. package/dist/server/modules/file/file.controller.js.map +1 -1
  154. package/dist/server/modules/file/file.resolver.js +1 -12
  155. package/dist/server/modules/file/file.resolver.js.map +1 -1
  156. package/dist/server/modules/file/file.service.d.ts +5 -1
  157. package/dist/server/modules/file/file.service.js +12 -3
  158. package/dist/server/modules/file/file.service.js.map +1 -1
  159. package/dist/server/modules/user/avatar.controller.d.ts +5 -1
  160. package/dist/server/modules/user/avatar.controller.js +26 -10
  161. package/dist/server/modules/user/avatar.controller.js.map +1 -1
  162. package/dist/server/modules/user/user.model.js +3 -2
  163. package/dist/server/modules/user/user.model.js.map +1 -1
  164. package/dist/server/modules/user/user.module.js +6 -2
  165. package/dist/server/modules/user/user.module.js.map +1 -1
  166. package/dist/server/modules/user/user.service.d.ts +1 -1
  167. package/dist/server/modules/user/user.service.js +6 -13
  168. package/dist/server/modules/user/user.service.js.map +1 -1
  169. package/dist/tsconfig.build.tsbuildinfo +1 -1
  170. package/docs/REQUEST-LIFECYCLE.md +57 -8
  171. package/docs/security-overrides.md +21 -3
  172. package/migration-guides/11.32.3-to-11.32.4.md +15 -3
  173. package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
  174. package/package.json +74 -42
  175. package/src/core/common/helpers/content-disposition.helper.ts +90 -0
  176. package/src/core/common/helpers/file.helper.ts +53 -10
  177. package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
  178. package/src/core/common/helpers/project-name.helper.ts +71 -0
  179. package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
  180. package/src/core/common/interfaces/server-options.interface.ts +439 -2
  181. package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
  182. package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
  183. package/src/core/common/services/core-cron-jobs.service.ts +589 -47
  184. package/src/core/common/services/core-redis-pubsub.ts +183 -0
  185. package/src/core/common/services/core-redis.service.ts +249 -0
  186. package/src/core/common/services/core-s3.service.ts +465 -0
  187. package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
  188. package/src/core/common/services/rate-limit-store.ts +492 -0
  189. package/src/core/modules/ai/README.md +25 -0
  190. package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
  191. package/src/core/modules/ai/services/core-ai.service.ts +33 -24
  192. package/src/core/modules/auth/core-auth.module.ts +7 -1
  193. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
  194. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
  195. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
  196. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
  197. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
  198. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
  199. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
  200. package/src/core/modules/file/README.md +277 -53
  201. package/src/core/modules/file/core-file.controller.ts +168 -12
  202. package/src/core/modules/file/core-file.resolver.ts +46 -12
  203. package/src/core/modules/file/core-file.service.ts +482 -16
  204. package/src/core/modules/file/file-metadata.helper.ts +142 -0
  205. package/src/core/modules/file/file-roles.helper.ts +110 -0
  206. package/src/core/modules/file/file-storage.helper.ts +163 -0
  207. package/src/core/modules/file/filesystem-file.helper.ts +184 -0
  208. package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
  209. package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
  210. package/src/core/modules/file/s3-file.helper.ts +209 -0
  211. package/src/core/modules/hub/README.md +48 -0
  212. package/src/core/modules/hub/core-hub.controller.ts +18 -16
  213. package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
  214. package/src/core/modules/hub/hub-buffer.ts +198 -0
  215. package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
  216. package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
  217. package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
  218. package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
  219. package/src/core/modules/migrate/README.md +33 -6
  220. package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
  221. package/src/core/modules/migrate/migration-runner.ts +10 -1
  222. package/src/core/modules/migrate/mongo-state-store.ts +179 -11
  223. package/src/core/modules/system-setup/README.md +7 -2
  224. package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
  225. package/src/core/modules/tenant/README.md +21 -1
  226. package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
  227. package/src/core/modules/tenant/core-tenant.module.ts +8 -4
  228. package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
  229. package/src/core/modules/tus/README.md +35 -2
  230. package/src/core/modules/tus/core-tus.controller.ts +64 -12
  231. package/src/core/modules/tus/core-tus.service.ts +358 -34
  232. package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
  233. package/src/core/modules/tus/tus-redis-locker.ts +139 -0
  234. package/src/core/modules/tus/tus.module.ts +78 -6
  235. package/src/core.module.ts +26 -1
  236. package/src/index.ts +10 -0
  237. package/src/main.ts +7 -1
  238. package/src/server/modules/file/file.controller.ts +7 -3
  239. package/src/server/modules/file/file.resolver.ts +5 -17
  240. package/src/server/modules/file/file.service.ts +42 -3
  241. package/src/server/modules/user/avatar.controller.ts +39 -9
  242. package/src/server/modules/user/user.model.ts +9 -3
  243. package/src/server/modules/user/user.module.ts +12 -3
  244. package/src/server/modules/user/user.service.ts +20 -17
  245. package/src/test/README.md +7 -2
  246. package/src/core/common/helpers/file.helper.spec.ts +0 -145
  247. package/src/core/common/helpers/logging.helper.spec.ts +0 -61
  248. package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
  249. package/src/core/common/services/brevo.service.spec.ts +0 -266
  250. package/src/core/modules/file/core-file.controller.spec.ts +0 -164
  251. package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
  252. package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
  253. package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
  254. package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
  255. package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
  256. package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
  257. package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
  258. package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
  259. package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
  260. package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
@@ -1,6 +1,5 @@
1
1
  import { Inject, Injectable, Optional, UnauthorizedException, UnprocessableEntityException } from '@nestjs/common';
2
2
  import { InjectModel } from '@nestjs/mongoose';
3
- import fs = require('fs');
4
3
  import { PubSub } from 'graphql-subscriptions';
5
4
  import { Model } from 'mongoose';
6
5
 
@@ -94,10 +93,22 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
94
93
  }
95
94
 
96
95
  /**
97
- * Set avatar image
96
+ * Point the user's avatar at an already-stored file
97
+ *
98
+ * Takes the file ID rather than the upload itself: the bytes belong in the central
99
+ * file storage (GridFS/S3) so every replica can serve them, and putting that
100
+ * dependency here is not possible — `UserService` is instantiated by
101
+ * `CoreAuthModule`, which knows nothing about the project's `FileModule`.
102
+ * `AvatarController` therefore stores the file and calls this with the resulting id.
103
+ *
104
+ * @returns the PREVIOUS avatar id, so the caller can delete the orphaned file
98
105
  */
99
- async setAvatar(file: Express.Multer.File, user: User): Promise<string> {
100
- const dbUser = await this.mainDbModel.findOne({ id: user.id }).exec();
106
+ async setAvatar(avatarId: string, user: User): Promise<string> {
107
+ // `findById`, not `findOne({ id })`: `id` is a Mongoose virtual, so it exists on the
108
+ // document but not in MongoDB — a filter on it matches nothing and every call ended in
109
+ // "session user no longer exists". Broken since the Mongoose migration and never noticed,
110
+ // because no test covers the avatar endpoint.
111
+ const dbUser = await this.mainDbModel.findById(user.id).exec();
101
112
  // Check user: the token is valid but the account no longer exists, so the session really is
102
113
  // invalid — 401 is right here. (A permission error would have to be 403, see accessDeniedException.)
103
114
  if (!dbUser) {
@@ -105,25 +116,17 @@ export class UserService extends CoreUserService<User, UserInput, UserCreateInpu
105
116
  }
106
117
 
107
118
  // Check file
108
- if (!file) {
119
+ if (!avatarId) {
109
120
  throw new UnprocessableEntityException('Missing avatar file');
110
121
  }
111
122
 
112
- // Remove old avatar image
113
- if (user.avatar) {
114
- fs.unlink(`${this.configService.configFastButReadOnly.staticAssets.path}/avatars/${user.avatar}`, (err) => {
115
- if (err) {
116
- console.error(err);
117
- }
118
- });
119
- }
123
+ const previousAvatar = dbUser.avatar;
120
124
 
121
- // Update user
122
- dbUser.avatar = file.filename;
125
+ // The avatar is referenced by file id and served via GET /files/id/:id
126
+ dbUser.avatar = avatarId;
123
127
 
124
128
  await dbUser.save();
125
129
 
126
- // Return user
127
- return file.filename;
130
+ return previousAvatar;
128
131
  }
129
132
  }
@@ -181,10 +181,15 @@ await testHelper.rest('/protected-endpoint', {
181
181
  Download a file and return the response with a `data` string property for content comparison.
182
182
 
183
183
  ```typescript
184
- // No authentication
185
- const res = await testHelper.download('/files/id/abc123');
184
+ // The core download routes are gated by `file.downloadRoles` (default ADMIN),
185
+ // so a request without credentials answers 401 — pass a token or a session cookie.
186
+ const res = await testHelper.download('/files/id/abc123', { token: adminToken });
186
187
  expect(res.statusCode).toEqual(200);
187
188
  expect(res.data).toEqual('file content');
189
+
190
+ // Without credentials:
191
+ const anonymous = await testHelper.download('/files/id/abc123');
192
+ expect(anonymous.statusCode).toEqual(401);
188
193
  ```
189
194
 
190
195
  ### `downloadBuffer(url, tokenOrOptions?)`
@@ -1,145 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { IMAGE_UPLOAD_ALLOW_LIST, multerFileFilter, multerOptionsForImageUpload, UploadAllowList } from './file.helper';
4
-
5
- /**
6
- * The filter used to be ONE unanchored `RegExp`, `.test()`ed against the
7
- * mimetype AND the extension. `.test()` searches for a substring, so every
8
- * alternative matched anywhere inside either value — `te?xt` matched the "text"
9
- * in `text/html`, and a file named `x.txt` sent as `text/html` passed both
10
- * halves of a filter documented as "no html".
11
- *
12
- * Consumer projects hit this for real. The tests below therefore cover three
13
- * things: the exact-matching allow-list, that the legacy `RegExp` form still
14
- * works, and — the point of the fix — that scriptable types are rejected on
15
- * BOTH paths, so an expression that already exists in a consumer project is
16
- * safe without being rewritten.
17
- */
18
-
19
- /** Run a filter the way multer does and report its verdict. */
20
- function verdict(
21
- filter: ReturnType<typeof multerFileFilter>,
22
- mimetype: string,
23
- originalname: string,
24
- ): { accepted: boolean; error?: string } {
25
- let accepted = false;
26
- let error: string | undefined;
27
- filter({}, { mimetype, originalname }, (err: any, result?: boolean) => {
28
- if (err) {
29
- error = err instanceof Error ? err.message : String(err);
30
- }
31
- accepted = result === true;
32
- });
33
- return { accepted, error };
34
- }
35
-
36
- const DOCUMENTS: UploadAllowList = {
37
- extensions: ['.csv', '.pdf', '.txt'],
38
- mimeTypes: ['application/pdf', 'text/csv', 'text/plain'],
39
- };
40
-
41
- describe('multerFileFilter', () => {
42
- describe('allow-list form', () => {
43
- it('accepts a mimetype and extension that are both on the list', () => {
44
- expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain', 'notes.txt').accepted).toBe(true);
45
- expect(verdict(multerFileFilter(DOCUMENTS), 'application/pdf', 'report.pdf').accepted).toBe(true);
46
- });
47
-
48
- it('rejects as soon as one half is missing from the list', () => {
49
- expect(verdict(multerFileFilter(DOCUMENTS), 'application/x-msdownload', 'setup.pdf').accepted).toBe(false);
50
- expect(verdict(multerFileFilter(DOCUMENTS), 'application/pdf', 'setup.exe').accepted).toBe(false);
51
- });
52
-
53
- it('matches whole values, never substrings', () => {
54
- // Only an unanchored matcher would let these through.
55
- expect(verdict(multerFileFilter(DOCUMENTS), 'evil/text-plain', 'notes.txt').accepted).toBe(false);
56
- expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain', 'notes.txtx').accepted).toBe(false);
57
- });
58
-
59
- it('ignores mimetype parameters and casing', () => {
60
- expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain; charset=utf-8', 'notes.txt').accepted).toBe(true);
61
- expect(verdict(multerFileFilter(IMAGE_UPLOAD_ALLOW_LIST), 'IMAGE/PNG', 'PHOTO.PNG').accepted).toBe(true);
62
- });
63
-
64
- it('rejects a file without an extension', () => {
65
- expect(verdict(multerFileFilter(DOCUMENTS), 'text/plain', 'README').accepted).toBe(false);
66
- });
67
- });
68
-
69
- describe('scriptable types are rejected on BOTH forms', () => {
70
- // This is what makes an ALREADY EXISTING consumer expression safe.
71
- const legacy = /jpeg|jpg|png|te?xt|csv/;
72
-
73
- it('rejects text/html even when the expression matches it as a substring', () => {
74
- const result = verdict(multerFileFilter(legacy), 'text/html', 'invoice.txt');
75
- expect(result.accepted).toBe(false);
76
- expect(result.error).toMatch(/may execute as script/);
77
- });
78
-
79
- it('rejects text/xml and application/xhtml+xml', () => {
80
- expect(verdict(multerFileFilter(legacy), 'text/xml', 'notes.txt').accepted).toBe(false);
81
- expect(verdict(multerFileFilter(legacy), 'application/xhtml+xml', 'notes.txt').accepted).toBe(false);
82
- });
83
-
84
- it('rejects SVG on the image default', () => {
85
- expect(verdict(multerFileFilter(), 'image/svg+xml', 'logo.svg').accepted).toBe(false);
86
- });
87
-
88
- it('rejects a scriptable EXTENSION even under a harmless mimetype', () => {
89
- expect(verdict(multerFileFilter(legacy), 'text/plain', 'payload.html').accepted).toBe(false);
90
- expect(verdict(multerFileFilter(legacy), 'image/png', 'payload.svg').accepted).toBe(false);
91
- });
92
-
93
- it('allows them only when the caller opts in explicitly', () => {
94
- const filter = multerFileFilter(
95
- { extensions: ['.svg'], mimeTypes: ['image/svg+xml'] },
96
- {
97
- allowScriptableTypes: true,
98
- },
99
- );
100
- expect(verdict(filter, 'image/svg+xml', 'logo.svg').accepted).toBe(true);
101
- });
102
- });
103
-
104
- describe('legacy RegExp form stays usable', () => {
105
- it('still accepts what it accepted before', () => {
106
- const filter = multerFileFilter(/jpeg|jpg|png/);
107
- expect(verdict(filter, 'image/jpeg', 'photo.jpg').accepted).toBe(true);
108
- expect(verdict(filter, 'image/png', 'photo.png').accepted).toBe(true);
109
- expect(verdict(filter, 'application/pdf', 'report.pdf').accepted).toBe(false);
110
- });
111
- });
112
-
113
- describe('rejection reporting', () => {
114
- it('reports a real Error, not a bare string', () => {
115
- let received: unknown;
116
- multerFileFilter(DOCUMENTS)({}, { mimetype: 'application/x-foo', originalname: 'x.foo' }, (err: unknown) => {
117
- received = err;
118
- });
119
- // A bare string leaves multer with an "error" that has no `message`,
120
- // which NestJS's transformException cannot map to a 4xx.
121
- expect(received).toBeInstanceOf(Error);
122
- });
123
- });
124
- });
125
-
126
- describe('multerOptionsForImageUpload', () => {
127
- it('applies the image allow-list by default', () => {
128
- const filter = multerOptionsForImageUpload({}).fileFilter!;
129
- expect(verdict(filter as any, 'image/png', 'a.png').accepted).toBe(true);
130
- expect(verdict(filter as any, 'image/svg+xml', 'a.svg').accepted).toBe(false);
131
- });
132
-
133
- it('still installs a filter when fileTypeRegex is explicitly undefined', () => {
134
- // Previously this disabled filtering entirely — on a helper named
135
- // "ImageUpload". Safe-by-default instead; see the migration guide.
136
- const filter = multerOptionsForImageUpload({ fileTypeRegex: undefined }).fileFilter;
137
- expect(filter).toBeDefined();
138
- expect(verdict(filter as any, 'text/html', 'a.txt').accepted).toBe(false);
139
- });
140
-
141
- it('honours an explicit allowList', () => {
142
- const filter = multerOptionsForImageUpload({ allowList: DOCUMENTS }).fileFilter!;
143
- expect(verdict(filter as any, 'application/pdf', 'report.pdf').accepted).toBe(true);
144
- });
145
- });
@@ -1,61 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { redactSensitiveText } from './logging.helper';
4
-
5
- describe('redactSensitiveText', () => {
6
- it('redacts JWTs appearing anywhere in a line', () => {
7
- const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U';
8
- const out = redactSensitiveText(`token issued: ${jwt} done`);
9
-
10
- expect(out).not.toContain(jwt);
11
- expect(out).toContain('token issued:');
12
- expect(out).toContain('done');
13
- });
14
-
15
- it('redacts Authorization Bearer values', () => {
16
- const out = redactSensitiveText('Authorization: Bearer abcdef1234567890secret');
17
-
18
- expect(out).not.toContain('abcdef1234567890secret');
19
- expect(out.toLowerCase()).toContain('authorization');
20
- });
21
-
22
- it('redacts key/value secrets regardless of separator or quoting', () => {
23
- expect(redactSensitiveText('password=SuperSecret123')).not.toContain('SuperSecret123');
24
- expect(redactSensitiveText('token: "abcd1234efgh"')).not.toContain('abcd1234efgh');
25
- expect(redactSensitiveText("apiKey='pk_live_9999'")).not.toContain('pk_live_9999');
26
- expect(redactSensitiveText('client_secret=verySecretValue')).not.toContain('verySecretValue');
27
- });
28
-
29
- it('redacts cookie headers', () => {
30
- const out = redactSensitiveText('cookie: session=abc123; other=xyz789');
31
-
32
- expect(out).not.toContain('abc123');
33
- expect(out).not.toContain('xyz789');
34
- });
35
-
36
- it('redacts reset/verification tokens carried as a URL PATH segment (not key=value)', () => {
37
- const link = 'https://app.example.com/verify/AbCdEf0123456789XyZtoken please click';
38
- const out = redactSensitiveText(link);
39
- expect(out).not.toContain('AbCdEf0123456789XyZtoken');
40
- expect(out).toContain('/verify/');
41
- // also for reset / set-password style links
42
- expect(redactSensitiveText('/reset/SUPERSECRETVALUE1234567890')).not.toContain('SUPERSECRETVALUE1234567890');
43
- expect(redactSensitiveText('/set-password/ZZZ1112223334445556667')).not.toContain('ZZZ1112223334445556667');
44
- });
45
-
46
- it('leaves harmless short path segments untouched', () => {
47
- // Only long token-like segments (>= 16 chars) are masked — normal routes stay readable.
48
- const text = 'GET /verify/email done';
49
- expect(redactSensitiveText(text)).toBe(text);
50
- });
51
-
52
- it('leaves harmless text untouched', () => {
53
- const text = 'User signed in successfully with id 507f1f77bcf86cd799439011';
54
- expect(redactSensitiveText(text)).toBe(text);
55
- });
56
-
57
- it('handles empty / non-secret input without throwing', () => {
58
- expect(redactSensitiveText('')).toBe('');
59
- expect(redactSensitiveText('plain message')).toBe('plain message');
60
- });
61
- });
@@ -1,310 +0,0 @@
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
- });