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.
- package/package.json +1 -1
- package/sample/crud/.env +156 -8
- package/sample/crud/.env.example +153 -28
- package/sample/crud/mega.config.js +61 -2
- package/sample/crud/package.json +2 -2
- package/sample/crud/yarn.lock +1 -1
- package/src/cli/commands/new.js +115 -67
- package/src/cli/commands/scaffold.js +6 -12
- package/src/cli/index.js +133 -12
- package/src/core/boot.js +30 -1
- package/src/core/config-validator.js +25 -0
- package/src/core/mega-app.js +25 -21
- package/src/core/mega-cluster.js +50 -12
- package/src/core/scope-registry.js +0 -1
- package/src/lib/mega-logger.js +1 -1
- package/src/lib/mega-shutdown.js +51 -13
- package/sample/crud/test/apps/main/auth-flow.integration.test.js +0 -177
- package/sample/crud/test/apps/main/auth-service.test.js +0 -93
- package/sample/crud/test/apps/main/chat-channel.test.js +0 -149
- package/sample/crud/test/apps/main/cron-demo-service.test.js +0 -93
- package/sample/crud/test/apps/main/demo-flow.integration.test.js +0 -386
- package/sample/crud/test/apps/main/email-job.test.js +0 -76
- package/sample/crud/test/apps/main/guide-service.test.js +0 -68
- package/sample/crud/test/apps/main/hash-task.test.js +0 -30
- package/sample/crud/test/apps/main/jobs-demo-service.test.js +0 -88
- package/sample/crud/test/apps/main/logs-demo-service.test.js +0 -85
- package/sample/crud/test/apps/main/metrics-demo-service.test.js +0 -90
- package/sample/crud/test/apps/main/note-service.test.js +0 -68
- package/sample/crud/test/apps/main/perf-service.test.js +0 -121
- package/sample/crud/test/apps/main/perf.integration.test.js +0 -202
- package/sample/crud/test/apps/main/redis-demo-service.test.js +0 -98
- package/sample/crud/test/apps/main/tracing-demo-service.test.js +0 -90
- package/sample/crud/test/apps/main/upload-demo-service.test.js +0 -61
- package/sample/crud/test/apps/main/user-service.test.js +0 -65
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +0 -233
- package/templates/project/app.config.tpl +0 -8
- package/templates/project/app.config.views.tpl +0 -37
- package/templates/project/ecosystem.config.tpl +0 -10
- package/templates/project/env.tpl +0 -12
- package/templates/project/gitignore.tpl +0 -8
- package/templates/project/locales/client/en.json.tpl +0 -3
- package/templates/project/locales/client/ko.json.tpl +0 -3
- package/templates/project/locales/server/en.json.tpl +0 -17
- package/templates/project/locales/server/ko.json.tpl +0 -17
- package/templates/project/mega.config.tpl +0 -11
- package/templates/project/package.tpl +0 -25
- package/templates/project/public/css/app.css +0 -101
- package/templates/project/public/js/app.js +0 -54
- package/templates/project/public/js/theme-init.js +0 -12
- package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +0 -7
- package/templates/project/public/vendor/bootstrap/bootstrap.min.css +0 -6
- package/templates/project/readme.tpl +0 -48
- package/templates/project/route.test.tpl +0 -13
- package/templates/project/route.test.views.tpl +0 -15
- package/templates/project/route.tpl +0 -10
- package/templates/project/route.views.tpl +0 -10
- package/templates/project/views/index.ejs.tpl +0 -58
- package/templates/project/views/layout.ejs.tpl +0 -73
- package/templates/project/vitest.config.tpl +0 -8
package/src/core/mega-app.js
CHANGED
|
@@ -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
|
-
// /
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
|
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',
|
package/src/core/mega-cluster.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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)
|
package/src/lib/mega-logger.js
CHANGED
|
@@ -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
|
|
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) {
|
package/src/lib/mega-shutdown.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
logShutdown('info', `hook '${name}' done`, { hook: name })
|
|
170
196
|
} catch (err) {
|
|
171
197
|
// silent 금지. 핸들러 실패 시 warn 로그 + 다음 hook 진행.
|
|
172
|
-
|
|
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
|
-
|
|
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
|
-
})
|