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.
- package/package.json +1 -1
- package/sample/crud/yarn.lock +1 -1
- package/src/adapters/file-adapter.js +5 -0
- package/src/adapters/mega-cache-adapter.js +6 -2
- package/src/adapters/nats-adapter.js +6 -1
- package/src/cli/commands/console-cmd.js +4 -2
- package/src/cli/commands/new.js +115 -67
- package/src/cli/commands/scaffold.js +6 -12
- package/src/cli/index.js +1 -1
- package/src/core/mega-app.js +11 -3
- package/src/core/mega-cluster.js +27 -17
- package/src/core/ws-upgrade.js +19 -1
- package/src/lib/asp/nonce-cache.js +12 -0
- package/src/lib/logger/telegram-core.js +33 -5
- package/src/lib/logger/telegram-transport.js +22 -2
- package/src/lib/mega-job-queue.js +22 -1
- package/src/lib/mega-logger.js +41 -2
- package/src/lib/mega-shutdown.js +46 -2
- 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
|
@@ -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
|
-
|
|
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()))
|
|
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
|
-
|
|
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
|
)
|
package/src/lib/mega-logger.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
150
|
+
// 기본 시크릿 redact 를 **항상** 적용 + 사용자 설정 병합(H2 보안). 미지정이어도 마스킹된다.
|
|
151
|
+
redact: mergeRedact(c.redact),
|
|
113
152
|
transport: { targets },
|
|
114
153
|
}
|
|
115
154
|
}
|
package/src/lib/mega-shutdown.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
})
|