mega-framework 0.1.0
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/.env +127 -0
- package/.env.example +186 -0
- package/.prettierrc.json +8 -0
- package/CHANGELOG.md +259 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/bin/mega-ws-hub.js +15 -0
- package/bin/mega.js +38 -0
- package/docker-compose.yml +201 -0
- package/eslint.config.js +57 -0
- package/infra/otel-collector-config.yaml +43 -0
- package/jsconfig.json +18 -0
- package/package.json +121 -0
- package/sample/crud/.env +18 -0
- package/sample/crud/.env.example +50 -0
- package/sample/crud/README.md +85 -0
- package/sample/crud/apps/main/app.config.js +114 -0
- package/sample/crud/apps/main/channels/chat-bus.js +115 -0
- package/sample/crud/apps/main/channels/chat-channel.js +145 -0
- package/sample/crud/apps/main/controllers/auth-controller.js +144 -0
- package/sample/crud/apps/main/controllers/cron-controller.js +34 -0
- package/sample/crud/apps/main/controllers/guide-controller.js +37 -0
- package/sample/crud/apps/main/controllers/jobs-controller.js +43 -0
- package/sample/crud/apps/main/controllers/logs-controller.js +35 -0
- package/sample/crud/apps/main/controllers/metrics-controller.js +22 -0
- package/sample/crud/apps/main/controllers/note-controller.js +116 -0
- package/sample/crud/apps/main/controllers/perf-controller.js +38 -0
- package/sample/crud/apps/main/controllers/redis-controller.js +36 -0
- package/sample/crud/apps/main/controllers/tracing-controller.js +43 -0
- package/sample/crud/apps/main/controllers/upload-controller.js +98 -0
- package/sample/crud/apps/main/controllers/user-controller.js +34 -0
- package/sample/crud/apps/main/controllers/web-controller.js +137 -0
- package/sample/crud/apps/main/controllers/worker-controller.js +57 -0
- package/sample/crud/apps/main/controllers/ws-controller.js +29 -0
- package/sample/crud/apps/main/jobs/email-job.js +72 -0
- package/sample/crud/apps/main/locales/client/en.json +3 -0
- package/sample/crud/apps/main/locales/client/ko.json +3 -0
- package/sample/crud/apps/main/locales/server/en.json +316 -0
- package/sample/crud/apps/main/locales/server/ko.json +316 -0
- package/sample/crud/apps/main/middleware/web-auth.js +40 -0
- package/sample/crud/apps/main/middleware/ws-auth.js +48 -0
- package/sample/crud/apps/main/migrations/20260606000001-create-users.js +27 -0
- package/sample/crud/apps/main/migrations/20260606000002-add-auth-to-users.js +30 -0
- package/sample/crud/apps/main/models/note.js +71 -0
- package/sample/crud/apps/main/models/user.js +86 -0
- package/sample/crud/apps/main/public/css/app.css +101 -0
- package/sample/crud/apps/main/public/css/guide.css +137 -0
- package/sample/crud/apps/main/public/js/app.js +54 -0
- package/sample/crud/apps/main/public/js/perf.js +129 -0
- package/sample/crud/apps/main/public/js/theme-init.js +12 -0
- package/sample/crud/apps/main/public/js/upload-demo.js +63 -0
- package/sample/crud/apps/main/public/js/worker-demo.js +92 -0
- package/sample/crud/apps/main/public/js/ws-chat.js +161 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/crud/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/crud/apps/main/public/vendor/highlight/github-dark.css +109 -0
- package/sample/crud/apps/main/public/vendor/highlight/github.css +118 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/README.md +19 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.d.ts +196 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm.js +1187 -0
- package/sample/crud/apps/main/public/vendor/mega-client-wasm/mega_client_wasm_bg.wasm +0 -0
- package/sample/crud/apps/main/routes/auth.js +15 -0
- package/sample/crud/apps/main/routes/cron.js +14 -0
- package/sample/crud/apps/main/routes/guide.js +25 -0
- package/sample/crud/apps/main/routes/jobs.js +14 -0
- package/sample/crud/apps/main/routes/logs.js +28 -0
- package/sample/crud/apps/main/routes/metrics.js +13 -0
- package/sample/crud/apps/main/routes/notes.js +19 -0
- package/sample/crud/apps/main/routes/perf.js +47 -0
- package/sample/crud/apps/main/routes/redis.js +14 -0
- package/sample/crud/apps/main/routes/tracing.js +14 -0
- package/sample/crud/apps/main/routes/upload.js +16 -0
- package/sample/crud/apps/main/routes/users.js +54 -0
- package/sample/crud/apps/main/routes/web.js +23 -0
- package/sample/crud/apps/main/routes/worker.js +15 -0
- package/sample/crud/apps/main/routes/ws.js +30 -0
- package/sample/crud/apps/main/schedules/cron-counter-schedule.js +30 -0
- package/sample/crud/apps/main/services/auth-service.js +74 -0
- package/sample/crud/apps/main/services/cron-demo-service.js +66 -0
- package/sample/crud/apps/main/services/guide-service.js +145 -0
- package/sample/crud/apps/main/services/jobs-demo-service.js +83 -0
- package/sample/crud/apps/main/services/logs-demo-service.js +59 -0
- package/sample/crud/apps/main/services/metrics-demo-service.js +144 -0
- package/sample/crud/apps/main/services/note-service.js +75 -0
- package/sample/crud/apps/main/services/perf-service.js +302 -0
- package/sample/crud/apps/main/services/redis-demo-service.js +75 -0
- package/sample/crud/apps/main/services/tracing-demo-service.js +69 -0
- package/sample/crud/apps/main/services/upload-demo-service.js +48 -0
- package/sample/crud/apps/main/services/user-service.js +65 -0
- package/sample/crud/apps/main/views/auth/login.ejs +57 -0
- package/sample/crud/apps/main/views/auth/register.ejs +71 -0
- package/sample/crud/apps/main/views/cron/index.ejs +92 -0
- package/sample/crud/apps/main/views/guide/index.ejs +24 -0
- package/sample/crud/apps/main/views/guide/page.ejs +64 -0
- package/sample/crud/apps/main/views/home.ejs +82 -0
- package/sample/crud/apps/main/views/jobs/index.ejs +113 -0
- package/sample/crud/apps/main/views/layouts/main.ejs +112 -0
- package/sample/crud/apps/main/views/logs/index.ejs +80 -0
- package/sample/crud/apps/main/views/metrics/index.ejs +123 -0
- package/sample/crud/apps/main/views/notes/edit.ejs +45 -0
- package/sample/crud/apps/main/views/notes/list.ejs +74 -0
- package/sample/crud/apps/main/views/notes/new.ejs +45 -0
- package/sample/crud/apps/main/views/perf/index.ejs +90 -0
- package/sample/crud/apps/main/views/redis/index.ejs +65 -0
- package/sample/crud/apps/main/views/tracing/index.ejs +106 -0
- package/sample/crud/apps/main/views/upload/index.ejs +79 -0
- package/sample/crud/apps/main/views/users/edit.ejs +48 -0
- package/sample/crud/apps/main/views/users/list.ejs +81 -0
- package/sample/crud/apps/main/views/users/new.ejs +48 -0
- package/sample/crud/apps/main/views/worker/index.ejs +70 -0
- package/sample/crud/apps/main/views/ws/index.ejs +62 -0
- package/sample/crud/apps/main/workers/hash-worker.js +17 -0
- package/sample/crud/apps/main/workers/hash.task.js +22 -0
- package/sample/crud/ecosystem.config.cjs +9 -0
- package/sample/crud/mega.config.js +105 -0
- package/sample/crud/package-lock.json +5665 -0
- package/sample/crud/package.json +28 -0
- package/sample/crud/test/apps/main/auth-flow.integration.test.js +177 -0
- package/sample/crud/test/apps/main/auth-service.test.js +93 -0
- package/sample/crud/test/apps/main/chat-bus.test.js +101 -0
- package/sample/crud/test/apps/main/chat-channel.test.js +144 -0
- package/sample/crud/test/apps/main/cron-demo-service.test.js +93 -0
- package/sample/crud/test/apps/main/demo-flow.integration.test.js +386 -0
- package/sample/crud/test/apps/main/email-job.test.js +76 -0
- package/sample/crud/test/apps/main/guide-service.test.js +68 -0
- package/sample/crud/test/apps/main/hash-task.test.js +30 -0
- package/sample/crud/test/apps/main/jobs-demo-service.test.js +88 -0
- package/sample/crud/test/apps/main/logs-demo-service.test.js +85 -0
- package/sample/crud/test/apps/main/metrics-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/note-service.test.js +68 -0
- package/sample/crud/test/apps/main/perf-service.test.js +121 -0
- package/sample/crud/test/apps/main/perf.integration.test.js +202 -0
- package/sample/crud/test/apps/main/redis-demo-service.test.js +98 -0
- package/sample/crud/test/apps/main/tracing-demo-service.test.js +90 -0
- package/sample/crud/test/apps/main/upload-demo-service.test.js +61 -0
- package/sample/crud/test/apps/main/user-service.test.js +65 -0
- package/sample/crud/test/apps/main/ws-chat.integration.test.js +232 -0
- package/sample/crud/vitest.config.js +8 -0
- package/sample/crud/yarn.lock +2142 -0
- package/sample/simple/.env.example +15 -0
- package/sample/simple/README.md +52 -0
- package/sample/simple/apps/main/app.config.js +35 -0
- package/sample/simple/apps/main/controllers/pages-controller.js +22 -0
- package/sample/simple/apps/main/locales/client/en.json +3 -0
- package/sample/simple/apps/main/locales/client/ko.json +3 -0
- package/sample/simple/apps/main/locales/server/en.json +23 -0
- package/sample/simple/apps/main/locales/server/ko.json +23 -0
- package/sample/simple/apps/main/public/css/app.css +101 -0
- package/sample/simple/apps/main/public/hello.txt +1 -0
- package/sample/simple/apps/main/public/js/app.js +54 -0
- package/sample/simple/apps/main/public/js/theme-init.js +12 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/sample/simple/apps/main/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/sample/simple/apps/main/routes/index.js +9 -0
- package/sample/simple/apps/main/routes/pages.js +12 -0
- package/sample/simple/apps/main/views/index.ejs +56 -0
- package/sample/simple/apps/main/views/layouts/main.ejs +74 -0
- package/sample/simple/ecosystem.config.cjs +10 -0
- package/sample/simple/mega.config.js +27 -0
- package/sample/simple/package-lock.json +1851 -0
- package/sample/simple/package.json +25 -0
- package/sample/simple/test/apps/main/index.test.js +13 -0
- package/sample/simple/vitest.config.js +8 -0
- package/src/adapters/adapter-manager.js +305 -0
- package/src/adapters/adapter-options.js +208 -0
- package/src/adapters/file-adapter.js +350 -0
- package/src/adapters/file-session-adapter.js +363 -0
- package/src/adapters/index.js +38 -0
- package/src/adapters/maria-adapter.js +425 -0
- package/src/adapters/mega-adapter.js +511 -0
- package/src/adapters/mega-bus-adapter.js +81 -0
- package/src/adapters/mega-cache-adapter.js +94 -0
- package/src/adapters/mega-db-adapter.js +72 -0
- package/src/adapters/mega-lock-adapter.js +118 -0
- package/src/adapters/mega-log-sink-adapter.js +46 -0
- package/src/adapters/mega-session-adapter.js +72 -0
- package/src/adapters/mongo-adapter.js +396 -0
- package/src/adapters/nats-adapter.js +370 -0
- package/src/adapters/postgres-adapter.js +341 -0
- package/src/adapters/redis-adapter.js +331 -0
- package/src/adapters/redis-session-adapter.js +261 -0
- package/src/adapters/redlock-adapter.js +385 -0
- package/src/adapters/registry.js +157 -0
- package/src/adapters/sqlite-adapter.js +309 -0
- package/src/auth/index.js +103 -0
- package/src/cli/commands/console-cmd.js +56 -0
- package/src/cli/commands/new.js +101 -0
- package/src/cli/commands/routes.js +107 -0
- package/src/cli/commands/scaffold.js +120 -0
- package/src/cli/commands/test-cmd.js +45 -0
- package/src/cli/generators/index.js +368 -0
- package/src/cli/index.js +472 -0
- package/src/cli/template-engine.js +72 -0
- package/src/cli/ws-hub.js +582 -0
- package/src/core/ajv-mapper.js +80 -0
- package/src/core/boot.js +323 -0
- package/src/core/cluster-metrics.js +278 -0
- package/src/core/config-loader.js +115 -0
- package/src/core/config-validator.js +322 -0
- package/src/core/ctx-builder.js +253 -0
- package/src/core/envelope.js +88 -0
- package/src/core/error-mapper.js +116 -0
- package/src/core/formbody.js +69 -0
- package/src/core/hub-link.js +552 -0
- package/src/core/i18n.js +525 -0
- package/src/core/index.js +63 -0
- package/src/core/mega-app.js +1138 -0
- package/src/core/mega-cluster.js +232 -0
- package/src/core/mega-server.js +176 -0
- package/src/core/mega-service.js +41 -0
- package/src/core/migration-runner.js +196 -0
- package/src/core/multipart.js +282 -0
- package/src/core/openapi.js +114 -0
- package/src/core/router.js +388 -0
- package/src/core/routes-loader.js +57 -0
- package/src/core/scope-registry.js +53 -0
- package/src/core/security.js +275 -0
- package/src/core/services-loader.js +98 -0
- package/src/core/session-cleanup-schedule.js +57 -0
- package/src/core/session-store.js +55 -0
- package/src/core/session.js +414 -0
- package/src/core/static-assets.js +126 -0
- package/src/core/template.js +294 -0
- package/src/core/workers-manager.js +193 -0
- package/src/core/ws-compression.js +112 -0
- package/src/core/ws-controller.js +109 -0
- package/src/core/ws-message.js +176 -0
- package/src/core/ws-upgrade.js +445 -0
- package/src/errors/config-error.js +16 -0
- package/src/errors/http-errors.js +130 -0
- package/src/errors/index.js +19 -0
- package/src/errors/mega-error.js +34 -0
- package/src/eslint-plugin/index.js +15 -0
- package/src/eslint-plugin/no-direct-model-import.js +113 -0
- package/src/index.js +131 -0
- package/src/lib/asp/config.js +83 -0
- package/src/lib/asp/crypto.js +145 -0
- package/src/lib/asp/errors.js +49 -0
- package/src/lib/asp/nonce-cache.js +94 -0
- package/src/lib/asp/plugin.js +263 -0
- package/src/lib/asp/ws-terminator.js +101 -0
- package/src/lib/env-mapper.js +222 -0
- package/src/lib/hub-protocol.js +322 -0
- package/src/lib/index.js +42 -0
- package/src/lib/logger/telegram-core.js +150 -0
- package/src/lib/logger/telegram-transport.js +126 -0
- package/src/lib/mega-brute-force.js +225 -0
- package/src/lib/mega-circuit-breaker.js +412 -0
- package/src/lib/mega-cron.js +169 -0
- package/src/lib/mega-hash.js +179 -0
- package/src/lib/mega-health.js +91 -0
- package/src/lib/mega-job-queue.js +600 -0
- package/src/lib/mega-job-worker.js +295 -0
- package/src/lib/mega-job.js +140 -0
- package/src/lib/mega-logger.js +128 -0
- package/src/lib/mega-metrics.js +661 -0
- package/src/lib/mega-plugin.js +650 -0
- package/src/lib/mega-retry.js +95 -0
- package/src/lib/mega-schedule.js +507 -0
- package/src/lib/mega-shutdown.js +176 -0
- package/src/lib/mega-tracing.js +715 -0
- package/src/lib/mega-worker.js +653 -0
- package/src/lib/worker-runner/process-entry.js +30 -0
- package/src/lib/worker-runner/task-dispatch.js +72 -0
- package/src/lib/worker-runner/thread-entry.js +26 -0
- package/src/models/index.js +7 -0
- package/src/models/mega-model.js +151 -0
- package/src/test/index.js +288 -0
- package/templates/adapter/code.tpl +40 -0
- package/templates/adapter/test.tpl +13 -0
- package/templates/app/app.config.tpl +10 -0
- package/templates/app/route.tpl +10 -0
- package/templates/app/test.tpl +13 -0
- package/templates/channel/code.tpl +38 -0
- package/templates/channel/test.tpl +19 -0
- package/templates/controller/code.tpl +16 -0
- package/templates/controller/route.tpl +9 -0
- package/templates/controller/test.tpl +14 -0
- package/templates/job/code.tpl +23 -0
- package/templates/job/test.tpl +17 -0
- package/templates/locale/code.tpl +3 -0
- package/templates/locale/test.tpl +13 -0
- package/templates/middleware/code.tpl +13 -0
- package/templates/middleware/test.tpl +11 -0
- package/templates/migration/code.tpl +20 -0
- package/templates/migration/test.tpl +14 -0
- package/templates/model/code.tpl +21 -0
- package/templates/model/test.tpl +29 -0
- package/templates/project/app.config.tpl +8 -0
- package/templates/project/app.config.views.tpl +37 -0
- package/templates/project/ecosystem.config.tpl +10 -0
- package/templates/project/env.tpl +12 -0
- package/templates/project/gitignore.tpl +8 -0
- package/templates/project/locales/client/en.json.tpl +3 -0
- package/templates/project/locales/client/ko.json.tpl +3 -0
- package/templates/project/locales/server/en.json.tpl +17 -0
- package/templates/project/locales/server/ko.json.tpl +17 -0
- package/templates/project/mega.config.tpl +11 -0
- package/templates/project/package.tpl +25 -0
- package/templates/project/public/css/app.css +101 -0
- package/templates/project/public/js/app.js +54 -0
- package/templates/project/public/js/theme-init.js +12 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.bundle.min.js +7 -0
- package/templates/project/public/vendor/bootstrap/bootstrap.min.css +6 -0
- package/templates/project/readme.tpl +48 -0
- package/templates/project/route.test.tpl +13 -0
- package/templates/project/route.test.views.tpl +15 -0
- package/templates/project/route.tpl +10 -0
- package/templates/project/route.views.tpl +10 -0
- package/templates/project/views/index.ejs.tpl +58 -0
- package/templates/project/views/layout.ejs.tpl +73 -0
- package/templates/project/vitest.config.tpl +8 -0
- package/templates/route/code.tpl +11 -0
- package/templates/route/test.tpl +26 -0
- package/templates/schedule/code.tpl +19 -0
- package/templates/schedule/test.tpl +17 -0
- package/templates/service/code.tpl +18 -0
- package/templates/service/test.tpl +17 -0
- package/templates/worker/code.tpl +14 -0
- package/templates/worker/task.tpl +13 -0
- package/templates/worker/test.tpl +18 -0
- package/vitest.config.js +33 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sample-crud",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"dev": "NODE_ENV=development mega start",
|
|
11
|
+
"start": "NODE_ENV=production mega start",
|
|
12
|
+
"migrate": "mega migrate",
|
|
13
|
+
"migrate:down": "mega migrate:down",
|
|
14
|
+
"migrate:status": "mega migrate:status",
|
|
15
|
+
"scheduler": "mega scheduler",
|
|
16
|
+
"worker": "mega worker",
|
|
17
|
+
"test": "mega test"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"highlight.js": "^11.11.1",
|
|
21
|
+
"marked": "^18.0.5",
|
|
22
|
+
"mega-framework": "file:../.."
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"concurrently": "^9.0.0",
|
|
26
|
+
"vitest": "^4.0.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* chat-bus 단위 테스트(ADR-158) — redis pub/sub cluster broadcast 의 송수신 계약.
|
|
4
|
+
*
|
|
5
|
+
* 인프라 불필요: ioredis 를 fake(duplicate→fake subscriber, publish spy)로 대체해
|
|
6
|
+
* PUBLISH 직렬화·SUBSCRIBE→로컬 전달·exceptSessionIds 제외·손상 페이로드 무시를 검증한다.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, test, expect, vi, afterEach } from 'vitest'
|
|
9
|
+
import {
|
|
10
|
+
ensureSubscriber,
|
|
11
|
+
registerConn,
|
|
12
|
+
unregisterConn,
|
|
13
|
+
publish,
|
|
14
|
+
closeChatBus,
|
|
15
|
+
BCAST_CHANNEL,
|
|
16
|
+
} from '../../../apps/main/channels/chat-bus.js'
|
|
17
|
+
|
|
18
|
+
/** fake ioredis — duplicate 는 메시지 핸들러를 캡처하는 fake 구독자를 돌려준다. */
|
|
19
|
+
function fakeRedis() {
|
|
20
|
+
const handlers = {}
|
|
21
|
+
const sub = {
|
|
22
|
+
on: vi.fn((/** @type {string} */ ev, /** @type {Function} */ cb) => {
|
|
23
|
+
handlers[ev] = cb
|
|
24
|
+
}),
|
|
25
|
+
subscribe: vi.fn(async () => 1),
|
|
26
|
+
quit: vi.fn(async () => 'OK'),
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
duplicate: vi.fn(() => sub),
|
|
30
|
+
publish: vi.fn(async () => 1),
|
|
31
|
+
_sub: sub,
|
|
32
|
+
/** 구독 채널로 메시지 1건 주입. */
|
|
33
|
+
emit: (raw) => handlers.message?.(BCAST_CHANNEL, raw),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const app = { fastify: { log: { warn: vi.fn(), error: vi.fn() } } }
|
|
38
|
+
const mkSock = () => ({ isOpen: true, send: vi.fn() })
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
await closeChatBus()
|
|
42
|
+
vi.clearAllMocks()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('publish 는 채널에 envelope 을 직렬화해 PUBLISH 한다', async () => {
|
|
46
|
+
const redis = fakeRedis()
|
|
47
|
+
const env = { message: { type: 'chat.msg', payload: { text: 'hi' } } }
|
|
48
|
+
await publish(/** @type {any} */ (redis), env)
|
|
49
|
+
expect(redis.publish).toHaveBeenCalledWith(BCAST_CHANNEL, JSON.stringify(env))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('ensureSubscriber 는 워커당 1회만 구독자를 만든다', () => {
|
|
53
|
+
const redis = fakeRedis()
|
|
54
|
+
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
55
|
+
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
56
|
+
expect(redis.duplicate).toHaveBeenCalledTimes(1)
|
|
57
|
+
expect(redis._sub.subscribe).toHaveBeenCalledWith(BCAST_CHANNEL)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('구독 메시지를 로컬 연결에 전달하고 exceptSessionIds 는 건너뛴다', () => {
|
|
61
|
+
const redis = fakeRedis()
|
|
62
|
+
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
63
|
+
const a = mkSock()
|
|
64
|
+
const b = mkSock()
|
|
65
|
+
registerConn(a, { sessionId: 'sA', userName: 'a' })
|
|
66
|
+
registerConn(b, { sessionId: 'sB', userName: 'b' })
|
|
67
|
+
|
|
68
|
+
redis.emit(JSON.stringify({ message: { type: 'chat.msg', payload: { text: 'yo' } }, exceptSessionIds: ['sA'] }))
|
|
69
|
+
|
|
70
|
+
expect(a.send).not.toHaveBeenCalled() // 제외됨.
|
|
71
|
+
expect(b.send).toHaveBeenCalledWith({ type: 'chat.msg', payload: { text: 'yo' } })
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('except 없으면 모든 로컬 연결에 전달(본인 echo 포함)', () => {
|
|
75
|
+
const redis = fakeRedis()
|
|
76
|
+
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
77
|
+
const a = mkSock()
|
|
78
|
+
registerConn(a, { sessionId: 'sA', userName: 'a' })
|
|
79
|
+
redis.emit(JSON.stringify({ message: { type: 'chat.msg', payload: { text: 'echo' } } }))
|
|
80
|
+
expect(a.send).toHaveBeenCalledWith({ type: 'chat.msg', payload: { text: 'echo' } })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('unregister 된 연결에는 전달하지 않는다', () => {
|
|
84
|
+
const redis = fakeRedis()
|
|
85
|
+
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
86
|
+
const a = mkSock()
|
|
87
|
+
registerConn(a, { sessionId: 'sA', userName: 'a' })
|
|
88
|
+
unregisterConn(a)
|
|
89
|
+
redis.emit(JSON.stringify({ message: { type: 'chat.msg', payload: {} } }))
|
|
90
|
+
expect(a.send).not.toHaveBeenCalled()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('손상 페이로드는 전달하지 않고 warn 로그', () => {
|
|
94
|
+
const redis = fakeRedis()
|
|
95
|
+
ensureSubscriber(app, /** @type {any} */ (redis))
|
|
96
|
+
const a = mkSock()
|
|
97
|
+
registerConn(a, { sessionId: 'sA', userName: 'a' })
|
|
98
|
+
redis.emit('{not json')
|
|
99
|
+
expect(a.send).not.toHaveBeenCalled()
|
|
100
|
+
expect(app.fastify.log.warn).toHaveBeenCalled()
|
|
101
|
+
})
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* ChatChannel 단위 테스트(ADR-158) — 라이프사이클 훅이 chat-bus(redis pub/sub) + roster HASH 를
|
|
4
|
+
* 올바르게 호출하는지 mock 으로 검증. 인프라 불필요(redis native·chat-bus 모듈은 spy).
|
|
5
|
+
*
|
|
6
|
+
* 채널은 cluster-wide 전파를 chat-bus.publish 에, 로컬 등록을 register/unregisterConn 에, 접속자 명단을
|
|
7
|
+
* redis HASH(hset/hvals/hdel)에 위임한다 — 그 위임 계약을 단언한다(app.broadcast/joinSession 미사용).
|
|
8
|
+
*/
|
|
9
|
+
import { describe, test, expect, vi, beforeEach } from 'vitest'
|
|
10
|
+
|
|
11
|
+
// chat-bus(redis pub/sub) 모듈은 mock — 채널이 올바른 env 로 호출하는지만 본다.
|
|
12
|
+
vi.mock('../../../apps/main/channels/chat-bus.js', () => ({
|
|
13
|
+
ensureSubscriber: vi.fn(),
|
|
14
|
+
registerConn: vi.fn(),
|
|
15
|
+
unregisterConn: vi.fn(),
|
|
16
|
+
publish: vi.fn(async () => {}),
|
|
17
|
+
ROSTER_KEY: 'ws:chat:roster',
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
import { ChatChannel } from '../../../apps/main/channels/chat-channel.js'
|
|
21
|
+
import * as chatBus from '../../../apps/main/channels/chat-bus.js'
|
|
22
|
+
|
|
23
|
+
/** native redis spy — roster HASH + 기록 리스트. @param {object} [o] */
|
|
24
|
+
function fakeNative({ roster = ['kim'], history = [] } = {}) {
|
|
25
|
+
return {
|
|
26
|
+
hset: vi.fn(async () => 1),
|
|
27
|
+
hvals: vi.fn(async () => roster),
|
|
28
|
+
hdel: vi.fn(async () => 1),
|
|
29
|
+
rpush: vi.fn(async () => 1),
|
|
30
|
+
ltrim: vi.fn(async () => 'OK'),
|
|
31
|
+
expire: vi.fn(async () => 1),
|
|
32
|
+
lrange: vi.fn(async () => history),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 채널 ctx mock — auth/app/cache/log. @param {object} [o] */
|
|
37
|
+
function makeCtx({ roster = ['kim'], history = [], userName = 'kim' } = {}) {
|
|
38
|
+
const native = fakeNative({ roster, history })
|
|
39
|
+
return {
|
|
40
|
+
ctx: {
|
|
41
|
+
auth: { userId: 'u1', sessionId: 's1', userName },
|
|
42
|
+
app: { fastify: { log: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() } } },
|
|
43
|
+
cache: vi.fn(() => ({ native })),
|
|
44
|
+
log: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
|
|
45
|
+
},
|
|
46
|
+
native,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** sock mock — send spy + id + isOpen. */
|
|
51
|
+
function makeSock() {
|
|
52
|
+
return { id: 'conn-1', isOpen: true, send: vi.fn(), close: vi.fn() }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => vi.clearAllMocks())
|
|
56
|
+
|
|
57
|
+
describe('ChatChannel.onConnect', () => {
|
|
58
|
+
test('구독자 보장 + 로컬등록 + roster HSET + 기록재생 + 입장 publish(본인 제외) + 워커PID', async () => {
|
|
59
|
+
const { ctx, native } = makeCtx({
|
|
60
|
+
roster: ['kim', 'old'],
|
|
61
|
+
history: [JSON.stringify({ userId: 'u0', userName: 'old', text: 'hi', ts: 1 })],
|
|
62
|
+
})
|
|
63
|
+
const sock = makeSock()
|
|
64
|
+
await new ChatChannel().onConnect(sock, ctx)
|
|
65
|
+
|
|
66
|
+
expect(chatBus.ensureSubscriber).toHaveBeenCalledWith(ctx.app, native)
|
|
67
|
+
expect(chatBus.registerConn).toHaveBeenCalledWith(sock, { sessionId: 's1', userName: 'kim' })
|
|
68
|
+
expect(native.hset).toHaveBeenCalledWith('ws:chat:roster', 's1', 'kim')
|
|
69
|
+
|
|
70
|
+
// 본인에게: chat.history(me + items + 명단 + 워커PID).
|
|
71
|
+
const hist = sock.send.mock.calls.find(([m]) => m.type === 'chat.history')[0]
|
|
72
|
+
expect(hist.payload.me).toEqual({ userId: 'u1', userName: 'kim' })
|
|
73
|
+
expect(hist.payload.items).toHaveLength(1)
|
|
74
|
+
expect(hist.payload.online).toBe(2)
|
|
75
|
+
expect(hist.payload.members).toEqual(['kim', 'old'])
|
|
76
|
+
expect(hist.payload.workerPid).toBe(process.pid)
|
|
77
|
+
|
|
78
|
+
// 전 클러스터에: 입장 presence(본인 sessionId 제외).
|
|
79
|
+
const pub = chatBus.publish.mock.calls.at(-1)[1]
|
|
80
|
+
expect(pub).toMatchObject({
|
|
81
|
+
message: { type: 'chat.presence', payload: { event: 'join', userName: 'kim', online: 2 } },
|
|
82
|
+
exceptSessionIds: ['s1'],
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('redis 없으면 1011 로 닫는다(전파 transport 부재)', async () => {
|
|
87
|
+
const sock = makeSock()
|
|
88
|
+
const ctx = { auth: { userId: 'u1', sessionId: 's1', userName: 'kim' }, cache: vi.fn(() => ({ native: null })), log: { error: vi.fn() } }
|
|
89
|
+
await new ChatChannel().onConnect(sock, ctx)
|
|
90
|
+
expect(sock.close).toHaveBeenCalledWith(1011, expect.any(String))
|
|
91
|
+
expect(chatBus.registerConn).not.toHaveBeenCalled()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('손상된 기록 1건은 건너뛰고 나머지는 재생(debug 로그)', async () => {
|
|
95
|
+
const { ctx } = makeCtx({ history: ['{bad', JSON.stringify({ userName: 'a', text: 'ok', ts: 2 })] })
|
|
96
|
+
const sock = makeSock()
|
|
97
|
+
await new ChatChannel().onConnect(sock, ctx)
|
|
98
|
+
const hist = sock.send.mock.calls.find(([m]) => m.type === 'chat.history')[0]
|
|
99
|
+
expect(hist.payload.items).toHaveLength(1)
|
|
100
|
+
expect(ctx.log.debug).toHaveBeenCalled()
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('ChatChannel.chat.send', () => {
|
|
105
|
+
test('검증된 text 를 기록 적재 + 전 클러스터 publish(본인 포함)', async () => {
|
|
106
|
+
const { ctx, native } = makeCtx()
|
|
107
|
+
await new ChatChannel()['chat.send'](makeSock(), { type: 'chat.send', payload: { text: ' hello ' } }, ctx)
|
|
108
|
+
expect(native.rpush).toHaveBeenCalled()
|
|
109
|
+
expect(native.ltrim).toHaveBeenCalledWith('ws:chat:history', -30, -1)
|
|
110
|
+
const pub = chatBus.publish.mock.calls.at(-1)[1]
|
|
111
|
+
expect(pub.message.type).toBe('chat.msg')
|
|
112
|
+
expect(pub.message.payload).toMatchObject({ userId: 'u1', userName: 'kim', text: 'hello' })
|
|
113
|
+
expect(pub.exceptSessionIds).toBeUndefined() // 본인도 echo.
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test('공백뿐인 메시지는 무시(전파·적재 없음)', async () => {
|
|
117
|
+
const { ctx, native } = makeCtx()
|
|
118
|
+
await new ChatChannel()['chat.send'](makeSock(), { type: 'chat.send', payload: { text: ' ' } }, ctx)
|
|
119
|
+
expect(chatBus.publish).not.toHaveBeenCalled()
|
|
120
|
+
expect(native.rpush).not.toHaveBeenCalled()
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('ChatChannel.onDisconnect', () => {
|
|
125
|
+
test('로컬 해제 + roster HDEL + 퇴장 publish(본인 제외)', async () => {
|
|
126
|
+
const { ctx, native } = makeCtx({ roster: [] })
|
|
127
|
+
const sock = makeSock()
|
|
128
|
+
await new ChatChannel().onDisconnect(sock, ctx)
|
|
129
|
+
expect(chatBus.unregisterConn).toHaveBeenCalledWith(sock)
|
|
130
|
+
expect(native.hdel).toHaveBeenCalledWith('ws:chat:roster', 's1')
|
|
131
|
+
const pub = chatBus.publish.mock.calls.at(-1)[1]
|
|
132
|
+
expect(pub.message).toMatchObject({ type: 'chat.presence', payload: { event: 'leave', userName: 'kim' } })
|
|
133
|
+
expect(pub.exceptSessionIds).toEqual(['s1'])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
test('auth 없으면 로컬 해제만(전파 없음)', async () => {
|
|
137
|
+
const { ctx } = makeCtx()
|
|
138
|
+
ctx.auth = null
|
|
139
|
+
const sock = makeSock()
|
|
140
|
+
await new ChatChannel().onDisconnect(sock, ctx)
|
|
141
|
+
expect(chatBus.unregisterConn).toHaveBeenCalledWith(sock)
|
|
142
|
+
expect(chatBus.publish).not.toHaveBeenCalled()
|
|
143
|
+
})
|
|
144
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* CronDemoService 단위 테스트 — 'demo' 캐시 native(incr/get/lpush/ltrim/lrange)를 가짜로 갈음해 redis 없이
|
|
4
|
+
* 카운터/이력 로직과 다음 실행 시각 계산(MegaCron)을 검증한다. 실 redis 흐름은 통합 검증(E2E)이 커버한다.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, test, expect, vi } from 'vitest'
|
|
7
|
+
import { CronDemoService } from '../../../apps/main/services/cron-demo-service.js'
|
|
8
|
+
|
|
9
|
+
/** incr/get/lpush/ltrim/lrange 를 추적하는 가짜 native + ctx 를 만든다. */
|
|
10
|
+
function makeCtx() {
|
|
11
|
+
/** @type {Map<string, number>} */
|
|
12
|
+
const counters = new Map()
|
|
13
|
+
/** @type {Map<string, string[]>} LIST(머리 삽입). */
|
|
14
|
+
const lists = new Map()
|
|
15
|
+
const native = {
|
|
16
|
+
/** @param {string} k */
|
|
17
|
+
async incr(k) {
|
|
18
|
+
const n = (counters.get(k) ?? 0) + 1
|
|
19
|
+
counters.set(k, n)
|
|
20
|
+
return n
|
|
21
|
+
},
|
|
22
|
+
/** @param {string} k */
|
|
23
|
+
async get(k) {
|
|
24
|
+
return counters.has(k) ? String(counters.get(k)) : null
|
|
25
|
+
},
|
|
26
|
+
/** @param {string} k @param {string} v */
|
|
27
|
+
async lpush(k, v) {
|
|
28
|
+
const arr = lists.get(k) ?? []
|
|
29
|
+
arr.unshift(v)
|
|
30
|
+
lists.set(k, arr)
|
|
31
|
+
return arr.length
|
|
32
|
+
},
|
|
33
|
+
ltrim: vi.fn(async (/** @type {string} */ k, /** @type {number} */ _s, /** @type {number} */ stop) => {
|
|
34
|
+
const arr = lists.get(k) ?? []
|
|
35
|
+
lists.set(k, arr.slice(0, stop + 1))
|
|
36
|
+
return 'OK'
|
|
37
|
+
}),
|
|
38
|
+
/** @param {string} k @param {number} start @param {number} stop */
|
|
39
|
+
async lrange(k, start, stop) {
|
|
40
|
+
return (lists.get(k) ?? []).slice(start, stop + 1)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
const cache = { native }
|
|
44
|
+
const ctx = { log: { debug() {} }, cache: (/** @type {string} */ a) => (a === 'demo' ? cache : null) }
|
|
45
|
+
return { ctx, native, counters, lists }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('CronDemoService — tick', () => {
|
|
49
|
+
test('호출마다 카운터가 1씩 증가하고 이력 LIST 머리에 source 와 함께 쌓인다', async () => {
|
|
50
|
+
const { ctx, native, lists } = makeCtx()
|
|
51
|
+
const svc = new CronDemoService(/** @type {any} */ (ctx))
|
|
52
|
+
const first = await svc.tick('schedule')
|
|
53
|
+
expect(first).toMatchObject({ count: 1, source: 'schedule' })
|
|
54
|
+
expect(typeof first.at).toBe('string')
|
|
55
|
+
const second = await svc.tick('manual')
|
|
56
|
+
expect(second.count).toBe(2)
|
|
57
|
+
// LTRIM 으로 최근 N건만 유지(매 tick 호출).
|
|
58
|
+
expect(native.ltrim).toHaveBeenCalledWith(CronDemoService.HISTORY_KEY, 0, CronDemoService.HISTORY_MAX - 1)
|
|
59
|
+
const stored = (lists.get(CronDemoService.HISTORY_KEY) ?? []).map((s) => JSON.parse(s))
|
|
60
|
+
// 최신이 머리(앞) — manual 이 먼저.
|
|
61
|
+
expect(stored[0]).toMatchObject({ count: 2, source: 'manual' })
|
|
62
|
+
expect(stored[1]).toMatchObject({ count: 1, source: 'schedule' })
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('CronDemoService — snapshot', () => {
|
|
67
|
+
test('누적 카운터 + 최신순 이력 + 다음 실행 시각(미래, 오름차순)을 돌려준다', async () => {
|
|
68
|
+
const { ctx } = makeCtx()
|
|
69
|
+
const svc = new CronDemoService(/** @type {any} */ (ctx))
|
|
70
|
+
await svc.tick('schedule')
|
|
71
|
+
await svc.tick('schedule')
|
|
72
|
+
const snap = await svc.snapshot()
|
|
73
|
+
expect(snap.count).toBe(2)
|
|
74
|
+
expect(snap.cron).toBe(CronDemoService.CRON_EXPR)
|
|
75
|
+
expect(snap.timezone).toBe(CronDemoService.TIMEZONE)
|
|
76
|
+
expect(snap.history).toHaveLength(2)
|
|
77
|
+
expect(snap.nextRuns.length).toBeGreaterThan(0)
|
|
78
|
+
// 모두 미래 + 오름차순.
|
|
79
|
+
const now = Date.now()
|
|
80
|
+
for (const d of snap.nextRuns) expect(d.getTime()).toBeGreaterThan(now)
|
|
81
|
+
for (let i = 1; i < snap.nextRuns.length; i++) {
|
|
82
|
+
expect(snap.nextRuns[i].getTime()).toBeGreaterThan(snap.nextRuns[i - 1].getTime())
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('카운터 미존재(get=null) 시 0 으로 본다', async () => {
|
|
87
|
+
const { ctx } = makeCtx()
|
|
88
|
+
const svc = new CronDemoService(/** @type {any} */ (ctx))
|
|
89
|
+
const snap = await svc.snapshot()
|
|
90
|
+
expect(snap.count).toBe(0)
|
|
91
|
+
expect(snap.history).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
})
|