mega-framework 0.1.4 → 0.1.6

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 (59) hide show
  1. package/package.json +1 -1
  2. package/sample/crud/.env +156 -8
  3. package/sample/crud/.env.example +153 -28
  4. package/sample/crud/mega.config.js +61 -2
  5. package/sample/crud/package.json +2 -2
  6. package/sample/crud/yarn.lock +1 -1
  7. package/src/cli/commands/new.js +115 -67
  8. package/src/cli/commands/scaffold.js +6 -12
  9. package/src/cli/index.js +133 -12
  10. package/src/core/boot.js +30 -1
  11. package/src/core/config-validator.js +25 -0
  12. package/src/core/mega-app.js +25 -21
  13. package/src/core/mega-cluster.js +50 -12
  14. package/src/core/scope-registry.js +0 -1
  15. package/src/lib/mega-logger.js +1 -1
  16. package/src/lib/mega-shutdown.js +51 -13
  17. package/sample/crud/test/apps/main/auth-flow.integration.test.js +0 -177
  18. package/sample/crud/test/apps/main/auth-service.test.js +0 -93
  19. package/sample/crud/test/apps/main/chat-channel.test.js +0 -149
  20. package/sample/crud/test/apps/main/cron-demo-service.test.js +0 -93
  21. package/sample/crud/test/apps/main/demo-flow.integration.test.js +0 -386
  22. package/sample/crud/test/apps/main/email-job.test.js +0 -76
  23. package/sample/crud/test/apps/main/guide-service.test.js +0 -68
  24. package/sample/crud/test/apps/main/hash-task.test.js +0 -30
  25. package/sample/crud/test/apps/main/jobs-demo-service.test.js +0 -88
  26. package/sample/crud/test/apps/main/logs-demo-service.test.js +0 -85
  27. package/sample/crud/test/apps/main/metrics-demo-service.test.js +0 -90
  28. package/sample/crud/test/apps/main/note-service.test.js +0 -68
  29. package/sample/crud/test/apps/main/perf-service.test.js +0 -121
  30. package/sample/crud/test/apps/main/perf.integration.test.js +0 -202
  31. package/sample/crud/test/apps/main/redis-demo-service.test.js +0 -98
  32. package/sample/crud/test/apps/main/tracing-demo-service.test.js +0 -90
  33. package/sample/crud/test/apps/main/upload-demo-service.test.js +0 -61
  34. package/sample/crud/test/apps/main/user-service.test.js +0 -65
  35. package/sample/crud/test/apps/main/ws-chat.integration.test.js +0 -233
  36. package/templates/project/app.config.tpl +0 -8
  37. package/templates/project/app.config.views.tpl +0 -37
  38. package/templates/project/ecosystem.config.tpl +0 -10
  39. package/templates/project/env.tpl +0 -12
  40. package/templates/project/gitignore.tpl +0 -8
  41. package/templates/project/locales/client/en.json.tpl +0 -3
  42. package/templates/project/locales/client/ko.json.tpl +0 -3
  43. package/templates/project/locales/server/en.json.tpl +0 -17
  44. package/templates/project/locales/server/ko.json.tpl +0 -17
  45. package/templates/project/mega.config.tpl +0 -11
  46. package/templates/project/package.tpl +0 -25
  47. package/templates/project/public/css/app.css +0 -101
  48. package/templates/project/public/js/app.js +0 -54
  49. package/templates/project/public/js/theme-init.js +0 -12
  50. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +0 -7
  51. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +0 -6
  52. package/templates/project/readme.tpl +0 -48
  53. package/templates/project/route.test.tpl +0 -13
  54. package/templates/project/route.test.views.tpl +0 -15
  55. package/templates/project/route.tpl +0 -10
  56. package/templates/project/route.views.tpl +0 -10
  57. package/templates/project/views/index.ejs.tpl +0 -58
  58. package/templates/project/views/layout.ejs.tpl +0 -73
  59. package/templates/project/vitest.config.tpl +0 -8
@@ -403,36 +403,40 @@ export class MegaApp {
403
403
  // onRoute 훅 등록 이후라 자동 envelope 적용됨. config.skip{Asp,Csrf,RateLimit} 3종이 각 보안 hook 의
404
404
  // 면제 신호다(ADR-072 면제 실효, ADR-127): ASP onRequest·CSRF preHandler·rate-limit allowList 가 검사.
405
405
  const HEALTH_EXEMPT = { skipAsp: true, skipCsrf: true, skipRateLimit: true }
406
-
407
- // /healthliveness (항상 200)
408
- this.fastify.get('/health', { config: HEALTH_EXEMPT }, async () => ({
409
- status: 'ok',
410
- app: this.name,
411
- uptime_ms: Math.floor(process.uptime() * 1000),
412
- ts: Date.now(),
413
- }))
414
-
415
- // /health/ready — readiness (checkAll 후 200 or 503)
416
- this.fastify.get('/health/ready', { config: HEALTH_EXEMPT }, async (req, reply) => {
417
- const snapshot = await MegaHealth.checkAll()
418
- if (!snapshot.ok) reply.code(503)
419
- return {
406
+ const healthCfg = opts.health && typeof opts.health === 'object' ? opts.health : {}
407
+ // 헬스 경로(ADR-072/181)설정 가능. 미지정/빈 문자열이면 기본 /health · /health/ready.
408
+ const livePath = typeof healthCfg.paths?.live === 'string' && healthCfg.paths.live.length > 0 ? healthCfg.paths.live : '/health'
409
+ const readyPath = typeof healthCfg.paths?.ready === 'string' && healthCfg.paths.ready.length > 0 ? healthCfg.paths.ready : '/health/ready'
410
+
411
+ // health 라우트 등록 — `health.enabled:false` 면 생략(ADR-181). 기본(미지정/true) 등록한다.
412
+ if (healthCfg.enabled !== false) {
413
+ // liveness (항상 200)
414
+ this.fastify.get(livePath, { config: HEALTH_EXEMPT }, async () => ({
415
+ status: 'ok',
420
416
  app: this.name,
421
- ...snapshot,
422
417
  uptime_ms: Math.floor(process.uptime() * 1000),
423
418
  ts: Date.now(),
424
- }
425
- })
419
+ }))
420
+
421
+ // readiness (checkAll 후 200 or 503)
422
+ this.fastify.get(readyPath, { config: HEALTH_EXEMPT }, async (req, reply) => {
423
+ const snapshot = await MegaHealth.checkAll()
424
+ if (!snapshot.ok) reply.code(503)
425
+ return {
426
+ app: this.name,
427
+ ...snapshot,
428
+ uptime_ms: Math.floor(process.uptime() * 1000),
429
+ ts: Date.now(),
430
+ }
431
+ })
432
+ }
426
433
 
427
434
  // /metrics — Prometheus 옵트인 (ADR-072/131). exposeMetrics:true 일 때만 등록 →
428
435
  // 디폴트(미등록)는 Fastify 가 404(roadmap 검증 기준). /health 와 동일 보안 면제(HEALTH_EXEMPT).
429
436
  // 접근 제어 = IP allowList(ADR-131) — 빈 list 면 메인 포트 전체 노출(운영자 결정).
430
- const healthCfg = opts.health && typeof opts.health === 'object' ? opts.health : {}
431
437
  if (healthCfg.exposeMetrics === true) {
432
438
  const metricsPath = typeof healthCfg.metricsPath === 'string' && healthCfg.metricsPath.length > 0 ? healthCfg.metricsPath : '/metrics'
433
- // metricsPath 충돌 검증(ADR-072/04-data-models) — health 경로와 겹치면 부팅 throw(fail-fast).
434
- const livePath = healthCfg.paths?.live ?? '/health'
435
- const readyPath = healthCfg.paths?.ready ?? '/health/ready'
439
+ // metricsPath 충돌 검증(ADR-072/04-data-models) — health 경로(위에서 해석)와 겹치면 부팅 throw(fail-fast).
436
440
  if (metricsPath === livePath || metricsPath === readyPath) {
437
441
  throw new MegaConfigError(
438
442
  'health.metrics_path_conflict',
@@ -35,6 +35,8 @@ export class MegaCluster {
35
35
  * @param {number} [opts.gracePeriodMs=30000] - SIGTERM 후 강제 kill 까지 대기
36
36
  * @param {import('node:cluster').Cluster} [opts._cluster] - 테스트용 cluster 주입(기본 node:cluster). per ADR-165
37
37
  * @param {NodeJS.Process} [opts._proc] - 테스트용 process 주입(기본 전역 process). per ADR-165
38
+ * @param {{ info?: Function, warn?: Function, error?: Function } | null} [opts.logger] - 조율 로그용 pino 로거.
39
+ * 미설정이면 console 폴백(마스터는 bootApp 을 안 해 pino 인스턴스가 없을 수 있다, ADR-180).
38
40
  */
39
41
  constructor(opts = {}) {
40
42
  this._instances = resolveInstances(opts.instances)
@@ -45,6 +47,8 @@ export class MegaCluster {
45
47
  // cluster/process 를 주입 seam 으로 분리해 fake 로 in-process 단위 검증한다. per ADR-165
46
48
  this._cluster = opts._cluster ?? cluster
47
49
  this._proc = opts._proc ?? process
50
+ /** @type {{ info?: Function, warn?: Function, error?: Function } | null} 조율 로그용 로거(없으면 console). */
51
+ this._logger = opts.logger ?? null
48
52
  /** @type {Set<MegaWorker>} */
49
53
  this._workers = new Set()
50
54
  /** @type {(() => Promise<void>) | null} */
@@ -92,12 +96,43 @@ export class MegaCluster {
92
96
  return !this._cluster.isPrimary
93
97
  }
94
98
 
99
+ /**
100
+ * 조율 로그용 로거 주입(생성 후, start 전에 호출). 마스터는 bootApp 을 안 해 pino 가 없으므로 CLI 가
101
+ * config 로 만든 인스턴스를 넘긴다(ADR-180). 미주입이면 console 폴백.
102
+ * @param {{ info?: Function, warn?: Function, error?: Function } | null | undefined} logger
103
+ * @returns {void}
104
+ */
105
+ setLogger(logger) {
106
+ this._logger = logger ?? null
107
+ }
108
+
109
+ /**
110
+ * @private 조율 로그 — 주입 로거가 있으면 그 레벨로, 없으면 console 폴백(`[mega-cluster]` 접두).
111
+ * @param {'info'|'warn'|'error'} level @param {string} msg
112
+ */
113
+ _log(level, msg) {
114
+ const log = this._logger
115
+ const text = `[mega-cluster] ${msg}`
116
+ if (log && typeof (/** @type {any} */ (log)[level]) === 'function') /** @type {any} */ (log)[level](text)
117
+ else (level === 'warn' ? console.warn : level === 'error' ? console.error : console.log)(text)
118
+ }
119
+
95
120
  /**
96
121
  * @private 마스터 프로세스 부팅 시퀀스.
97
122
  * @param {() => Promise<void>} workerFn
98
123
  */
99
124
  async _startPrimary(workerFn) {
100
125
  this._workerFn = workerFn
126
+ // 멀티워커 + watch 방어(ADR-182) — 워커는 마스터의 `execArgv` 를 상속하므로, `--watch*` 가 있으면
127
+ // 제거해 각 워커가 자기-watch 로 폭주(파일 변경 시 제각각 재시작)하는 것을 막는다. dev 권장 경로는
128
+ // 단일 프로세스(`mega start --watch`)라 보통 안 타지만, 수동 `node --watch … mega start --cluster N`
129
+ // 케이스의 방어 가드다. 단언이 아닌 setupPrimary 로 fork 전에 1회 적용.
130
+ const execArgv = this._proc.execArgv ?? []
131
+ const cleaned = execArgv.filter((a) => !a.startsWith('--watch'))
132
+ if (cleaned.length !== execArgv.length && typeof this._cluster.setupPrimary === 'function') {
133
+ this._cluster.setupPrimary({ execArgv: cleaned })
134
+ this._log('warn', 'stripped --watch* from worker execArgv (multi-worker watch chaos guard, ADR-182)')
135
+ }
101
136
  // workerFn 은 마스터에서 사용 안 함 (정보 전달용 placeholder)
102
137
  for (let i = 0; i < this._instances; i++) {
103
138
  this._forkWorker()
@@ -107,13 +142,13 @@ export class MegaCluster {
107
142
  const onSignal = (/** @type {string} */ sig) => {
108
143
  if (this._shuttingDown) return
109
144
  this._shuttingDown = true
110
- console.log(`[mega-cluster] received ${sig}, broadcasting shutdown to ${this._workers.size} workers (grace ${this._gracePeriodMs}ms)`)
145
+ this._log('info', `received ${sig}, broadcasting shutdown to ${this._workers.size} workers (grace ${this._gracePeriodMs}ms)`)
111
146
  this._broadcastShutdown()
112
147
  // grace 초과 시 강제 kill. 핸들을 보관해 전원 정상종료(exit 핸들러) 시 취소 — 정상 종료가 이 타이머의
113
148
  // exit(1) 과 경합해 exit code 가 흔들리지 않게 한다(k8s/systemd 종료 판정 노이즈 제거).
114
149
  this._graceTimer = setTimeout(() => {
115
150
  for (const w of this._workers) {
116
- try { w.kill('SIGKILL') } catch (err) { console.warn('[mega-cluster] SIGKILL failed:', err.message) }
151
+ try { w.kill('SIGKILL') } catch (err) { this._log('warn', `SIGKILL failed: ${err.message}`) }
117
152
  }
118
153
  this._proc.exit(1)
119
154
  }, this._gracePeriodMs)
@@ -134,7 +169,7 @@ export class MegaCluster {
134
169
  if (this._workers.size === 0) {
135
170
  if (this._graceTimer) clearTimeout(this._graceTimer) // 전원 정상종료 — 강제-kill 타이머 취소(exit code 경합 제거).
136
171
  this._graceTimer = null
137
- console.log('[mega-cluster] all workers exited, primary exiting 0')
172
+ this._log('info', 'all workers exited, primary exiting 0')
138
173
  this._proc.exit(0)
139
174
  }
140
175
  return
@@ -152,19 +187,21 @@ export class MegaCluster {
152
187
  if (this._rapidCrashTimes.length >= this._maxRapidRespawn) {
153
188
  // H-1 — crash-loop 포기 시 그냥 return 하면 이벤트루프가 비어 exit 0(성공)으로 보인다 →
154
189
  // systemd/k8s 가 "정상 종료" 로 오판. 명시적 exit 1 로 비정상 종료를 알려 재시작을 유도한다.
155
- console.error(
156
- `[mega-cluster] rapid crash-loop detected (${this._rapidCrashTimes.length} rapid crashes within ${windowMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
190
+ this._log(
191
+ 'error',
192
+ `rapid crash-loop detected (${this._rapidCrashTimes.length} rapid crashes within ${windowMs}ms), giving up — exiting 1. Last exit: code=${code}, signal=${signal}.`,
157
193
  )
158
194
  this._proc.exit(1)
159
195
  return // 실 process.exit 은 즉시 종료하나, 주입된 fake 는 계속 실행되므로 명시적 중단
160
196
  }
161
197
  }
162
- console.warn(
163
- `[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crashes=${this._rapidCrashTimes.length}/${this._maxRapidRespawn} in ${windowMs}ms)`,
198
+ this._log(
199
+ 'warn',
200
+ `worker ${worker.process.pid} died (code=${code}, signal=${signal}, lifetime=${lifetimeMs}ms), respawning (rapid-crashes=${this._rapidCrashTimes.length}/${this._maxRapidRespawn} in ${windowMs}ms)`,
164
201
  )
165
202
  this._forkWorker()
166
203
  } else {
167
- console.warn(`[mega-cluster] worker ${worker.process.pid} died (code=${code}, signal=${signal}), respawn disabled`)
204
+ this._log('warn', `worker ${worker.process.pid} died (code=${code}, signal=${signal}), respawn disabled`)
168
205
  }
169
206
  })
170
207
  }
@@ -176,7 +213,7 @@ export class MegaCluster {
176
213
  w.send({ type: SHUTDOWN_MSG })
177
214
  } catch (err) {
178
215
  // 워커가 이미 disconnected — 무시 + 로그 (silent 금지)
179
- console.warn(`[mega-cluster] failed to send shutdown to worker ${w.process.pid}:`, err.message)
216
+ this._log('warn', `failed to send shutdown to worker ${w.process.pid}: ${err.message}`)
180
217
  }
181
218
  }
182
219
  }
@@ -199,8 +236,9 @@ export class MegaCluster {
199
236
  // 핸들러가 있었으면 true, 없으면 false → 무핸들러 경고.
200
237
  const hadListener = this._proc.emit('SIGTERM')
201
238
  if (!hadListener) {
202
- console.warn(
203
- `[mega-cluster] worker ${this._proc.pid} has no SIGTERM handler registered. Will be force-killed by master after grace period. ` +
239
+ this._log(
240
+ 'warn',
241
+ `worker ${this._proc.pid} has no SIGTERM handler registered. Will be force-killed by master after grace period. ` +
204
242
  `Register a SIGTERM handler (or use MegaShutdown.setupSignals) to enable graceful shutdown.`,
205
243
  )
206
244
  }
@@ -211,7 +249,7 @@ export class MegaCluster {
211
249
  try {
212
250
  await workerFn()
213
251
  } catch (err) {
214
- console.error(`[mega-cluster] worker ${this._proc.pid} workerFn failed:`, err)
252
+ this._log('error', `worker ${this._proc.pid} workerFn failed: ${/** @type {any} */ (err)?.stack ?? err}`)
215
253
  this._proc.exit(1)
216
254
  }
217
255
  }
@@ -16,7 +16,6 @@ export const GLOBAL_ONLY_KEYS = Object.freeze([
16
16
  'apps', // 활성 앱 whitelist (ADR-066)
17
17
  'asp', // masterSecret 등 시크릿
18
18
  'health', // /health, /health/ready
19
- 'tracing', // OpenTelemetry (ADR-077)
20
19
  'plugins', // 명시 등록 배열 (ADR-079)
21
20
  'jobs', // MegaJob 서브클래스 배열 — mega worker 가 소비 (ADR-123)
22
21
  'schedules', // MegaSchedule 서브클래스 배열 — mega scheduler 가 소비 (ADR-123)
@@ -133,7 +133,7 @@ function mergeRedact(userRedact) {
133
133
  /**
134
134
  * `logger` config → pino 옵션(또는 비활성 시 `null`). Fastify `logger` 또는 `pino()` 에 그대로 전달 가능.
135
135
  *
136
- * @param {unknown} config - `MegaLoggerConfig`(`{ level, sinks, redact?, includeRequestId? }`).
136
+ * @param {unknown} config - `MegaLoggerConfig`(`{ level, sinks, redact? }`).
137
137
  * @returns {{ level: string, mixin: Function, redact: { paths: string[] }, transport: { targets: any[] } } | null}
138
138
  */
139
139
  export function buildLoggerOptions(config) {
@@ -37,6 +37,14 @@ let globalErrorsRegistered = false
37
37
  let unhandledRejectionHandler = null
38
38
  /** @type {((err: Error, origin?: string) => void) | null} */
39
39
  let uncaughtExceptionHandler = null
40
+ /**
41
+ * 전역 에러 핸들러가 fatal 로그에 쓸 로거. boot 이 pino 공유 인스턴스(appLogger)를 주입한다(setLogger).
42
+ * 미설정(부팅 전 조기 크래시·로거 비활성 프로세스)이면 console.error 로 폴백한다. process 레벨 핸들러는
43
+ * bootApp 의 DI 그래프 밖이라 모듈 스코프 변수로 받아 둔다(전역 오염 회피, ADR-178).
44
+ * @typedef {{ info?: (...args: any[]) => void, warn?: (...args: any[]) => void, error?: (...args: any[]) => void, fatal?: (...args: any[]) => void }} ShutdownLogger
45
+ * @type {ShutdownLogger | null}
46
+ */
47
+ let shutdownLogger = null
40
48
 
41
49
  /**
42
50
  * cleanup hook 등록. 순서는 등록 역순(LIFO)으로 실행.
@@ -111,22 +119,40 @@ function setupGlobalErrorHandlers({ exitCode = 1 } = {}) {
111
119
  if (globalErrorsRegistered) return
112
120
  globalErrorsRegistered = true
113
121
  unhandledRejectionHandler = (reason) => {
114
- const log = /** @type {any} */ (globalThis).logger
115
122
  const err = reason instanceof Error ? reason : new Error(`unhandledRejection: ${String(reason)}`)
116
- if (log?.fatal) log.fatal({ err }, 'unhandledRejection — initiating graceful shutdown')
117
- else console.error('[mega-shutdown] unhandledRejection — initiating graceful shutdown:', err)
123
+ logShutdown('fatal', 'unhandledRejection — initiating graceful shutdown', { err })
118
124
  void now({ signal: 'unhandledRejection', exitCode })
119
125
  }
120
126
  uncaughtExceptionHandler = (err, origin) => {
121
- const log = /** @type {any} */ (globalThis).logger
122
- if (log?.fatal) log.fatal({ err, origin }, 'uncaughtException — initiating graceful shutdown')
123
- else console.error('[mega-shutdown] uncaughtException — initiating graceful shutdown:', err, origin)
127
+ logShutdown('fatal', 'uncaughtException initiating graceful shutdown', { err, origin })
124
128
  void now({ signal: 'uncaughtException', exitCode })
125
129
  }
126
130
  process.on('unhandledRejection', unhandledRejectionHandler)
127
131
  process.on('uncaughtException', uncaughtExceptionHandler)
128
132
  }
129
133
 
134
+ /**
135
+ * 종료 시퀀스 로깅 — 주입된 로거(setLogger)가 있으면 그 레벨 메서드로, 없으면 console 폴백.
136
+ * process 레벨 종료 경로라 로거 미주입(부팅 전·로거 비활성 프로세스)일 수 있어 항상 폴백을 갖춘다.
137
+ * `fatal` 은 console 에 없으므로 폴백에선 `console.error` 로 매핑한다. fields 는 구조적 로그의 payload
138
+ * (pino `log.level(fields, msg)`)이자 console 폴백의 보조 인자.
139
+ * @param {'info'|'warn'|'error'|'fatal'} level - 로그 레벨.
140
+ * @param {string} msg - 메시지.
141
+ * @param {Record<string, any>} [fields] - 구조적 필드(선택).
142
+ * @returns {void}
143
+ */
144
+ function logShutdown(level, msg, fields) {
145
+ const log = shutdownLogger
146
+ if (log && typeof (/** @type {any} */ (log)[level]) === 'function') {
147
+ if (fields) /** @type {any} */ (log)[level](fields, msg)
148
+ else /** @type {any} */ (log)[level](msg)
149
+ return
150
+ }
151
+ const consoleFn = level === 'warn' ? console.warn : level === 'info' ? console.log : console.error
152
+ if (fields) consoleFn(`[mega-shutdown] ${msg}`, fields)
153
+ else consoleFn(`[mega-shutdown] ${msg}`)
154
+ }
155
+
130
156
  /**
131
157
  * 즉시 graceful shutdown 트리거.
132
158
  *
@@ -138,17 +164,17 @@ function setupGlobalErrorHandlers({ exitCode = 1 } = {}) {
138
164
  async function now({ signal, exitCode = 0 } = {}) {
139
165
  // M-3 — 두 번째 shutdown 시그널: 이미 진행 중이면 grace 무시하고 즉시 force exit 1.
140
166
  if (isShuttingDownFlag) {
141
- console.error('[mega-shutdown] MegaShutdown: second shutdown signal — force exit 1')
167
+ logShutdown('error', 'second shutdown signal — force exit 1')
142
168
  process.exit(1)
143
169
  return // process.exit mock 시(테스트) 아래로 흐르지 않도록 가드
144
170
  }
145
171
  isShuttingDownFlag = true
146
172
 
147
- console.log(`[mega-shutdown] starting (signal=${signal ?? 'manual'}, handlers=${handlers.length}, grace=${gracePeriodMs}ms)`)
173
+ logShutdown('info', 'shutdown starting', { signal: signal ?? 'manual', handlers: handlers.length, graceMs: gracePeriodMs })
148
174
 
149
175
  // hardKill 보호 — hardKillMs 초과 시 강제 종료
150
176
  const hardKillTimer = setTimeout(() => {
151
- console.error('[mega-shutdown] grace period exceeded, force exit(1)')
177
+ logShutdown('error', 'grace period exceeded force exit(1)', { hardKillMs })
152
178
  process.exit(1)
153
179
  }, hardKillMs)
154
180
  hardKillTimer.unref()
@@ -158,7 +184,7 @@ async function now({ signal, exitCode = 0 } = {}) {
158
184
  const { name, fn } = handlers[i]
159
185
  if (exitedHandlers.has(i)) continue
160
186
  try {
161
- console.log(`[mega-shutdown] running '${name}'`)
187
+ logShutdown('info', `running hook '${name}'`, { hook: name })
162
188
  await Promise.race([
163
189
  Promise.resolve(fn()),
164
190
  new Promise((_, reject) =>
@@ -166,18 +192,28 @@ async function now({ signal, exitCode = 0 } = {}) {
166
192
  ),
167
193
  ])
168
194
  exitedHandlers.add(i)
169
- console.log(`[mega-shutdown] '${name}' done`)
195
+ logShutdown('info', `hook '${name}' done`, { hook: name })
170
196
  } catch (err) {
171
197
  // silent 금지. 핸들러 실패 시 warn 로그 + 다음 hook 진행.
172
- console.warn(`[mega-shutdown] '${name}' failed (continuing):`, err?.message ?? err)
198
+ logShutdown('warn', `hook '${name}' failed (continuing)`, { hook: name, err: /** @type {any} */ (err)?.message ?? err })
173
199
  }
174
200
  }
175
201
 
176
202
  clearTimeout(hardKillTimer)
177
- console.log(`[mega-shutdown] complete, exit(${exitCode})`)
203
+ logShutdown('info', `shutdown complete exit(${exitCode})`, { exitCode })
178
204
  process.exit(exitCode)
179
205
  }
180
206
 
207
+ /**
208
+ * 전역 에러 핸들러(unhandledRejection/uncaughtException)가 fatal 로그에 쓸 로거를 주입한다(ADR-178).
209
+ * boot 이 pino 공유 인스턴스(appLogger)를 만들 때 호출한다. 미호출이면 핸들러는 console.error 로 폴백한다.
210
+ * @param {ShutdownLogger | null | undefined} logger - pino 호환 로거(info/warn/error/fatal 사용) 또는 null.
211
+ * @returns {void}
212
+ */
213
+ function setLogger(logger) {
214
+ shutdownLogger = logger ?? null
215
+ }
216
+
181
217
  /**
182
218
  * 테스트용 — 등록된 hook 수 (디버그).
183
219
  * @returns {number}
@@ -202,6 +238,7 @@ function _reset() {
202
238
  unhandledRejectionHandler = null
203
239
  uncaughtExceptionHandler = null
204
240
  globalErrorsRegistered = false
241
+ shutdownLogger = null
205
242
  }
206
243
 
207
244
  /**
@@ -214,6 +251,7 @@ export const MegaShutdown = {
214
251
  isShuttingDown,
215
252
  setupSignals,
216
253
  setupGlobalErrorHandlers,
254
+ setLogger,
217
255
  now,
218
256
  registeredCount,
219
257
  _reset,
@@ -1,177 +0,0 @@
1
- // @ts-check
2
- /**
3
- * 인증 흐름 통합 테스트(ADR-155) — 실 redis(세션·brute-force) + postgres 로 sample/crud 를 부팅하고
4
- * HTTP 전 경로(회원가입→자동로그인→보호자원→로그아웃→차단, 잘못된 비밀번호→brute-force 잠금)를 검증한다.
5
- *
6
- * 인프라(redis·pg) env 가 없으면 통째로 skip 한다(단위 테스트는 `auth-service.test.js` 가 인프라 없이 커버).
7
- * 실행에 필요한 env(.env): DATABASE_URL(또는 PG_URL)·REDIS_SESSION_URL·REDIS_RATE_URL·SESSION_SECRET.
8
- *
9
- * CSRF(쿠키 double-submit, ADR-051)가 켜져 있어 POST 마다 폼 토큰+쿠키를 왕복시킨다 — 실제 브라우저 폼과
10
- * 같은 경로다. 세션 쿠키(mega.sid)도 누적 쿠키 jar 로 왕복한다.
11
- */
12
- import { describe, test, expect, beforeAll, afterAll } from 'vitest'
13
- import { bootApp, MegaShutdown } from 'mega-framework'
14
- import { fileURLToPath } from 'node:url'
15
- import { dirname, resolve } from 'node:path'
16
- import { User } from '../../../apps/main/models/user.js'
17
-
18
- // 프로젝트 루트(mega.config.js 가 있는 sample/crud) — 이 파일 기준 상위 4단계.
19
- const PROJECT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
20
-
21
- // .env 가 DATABASE_URL 대신 PG_URL 만 줄 수 있으므로(로컬 관례) 매핑한다.
22
- if (!process.env.DATABASE_URL && process.env.PG_URL) process.env.DATABASE_URL = process.env.PG_URL
23
-
24
- const hasInfra = Boolean(process.env.DATABASE_URL && process.env.REDIS_SESSION_URL && process.env.REDIS_RATE_URL && process.env.SESSION_SECRET)
25
- const d = hasInfra ? describe : describe.skip
26
-
27
- /** set-cookie 를 누적 쿠키 jar 에 반영(maxAge=0 → 삭제). @param {any} res @param {Record<string,string>} jar */
28
- function applyCookies(res, jar) {
29
- const raw = res.headers['set-cookie']
30
- const arr = Array.isArray(raw) ? raw : raw ? [raw] : []
31
- for (const c of arr) {
32
- const pair = String(c).split(';')[0]
33
- const eq = pair.indexOf('=')
34
- if (eq === -1) continue
35
- const name = pair.slice(0, eq).trim()
36
- const val = pair.slice(eq + 1).trim()
37
- if (val === '') delete jar[name]
38
- else jar[name] = val
39
- }
40
- }
41
-
42
- /** @param {Record<string,string>} jar @returns {string} Cookie 헤더. */
43
- function cookieHeader(jar) {
44
- return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
45
- }
46
-
47
- /** 렌더된 폼 HTML 에서 hidden `_csrf` 토큰을 뽑는다. @param {string} html @returns {string} */
48
- function csrfFrom(html) {
49
- const m = /name="_csrf" value="([^"]+)"/.exec(html)
50
- if (!m) throw new Error('csrf token not found in form HTML')
51
- return m[1]
52
- }
53
-
54
- /** urlencoded 폼 바디 직렬화. @param {Record<string,string>} fields @returns {string} */
55
- function form(fields) {
56
- return Object.entries(fields).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
57
- }
58
-
59
- d('인증 흐름 E2E — sample/crud 실 redis+pg (ADR-155)', () => {
60
- /** @type {Awaited<ReturnType<typeof bootApp>>} */
61
- let boot
62
- /** @type {any} */
63
- let fastify
64
- const EMAIL = `itest-auth-${Date.now()}@example.com`
65
- const PASSWORD = 'secret-pass-123'
66
- const NAME = 'Auth Tester'
67
-
68
- beforeAll(async () => {
69
- MegaShutdown._reset()
70
- boot = await bootApp(PROJECT, { listen: false })
71
- const app = boot.megaApps.find((a) => a.name === 'main')
72
- fastify = app?.fastify
73
- await fastify.ready() // onReady → 세션 store connect(실 redis).
74
- // 스키마 보장(마이그레이션과 동치, 멱등) — 테스트 전제. 실제 운영은 `mega migrate` 가 적용한다.
75
- await User.query('CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, created_at TIMESTAMPTZ NOT NULL DEFAULT now())')
76
- await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT')
77
- await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ')
78
- await User.query('DELETE FROM users WHERE email = $1', [EMAIL])
79
- })
80
-
81
- afterAll(async () => {
82
- if (!boot) return
83
- await User.query('DELETE FROM users WHERE email = $1', [EMAIL]).catch(() => {})
84
- await fastify?.close().catch(() => {})
85
- const app = boot.megaApps.find((a) => a.name === 'main')
86
- await app?.sessionStore?.disconnect().catch(() => {})
87
- await boot.ctx.cache('rate').disconnect().catch(() => {})
88
- await boot.ctx.db('primary').disconnect().catch(() => {})
89
- MegaShutdown._reset()
90
- })
91
-
92
- test('비로그인: 랜딩(/)은 공개 200, /admin/users 는 로그인으로 302, /users API 는 401', async () => {
93
- const home = await fastify.inject({ method: 'GET', url: '/' })
94
- expect(home.statusCode).toBe(200)
95
-
96
- const admin = await fastify.inject({ method: 'GET', url: '/admin/users' })
97
- expect(admin.statusCode).toBe(302)
98
- expect(admin.headers.location).toBe('/auth/login')
99
-
100
- const api = await fastify.inject({ method: 'GET', url: '/users' })
101
- expect(api.statusCode).toBe(401)
102
- expect(api.json().error?.code).toBe('auth.required')
103
- })
104
-
105
- test('회원가입 → 자동 로그인 → 보호자원 접근 → API 접근 → 로그아웃 → 재차단', async () => {
106
- /** @type {Record<string,string>} */
107
- const jar = {}
108
-
109
- // 1) 회원가입 폼 GET — _csrf 쿠키 + 토큰 확보.
110
- const regForm = await fastify.inject({ method: 'GET', url: '/register' })
111
- expect(regForm.statusCode).toBe(200)
112
- applyCookies(regForm, jar)
113
- const regToken = csrfFrom(regForm.body)
114
-
115
- // 2) 회원가입 POST — 성공 시 자동 로그인(세션 발급) + /admin/users 로 302.
116
- const reg = await fastify.inject({
117
- method: 'POST',
118
- url: '/register',
119
- headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
120
- payload: form({ _csrf: regToken, name: NAME, email: EMAIL, password: PASSWORD }),
121
- })
122
- expect(reg.statusCode).toBe(302)
123
- expect(reg.headers.location).toBe('/admin/users?notice=registered')
124
- applyCookies(reg, jar)
125
- expect(jar['mega.sid']).toBeTruthy() // 세션 쿠키 발급됨.
126
-
127
- // 3) 보호 자원 GET — 세션 쿠키로 통과(200), 본문에 로그인 사용자 이름 표시.
128
- const admin = await fastify.inject({ method: 'GET', url: '/admin/users', headers: { cookie: cookieHeader(jar) } })
129
- expect(admin.statusCode).toBe(200)
130
- expect(admin.body).toContain(NAME)
131
- applyCookies(admin, jar)
132
-
133
- // 4) JSON API GET — 같은 세션으로 통과(envelope ok).
134
- const api = await fastify.inject({ method: 'GET', url: '/users', headers: { cookie: cookieHeader(jar) } })
135
- expect(api.statusCode).toBe(200)
136
- expect(api.json().ok).toBe(true)
137
-
138
- // 5) 로그아웃 POST — 보호 페이지에서 받은 _csrf 토큰으로.
139
- const logoutToken = csrfFrom(admin.body)
140
- const logout = await fastify.inject({
141
- method: 'POST',
142
- url: '/auth/logout',
143
- headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
144
- payload: form({ _csrf: logoutToken }),
145
- })
146
- expect(logout.statusCode).toBe(302)
147
- expect(logout.headers.location).toBe('/auth/login?notice=logged_out')
148
- applyCookies(logout, jar)
149
-
150
- // 6) 로그아웃 후 보호 자원 재차단(302 → 로그인).
151
- const blocked = await fastify.inject({ method: 'GET', url: '/admin/users', headers: { cookie: cookieHeader(jar) } })
152
- expect(blocked.statusCode).toBe(302)
153
- expect(blocked.headers.location).toBe('/auth/login')
154
- })
155
-
156
- test('잘못된 비밀번호 반복 → brute-force 잠금(423)', async () => {
157
- const lockEmail = `itest-lock-${Date.now()}@example.com`
158
- // 잠금 임계(기본 maxAttempts=5)까지 틀린 로그인을 반복한다. 없는 계정이라도 brute-force 는 subject(IP:email) 기준.
159
- let lastStatus = 0
160
- for (let i = 0; i < 6; i++) {
161
- /** @type {Record<string,string>} */
162
- const jar = {}
163
- const formPage = await fastify.inject({ method: 'GET', url: '/auth/login' })
164
- applyCookies(formPage, jar)
165
- const token = csrfFrom(formPage.body)
166
- const res = await fastify.inject({
167
- method: 'POST',
168
- url: '/auth/login',
169
- headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
170
- payload: form({ _csrf: token, email: lockEmail, password: 'definitely-wrong' }),
171
- })
172
- lastStatus = res.statusCode
173
- }
174
- // 임계 도달 후에는 잠금(423)으로 응답한다(401 invalid 가 아니라).
175
- expect(lastStatus).toBe(423)
176
- })
177
- })
@@ -1,93 +0,0 @@
1
- // @ts-check
2
- /**
3
- * AuthService 단위 테스트(ADR-155) — 모델(static)을 스파이로 갈음해 DB 없이 비즈니스 로직을 검증한다.
4
- * 해싱은 실 MegaHash(scrypt)를 그대로 써서 register→authenticate 왕복이 진짜로 맞물리는지 본다(인프라 불필요).
5
- */
6
- import { describe, test, expect, vi, afterEach } from 'vitest'
7
- import { AuthService } from '../../../apps/main/services/auth-service.js'
8
- import { User } from '../../../apps/main/models/user.js'
9
-
10
- /** @returns {AuthService} */
11
- function makeService() {
12
- return new AuthService(/** @type {any} */ ({ log: { debug() {} } }))
13
- }
14
-
15
- afterEach(() => vi.restoreAllMocks())
16
-
17
- describe('AuthService.register', () => {
18
- test('유효 입력 → 해시 후 User.register 위임(평문 미저장)', async () => {
19
- const reg = vi
20
- .spyOn(User, 'register')
21
- .mockResolvedValue(/** @type {any} */ ({ id: 1, name: 'Ada', email: 'ada@x.io', created_at: 't' }))
22
- const out = await makeService().register({ name: ' Ada ', email: ' Ada@X.io ', password: 'secret-12' })
23
- expect(out).toEqual({ id: 1, name: 'Ada', email: 'ada@x.io', created_at: 't' })
24
- const arg = reg.mock.calls[0][0]
25
- expect(arg.name).toBe('Ada') // trim.
26
- expect(arg.email).toBe('ada@x.io') // trim + lowercase.
27
- expect(arg.passwordHash).toMatch(/^\$scrypt\$/) // 평문이 아니라 scrypt 해시.
28
- expect(arg.passwordHash).not.toContain('secret-12')
29
- })
30
-
31
- test('이름/이메일 누락 → MegaValidationError(auth.invalid)', async () => {
32
- await expect(makeService().register({ name: '', email: 'a@b.c', password: 'secret-12' })).rejects.toMatchObject({
33
- code: 'auth.invalid',
34
- })
35
- })
36
-
37
- test('비밀번호 8자 미만 → MegaValidationError(auth.invalid)', async () => {
38
- await expect(makeService().register({ name: 'Ada', email: 'a@b.c', password: 'short' })).rejects.toMatchObject({
39
- code: 'auth.invalid',
40
- })
41
- })
42
-
43
- test('이메일 중복(23505) → MegaConflictError(user.email_taken)', async () => {
44
- vi.spyOn(User, 'register').mockRejectedValue(/** @type {any} */ (Object.assign(new Error('dup'), { code: '23505' })))
45
- await expect(makeService().register({ name: 'Ada', email: 'a@b.c', password: 'secret-12' })).rejects.toMatchObject({
46
- status: 409,
47
- code: 'user.email_taken',
48
- })
49
- })
50
- })
51
-
52
- describe('AuthService.authenticate', () => {
53
- test('올바른 비밀번호 → 신원 반환 + last_login 갱신', async () => {
54
- const { MegaHash } = await import('mega-framework')
55
- const hash = await MegaHash.password.hash('secret-12')
56
- vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(
57
- /** @type {any} */ ({ id: 7, name: 'Ada', email: 'ada@x.io', password_hash: hash }),
58
- )
59
- const touch = vi.spyOn(User, 'touchLastLogin').mockResolvedValue(undefined)
60
- const out = await makeService().authenticate({ email: 'Ada@X.io', password: 'secret-12' })
61
- expect(out).toEqual({ id: 7, name: 'Ada' })
62
- expect(touch).toHaveBeenCalledWith(7)
63
- })
64
-
65
- test('틀린 비밀번호 → null(이유 비노출), last_login 미갱신', async () => {
66
- const { MegaHash } = await import('mega-framework')
67
- const hash = await MegaHash.password.hash('secret-12')
68
- vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(
69
- /** @type {any} */ ({ id: 7, name: 'Ada', email: 'ada@x.io', password_hash: hash }),
70
- )
71
- const touch = vi.spyOn(User, 'touchLastLogin').mockResolvedValue(undefined)
72
- expect(await makeService().authenticate({ email: 'ada@x.io', password: 'wrong-pass' })).toBeNull()
73
- expect(touch).not.toHaveBeenCalled()
74
- })
75
-
76
- test('없는 이메일 → null', async () => {
77
- vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(null)
78
- expect(await makeService().authenticate({ email: 'nope@x.io', password: 'secret-12' })).toBeNull()
79
- })
80
-
81
- test('비밀번호 미설정 계정(admin CRUD 생성) → null', async () => {
82
- vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(
83
- /** @type {any} */ ({ id: 3, name: 'NoPass', email: 'np@x.io', password_hash: null }),
84
- )
85
- expect(await makeService().authenticate({ email: 'np@x.io', password: 'secret-12' })).toBeNull()
86
- })
87
-
88
- test('이메일/비밀번호 공란 → null(조회 안 함)', async () => {
89
- const find = vi.spyOn(User, 'findByEmailWithHash').mockResolvedValue(null)
90
- expect(await makeService().authenticate({ email: '', password: '' })).toBeNull()
91
- expect(find).not.toHaveBeenCalled()
92
- })
93
- })