mega-framework 0.1.3 → 0.1.5

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 (61) hide show
  1. package/package.json +1 -1
  2. package/sample/crud/yarn.lock +1 -1
  3. package/src/adapters/file-adapter.js +5 -0
  4. package/src/adapters/mega-cache-adapter.js +6 -2
  5. package/src/adapters/nats-adapter.js +6 -1
  6. package/src/cli/commands/console-cmd.js +4 -2
  7. package/src/cli/commands/new.js +115 -67
  8. package/src/cli/commands/scaffold.js +6 -12
  9. package/src/cli/index.js +1 -1
  10. package/src/core/mega-app.js +11 -3
  11. package/src/core/mega-cluster.js +27 -17
  12. package/src/core/ws-upgrade.js +19 -1
  13. package/src/lib/asp/nonce-cache.js +12 -0
  14. package/src/lib/logger/telegram-core.js +33 -5
  15. package/src/lib/logger/telegram-transport.js +22 -2
  16. package/src/lib/mega-job-queue.js +22 -1
  17. package/src/lib/mega-logger.js +41 -2
  18. package/src/lib/mega-shutdown.js +46 -2
  19. package/sample/crud/test/apps/main/auth-flow.integration.test.js +0 -177
  20. package/sample/crud/test/apps/main/auth-service.test.js +0 -93
  21. package/sample/crud/test/apps/main/chat-channel.test.js +0 -149
  22. package/sample/crud/test/apps/main/cron-demo-service.test.js +0 -93
  23. package/sample/crud/test/apps/main/demo-flow.integration.test.js +0 -386
  24. package/sample/crud/test/apps/main/email-job.test.js +0 -76
  25. package/sample/crud/test/apps/main/guide-service.test.js +0 -68
  26. package/sample/crud/test/apps/main/hash-task.test.js +0 -30
  27. package/sample/crud/test/apps/main/jobs-demo-service.test.js +0 -88
  28. package/sample/crud/test/apps/main/logs-demo-service.test.js +0 -85
  29. package/sample/crud/test/apps/main/metrics-demo-service.test.js +0 -90
  30. package/sample/crud/test/apps/main/note-service.test.js +0 -68
  31. package/sample/crud/test/apps/main/perf-service.test.js +0 -121
  32. package/sample/crud/test/apps/main/perf.integration.test.js +0 -202
  33. package/sample/crud/test/apps/main/redis-demo-service.test.js +0 -98
  34. package/sample/crud/test/apps/main/tracing-demo-service.test.js +0 -90
  35. package/sample/crud/test/apps/main/upload-demo-service.test.js +0 -61
  36. package/sample/crud/test/apps/main/user-service.test.js +0 -65
  37. package/sample/crud/test/apps/main/ws-chat.integration.test.js +0 -233
  38. package/templates/project/app.config.tpl +0 -8
  39. package/templates/project/app.config.views.tpl +0 -37
  40. package/templates/project/ecosystem.config.tpl +0 -10
  41. package/templates/project/env.tpl +0 -12
  42. package/templates/project/gitignore.tpl +0 -8
  43. package/templates/project/locales/client/en.json.tpl +0 -3
  44. package/templates/project/locales/client/ko.json.tpl +0 -3
  45. package/templates/project/locales/server/en.json.tpl +0 -17
  46. package/templates/project/locales/server/ko.json.tpl +0 -17
  47. package/templates/project/mega.config.tpl +0 -11
  48. package/templates/project/package.tpl +0 -25
  49. package/templates/project/public/css/app.css +0 -101
  50. package/templates/project/public/js/app.js +0 -54
  51. package/templates/project/public/js/theme-init.js +0 -12
  52. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +0 -7
  53. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +0 -6
  54. package/templates/project/readme.tpl +0 -48
  55. package/templates/project/route.test.tpl +0 -13
  56. package/templates/project/route.test.views.tpl +0 -15
  57. package/templates/project/route.tpl +0 -10
  58. package/templates/project/route.views.tpl +0 -10
  59. package/templates/project/views/index.ejs.tpl +0 -58
  60. package/templates/project/views/layout.ejs.tpl +0 -73
  61. package/templates/project/vitest.config.tpl +0 -8
@@ -61,6 +61,8 @@ export function httpsPost(url, body, { request = httpsRequestRaw, timeoutMs = 10
61
61
  * @param {string} [opts.retryDir] - retry queue 디렉터리(디폴트 './logs/telegram-retry').
62
62
  * @param {string} [opts.serviceName]
63
63
  * @param {number} [opts.retryDrainMs] - retry 드레인 주기(디폴트 30_000).
64
+ * @param {number} [opts.retryMaxEntries] - retry 큐 보관 최대 항목 수(디폴트 5000, 0=무제한, H4 cap).
65
+ * @param {number} [opts.retryMaxAgeMs] - retry 항목 최대 보존 ms(디폴트 24h, 0=무제한, H4 cap).
64
66
  * @param {(url: string, body: string) => Promise<{ statusCode: number }>} [opts.httpsRequest] - HTTP 주입(기본=httpsPost). 단위 테스트용 seam(ADR-165 동일 패턴) — pino worker 는 미전달.
65
67
  * @returns {import('node:stream').Writable}
66
68
  */
@@ -73,10 +75,20 @@ export default function telegramTransport(opts) {
73
75
  retryDir = './logs/telegram-retry',
74
76
  serviceName = 'mega',
75
77
  retryDrainMs = 30_000,
78
+ retryMaxEntries = 5000,
79
+ retryMaxAgeMs = 86_400_000,
76
80
  httpsRequest = httpsPost,
77
81
  } = opts
78
82
  const throttle = createThrottle(throttleMax, throttleWindowMs)
79
- const queue = new RetryQueue(join(retryDir, 'telegram-retry.jsonl'))
83
+ /** throttle 드롭된 메시지 수(폭주 억제) — 주기적으로 관측 노출(silent 드롭 사각 제거). */
84
+ let droppedByThrottle = 0
85
+ // retry queue cap(H4) — 무한 디스크/메모리 증가 방지. cap 드롭은 stderr 로 관측(텔레그램 sink 라
86
+ // logger 재귀를 피해 process.stderr 직접 사용).
87
+ const queue = new RetryQueue(join(retryDir, 'telegram-retry.jsonl'), {
88
+ maxEntries: retryMaxEntries,
89
+ maxAgeMs: retryMaxAgeMs,
90
+ onDrop: (n) => process.stderr.write(`[telegram-transport] dropped ${n} stale/over-cap retry entries (H4 cap)\n`),
91
+ })
80
92
 
81
93
  /** 한 건 전송 시도 — 실패/throw 시 retry queue 로. */
82
94
  async function trySend(/** @type {string} */ text) {
@@ -92,6 +104,11 @@ export default function telegramTransport(opts) {
92
104
  // 주기 드레인 — 쌓인 실패분을 재전송(여전히 실패하면 다시 큐로). unref 로 프로세스 종료 막지 않음.
93
105
  /** @returns {Promise<void>} 쌓인 실패분 재전송(여전히 실패하면 trySend 가 다시 큐로). */
94
106
  const drainTick = async () => {
107
+ // throttle 로 조용히 드롭된 수를 주기적으로 노출(관측 사각 제거, Med). stderr 직접(logger 재귀 회피).
108
+ if (droppedByThrottle > 0) {
109
+ process.stderr.write(`[telegram-transport] throttled-dropped ${droppedByThrottle} messages in last window\n`)
110
+ droppedByThrottle = 0
111
+ }
95
112
  const pending = await queue.drain().catch(() => /** @type {string[]} */ ([]))
96
113
  for (const text of pending) await trySend(text)
97
114
  }
@@ -113,7 +130,10 @@ export default function telegramTransport(opts) {
113
130
  } catch {
114
131
  continue // 손상 라인 skip — 다음 라인 계속.
115
132
  }
116
- if (!throttle.tryAcquire(Date.now())) continue // 폭주 억제(초과분 드롭).
133
+ if (!throttle.tryAcquire(Date.now())) {
134
+ droppedByThrottle++ // 폭주 억제(초과분 드롭) — 수를 세어 drainTick 에서 관측 노출.
135
+ continue
136
+ }
117
137
  void trySend(formatMessage(record, serviceName))
118
138
  }
119
139
  cb()
@@ -62,6 +62,19 @@ const NOT_FOUND_CODE = '404'
62
62
  /** DLQ 스트림 `max_age` 디폴트(ms) — 7일. 무한 적재 방지(ADR-134). `dlqMaxAgeMs: 0` 으로 끌 수 있다. */
63
63
  export const DEFAULT_DLQ_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
64
64
 
65
+ /** DLQ 봉투에 싣는 error.stack 최대 길이(자) — poison 잡 폭주 시 DLQ 비대 방지(Med). */
66
+ const DLQ_MAX_STACK_LEN = 4000
67
+
68
+ /**
69
+ * DLQ 봉투용 stack 문자열을 상한으로 자른다. 초과 시 말미에 잘림 표식을 붙인다.
70
+ * @param {string | undefined} stack
71
+ * @returns {string | undefined}
72
+ */
73
+ function truncateStack(stack) {
74
+ if (typeof stack !== 'string' || stack.length <= DLQ_MAX_STACK_LEN) return stack
75
+ return stack.slice(0, DLQ_MAX_STACK_LEN) + `\n… [truncated ${stack.length - DLQ_MAX_STACK_LEN} chars]`
76
+ }
77
+
65
78
  /**
66
79
  * @typedef {Object} MegaJobQueueOptions
67
80
  * @property {import('nats').NatsConnection} nc - **연결된** NatsConnection(`ctx.bus(alias).native`).
@@ -387,6 +400,11 @@ export class MegaJobQueue extends EventEmitter {
387
400
  * (소비 워커 라이프사이클·bus 별명 배선은 `MegaJobWorker` 영역 — 본 메서드는 "처리 베이스".
388
401
  * 정본 `MegaWorker` = CPU `worker_threads` 풀로 별개 추상(ADR-120/ADR-121).)
389
402
  *
403
+ * ⚠️ **head-of-line blocking 운영 주의(Med)**: in-flight 상한은 `concurrency`(= `max_ack_pending`)다.
404
+ * 일시 장애로 **모든 in-flight 가 동시에 긴 백오프**(`static backoff.max` 큼 + `retries` 많음)에 들어가면
405
+ * 그 subject 처리량이 0 에 수렴하고 정상 메시지도 뒤에서 대기한다(메모리는 안전 — 무한 증가 X). 큰
406
+ * backoff 를 쓰면 `concurrency` 를 충분히 키우고, 재시도 적체를 메트릭(`job:start`/`fail` 이벤트)으로 관측하라.
407
+ *
390
408
  * @param {typeof MegaJob} JobClass @param {MegaJob} instance - `run` 호출 대상(서브클래스 인스턴스).
391
409
  * @param {Record<string, any>} ctx - run 에 넘길 컨텍스트.
392
410
  * @returns {Promise<{ stop: () => Promise<void> }>}
@@ -489,9 +507,11 @@ export class MegaJobQueue extends EventEmitter {
489
507
  this.#safeEmit('start', { subject, seq })
490
508
  let settled = false
491
509
  // 재시도 동안 메시지를 점유하므로 ack_wait 만료(→중복 재전달)를 막기 위해 working() 으로 lease 갱신.
510
+ // unref — in-flight 잡이 길게 돌아도 이 타이머가 프로세스 자연 종료를 막지 않게(형제 모듈 정합, Med).
492
511
  const heartbeat = setInterval(() => {
493
512
  if (!settled) msg.working()
494
513
  }, this.#heartbeatMs)
514
+ if (typeof heartbeat.unref === 'function') heartbeat.unref()
495
515
 
496
516
  // run(재시도) 결과를 변수에 담는다 — ack/DLQ(부작용) + emit 은 try/catch **밖**에서 처리한다(M-3·L-3).
497
517
  // 이유: emit('done')/emit('fail') 리스너가 throw 했을 때 run 의 catch 가 잡으면 성공 잡이 spurious
@@ -546,7 +566,8 @@ export class MegaJobQueue extends EventEmitter {
546
566
  originalSubject: subject,
547
567
  failedAt: new Date().toISOString(),
548
568
  deliveryCount: msg.info.deliveryCount,
549
- error: { name: error.name, message: error.message, stack: error.stack },
569
+ // stack 전체를 봉투에 담으면 poison 폭주 시 DLQ 직렬화 비용·디스크가 급증한다 → 상한으로 truncate(Med).
570
+ error: { name: error.name, message: error.message, stack: truncateStack(error.stack) },
550
571
  payload,
551
572
  }),
552
573
  )
@@ -92,11 +92,49 @@ export function buildTargets(sinks, level) {
92
92
  return targets
93
93
  }
94
94
 
95
+ /**
96
+ * 기본 시크릿 redact 경로(ADR-023, H2 보안). 사용자가 `logger.redact` 를 지정하지 않아도 **항상** 적용해
97
+ * 토큰·비밀번호·인증 헤더가 stdout/파일/텔레그램(외부 sink)으로 평문 유출되는 것을 막는다(CLAUDE.md P5).
98
+ * pino(fast-redact) 경로 문법으로 부팅 시 1회 검증됨(leading wildcard `*.x` 포함). 사용자 redact 는 병합된다.
99
+ * @type {ReadonlyArray<string>}
100
+ */
101
+ export const DEFAULT_REDACT_PATHS = Object.freeze([
102
+ 'req.headers.authorization',
103
+ 'req.headers.cookie',
104
+ '*.password',
105
+ '*.token',
106
+ '*.secret',
107
+ '*.accessToken',
108
+ '*.refreshToken',
109
+ '*.apiKey',
110
+ ])
111
+
112
+ /**
113
+ * 사용자 redact 설정(배열 또는 `{ paths, censor?, remove? }`)을 기본 경로와 **병합**한다(중복 제거).
114
+ * 사용자가 미지정이어도 기본 경로는 항상 적용된다.
115
+ * @param {unknown} userRedact
116
+ * @returns {{ paths: string[], censor?: any, remove?: boolean }}
117
+ */
118
+ function mergeRedact(userRedact) {
119
+ /** @type {string[]} */
120
+ let userPaths = []
121
+ /** @type {Record<string, any>} */
122
+ let extra = {}
123
+ if (Array.isArray(userRedact)) {
124
+ userPaths = userRedact.filter((p) => typeof p === 'string')
125
+ } else if (userRedact && typeof userRedact === 'object' && Array.isArray(/** @type {any} */ (userRedact).paths)) {
126
+ const { paths, ...rest } = /** @type {any} */ (userRedact)
127
+ userPaths = paths.filter((/** @type {any} */ p) => typeof p === 'string')
128
+ extra = rest // censor/remove 보존.
129
+ }
130
+ return { paths: [...new Set([...DEFAULT_REDACT_PATHS, ...userPaths])], ...extra }
131
+ }
132
+
95
133
  /**
96
134
  * `logger` config → pino 옵션(또는 비활성 시 `null`). Fastify `logger` 또는 `pino()` 에 그대로 전달 가능.
97
135
  *
98
136
  * @param {unknown} config - `MegaLoggerConfig`(`{ level, sinks, redact?, includeRequestId? }`).
99
- * @returns {{ level: string, mixin: Function, redact?: string[], transport: { targets: any[] } } | null}
137
+ * @returns {{ level: string, mixin: Function, redact: { paths: string[] }, transport: { targets: any[] } } | null}
100
138
  */
101
139
  export function buildLoggerOptions(config) {
102
140
  if (!config || typeof config !== 'object') return null
@@ -109,7 +147,8 @@ export function buildLoggerOptions(config) {
109
147
  level,
110
148
  // trace_id/span_id 자동 주입(ADR-116) — 활성 span 없으면 빈 객체(0 비용).
111
149
  mixin: MegaTracing.logMixin,
112
- ...(Array.isArray(c.redact) && c.redact.length > 0 ? { redact: c.redact } : {}),
150
+ // 기본 시크릿 redact **항상** 적용 + 사용자 설정 병합(H2 보안). 미지정이어도 마스킹된다.
151
+ redact: mergeRedact(c.redact),
113
152
  transport: { targets },
114
153
  }
115
154
  }
@@ -31,6 +31,12 @@ let isShuttingDownFlag = false
31
31
  let gracePeriodMs = 30_000
32
32
  let hardKillMs = 60_000
33
33
  let exitedHandlers = new Set()
34
+ /** 전역 에러 핸들러 등록 여부 + 참조(reset 시 removeListener 용). @type {boolean} */
35
+ let globalErrorsRegistered = false
36
+ /** @type {((reason: unknown) => void) | null} */
37
+ let unhandledRejectionHandler = null
38
+ /** @type {((err: Error, origin?: string) => void) | null} */
39
+ let uncaughtExceptionHandler = null
34
40
 
35
41
  /**
36
42
  * cleanup hook 등록. 순서는 등록 역순(LIFO)으로 실행.
@@ -74,8 +80,9 @@ function isShuttingDown() {
74
80
  }
75
81
 
76
82
  /**
77
- * SIGTERM/SIGINT 시그널 등록 (한 번만 등록됨).
78
- * @param {{ gracePeriodMs?: number, hardKillMs?: number, signals?: string[] }} [opts]
83
+ * SIGTERM/SIGINT 시그널 등록 (한 번만 등록됨). 전역 에러 핸들러(unhandledRejection/uncaughtException)도
84
+ * 기본 등록한다 `globalErrorHandlers: false` 있다(REPL 등).
85
+ * @param {{ gracePeriodMs?: number, hardKillMs?: number, signals?: string[], globalErrorHandlers?: boolean }} [opts]
79
86
  */
80
87
  function setupSignals(opts = {}) {
81
88
  if (signalsRegistered) return // 멱등성
@@ -88,6 +95,36 @@ function setupSignals(opts = {}) {
88
95
  void now({ signal: sig, exitCode: 0 })
89
96
  })
90
97
  }
98
+ // 전역 에러 핸들러도 함께 등록(opt-out: globalErrorHandlers === false). REPL(console-cmd) 등은 끌 수 있다.
99
+ if (opts.globalErrorHandlers !== false) setupGlobalErrorHandlers()
100
+ }
101
+
102
+ /**
103
+ * 전역 `unhandledRejection`/`uncaughtException` 핸들러 등록 (ADR-178, 멱등). floating promise reject 나
104
+ * 미처리 예외로 프로세스가 **graceful 없이 즉사**(어댑터 disconnect·드레인 미실행 → 커넥션/락 누수, 진행 중
105
+ * 잡 유실)하는 것을 막는다. 둘 다 fatal 로깅 후 graceful shutdown(exit 1)을 트리거한다 — uncaughtException
106
+ * 이후의 프로세스 상태는 신뢰 불가라 복구하지 않고 정리 후 종료가 표준.
107
+ * @param {{ exitCode?: number }} [opts]
108
+ * @returns {void}
109
+ */
110
+ function setupGlobalErrorHandlers({ exitCode = 1 } = {}) {
111
+ if (globalErrorsRegistered) return
112
+ globalErrorsRegistered = true
113
+ unhandledRejectionHandler = (reason) => {
114
+ const log = /** @type {any} */ (globalThis).logger
115
+ 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)
118
+ void now({ signal: 'unhandledRejection', exitCode })
119
+ }
120
+ 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)
124
+ void now({ signal: 'uncaughtException', exitCode })
125
+ }
126
+ process.on('unhandledRejection', unhandledRejectionHandler)
127
+ process.on('uncaughtException', uncaughtExceptionHandler)
91
128
  }
92
129
 
93
130
  /**
@@ -159,6 +196,12 @@ function _reset() {
159
196
  gracePeriodMs = 30_000
160
197
  hardKillMs = 60_000
161
198
  exitedHandlers = new Set()
199
+ // 전역 에러 핸들러도 떼어 테스트 간 누적 방지.
200
+ if (unhandledRejectionHandler) process.removeListener('unhandledRejection', unhandledRejectionHandler)
201
+ if (uncaughtExceptionHandler) process.removeListener('uncaughtException', uncaughtExceptionHandler)
202
+ unhandledRejectionHandler = null
203
+ uncaughtExceptionHandler = null
204
+ globalErrorsRegistered = false
162
205
  }
163
206
 
164
207
  /**
@@ -170,6 +213,7 @@ export const MegaShutdown = {
170
213
  unregister,
171
214
  isShuttingDown,
172
215
  setupSignals,
216
+ setupGlobalErrorHandlers,
173
217
  now,
174
218
  registeredCount,
175
219
  _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
- })