mega-framework 0.1.4 → 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 (48) hide show
  1. package/package.json +1 -1
  2. package/sample/crud/yarn.lock +1 -1
  3. package/src/cli/commands/new.js +115 -67
  4. package/src/cli/commands/scaffold.js +6 -12
  5. package/src/cli/index.js +1 -1
  6. package/sample/crud/test/apps/main/auth-flow.integration.test.js +0 -177
  7. package/sample/crud/test/apps/main/auth-service.test.js +0 -93
  8. package/sample/crud/test/apps/main/chat-channel.test.js +0 -149
  9. package/sample/crud/test/apps/main/cron-demo-service.test.js +0 -93
  10. package/sample/crud/test/apps/main/demo-flow.integration.test.js +0 -386
  11. package/sample/crud/test/apps/main/email-job.test.js +0 -76
  12. package/sample/crud/test/apps/main/guide-service.test.js +0 -68
  13. package/sample/crud/test/apps/main/hash-task.test.js +0 -30
  14. package/sample/crud/test/apps/main/jobs-demo-service.test.js +0 -88
  15. package/sample/crud/test/apps/main/logs-demo-service.test.js +0 -85
  16. package/sample/crud/test/apps/main/metrics-demo-service.test.js +0 -90
  17. package/sample/crud/test/apps/main/note-service.test.js +0 -68
  18. package/sample/crud/test/apps/main/perf-service.test.js +0 -121
  19. package/sample/crud/test/apps/main/perf.integration.test.js +0 -202
  20. package/sample/crud/test/apps/main/redis-demo-service.test.js +0 -98
  21. package/sample/crud/test/apps/main/tracing-demo-service.test.js +0 -90
  22. package/sample/crud/test/apps/main/upload-demo-service.test.js +0 -61
  23. package/sample/crud/test/apps/main/user-service.test.js +0 -65
  24. package/sample/crud/test/apps/main/ws-chat.integration.test.js +0 -233
  25. package/templates/project/app.config.tpl +0 -8
  26. package/templates/project/app.config.views.tpl +0 -37
  27. package/templates/project/ecosystem.config.tpl +0 -10
  28. package/templates/project/env.tpl +0 -12
  29. package/templates/project/gitignore.tpl +0 -8
  30. package/templates/project/locales/client/en.json.tpl +0 -3
  31. package/templates/project/locales/client/ko.json.tpl +0 -3
  32. package/templates/project/locales/server/en.json.tpl +0 -17
  33. package/templates/project/locales/server/ko.json.tpl +0 -17
  34. package/templates/project/mega.config.tpl +0 -11
  35. package/templates/project/package.tpl +0 -25
  36. package/templates/project/public/css/app.css +0 -101
  37. package/templates/project/public/js/app.js +0 -54
  38. package/templates/project/public/js/theme-init.js +0 -12
  39. package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +0 -7
  40. package/templates/project/public/vendor/bootstrap/bootstrap.min.css +0 -6
  41. package/templates/project/readme.tpl +0 -48
  42. package/templates/project/route.test.tpl +0 -13
  43. package/templates/project/route.test.views.tpl +0 -15
  44. package/templates/project/route.tpl +0 -10
  45. package/templates/project/route.views.tpl +0 -10
  46. package/templates/project/views/index.ejs.tpl +0 -58
  47. package/templates/project/views/layout.ejs.tpl +0 -73
  48. package/templates/project/vitest.config.tpl +0 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mega-framework",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Node.js 마이크로프레임웍 + CLI — Slim의 가벼움 + Rails의 관례",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1234,7 +1234,7 @@ marked@^18.0.5:
1234
1234
  integrity sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==
1235
1235
 
1236
1236
  "mega-framework@file:../..":
1237
- version "0.1.3"
1237
+ version "0.1.4"
1238
1238
  dependencies:
1239
1239
  "@fastify/cookie" "^11.0.2"
1240
1240
  "@fastify/cors" "^11.2.0"
@@ -1,26 +1,94 @@
1
1
  // @ts-check
2
2
  /**
3
- * `mega new <project>` — 폴더에서 멀티앱 hello world 까지 명령 스캐폴드 (roadmap §336, ADR-142).
3
+ * `mega new <project>` — `sample/crud` 데모앱을 그대로 스캐폴드한다(ADR-179, 기존 미니멀/--views 모드 대체).
4
4
  *
5
- * 기본 `main` + `*.localhost` 호스트 + README · `.env.example` · `.gitignore` · `package.json`
6
- * (scripts + concurrently devDep + PM2 `ecosystem.config.cjs`) 를 만든다. `ejsMate` 옵트인이면 EJS +
7
- * ejs-mate 뷰 골격(ADR-011/136) + i18next 다국어(ADR-037~039) + Bootstrap 5 디자인(ADR-151)을 더한다
8
- * 렌더 라우트, ko/en 로케일, 그리고 토큰 치환 없이 그대로 복사하는 정적 자산(vendored Bootstrap +
9
- * 브랜드 CSS/JS). 신규 dep 설치는 하지 않고(네트워크 회피) 안내만 출력한다.
5
+ * 폴더에 14개 기능(auth·notes/users CRUD·WS 채팅·jobs·cron·metrics·logs·perf·redis·tracing·upload·
6
+ * worker·guide)을 모두 갖춘 멀티앱 데모를 찍어낸다. 원본은 레포의 `sample/crud`(npm `files` 포함돼
7
+ * 패키지에도 동봉) 트리를 **바이트 그대로 복사**하고, 프로젝트 이름이 박힌 소수 파일(package.json·README·
8
+ * ecosystem·.env)만 토큰 치환한다 `sample-crud` 프로젝트명, dep `file:../..` 실제 버전(`^x.y.z`).
9
+ * 로컬 인프라 기동용 루트 `docker-compose.yml` 함께 복사한다(개발자 학습용, ADR-103).
10
+ *
11
+ * 복사 제외: 빌드 산출물(node_modules·coverage)·로크파일(package-lock/yarn.lock)·런타임 로그(*.log)·VCS,
12
+ * 그리고 런타임 업로드 데이터(`var/uploads/*` — 개인정보 포함 가능). 업로드 데모가 쓰는 디렉토리는
13
+ * 빈 `.gitkeep` 으로만 만든다. 신규 dep 설치는 하지 않고(네트워크 회피) 안내만 출력한다.
10
14
  *
11
15
  * @module cli/commands/new
12
16
  */
13
- import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
- import { dirname, join, resolve } from 'node:path'
17
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { dirname, join, relative, resolve, sep } from 'node:path'
15
19
  import { fileURLToPath } from 'node:url'
16
- import { nameVariants, renderTemplate } from '../template-engine.js'
20
+ import { nameVariants } from '../template-engine.js'
21
+
22
+ /** 이 파일이 있는 디렉토리(src/cli/commands). 아래 원본 경로의 기준점. */
23
+ const HERE = dirname(fileURLToPath(import.meta.url))
24
+ /** 스캐폴드 원본 = 레포의 `sample/crud` 데모앱(src/cli/commands 기준 3단계 위 + sample/crud). */
25
+ const SAMPLE_ROOT = resolve(HERE, '../../../sample/crud')
26
+ /** 루트 `docker-compose.yml` — 로컬 인프라(postgres·mariadb·mongo·redis·nats·zipkin·otel) 기동용. */
27
+ const COMPOSE_SRC = resolve(HERE, '../../../docker-compose.yml')
28
+ /** 프레임워크 자체 package.json — 스캐폴드 dep 버전 핀(`^x.y.z`) 의 소스. */
29
+ const FRAMEWORK_PKG = resolve(HERE, '../../../package.json')
30
+
31
+ /** 복사 제외 디렉토리/파일 basename — 빌드 산출물·로크파일·VCS·런타임 데이터 디렉토리. */
32
+ const EXCLUDE_NAMES = new Set([
33
+ 'node_modules',
34
+ '.git',
35
+ 'coverage',
36
+ '.nyc_output',
37
+ '.vitest',
38
+ 'package-lock.json',
39
+ 'yarn.lock',
40
+ 'pnpm-lock.yaml',
41
+ '.DS_Store',
42
+ ])
43
+ /** 복사 제외 상대경로 prefix — 런타임 업로드 산출물(개인정보 포함 가능, 대신 .gitkeep 만 생성). */
44
+ const EXCLUDE_PREFIXES = ['var/uploads']
45
+ /** 프로젝트 이름 토큰 치환을 적용할 텍스트 파일(그 외는 바이트 그대로 복사 — 바이너리 보존). */
46
+ const TRANSFORM_FILES = new Set(['package.json', 'README.md', 'ecosystem.config.cjs', '.env', '.env.example'])
47
+
48
+ /**
49
+ * 한 상대경로가 복사 제외 대상인지.
50
+ * @param {string} rel - POSIX 슬래시 상대경로.
51
+ * @returns {boolean}
52
+ */
53
+ function isExcluded(rel) {
54
+ if (rel.endsWith('.log')) return true
55
+ if (EXCLUDE_PREFIXES.some((p) => rel === p || rel.startsWith(`${p}/`))) return true
56
+ return rel.split('/').some((seg) => EXCLUDE_NAMES.has(seg))
57
+ }
17
58
 
18
- /** templates/project 루트. src/cli/commands/new.js 기준 3단계 위 + templates/project. */
19
- const PROJECT_TEMPLATES = resolve(dirname(fileURLToPath(import.meta.url)), '../../../templates/project')
59
+ /**
60
+ * 디렉토리를 재귀 순회해 복사 대상 파일의 상대경로(POSIX 슬래시) 목록을 만든다. 제외 디렉토리는
61
+ * 통째로 건너뛴다(node_modules 등 큰 트리 순회 비용 회피).
62
+ * @param {string} dir - 현재 순회 디렉토리(절대).
63
+ * @param {string} base - 상대경로 기준 루트(절대).
64
+ * @param {string[]} acc - 누적 결과.
65
+ * @returns {string[]}
66
+ */
67
+ function walk(dir, base, acc) {
68
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
69
+ const abs = join(dir, ent.name)
70
+ const rel = relative(base, abs).split(sep).join('/')
71
+ if (isExcluded(rel)) continue
72
+ if (ent.isDirectory()) walk(abs, base, acc)
73
+ else acc.push(rel)
74
+ }
75
+ return acc
76
+ }
20
77
 
21
- /** 프로젝트 템플릿을 읽어 토큰 치환. @param {string} file @param {Record<string,string>} vars @returns {string} */
22
- function renderProject(file, vars) {
23
- return renderTemplate(readFileSync(join(PROJECT_TEMPLATES, file), 'utf8'), vars)
78
+ /**
79
+ * 토큰 치환 — `sample-crud` 를 프로젝트명으로, package.json 의 모노레포 dep(`file:../..`) 실제 버전으로.
80
+ * @param {string} rel - 파일 상대경로.
81
+ * @param {string} content - 원본 텍스트.
82
+ * @param {{ name: string, version: string }} vars
83
+ * @returns {string}
84
+ */
85
+ function transformText(rel, content, vars) {
86
+ let out = content.replaceAll('sample-crud', vars.name)
87
+ if (rel === 'package.json') {
88
+ // 모노레포 로컬 링크 → 퍼블리시된 프레임워크 버전 핀. 스캐폴드된 프로젝트는 독립 패키지라 file: 가 안 풀린다.
89
+ out = out.replace('"file:../.."', `"^${vars.version}"`)
90
+ }
91
+ return out
24
92
  }
25
93
 
26
94
  /**
@@ -28,74 +96,54 @@ function renderProject(file, vars) {
28
96
  * @param {string} targetDir - 프로젝트 루트 절대/상대 경로.
29
97
  * @param {object} [opts]
30
98
  * @param {string} [opts.name] - 프로젝트 이름(기본: targetDir 의 마지막 세그먼트).
31
- * @param {boolean} [opts.ejsMate] - EJS + ejs-mate 뷰 골격 포함(기본 false).
32
99
  * @param {boolean} [opts.force] - 기존 파일 덮어쓰기(기본 false — 건너뜀).
33
100
  * @returns {{ root: string, written: string[], skipped: string[] }}
34
101
  */
35
- export function scaffoldProject(targetDir, { name, ejsMate = false, force = false } = {}) {
102
+ export function scaffoldProject(targetDir, { name, force = false } = {}) {
36
103
  const root = resolve(targetDir)
37
104
  const projectName = nameVariants(name ?? root.split(/[/\\]/).pop() ?? 'mega-app').kebab
38
- const vars = { name: projectName, Name: nameVariants(projectName).pascal }
39
-
40
- // 옵트인이면 GET / 렌더로 바꾸고(JSON hello 대신) 그에 맞는 테스트를 쓴다.
41
- /** @type {Array<{ rel: string, tpl: string }>} */
42
- const files = [
43
- { rel: 'mega.config.js', tpl: 'mega.config.tpl' },
44
- { rel: 'apps/main/app.config.js', tpl: ejsMate ? 'app.config.views.tpl' : 'app.config.tpl' },
45
- { rel: 'apps/main/routes/index.js', tpl: ejsMate ? 'route.views.tpl' : 'route.tpl' },
46
- { rel: 'test/apps/main/index.test.js', tpl: ejsMate ? 'route.test.views.tpl' : 'route.test.tpl' },
47
- { rel: 'package.json', tpl: 'package.tpl' },
48
- { rel: 'README.md', tpl: 'readme.tpl' },
49
- { rel: '.env.example', tpl: 'env.tpl' },
50
- { rel: '.gitignore', tpl: 'gitignore.tpl' },
51
- { rel: 'vitest.config.js', tpl: 'vitest.config.tpl' },
52
- { rel: 'ecosystem.config.cjs', tpl: 'ecosystem.config.tpl' },
53
- ]
54
- if (ejsMate) {
55
- files.push(
56
- { rel: 'apps/main/views/layouts/main.ejs', tpl: 'views/layout.ejs.tpl' },
57
- { rel: 'apps/main/views/index.ejs', tpl: 'views/index.ejs.tpl' },
58
- { rel: 'apps/main/locales/server/ko.json', tpl: 'locales/server/ko.json.tpl' },
59
- { rel: 'apps/main/locales/server/en.json', tpl: 'locales/server/en.json.tpl' },
60
- { rel: 'apps/main/locales/client/ko.json', tpl: 'locales/client/ko.json.tpl' },
61
- { rel: 'apps/main/locales/client/en.json', tpl: 'locales/client/en.json.tpl' },
62
- )
63
- }
64
-
65
- // 정적 자산 — 토큰 치환 없이 바이트 그대로 복사한다(vendored Bootstrap 5 min 번들 + 브랜드 CSS/JS).
66
- // 이들은 템플릿이 아니라 정적 파일이므로 renderTemplate(`{{token}}`)을 거치지 않는다.
67
- /** @type {Array<{ rel: string, src: string }>} */
68
- const assets = ejsMate
69
- ? [
70
- { rel: 'apps/main/public/vendor/bootstrap/bootstrap.min.css', src: 'public/vendor/bootstrap/bootstrap.min.css' },
71
- { rel: 'apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js', src: 'public/vendor/bootstrap/bootstrap.bundle.min.js' },
72
- { rel: 'apps/main/public/css/app.css', src: 'public/css/app.css' },
73
- { rel: 'apps/main/public/js/app.js', src: 'public/js/app.js' },
74
- { rel: 'apps/main/public/js/theme-init.js', src: 'public/js/theme-init.js' },
75
- ]
76
- : []
105
+ /** @type {string} */
106
+ const version = JSON.parse(readFileSync(FRAMEWORK_PKG, 'utf8')).version
107
+ const vars = { name: projectName, version }
77
108
 
78
109
  /** @type {string[]} */ const written = []
79
110
  /** @type {string[]} */ const skipped = []
80
- for (const { rel, tpl } of files) {
81
- const out = join(root, rel)
111
+
112
+ /**
113
+ * 출력 파일 1개를 기록(존재 + !force 면 skip).
114
+ * @param {string} out - 출력 절대경로.
115
+ * @param {() => void} write - 실제 기록 동작.
116
+ */
117
+ const emit = (out, write) => {
82
118
  if (existsSync(out) && !force) {
83
119
  skipped.push(out)
84
- continue
120
+ return
85
121
  }
86
122
  mkdirSync(dirname(out), { recursive: true })
87
- writeFileSync(out, renderProject(tpl, vars))
123
+ write()
88
124
  written.push(out)
89
125
  }
90
- for (const { rel, src } of assets) {
126
+
127
+ // 1) sample/crud 트리 복사(제외 규칙 적용). 토큰 치환 대상만 텍스트로 렌더, 그 외는 바이트 그대로.
128
+ for (const rel of walk(SAMPLE_ROOT, SAMPLE_ROOT, [])) {
129
+ const src = join(SAMPLE_ROOT, rel)
91
130
  const out = join(root, rel)
92
- if (existsSync(out) && !force) {
93
- skipped.push(out)
94
- continue
95
- }
96
- mkdirSync(dirname(out), { recursive: true })
97
- copyFileSync(join(PROJECT_TEMPLATES, src), out)
98
- written.push(out)
131
+ emit(out, () => {
132
+ if (TRANSFORM_FILES.has(rel)) {
133
+ writeFileSync(out, transformText(rel, readFileSync(src, 'utf8'), vars))
134
+ } else {
135
+ copyFileSync(src, out)
136
+ }
137
+ })
99
138
  }
139
+
140
+ // 2) 루트 docker-compose.yml — 로컬 인프라(개발자 학습용). 프로젝트 이름과 무관해 그대로 복사.
141
+ const compose = join(root, 'docker-compose.yml')
142
+ emit(compose, () => copyFileSync(COMPOSE_SRC, compose))
143
+
144
+ // 3) 업로드 데모가 쓰는 디렉토리 placeholder — 실제 업로드 데이터(개인정보 PDF 등)는 동봉하지 않는다.
145
+ const gitkeep = join(root, 'var/uploads/.gitkeep')
146
+ emit(gitkeep, () => writeFileSync(gitkeep, ''))
147
+
100
148
  return { root, written, skipped }
101
149
  }
@@ -7,7 +7,6 @@
7
7
  * @module cli/commands/scaffold
8
8
  */
9
9
  import { Command } from 'commander'
10
- import prompts from 'prompts'
11
10
  import { generate, GENERATOR_KINDS } from '../generators/index.js'
12
11
  import { scaffoldProject } from './new.js'
13
12
  import { runRoutesCommand } from './routes.js'
@@ -50,21 +49,16 @@ export async function runScaffoldCommand(argv, { out, err, projectRoot, logger }
50
49
 
51
50
  program
52
51
  .command('new <project>')
53
- .description(' 폴더에서 멀티앱 hello world 스캐폴드')
54
- .option('--views', 'EJS + ejs-mate 뷰 골격 포함')
52
+ .description('sample/crud 데모앱(14기능) 전체를 폴더에 스캐폴드')
55
53
  .option('--force', '기존 파일 덮어쓰기')
56
- .action(async (/** @type {string} */ project, /** @type {any} */ opts) => {
57
- // --views 미지정 + 대화형 터미널이면 ejs-mate 옵트인을 묻는다(비-TTY/CI 는 기본 false).
58
- let ejsMate = opts.views === true
59
- if (opts.views === undefined && process.stdin.isTTY) {
60
- const ans = /** @type {any} */ (await prompts({ type: 'confirm', name: 'views', message: 'EJS + ejs-mate 뷰 골격을 포함할까요?', initial: false }))
61
- ejsMate = ans.views === true
62
- }
54
+ .action((/** @type {string} */ project, /** @type {any} */ opts) => {
63
55
  const target = project === '.' ? projectRoot : `${projectRoot}/${project}`
64
- const r = scaffoldProject(target, { name: project === '.' ? undefined : project, ejsMate, force: opts.force === true })
56
+ const r = scaffoldProject(target, { name: project === '.' ? undefined : project, force: opts.force === true })
65
57
  out(`mega: scaffolded project at ${r.root}`)
66
58
  reportFiles(out, r, r.root)
67
- out('\n다음 단계:\n cd ' + project + '\n npm install\n npm run dev')
59
+ // 풀 데모는 로컬 인프라(postgres·redis·mongo·nats)가 있어야 부팅된다 docker compose 띄운 마이그레이션.
60
+ const cd = project === '.' ? '' : ` cd ${project}\n`
61
+ out(`\n다음 단계:\n${cd} npm install\n docker compose up -d # 로컬 인프라 기동(postgres·redis·mongo·nats)\n npm run migrate # DB 스키마 생성\n npm run dev # 개발 서버`)
68
62
  })
69
63
 
70
64
  program
package/src/cli/index.js CHANGED
@@ -45,7 +45,7 @@ Usage:
45
45
  mega migrate [--db KEY] [--root DIR] pending 마이그레이션 일괄 적용(up)
46
46
  mega migrate:down [--db KEY] 마지막 적용 마이그레이션 1개 롤백(down)
47
47
  mega migrate:status [--db KEY] 적용/미적용 마이그레이션 목록
48
- mega new <project> [--views] [--force] 폴더에서 멀티앱 hello world 스캐폴드
48
+ mega new <project> [--force] sample/crud 데모앱(14기능) 전체를 스캐폴드
49
49
  mega g <kind> <name> [--app A] [--version vN] 코드+테스트 생성 (별칭: generate)
50
50
  [--kind K] [--lng L] [--force]
51
51
  mega routes [--root DIR] 등록 라우트 트리 출력
@@ -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
- })